From b67fa07b163cde4b24e8eb42d36cfe9347239e2e Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 19 Sep 2024 00:39:54 +1000 Subject: [PATCH 01/11] skip failing test suite (#193294) --- .../test_suites/observability/onboarding/firehose.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test_serverless/functional/test_suites/observability/onboarding/firehose.ts b/x-pack/test_serverless/functional/test_suites/observability/onboarding/firehose.ts index 7380de0dfc2f3..91b536c79c5e8 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/onboarding/firehose.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/onboarding/firehose.ts @@ -20,7 +20,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const synthtrace = getService('svlLogsSynthtraceClient'); - describe('Onboarding Firehose Quickstart Flow', () => { + // Failing: See https://github.com/elastic/kibana/issues/193294 + describe.skip('Onboarding Firehose Quickstart Flow', () => { before(async () => { await PageObjects.svlCommonPage.loginAsAdmin(); // Onboarding requires admin role await PageObjects.common.navigateToUrlWithBrowserHistory( From 5040e3580cb4a377257c0fe6f821a1f91908db7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 18 Sep 2024 15:54:13 +0100 Subject: [PATCH 02/11] [Chrome service] Expose handler to toggle the sidenav (#193192) --- .../src/chrome_service.test.tsx | 35 +++++++++++++++++- .../src/chrome_service.tsx | 20 ++++++++--- .../src/ui/project/header.test.tsx | 1 + .../src/ui/project/header.tsx | 8 ++++- .../src/ui/project/navigation.tsx | 36 ++++++++----------- .../src/chrome_service.mock.ts | 7 ++-- .../core-chrome-browser/src/contracts.ts | 16 ++++++--- .../chrome/navigation/src/services.tsx | 2 +- .../shared-ux/chrome/navigation/src/types.ts | 4 ++- 9 files changed, 93 insertions(+), 36 deletions(-) diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx index 9ef4f740167fe..dc1ad36f01c5e 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx @@ -11,7 +11,7 @@ import { registerAnalyticsContextProviderMock } from './chrome_service.test.mock import { shallow, mount } from 'enzyme'; import React from 'react'; import * as Rx from 'rxjs'; -import { toArray } from 'rxjs'; +import { toArray, firstValueFrom } from 'rxjs'; import { injectedMetadataServiceMock } from '@kbn/core-injected-metadata-browser-mocks'; import { docLinksServiceMock } from '@kbn/core-doc-links-browser-mocks'; import { httpServiceMock } from '@kbn/core-http-browser-mocks'; @@ -556,6 +556,39 @@ describe('start', () => { `); }); }); + + describe('side nav', () => { + describe('isCollapsed$', () => { + it('should return false by default', async () => { + const { chrome, service } = await start(); + const isCollapsed = await firstValueFrom(chrome.sideNav.getIsCollapsed$()); + service.stop(); + expect(isCollapsed).toBe(false); + }); + + it('should read the localStorage value', async () => { + store.set('core.chrome.isSideNavCollapsed', 'true'); + const { chrome, service } = await start(); + const isCollapsed = await firstValueFrom(chrome.sideNav.getIsCollapsed$()); + service.stop(); + expect(isCollapsed).toBe(true); + }); + }); + + describe('setIsCollapsed', () => { + it('should update the isCollapsed$ observable', async () => { + const { chrome, service } = await start(); + const isCollapsed$ = chrome.sideNav.getIsCollapsed$(); + const isCollapsed = await firstValueFrom(isCollapsed$); + + chrome.sideNav.setIsCollapsed(!isCollapsed); + + const updatedIsCollapsed = await firstValueFrom(isCollapsed$); + service.stop(); + expect(updatedIsCollapsed).toBe(!isCollapsed); + }); + }); + }); }); describe('stop', () => { diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx index 3eb846cc15dc8..4605dd02fd229 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx @@ -54,6 +54,7 @@ import type { InternalChromeStart } from './types'; import { HeaderTopBanner } from './ui/header/header_top_banner'; const IS_LOCKED_KEY = 'core.chrome.isLocked'; +const IS_SIDENAV_COLLAPSED_KEY = 'core.chrome.isSideNavCollapsed'; const SNAPSHOT_REGEX = /-snapshot/i; interface ConstructorParams { @@ -86,7 +87,9 @@ export class ChromeService { private readonly docTitle = new DocTitleService(); private readonly projectNavigation: ProjectNavigationService; private mutationObserver: MutationObserver | undefined; - private readonly isSideNavCollapsed$ = new BehaviorSubject(true); + private readonly isSideNavCollapsed$ = new BehaviorSubject( + localStorage.getItem(IS_SIDENAV_COLLAPSED_KEY) === 'true' + ); private logger: Logger; private isServerless = false; @@ -360,6 +363,11 @@ export class ChromeService { projectNavigation.setProjectName(projectName); }; + const setIsSideNavCollapsed = (isCollapsed: boolean) => { + localStorage.setItem(IS_SIDENAV_COLLAPSED_KEY, JSON.stringify(isCollapsed)); + this.isSideNavCollapsed$.next(isCollapsed); + }; + if (!this.params.browserSupportsCsp && injectedMetadata.getCspConfig().warnLegacyBrowsers) { notifications.toasts.addWarning({ title: mountReactNode( @@ -431,9 +439,8 @@ export class ChromeService { docLinks={docLinks} kibanaVersion={injectedMetadata.getKibanaVersion()} prependBasePath={http.basePath.prepend} - toggleSideNav={(isCollapsed) => { - this.isSideNavCollapsed$.next(isCollapsed); - }} + isSideNavCollapsed$={this.isSideNavCollapsed$} + toggleSideNav={setIsSideNavCollapsed} > @@ -556,7 +563,10 @@ export class ChromeService { getBodyClasses$: () => bodyClasses$.pipe(takeUntil(this.stop$)), setChromeStyle, getChromeStyle$: () => chromeStyle$, - getIsSideNavCollapsed$: () => this.isSideNavCollapsed$.asObservable(), + sideNav: { + getIsCollapsed$: () => this.isSideNavCollapsed$.asObservable(), + setIsCollapsed: setIsSideNavCollapsed, + }, getActiveSolutionNavId$: () => projectNavigation.getActiveSolutionNavId$(), project: { setHome: setProjectHome, diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx index 3e4bc7a8a1fe5..743cd1726e03e 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx @@ -35,6 +35,7 @@ describe('Header', () => { navControlsCenter$: Rx.of([]), navControlsRight$: Rx.of([]), customBranding$: Rx.of({}), + isSideNavCollapsed$: Rx.of(false), prependBasePath: (str) => `hello/world/${str}`, toggleSideNav: jest.fn(), }; diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx index f3d92ff31638d..bf8b103709260 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx @@ -130,6 +130,7 @@ export interface Props { navControlsCenter$: Observable; navControlsRight$: Observable; prependBasePath: (url: string) => string; + isSideNavCollapsed$: Observable; toggleSideNav: (isCollapsed: boolean) => void; } @@ -248,7 +249,12 @@ export const ProjectHeader = ({ - {children} + + {children} + diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx index 9daf99bbbfc23..a607d69fb0633 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx @@ -7,34 +7,28 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useEffect, useRef, FC, PropsWithChildren } from 'react'; +import React, { FC, PropsWithChildren } from 'react'; import { EuiCollapsibleNavBeta } from '@elastic/eui'; -import useLocalStorage from 'react-use/lib/useLocalStorage'; +import useObservable from 'react-use/lib/useObservable'; +import type { Observable } from 'rxjs'; -const LOCAL_STORAGE_IS_COLLAPSED_KEY = 'PROJECT_NAVIGATION_COLLAPSED' as const; +interface Props { + toggleSideNav: (isVisible: boolean) => void; + isSideNavCollapsed$: Observable; +} -export const ProjectNavigation: FC< - PropsWithChildren<{ toggleSideNav: (isVisible: boolean) => void }> -> = ({ children, toggleSideNav }) => { - const isMounted = useRef(false); - const [isCollapsed, setIsCollapsed] = useLocalStorage(LOCAL_STORAGE_IS_COLLAPSED_KEY, false); - const onCollapseToggle = (nextIsCollapsed: boolean) => { - setIsCollapsed(nextIsCollapsed); - toggleSideNav(nextIsCollapsed); - }; - - useEffect(() => { - if (!isMounted.current && isCollapsed !== undefined) { - toggleSideNav(isCollapsed); - } - isMounted.current = true; - }, [isCollapsed, toggleSideNav]); +export const ProjectNavigation: FC> = ({ + children, + isSideNavCollapsed$, + toggleSideNav, +}) => { + const isCollapsed = useObservable(isSideNavCollapsed$, false); return ( { setBadge: jest.fn(), getBreadcrumbs$: jest.fn(), setBreadcrumbs: jest.fn(), - getIsSideNavCollapsed$: jest.fn(), + sideNav: { + getIsCollapsed$: jest.fn(), + setIsCollapsed: jest.fn(), + }, getBreadcrumbsAppendExtension$: jest.fn(), setBreadcrumbsAppendExtension: jest.fn(), getGlobalHelpExtensionMenuLinks$: jest.fn(), @@ -94,7 +97,7 @@ const createStartContractMock = () => { startContract.getIsNavDrawerLocked$.mockReturnValue(new BehaviorSubject(false)); startContract.getBodyClasses$.mockReturnValue(new BehaviorSubject([])); startContract.hasHeaderBanner$.mockReturnValue(new BehaviorSubject(false)); - startContract.getIsSideNavCollapsed$.mockReturnValue(new BehaviorSubject(false)); + startContract.sideNav.getIsCollapsed$.mockReturnValue(new BehaviorSubject(false)); return startContract; }; diff --git a/packages/core/chrome/core-chrome-browser/src/contracts.ts b/packages/core/chrome/core-chrome-browser/src/contracts.ts index c326e7107aa2a..1e9ea66bc0920 100644 --- a/packages/core/chrome/core-chrome-browser/src/contracts.ts +++ b/packages/core/chrome/core-chrome-browser/src/contracts.ts @@ -173,10 +173,18 @@ export interface ChromeStart { */ getChromeStyle$(): Observable; - /** - * Get an observable of the current collapsed state of the side nav. - */ - getIsSideNavCollapsed$(): Observable; + sideNav: { + /** + * Get an observable of the current collapsed state of the side nav. + */ + getIsCollapsed$(): Observable; + + /** + * Set the collapsed state of the side nav. + * @param isCollapsed The collapsed state of the side nav. + */ + setIsCollapsed(isCollapsed: boolean): void; + }; /** * Get the id of the currently active project navigation or `null` otherwise. diff --git a/packages/shared-ux/chrome/navigation/src/services.tsx b/packages/shared-ux/chrome/navigation/src/services.tsx index fec11110f8b6a..1b0102533e53e 100644 --- a/packages/shared-ux/chrome/navigation/src/services.tsx +++ b/packages/shared-ux/chrome/navigation/src/services.tsx @@ -36,7 +36,7 @@ export const NavigationKibanaProvider: FC ({ diff --git a/packages/shared-ux/chrome/navigation/src/types.ts b/packages/shared-ux/chrome/navigation/src/types.ts index c7f882c3580a6..9db808e37799a 100644 --- a/packages/shared-ux/chrome/navigation/src/types.ts +++ b/packages/shared-ux/chrome/navigation/src/types.ts @@ -53,7 +53,9 @@ export interface NavigationKibanaDependencies { navLinks: { getNavLinks$: () => Observable>; }; - getIsSideNavCollapsed$: () => Observable; + sideNav: { + getIsCollapsed$: () => Observable; + }; }; http: { basePath: BasePathService; From e3f3c68e8d2e106740db9b4612df867c583eb512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 18 Sep 2024 16:06:13 +0100 Subject: [PATCH 03/11] [Inventory][ECO] Entities table (#193272) Real data: Screenshot 2024-09-18 at 14 23 17 Storybook: Screenshot 2024-09-18 at 14 23 22 Tooltips: Screenshot 2024-09-18 at 13 49 19 - Use ESQL to fetch the top 500 entities sorted by last seen property. - Display 20 entities per page. - Sorting is handles by the server and saved on the URL - Current page is saved on the URL - Filter entities types `service`, `host` or `container` - Filter only entities from the built in definition - LIMITATION: The EuiGrid doesn't have an embedded loading state, for now, I'm switching the entire view to display a loading spinner while data is being fetched. - PLUS: Storybook created with mock data. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../client/create_observability_es_client.ts | 24 +- .../es/utils/esql_result_to_plain_objects.ts | 20 + .../inventory/common/entities.ts | 29 +- .../inventory/common/es_fields/entities.ts | 12 + .../entities_grid/entities_grid.stories.tsx | 65 + .../public/components/entities_grid/index.tsx | 229 +- .../entities_grid/mock/entities_mock.ts | 3014 +++++++++++++++++ .../public/pages/inventory_page/index.tsx | 57 +- .../inventory/public/routes/config.tsx | 16 + .../routes/entities/get_latest_entities.ts | 66 +- .../inventory/server/routes/entities/route.ts | 35 +- .../inventory/server/utils/with_apm_span.ts | 7 - .../inventory/tsconfig.json | 4 +- 13 files changed, 3501 insertions(+), 77 deletions(-) create mode 100644 x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts create mode 100644 x-pack/plugins/observability_solution/inventory/common/es_fields/entities.ts create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts delete mode 100644 x-pack/plugins/observability_solution/inventory/server/utils/with_apm_span.ts diff --git a/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts b/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts index 2e57653365b0b..0011e0f17c1c0 100644 --- a/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts +++ b/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts @@ -6,8 +6,9 @@ */ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; -import type { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; +import type { ESQLSearchResponse, ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; import { withSpan } from '@kbn/apm-utils'; +import type { EsqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; type SearchRequest = ESSearchRequest & { index: string | string[]; @@ -24,6 +25,7 @@ export interface ObservabilityElasticsearchClient { operationName: string, parameters: TSearchRequest ): Promise>; + esql(operationName: string, parameters: EsqlQueryRequest): Promise; client: ElasticsearchClient; } @@ -38,6 +40,26 @@ export function createObservabilityEsClient({ }): ObservabilityElasticsearchClient { return { client, + esql(operationName: string, parameters: EsqlQueryRequest) { + logger.trace(() => `Request (${operationName}):\n${JSON.stringify(parameters, null, 2)}`); + return withSpan({ name: operationName, labels: { plugin } }, () => { + return client.esql.query( + { ...parameters }, + { + querystring: { + drop_null_columns: true, + }, + } + ); + }) + .then((response) => { + logger.trace(() => `Response (${operationName}):\n${JSON.stringify(response, null, 2)}`); + return response as unknown as ESQLSearchResponse; + }) + .catch((error) => { + throw error; + }); + }, search( operationName: string, parameters: SearchRequest diff --git a/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts new file mode 100644 index 0000000000000..ad48bcb311b25 --- /dev/null +++ b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts @@ -0,0 +1,20 @@ +/* + * 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 { ESQLSearchResponse } from '@kbn/es-types'; + +export function esqlResultToPlainObjects>( + result: ESQLSearchResponse +): T[] { + return result.values.map((row) => { + return row.reduce>((acc, value, index) => { + const column = result.columns[index]; + acc[column.name] = value; + return acc; + }, {}); + }) as T[]; +} diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index d72fa46969b8a..d8353cf3a97f0 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -4,23 +4,14 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import * as t from 'io-ts'; -export interface LatestEntity { - agent: { - name: string[]; - }; - data_stream: { - type: string[]; - }; - cloud: { - availability_zone: string[]; - }; - entity: { - firstSeenTimestamp: string; - lastSeenTimestamp: string; - type: string; - displayName: string; - id: string; - identityFields: string[]; - }; -} +export const entityTypeRt = t.union([ + t.literal('service'), + t.literal('host'), + t.literal('container'), +]); + +export type EntityType = t.TypeOf; + +export const MAX_NUMBER_OF_ENTITIES = 500; diff --git a/x-pack/plugins/observability_solution/inventory/common/es_fields/entities.ts b/x-pack/plugins/observability_solution/inventory/common/es_fields/entities.ts new file mode 100644 index 0000000000000..9b619dddbb2df --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/common/es_fields/entities.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ENTITY_LAST_SEEN = 'entity.lastSeenTimestamp'; +export const ENTITY_ID = 'entity.id'; +export const ENTITY_TYPE = 'entity.type'; +export const ENTITY_DISPLAY_NAME = 'entity.displayName'; +export const ENTITY_DEFINITION_ID = 'entity.definitionId'; diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx new file mode 100644 index 0000000000000..b0e6c2fcc5ee4 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx @@ -0,0 +1,65 @@ +/* + * 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 { EuiDataGridSorting } from '@elastic/eui'; +import { Meta, Story } from '@storybook/react'; +import React, { useMemo, useState } from 'react'; +import { orderBy } from 'lodash'; +import { EntitiesGrid } from '.'; +import { ENTITY_LAST_SEEN } from '../../../common/es_fields/entities'; +import { entitiesMock } from './mock/entities_mock'; + +const stories: Meta<{}> = { + title: 'app/inventory/entities_grid', + component: EntitiesGrid, +}; +export default stories; + +export const Example: Story<{}> = () => { + const [pageIndex, setPageIndex] = useState(0); + const [sort, setSort] = useState({ + id: ENTITY_LAST_SEEN, + direction: 'desc', + }); + + const sortedItems = useMemo( + () => orderBy(entitiesMock, sort.id, sort.direction), + [sort.direction, sort.id] + ); + + return ( + + ); +}; + +export const EmptyGridExample: Story<{}> = () => { + const [pageIndex, setPageIndex] = useState(0); + const [sort, setSort] = useState({ + id: ENTITY_LAST_SEEN, + direction: 'desc', + }); + + return ( + + ); +}; diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx index e689063882c40..c9b91f165fedd 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx @@ -5,53 +5,186 @@ * 2.0. */ import { + EuiBadge, + EuiButtonIcon, EuiDataGrid, EuiDataGridCellValueElementProps, EuiDataGridColumn, + EuiDataGridSorting, + EuiLink, EuiLoadingSpinner, + EuiText, + EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useAbortableAsync } from '@kbn/observability-utils/hooks/use_abortable_async'; -import React, { useState } from 'react'; -import { useKibana } from '../../hooks/use_kibana'; +import { FormattedDate, FormattedMessage, FormattedTime } from '@kbn/i18n-react'; +import { last } from 'lodash'; +import React, { useCallback, useState } from 'react'; +import { + ENTITY_DISPLAY_NAME, + ENTITY_LAST_SEEN, + ENTITY_TYPE, +} from '../../../common/es_fields/entities'; +import { APIReturnType } from '../../api'; + +type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>; + +type EntityColumnIds = typeof ENTITY_DISPLAY_NAME | typeof ENTITY_LAST_SEEN | typeof ENTITY_TYPE; + +const CustomHeaderCell = ({ title, tooltipContent }: { title: string; tooltipContent: string }) => ( + <> + {title} + + + + +); + +const entityNameLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.entityNameLabel', { + defaultMessage: 'Entity name', +}); +const entityTypeLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.typeLabel', { + defaultMessage: 'Type', +}); +const entityLastSeenLabel = i18n.translate( + 'xpack.inventory.entitiesGrid.euiDataGrid.lastSeenLabel', + { + defaultMessage: 'Last seen', + } +); const columns: EuiDataGridColumn[] = [ { - id: 'entityName', - displayAsText: 'Entity name', + id: ENTITY_DISPLAY_NAME, + // keep it for accessibility purposes + displayAsText: entityNameLabel, + display: ( + + ), + isSortable: true, }, { - id: 'entityType', - displayAsText: 'Type', + id: ENTITY_TYPE, + // keep it for accessibility purposes + displayAsText: entityTypeLabel, + display: ( + + ), + isSortable: true, + }, + { + id: ENTITY_LAST_SEEN, + // keep it for accessibility purposes + displayAsText: entityLastSeenLabel, + display: ( + + ), + defaultSortDirection: 'desc', + isSortable: true, + schema: 'datetime', }, ]; -export function EntitiesGrid() { - const { - services: { inventoryAPIClient }, - } = useKibana(); +interface Props { + loading: boolean; + entities: InventoryEntitiesAPIReturnType['entities']; + sortDirection: 'asc' | 'desc'; + sortField: string; + pageIndex: number; + onChangeSort: (sorting: EuiDataGridSorting['columns'][0]) => void; + onChangePage: (nextPage: number) => void; +} + +const PAGE_SIZE = 20; + +export function EntitiesGrid({ + entities, + loading, + sortDirection, + sortField, + pageIndex, + onChangePage, + onChangeSort, +}: Props) { const [visibleColumns, setVisibleColumns] = useState(columns.map(({ id }) => id)); - const { value = { entities: [] }, loading } = useAbortableAsync( - ({ signal }) => { - return inventoryAPIClient.fetch('GET /internal/inventory/entities', { - signal, - }); + + const onSort: EuiDataGridSorting['onSort'] = useCallback( + (newSortingColumns) => { + const lastItem = last(newSortingColumns); + if (lastItem) { + onChangeSort(lastItem); + } }, - [inventoryAPIClient] + [onChangeSort] + ); + + const renderCellValue = useCallback( + ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { + const entity = entities[rowIndex]; + if (entity === undefined) { + return null; + } + + const columnEntityTableId = columnId as EntityColumnIds; + switch (columnEntityTableId) { + case ENTITY_TYPE: + return {entity[columnEntityTableId]}; + case ENTITY_LAST_SEEN: + return ( + + ), + time: ( + + ), + }} + /> + ); + case ENTITY_DISPLAY_NAME: + return ( + // TODO: link to the appropriate page based on entity type https://github.com/elastic/kibana/issues/192676 + + {entity[columnEntityTableId]} + + ); + default: + return entity[columnId as EntityColumnIds] || ''; + } + }, + [entities] ); if (loading) { return ; } - function CellValue({ rowIndex, columnId, setCellProps }: EuiDataGridCellValueElementProps) { - const data = value.entities[rowIndex]; - if (data === undefined) { - return null; - } - - return <>{data.entity.displayName}; - } + const currentPage = pageIndex + 1; return ( + + {Math.min(entities.length, pageIndex * PAGE_SIZE + 1)}- + {Math.min(entities.length, PAGE_SIZE * currentPage)} + + ), + total: entities.length, + boldEntites: ( + + {i18n.translate( + 'xpack.inventory.entitiesGrid.euiDataGrid.headerLeft.entites', + { defaultMessage: 'Entities' } + )} + + ), + }} + /> + + ), + }, + }, + }} + sorting={{ columns: [{ id: sortField, direction: sortDirection }], onSort }} + pagination={{ + pageIndex, + pageSize: PAGE_SIZE, + onChangeItemsPerPage: () => {}, + onChangePage, + pageSizeOptions: [], + }} /> ); } diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts new file mode 100644 index 0000000000000..16fb73be75534 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts @@ -0,0 +1,3014 @@ +/* + * 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 { APIReturnType } from '../../../api'; + +type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>; + +export const entitiesMock: InventoryEntitiesAPIReturnType['entities'] = [ + { + 'entity.lastSeenTimestamp': '2023-08-20T10:50:06.384Z', + 'entity.type': 'host', + 'entity.displayName': 'Spider-Man', + 'entity.id': '0', + }, + { + 'entity.lastSeenTimestamp': '2024-06-16T21:48:16.259Z', + 'entity.type': 'service', + 'entity.displayName': 'Iron Man', + 'entity.id': '1', + }, + { + 'entity.lastSeenTimestamp': '2024-04-28T03:31:57.528Z', + 'entity.type': 'host', + 'entity.displayName': 'Captain America', + 'entity.id': '2', + }, + { + 'entity.lastSeenTimestamp': '2024-05-14T11:32:04.275Z', + 'entity.type': 'host', + 'entity.displayName': 'Hulk', + 'entity.id': '3', + }, + { + 'entity.lastSeenTimestamp': '2023-12-05T13:33:54.028Z', + 'entity.type': 'container', + 'entity.displayName': 'Thor', + 'entity.id': '4', + }, + { + 'entity.lastSeenTimestamp': '2023-11-27T06:18:52.650Z', + 'entity.type': 'service', + 'entity.displayName': 'Black Widow', + 'entity.id': '5', + }, + { + 'entity.lastSeenTimestamp': '2023-06-23T21:20:41.460Z', + 'entity.type': 'container', + 'entity.displayName': 'Batman', + 'entity.id': '6', + }, + { + 'entity.lastSeenTimestamp': '2023-12-08T03:23:09.317Z', + 'entity.type': 'service', + 'entity.displayName': 'Superman', + 'entity.id': '7', + }, + { + 'entity.lastSeenTimestamp': '2024-08-07T19:06:52.169Z', + 'entity.type': 'service', + 'entity.displayName': 'Wonder Woman', + 'entity.id': '8', + }, + { + 'entity.lastSeenTimestamp': '2024-08-15T01:15:23.589Z', + 'entity.type': 'container', + 'entity.displayName': 'Aquaman', + 'entity.id': '9', + }, + { + 'entity.lastSeenTimestamp': '2024-05-18T09:44:35.799Z', + 'entity.type': 'service', + 'entity.displayName': 'Flash', + 'entity.id': '10', + }, + { + 'entity.lastSeenTimestamp': '2023-12-20T19:12:29.251Z', + 'entity.type': 'container', + 'entity.displayName': 'Cyborg', + 'entity.id': '11', + }, + { + 'entity.lastSeenTimestamp': '2024-04-04T02:52:28.431Z', + 'entity.type': 'container', + 'entity.displayName': 'Wolverine', + 'entity.id': '12', + }, + { + 'entity.lastSeenTimestamp': '2023-07-14T05:13:12.906Z', + 'entity.type': 'host', + 'entity.displayName': 'Deadpool', + 'entity.id': '13', + }, + { + 'entity.lastSeenTimestamp': '2023-07-21T07:30:55.389Z', + 'entity.type': 'service', + 'entity.displayName': 'Green Lantern', + 'entity.id': '14', + }, + { + 'entity.lastSeenTimestamp': '2024-06-16T09:30:32.331Z', + 'entity.type': 'service', + 'entity.displayName': 'Doctor Strange', + 'entity.id': '15', + }, + { + 'entity.lastSeenTimestamp': '2023-08-24T08:05:46.687Z', + 'entity.type': 'container', + 'entity.displayName': 'Ant-Man', + 'entity.id': '16', + }, + { + 'entity.lastSeenTimestamp': '2024-03-23T09:37:36.874Z', + 'entity.type': 'service', + 'entity.displayName': 'Scarlet Witch', + 'entity.id': '17', + }, + { + 'entity.lastSeenTimestamp': '2023-05-12T02:34:46.188Z', + 'entity.type': 'host', + 'entity.displayName': 'Black Panther', + 'entity.id': '18', + }, + { + 'entity.lastSeenTimestamp': '2023-01-05T07:16:17.213Z', + 'entity.type': 'container', + 'entity.displayName': 'Captain Marvel', + 'entity.id': '19', + }, + { + 'entity.lastSeenTimestamp': '2024-05-28T04:08:43.047Z', + 'entity.type': 'host', + 'entity.displayName': 'Hawkeye', + 'entity.id': '20', + }, + { + 'entity.lastSeenTimestamp': '2024-04-23T02:01:01.149Z', + 'entity.type': 'service', + 'entity.displayName': 'Vision', + 'entity.id': '21', + }, + { + 'entity.lastSeenTimestamp': '2023-04-08T10:40:14.658Z', + 'entity.type': 'host', + 'entity.displayName': 'Shazam', + 'entity.id': '22', + }, + { + 'entity.lastSeenTimestamp': '2024-01-11T09:03:11.465Z', + 'entity.type': 'service', + 'entity.displayName': 'Nightwing', + 'entity.id': '23', + }, + { + 'entity.lastSeenTimestamp': '2024-04-27T22:35:18.822Z', + 'entity.type': 'container', + 'entity.displayName': 'Robin', + 'entity.id': '24', + }, + { + 'entity.lastSeenTimestamp': '2023-03-09T22:05:08.071Z', + 'entity.type': 'container', + 'entity.displayName': 'Starfire', + 'entity.id': '25', + }, + { + 'entity.lastSeenTimestamp': '2024-08-09T13:20:31.960Z', + 'entity.type': 'service', + 'entity.displayName': 'Beast Boy', + 'entity.id': '26', + }, + { + 'entity.lastSeenTimestamp': '2024-07-12T01:44:33.204Z', + 'entity.type': 'service', + 'entity.displayName': 'Raven', + 'entity.id': '27', + }, + { + 'entity.lastSeenTimestamp': '2023-01-31T00:08:53.817Z', + 'entity.type': 'service', + 'entity.displayName': 'Daredevil', + 'entity.id': '28', + }, + { + 'entity.lastSeenTimestamp': '2024-03-26T08:37:11.019Z', + 'entity.type': 'container', + 'entity.displayName': 'Luke Cage', + 'entity.id': '29', + }, + { + 'entity.lastSeenTimestamp': '2023-05-17T08:49:09.112Z', + 'entity.type': 'service', + 'entity.displayName': 'Jessica Jones', + 'entity.id': '30', + }, + { + 'entity.lastSeenTimestamp': '2024-06-15T20:05:12.395Z', + 'entity.type': 'service', + 'entity.displayName': 'Punisher', + 'entity.id': '31', + }, + { + 'entity.lastSeenTimestamp': '2024-07-30T06:53:16.477Z', + 'entity.type': 'service', + 'entity.displayName': 'Groot', + 'entity.id': '32', + }, + { + 'entity.lastSeenTimestamp': '2024-06-01T13:22:53.973Z', + 'entity.type': 'host', + 'entity.displayName': 'Rocket Raccoon', + 'entity.id': '33', + }, + { + 'entity.lastSeenTimestamp': '2024-09-12T17:44:12.492Z', + 'entity.type': 'container', + 'entity.displayName': 'Gamora', + 'entity.id': '34', + }, + { + 'entity.lastSeenTimestamp': '2024-03-28T13:44:52.732Z', + 'entity.type': 'service', + 'entity.displayName': 'Drax', + 'entity.id': '35', + }, + { + 'entity.lastSeenTimestamp': '2023-09-19T01:20:23.901Z', + 'entity.type': 'container', + 'entity.displayName': 'Mantis', + 'entity.id': '36', + }, + { + 'entity.lastSeenTimestamp': '2023-01-17T07:04:52.387Z', + 'entity.type': 'service', + 'entity.displayName': 'Winter Soldier', + 'entity.id': '37', + }, + { + 'entity.lastSeenTimestamp': '2023-10-07T15:08:39.776Z', + 'entity.type': 'host', + 'entity.displayName': 'Falcon', + 'entity.id': '38', + }, + { + 'entity.lastSeenTimestamp': '2024-05-01T17:45:43.595Z', + 'entity.type': 'host', + 'entity.displayName': 'Silver Surfer', + 'entity.id': '39', + }, + { + 'entity.lastSeenTimestamp': '2023-01-12T19:33:15.526Z', + 'entity.type': 'host', + 'entity.displayName': 'Moon Knight', + 'entity.id': '40', + }, + { + 'entity.lastSeenTimestamp': '2023-03-28T23:24:20.896Z', + 'entity.type': 'container', + 'entity.displayName': 'She-Hulk', + 'entity.id': '41', + }, + { + 'entity.lastSeenTimestamp': '2023-03-15T09:52:58.134Z', + 'entity.type': 'container', + 'entity.displayName': 'Blade', + 'entity.id': '42', + }, + { + 'entity.lastSeenTimestamp': '2023-04-18T07:38:32.158Z', + 'entity.type': 'container', + 'entity.displayName': 'Ghost Rider', + 'entity.id': '43', + }, + { + 'entity.lastSeenTimestamp': '2024-03-16T16:36:47.704Z', + 'entity.type': 'host', + 'entity.displayName': 'Cyclops', + 'entity.id': '44', + }, + { + 'entity.lastSeenTimestamp': '2023-06-11T13:40:02.951Z', + 'entity.type': 'service', + 'entity.displayName': 'Jean Grey', + 'entity.id': '45', + }, + { + 'entity.lastSeenTimestamp': '2024-09-11T23:54:53.129Z', + 'entity.type': 'container', + 'entity.displayName': 'Storm', + 'entity.id': '46', + }, + { + 'entity.lastSeenTimestamp': '2024-03-31T15:26:58.694Z', + 'entity.type': 'host', + 'entity.displayName': 'Iceman', + 'entity.id': '47', + }, + { + 'entity.lastSeenTimestamp': '2023-01-15T05:36:56.655Z', + 'entity.type': 'host', + 'entity.displayName': 'Colossus', + 'entity.id': '48', + }, + { + 'entity.lastSeenTimestamp': '2024-06-01T22:59:08.883Z', + 'entity.type': 'service', + 'entity.displayName': 'Kitty Pryde', + 'entity.id': '49', + }, + { + 'entity.lastSeenTimestamp': '2024-04-16T21:38:10.398Z', + 'entity.type': 'container', + 'entity.displayName': 'Psylocke', + 'entity.id': '50', + }, + { + 'entity.lastSeenTimestamp': '2023-02-13T07:41:37.539Z', + 'entity.type': 'container', + 'entity.displayName': 'Rogue', + 'entity.id': '51', + }, + { + 'entity.lastSeenTimestamp': '2023-12-11T14:40:29.422Z', + 'entity.type': 'service', + 'entity.displayName': 'Professor X', + 'entity.id': '52', + }, + { + 'entity.lastSeenTimestamp': '2023-03-06T09:50:33.183Z', + 'entity.type': 'host', + 'entity.displayName': 'Magneto', + 'entity.id': '53', + }, + { + 'entity.lastSeenTimestamp': '2024-06-30T14:52:19.840Z', + 'entity.type': 'host', + 'entity.displayName': 'Quicksilver', + 'entity.id': '54', + }, + { + 'entity.lastSeenTimestamp': '2023-08-16T01:03:06.855Z', + 'entity.type': 'container', + 'entity.displayName': 'Scarlet Witch', + 'entity.id': '55', + }, + { + 'entity.lastSeenTimestamp': '2023-12-19T23:23:08.821Z', + 'entity.type': 'host', + 'entity.displayName': 'Black Bolt', + 'entity.id': '56', + }, + { + 'entity.lastSeenTimestamp': '2024-01-04T06:04:23.837Z', + 'entity.type': 'service', + 'entity.displayName': 'Medusa', + 'entity.id': '57', + }, + { + 'entity.lastSeenTimestamp': '2024-01-02T11:03:36.265Z', + 'entity.type': 'container', + 'entity.displayName': 'Crystal', + 'entity.id': '58', + }, + { + 'entity.lastSeenTimestamp': '2023-01-14T04:12:51.710Z', + 'entity.type': 'service', + 'entity.displayName': 'Karnak', + 'entity.id': '59', + }, + { + 'entity.lastSeenTimestamp': '2023-09-16T15:31:25.215Z', + 'entity.type': 'container', + 'entity.displayName': 'Gorgon', + 'entity.id': '60', + }, + { + 'entity.lastSeenTimestamp': '2023-03-19T23:21:32.571Z', + 'entity.type': 'container', + 'entity.displayName': 'Triton', + 'entity.id': '61', + }, + { + 'entity.lastSeenTimestamp': '2024-02-08T21:57:35.600Z', + 'entity.type': 'host', + 'entity.displayName': 'Lockjaw', + 'entity.id': '62', + }, + { + 'entity.lastSeenTimestamp': '2024-02-26T03:18:43.161Z', + 'entity.type': 'container', + 'entity.displayName': 'Namor', + 'entity.id': '63', + }, + { + 'entity.lastSeenTimestamp': '2024-03-13T13:39:54.430Z', + 'entity.type': 'host', + 'entity.displayName': 'Hercules', + 'entity.id': '64', + }, + { + 'entity.lastSeenTimestamp': '2024-06-15T15:57:15.557Z', + 'entity.type': 'host', + 'entity.displayName': 'Valkyrie', + 'entity.id': '65', + }, + { + 'entity.lastSeenTimestamp': '2023-09-14T15:29:09.268Z', + 'entity.type': 'host', + 'entity.displayName': 'Sif', + 'entity.id': '66', + }, + { + 'entity.lastSeenTimestamp': '2023-06-06T11:32:45.998Z', + 'entity.type': 'service', + 'entity.displayName': 'Heimdall', + 'entity.id': '67', + }, + { + 'entity.lastSeenTimestamp': '2023-06-23T20:19:29.918Z', + 'entity.type': 'container', + 'entity.displayName': 'Loki', + 'entity.id': '68', + }, + { + 'entity.lastSeenTimestamp': '2024-02-15T19:08:56.703Z', + 'entity.type': 'service', + 'entity.displayName': 'Odin', + 'entity.id': '69', + }, + { + 'entity.lastSeenTimestamp': '2024-05-05T21:13:36.761Z', + 'entity.type': 'host', + 'entity.displayName': 'Enchantress', + 'entity.id': '70', + }, + { + 'entity.lastSeenTimestamp': '2023-07-29T20:51:41.023Z', + 'entity.type': 'container', + 'entity.displayName': 'Executioner', + 'entity.id': '71', + }, + { + 'entity.lastSeenTimestamp': '2023-08-06T17:17:53.101Z', + 'entity.type': 'container', + 'entity.displayName': 'Balder', + 'entity.id': '72', + }, + { + 'entity.lastSeenTimestamp': '2023-07-03T05:18:36.705Z', + 'entity.type': 'container', + 'entity.displayName': 'Beta Ray Bill', + 'entity.id': '73', + }, + { + 'entity.lastSeenTimestamp': '2023-05-26T14:32:39.569Z', + 'entity.type': 'container', + 'entity.displayName': 'Adam Warlock', + 'entity.id': '74', + }, + { + 'entity.lastSeenTimestamp': '2023-04-22T20:19:48.018Z', + 'entity.type': 'host', + 'entity.displayName': 'Ego the Living Planet', + 'entity.id': '75', + }, + { + 'entity.lastSeenTimestamp': '2024-09-01T05:03:37.465Z', + 'entity.type': 'container', + 'entity.displayName': 'Ronan the Accuser', + 'entity.id': '76', + }, + { + 'entity.lastSeenTimestamp': '2024-03-30T18:51:01.608Z', + 'entity.type': 'service', + 'entity.displayName': 'Nebula', + 'entity.id': '77', + }, + { + 'entity.lastSeenTimestamp': '2023-08-03T00:46:22.909Z', + 'entity.type': 'container', + 'entity.displayName': 'Yondu', + 'entity.id': '78', + }, + { + 'entity.lastSeenTimestamp': '2024-03-22T19:27:42.105Z', + 'entity.type': 'container', + 'entity.displayName': 'Star-Lord', + 'entity.id': '79', + }, + { + 'entity.lastSeenTimestamp': '2023-03-01T12:52:43.009Z', + 'entity.type': 'service', + 'entity.displayName': 'Elektra', + 'entity.id': '80', + }, + { + 'entity.lastSeenTimestamp': '2024-03-01T03:35:49.365Z', + 'entity.type': 'container', + 'entity.displayName': 'Bullseye', + 'entity.id': '81', + }, + { + 'entity.lastSeenTimestamp': '2023-04-23T03:29:05.951Z', + 'entity.type': 'service', + 'entity.displayName': 'Kingpin', + 'entity.id': '82', + }, + { + 'entity.lastSeenTimestamp': '2023-08-19T14:56:49.093Z', + 'entity.type': 'container', + 'entity.displayName': 'Iron Fist', + 'entity.id': '83', + }, + { + 'entity.lastSeenTimestamp': '2023-04-17T09:03:32.311Z', + 'entity.type': 'service', + 'entity.displayName': 'Misty Knight', + 'entity.id': '84', + }, + { + 'entity.lastSeenTimestamp': '2024-06-23T06:42:12.471Z', + 'entity.type': 'service', + 'entity.displayName': 'Colleen Wing', + 'entity.id': '85', + }, + { + 'entity.lastSeenTimestamp': '2023-10-20T10:59:37.573Z', + 'entity.type': 'host', + 'entity.displayName': 'Shang-Chi', + 'entity.id': '86', + }, + { + 'entity.lastSeenTimestamp': '2024-01-18T10:07:55.134Z', + 'entity.type': 'host', + 'entity.displayName': 'Black Cat', + 'entity.id': '87', + }, + { + 'entity.lastSeenTimestamp': '2024-09-04T14:02:31.795Z', + 'entity.type': 'container', + 'entity.displayName': 'Silver Sable', + 'entity.id': '88', + }, + { + 'entity.lastSeenTimestamp': '2023-09-21T16:08:59.195Z', + 'entity.type': 'container', + 'entity.displayName': 'Spider-Woman', + 'entity.id': '89', + }, + { + 'entity.lastSeenTimestamp': '2024-07-12T00:22:45.521Z', + 'entity.type': 'container', + 'entity.displayName': 'Dr. Nick', + 'entity.id': '90', + }, + { + 'entity.lastSeenTimestamp': '2023-06-27T20:43:47.331Z', + 'entity.type': 'container', + 'entity.displayName': 'Miles Morales', + 'entity.id': '91', + }, + { + 'entity.lastSeenTimestamp': '2023-11-15T05:35:28.421Z', + 'entity.type': 'host', + 'entity.displayName': 'Spider-Girl', + 'entity.id': '92', + }, + { + 'entity.lastSeenTimestamp': '2023-07-17T13:28:37.477Z', + 'entity.type': 'container', + 'entity.displayName': 'Nova', + 'entity.id': '93', + }, + { + 'entity.lastSeenTimestamp': '2024-05-13T09:58:21.185Z', + 'entity.type': 'container', + 'entity.displayName': 'Quasar', + 'entity.id': '94', + }, + { + 'entity.lastSeenTimestamp': '2023-09-22T18:29:20.589Z', + 'entity.type': 'container', + 'entity.displayName': 'Mar-Vell', + 'entity.id': '95', + }, + { + 'entity.lastSeenTimestamp': '2024-04-29T21:33:36.318Z', + 'entity.type': 'container', + 'entity.displayName': 'Monica Rambeau', + 'entity.id': '96', + }, + { + 'entity.lastSeenTimestamp': '2024-01-10T17:12:02.785Z', + 'entity.type': 'host', + 'entity.displayName': 'Photon', + 'entity.id': '97', + }, + { + 'entity.lastSeenTimestamp': '2024-08-03T04:59:46.730Z', + 'entity.type': 'container', + 'entity.displayName': 'Blue Marvel', + 'entity.id': '98', + }, + { + 'entity.lastSeenTimestamp': '2023-04-22T05:48:54.665Z', + 'entity.type': 'host', + 'entity.displayName': 'Sentry', + 'entity.id': '99', + }, + { + 'entity.lastSeenTimestamp': '2024-05-08T05:53:56.652Z', + 'entity.type': 'host', + 'entity.displayName': 'Hyperion', + 'entity.id': '100', + }, + { + 'entity.lastSeenTimestamp': '2024-08-21T08:45:38.667Z', + 'entity.type': 'service', + 'entity.displayName': 'Nighthawk', + 'entity.id': '101', + }, + { + 'entity.lastSeenTimestamp': '2024-08-15T14:03:39.798Z', + 'entity.type': 'host', + 'entity.displayName': 'Power Princess', + 'entity.id': '102', + }, + { + 'entity.lastSeenTimestamp': '2024-05-01T13:28:15.225Z', + 'entity.type': 'service', + 'entity.displayName': 'Doctor Spectrum', + 'entity.id': '103', + }, + { + 'entity.lastSeenTimestamp': '2023-01-21T21:03:45.309Z', + 'entity.type': 'container', + 'entity.displayName': 'Speed Demon', + 'entity.id': '104', + }, + { + 'entity.lastSeenTimestamp': '2023-03-29T06:15:14.140Z', + 'entity.type': 'container', + 'entity.displayName': 'Whizzer', + 'entity.id': '105', + }, + { + 'entity.lastSeenTimestamp': '2024-01-25T09:23:14.336Z', + 'entity.type': 'container', + 'entity.displayName': 'Scarlet Spider', + 'entity.id': '106', + }, + { + 'entity.lastSeenTimestamp': '2023-08-07T16:59:31.739Z', + 'entity.type': 'host', + 'entity.displayName': 'Kaine', + 'entity.id': '107', + }, + { + 'entity.lastSeenTimestamp': '2024-03-11T20:29:44.832Z', + 'entity.type': 'host', + 'entity.displayName': 'Ben Reilly', + 'entity.id': '108', + }, + { + 'entity.lastSeenTimestamp': '2023-05-08T00:40:17.226Z', + 'entity.type': 'service', + 'entity.displayName': 'Spider-Man 2099', + 'entity.id': '109', + }, + { + 'entity.lastSeenTimestamp': '2023-01-13T19:15:54.781Z', + 'entity.type': 'service', + 'entity.displayName': 'Spider-Ham', + 'entity.id': '110', + }, + { + 'entity.lastSeenTimestamp': '2024-09-02T15:35:26.309Z', + 'entity.type': 'container', + 'entity.displayName': 'Ultimate Spider-Man', + 'entity.id': '111', + }, + { + 'entity.lastSeenTimestamp': '2023-06-04T16:08:36.902Z', + 'entity.type': 'container', + 'entity.displayName': 'Spider-Man Noir', + 'entity.id': '112', + }, + { + 'entity.lastSeenTimestamp': '2023-02-12T13:28:29.732Z', + 'entity.type': 'service', + 'entity.displayName': 'Superior Spider-Man', + 'entity.id': '113', + }, + { + 'entity.lastSeenTimestamp': '2023-08-16T08:54:36.219Z', + 'entity.type': 'service', + 'entity.displayName': 'Agent Venom', + 'entity.id': '114', + }, + { + 'entity.lastSeenTimestamp': '2023-02-23T12:58:57.715Z', + 'entity.type': 'container', + 'entity.displayName': 'Venom', + 'entity.id': '115', + }, + { + 'entity.lastSeenTimestamp': '2023-06-19T18:17:35.424Z', + 'entity.type': 'container', + 'entity.displayName': 'Carnage', + 'entity.id': '116', + }, + { + 'entity.lastSeenTimestamp': '2024-05-02T11:58:44.239Z', + 'entity.type': 'service', + 'entity.displayName': 'Toxin', + 'entity.id': '117', + }, + { + 'entity.lastSeenTimestamp': '2023-12-27T14:15:59.641Z', + 'entity.type': 'host', + 'entity.displayName': 'Anti-Venom', + 'entity.id': '118', + }, + { + 'entity.lastSeenTimestamp': '2024-01-10T15:23:44.536Z', + 'entity.type': 'container', + 'entity.displayName': 'Morbius', + 'entity.id': '119', + }, + { + 'entity.lastSeenTimestamp': '2023-11-26T01:04:11.090Z', + 'entity.type': 'service', + 'entity.displayName': 'Kraven the Hunter', + 'entity.id': '120', + }, + { + 'entity.lastSeenTimestamp': '2024-02-21T04:11:13.221Z', + 'entity.type': 'container', + 'entity.displayName': 'The Lizard', + 'entity.id': '121', + }, + { + 'entity.lastSeenTimestamp': '2023-12-31T07:29:14.344Z', + 'entity.type': 'service', + 'entity.displayName': 'Sandman', + 'entity.id': '122', + }, + { + 'entity.lastSeenTimestamp': '2024-06-02T11:20:40.793Z', + 'entity.type': 'host', + 'entity.displayName': 'Rhino', + 'entity.id': '123', + }, + { + 'entity.lastSeenTimestamp': '2023-04-02T14:31:44.296Z', + 'entity.type': 'host', + 'entity.displayName': 'Shocker', + 'entity.id': '124', + }, + { + 'entity.lastSeenTimestamp': '2024-06-10T12:26:05.411Z', + 'entity.type': 'container', + 'entity.displayName': 'Vulture', + 'entity.id': '125', + }, + { + 'entity.lastSeenTimestamp': '2023-06-27T16:17:19.611Z', + 'entity.type': 'container', + 'entity.displayName': 'Mysterio', + 'entity.id': '126', + }, + { + 'entity.lastSeenTimestamp': '2023-08-29T04:54:25.898Z', + 'entity.type': 'service', + 'entity.displayName': 'Scorpion', + 'entity.id': '127', + }, + { + 'entity.lastSeenTimestamp': '2023-01-17T21:39:41.265Z', + 'entity.type': 'host', + 'entity.displayName': 'Chameleon', + 'entity.id': '128', + }, + { + 'entity.lastSeenTimestamp': '2023-06-07T03:03:11.032Z', + 'entity.type': 'host', + 'entity.displayName': 'Green Goblin', + 'entity.id': '129', + }, + { + 'entity.lastSeenTimestamp': '2023-05-19T19:18:21.005Z', + 'entity.type': 'service', + 'entity.displayName': 'Hobgoblin', + 'entity.id': '130', + }, + { + 'entity.lastSeenTimestamp': '2023-08-03T20:45:51.404Z', + 'entity.type': 'host', + 'entity.displayName': 'Demogoblin', + 'entity.id': '131', + }, + { + 'entity.lastSeenTimestamp': '2024-01-11T06:14:51.570Z', + 'entity.type': 'service', + 'entity.displayName': 'Red Goblin', + 'entity.id': '132', + }, + { + 'entity.lastSeenTimestamp': '2024-03-27T11:07:02.657Z', + 'entity.type': 'host', + 'entity.displayName': 'Doctor Octopus', + 'entity.id': '133', + }, + { + 'entity.lastSeenTimestamp': '2023-08-17T08:42:02.024Z', + 'entity.type': 'container', + 'entity.displayName': 'Electro', + 'entity.id': '134', + }, + { + 'entity.lastSeenTimestamp': '2023-07-02T16:02:17.438Z', + 'entity.type': 'container', + 'entity.displayName': 'Kingpin', + 'entity.id': '135', + }, + { + 'entity.lastSeenTimestamp': '2024-05-17T22:14:53.375Z', + 'entity.type': 'host', + 'entity.displayName': 'Tombstone', + 'entity.id': '136', + }, + { + 'entity.lastSeenTimestamp': '2023-05-30T09:26:45.647Z', + 'entity.type': 'service', + 'entity.displayName': 'Hammerhead', + 'entity.id': '137', + }, + { + 'entity.lastSeenTimestamp': '2024-09-08T03:21:22.494Z', + 'entity.type': 'host', + 'entity.displayName': 'Silvermane', + 'entity.id': '138', + }, + { + 'entity.lastSeenTimestamp': '2023-06-26T06:23:45.305Z', + 'entity.type': 'host', + 'entity.displayName': 'Hydro-Man', + 'entity.id': '139', + }, + { + 'entity.lastSeenTimestamp': '2024-08-15T13:29:01.603Z', + 'entity.type': 'host', + 'entity.displayName': 'Molten Man', + 'entity.id': '140', + }, + { + 'entity.lastSeenTimestamp': '2023-06-21T04:25:12.371Z', + 'entity.type': 'container', + 'entity.displayName': 'Morlun', + 'entity.id': '141', + }, + { + 'entity.lastSeenTimestamp': '2023-11-01T02:59:06.998Z', + 'entity.type': 'host', + 'entity.displayName': 'The Jackal', + 'entity.id': '142', + }, + { + 'entity.lastSeenTimestamp': '2023-06-25T15:27:39.801Z', + 'entity.type': 'service', + 'entity.displayName': 'Alistair Smythe', + 'entity.id': '143', + }, + { + 'entity.lastSeenTimestamp': '2023-12-07T19:13:02.711Z', + 'entity.type': 'service', + 'entity.displayName': 'The Beetle', + 'entity.id': '144', + }, + { + 'entity.lastSeenTimestamp': '2024-04-13T14:16:24.875Z', + 'entity.type': 'host', + 'entity.displayName': 'The Prowler', + 'entity.id': '145', + }, + { + 'entity.lastSeenTimestamp': '2023-11-02T20:25:05.117Z', + 'entity.type': 'host', + 'entity.displayName': 'Tarantula', + 'entity.id': '146', + }, + { + 'entity.lastSeenTimestamp': '2023-04-12T19:09:48.881Z', + 'entity.type': 'service', + 'entity.displayName': 'Black Tarantula', + 'entity.id': '147', + }, + { + 'entity.lastSeenTimestamp': '2024-01-25T01:37:16.115Z', + 'entity.type': 'host', + 'entity.displayName': 'White Tiger', + 'entity.id': '148', + }, + { + 'entity.lastSeenTimestamp': '2023-12-20T12:27:21.819Z', + 'entity.type': 'service', + 'entity.displayName': 'Nightcrawler', + 'entity.id': '149', + }, + { + 'entity.lastSeenTimestamp': '2024-06-11T05:30:01.226Z', + 'entity.type': 'container', + 'entity.displayName': 'Bishop', + 'entity.id': '150', + }, + { + 'entity.lastSeenTimestamp': '2023-09-24T00:18:40.137Z', + 'entity.type': 'service', + 'entity.displayName': 'Cable', + 'entity.id': '151', + }, + { + 'entity.lastSeenTimestamp': '2024-04-24T03:28:16.162Z', + 'entity.type': 'host', + 'entity.displayName': 'Domino', + 'entity.id': '152', + }, + { + 'entity.lastSeenTimestamp': '2023-04-08T07:23:33.921Z', + 'entity.type': 'host', + 'entity.displayName': 'Warpath', + 'entity.id': '153', + }, + { + 'entity.lastSeenTimestamp': '2023-04-12T23:26:45.533Z', + 'entity.type': 'service', + 'entity.displayName': 'Sunspot', + 'entity.id': '154', + }, + { + 'entity.lastSeenTimestamp': '2024-05-18T14:28:01.751Z', + 'entity.type': 'container', + 'entity.displayName': 'Cannonball', + 'entity.id': '155', + }, + { + 'entity.lastSeenTimestamp': '2023-03-14T17:08:06.243Z', + 'entity.type': 'container', + 'entity.displayName': 'Wolfsbane', + 'entity.id': '156', + }, + { + 'entity.lastSeenTimestamp': '2024-02-25T23:18:49.867Z', + 'entity.type': 'service', + 'entity.displayName': 'Magik', + 'entity.id': '157', + }, + { + 'entity.lastSeenTimestamp': '2024-07-14T14:31:58.080Z', + 'entity.type': 'container', + 'entity.displayName': 'Colossus', + 'entity.id': '158', + }, + { + 'entity.lastSeenTimestamp': '2023-05-09T22:32:41.723Z', + 'entity.type': 'container', + 'entity.displayName': 'Omega Red', + 'entity.id': '159', + }, + { + 'entity.lastSeenTimestamp': '2023-05-21T10:33:50.732Z', + 'entity.type': 'host', + 'entity.displayName': 'Juggernaut', + 'entity.id': '160', + }, + { + 'entity.lastSeenTimestamp': '2024-05-01T07:27:51.647Z', + 'entity.type': 'host', + 'entity.displayName': 'Sebastian Shaw', + 'entity.id': '161', + }, + { + 'entity.lastSeenTimestamp': '2024-01-25T09:47:54.565Z', + 'entity.type': 'service', + 'entity.displayName': 'Emma Frost', + 'entity.id': '162', + }, + { + 'entity.lastSeenTimestamp': '2023-10-25T15:51:18.513Z', + 'entity.type': 'host', + 'entity.displayName': 'Mystique', + 'entity.id': '163', + }, + { + 'entity.lastSeenTimestamp': '2023-03-27T07:26:04.804Z', + 'entity.type': 'service', + 'entity.displayName': 'Sabretooth', + 'entity.id': '164', + }, + { + 'entity.lastSeenTimestamp': '2024-07-22T15:29:51.446Z', + 'entity.type': 'host', + 'entity.displayName': 'Pyro', + 'entity.id': '165', + }, + { + 'entity.lastSeenTimestamp': '2024-06-26T09:09:57.169Z', + 'entity.type': 'host', + 'entity.displayName': 'Avalanche', + 'entity.id': '166', + }, + { + 'entity.lastSeenTimestamp': '2023-10-27T05:14:15.279Z', + 'entity.type': 'container', + 'entity.displayName': 'Destiny', + 'entity.id': '167', + }, + { + 'entity.lastSeenTimestamp': '2023-03-08T00:40:52.990Z', + 'entity.type': 'service', + 'entity.displayName': 'Forge', + 'entity.id': '168', + }, + { + 'entity.lastSeenTimestamp': '2023-11-05T16:40:30.510Z', + 'entity.type': 'host', + 'entity.displayName': 'Polaris', + 'entity.id': '169', + }, + { + 'entity.lastSeenTimestamp': '2024-08-12T05:04:27.632Z', + 'entity.type': 'service', + 'entity.displayName': 'Havok', + 'entity.id': '170', + }, + { + 'entity.lastSeenTimestamp': '2023-01-29T18:30:34.000Z', + 'entity.type': 'service', + 'entity.displayName': 'Multiple Man', + 'entity.id': '171', + }, + { + 'entity.lastSeenTimestamp': '2023-12-18T13:11:26.940Z', + 'entity.type': 'service', + 'entity.displayName': 'Strong Guy', + 'entity.id': '172', + }, + { + 'entity.lastSeenTimestamp': '2024-01-07T16:44:23.323Z', + 'entity.type': 'container', + 'entity.displayName': 'Feral', + 'entity.id': '173', + }, + { + 'entity.lastSeenTimestamp': '2023-03-19T22:38:34.493Z', + 'entity.type': 'container', + 'entity.displayName': 'Boom Boom', + 'entity.id': '174', + }, + { + 'entity.lastSeenTimestamp': '2023-05-31T07:23:57.500Z', + 'entity.type': 'container', + 'entity.displayName': 'Warlock', + 'entity.id': '175', + }, + { + 'entity.lastSeenTimestamp': '2024-07-11T01:57:10.851Z', + 'entity.type': 'host', + 'entity.displayName': 'Magus', + 'entity.id': '176', + }, + { + 'entity.lastSeenTimestamp': '2024-05-22T12:50:09.849Z', + 'entity.type': 'container', + 'entity.displayName': 'Blink', + 'entity.id': '177', + }, + { + 'entity.lastSeenTimestamp': '2023-10-20T11:56:09.004Z', + 'entity.type': 'service', + 'entity.displayName': 'Nocturne', + 'entity.id': '178', + }, + { + 'entity.lastSeenTimestamp': '2023-02-08T06:47:37.958Z', + 'entity.type': 'host', + 'entity.displayName': 'Morph', + 'entity.id': '179', + }, + { + 'entity.lastSeenTimestamp': '2023-09-16T01:14:58.701Z', + 'entity.type': 'host', + 'entity.displayName': 'Sunfire', + 'entity.id': '180', + }, + { + 'entity.lastSeenTimestamp': '2023-12-05T19:56:38.483Z', + 'entity.type': 'service', + 'entity.displayName': 'Thunderbird', + 'entity.id': '181', + }, + { + 'entity.lastSeenTimestamp': '2024-01-21T04:49:41.995Z', + 'entity.type': 'host', + 'entity.displayName': 'Banshee', + 'entity.id': '182', + }, + { + 'entity.lastSeenTimestamp': '2023-10-19T02:58:03.939Z', + 'entity.type': 'container', + 'entity.displayName': 'Syrin', + 'entity.id': '183', + }, + { + 'entity.lastSeenTimestamp': '2023-05-21T14:13:08.847Z', + 'entity.type': 'host', + 'entity.displayName': 'Moira MacTaggert', + 'entity.id': '184', + }, + { + 'entity.lastSeenTimestamp': '2024-02-09T05:57:59.984Z', + 'entity.type': 'container', + 'entity.displayName': 'Angel', + 'entity.id': '185', + }, + { + 'entity.lastSeenTimestamp': '2024-05-05T04:42:36.419Z', + 'entity.type': 'service', + 'entity.displayName': 'Archangel', + 'entity.id': '186', + }, + { + 'entity.lastSeenTimestamp': '2024-02-10T11:42:58.833Z', + 'entity.type': 'service', + 'entity.displayName': 'Iceman', + 'entity.id': '187', + }, + { + 'entity.lastSeenTimestamp': '2024-07-25T19:55:46.838Z', + 'entity.type': 'host', + 'entity.displayName': 'Beast', + 'entity.id': '188', + }, + { + 'entity.lastSeenTimestamp': '2024-09-11T05:07:10.339Z', + 'entity.type': 'host', + 'entity.displayName': 'Nightcrawler', + 'entity.id': '189', + }, + { + 'entity.lastSeenTimestamp': '2023-10-19T15:59:49.360Z', + 'entity.type': 'service', + 'entity.displayName': 'Phoenix', + 'entity.id': '190', + }, + { + 'entity.lastSeenTimestamp': '2024-09-07T17:32:26.019Z', + 'entity.type': 'host', + 'entity.displayName': 'X-Man', + 'entity.id': '191', + }, + { + 'entity.lastSeenTimestamp': '2023-07-13T12:49:11.603Z', + 'entity.type': 'container', + 'entity.displayName': 'Cable', + 'entity.id': '192', + }, + { + 'entity.lastSeenTimestamp': '2024-05-19T21:32:30.970Z', + 'entity.type': 'service', + 'entity.displayName': 'Deadpool', + 'entity.id': '193', + }, + { + 'entity.lastSeenTimestamp': '2023-12-12T00:33:27.870Z', + 'entity.type': 'host', + 'entity.displayName': 'Domino', + 'entity.id': '194', + }, + { + 'entity.lastSeenTimestamp': '2023-08-26T18:34:55.709Z', + 'entity.type': 'host', + 'entity.displayName': 'Shatterstar', + 'entity.id': '195', + }, + { + 'entity.lastSeenTimestamp': '2024-08-05T13:02:27.932Z', + 'entity.type': 'service', + 'entity.displayName': 'Warpath', + 'entity.id': '196', + }, + { + 'entity.lastSeenTimestamp': '2023-08-08T08:09:37.053Z', + 'entity.type': 'service', + 'entity.displayName': 'Rictor', + 'entity.id': '197', + }, + { + 'entity.lastSeenTimestamp': '2024-07-18T17:17:22.628Z', + 'entity.type': 'service', + 'entity.displayName': 'Boom Boom', + 'entity.id': '198', + }, + { + 'entity.lastSeenTimestamp': '2023-06-19T20:45:15.240Z', + 'entity.type': 'host', + 'entity.displayName': 'Magik', + 'entity.id': '199', + }, + { + 'entity.lastSeenTimestamp': '2023-07-29T15:18:44.936Z', + 'entity.type': 'container', + 'entity.displayName': 'Cannonball', + 'entity.id': '200', + }, + { + 'entity.lastSeenTimestamp': '2023-02-08T01:26:18.603Z', + 'entity.type': 'host', + 'entity.displayName': 'Sunspot', + 'entity.id': '201', + }, + { + 'entity.lastSeenTimestamp': '2023-02-22T16:06:39.387Z', + 'entity.type': 'service', + 'entity.displayName': 'Banshee', + 'entity.id': '202', + }, + { + 'entity.lastSeenTimestamp': '2023-04-27T03:32:37.015Z', + 'entity.type': 'host', + 'entity.displayName': 'Thunderbird', + 'entity.id': '203', + }, + { + 'entity.lastSeenTimestamp': '2023-09-08T13:07:04.895Z', + 'entity.type': 'service', + 'entity.displayName': 'X-23', + 'entity.id': '204', + }, + { + 'entity.lastSeenTimestamp': '2024-02-08T06:28:33.208Z', + 'entity.type': 'container', + 'entity.displayName': 'Daken', + 'entity.id': '205', + }, + { + 'entity.lastSeenTimestamp': '2024-01-19T19:28:19.416Z', + 'entity.type': 'host', + 'entity.displayName': 'Laura Kinney', + 'entity.id': '206', + }, + { + 'entity.lastSeenTimestamp': '2024-01-29T07:33:26.920Z', + 'entity.type': 'service', + 'entity.displayName': 'Jubilee', + 'entity.id': '207', + }, + { + 'entity.lastSeenTimestamp': '2023-02-20T10:19:34.322Z', + 'entity.type': 'host', + 'entity.displayName': 'Stepford Cuckoos', + 'entity.id': '208', + }, + { + 'entity.lastSeenTimestamp': '2024-06-03T03:31:08.704Z', + 'entity.type': 'service', + 'entity.displayName': 'Fantomex', + 'entity.id': '209', + }, + { + 'entity.lastSeenTimestamp': '2023-10-30T18:18:12.254Z', + 'entity.type': 'container', + 'entity.displayName': 'Marrow', + 'entity.id': '210', + }, + { + 'entity.lastSeenTimestamp': '2024-03-19T23:47:02.611Z', + 'entity.type': 'service', + 'entity.displayName': 'Pixie', + 'entity.id': '211', + }, + { + 'entity.lastSeenTimestamp': '2023-08-08T06:03:05.326Z', + 'entity.type': 'host', + 'entity.displayName': 'Armor', + 'entity.id': '212', + }, + { + 'entity.lastSeenTimestamp': '2023-04-05T11:25:37.426Z', + 'entity.type': 'service', + 'entity.displayName': 'Gentle', + 'entity.id': '213', + }, + { + 'entity.lastSeenTimestamp': '2023-01-10T22:18:30.812Z', + 'entity.type': 'container', + 'entity.displayName': 'Anole', + 'entity.id': '214', + }, + { + 'entity.lastSeenTimestamp': '2024-07-17T06:09:51.763Z', + 'entity.type': 'host', + 'entity.displayName': 'Rockslide', + 'entity.id': '215', + }, + { + 'entity.lastSeenTimestamp': '2024-02-02T00:44:56.270Z', + 'entity.type': 'host', + 'entity.displayName': 'Dust', + 'entity.id': '216', + }, + { + 'entity.lastSeenTimestamp': '2023-03-09T19:37:54.235Z', + 'entity.type': 'host', + 'entity.displayName': 'Mercury', + 'entity.id': '217', + }, + { + 'entity.lastSeenTimestamp': '2024-06-14T09:51:29.579Z', + 'entity.type': 'service', + 'entity.displayName': 'Surge', + 'entity.id': '218', + }, + { + 'entity.lastSeenTimestamp': '2024-03-12T17:28:48.254Z', + 'entity.type': 'host', + 'entity.displayName': 'Hellion', + 'entity.id': '219', + }, + { + 'entity.lastSeenTimestamp': '2023-04-09T07:19:02.429Z', + 'entity.type': 'service', + 'entity.displayName': 'Elixir', + 'entity.id': '220', + }, + { + 'entity.lastSeenTimestamp': '2024-05-10T08:28:21.025Z', + 'entity.type': 'host', + 'entity.displayName': 'X-23', + 'entity.id': '221', + }, + { + 'entity.lastSeenTimestamp': '2023-05-01T16:23:41.343Z', + 'entity.type': 'host', + 'entity.displayName': 'Prodigy', + 'entity.id': '222', + }, + { + 'entity.lastSeenTimestamp': '2023-02-03T07:17:47.909Z', + 'entity.type': 'container', + 'entity.displayName': 'Blindfold', + 'entity.id': '223', + }, + { + 'entity.lastSeenTimestamp': '2023-06-15T00:56:00.094Z', + 'entity.type': 'service', + 'entity.displayName': 'Ink', + 'entity.id': '224', + }, + { + 'entity.lastSeenTimestamp': '2024-04-28T22:32:11.149Z', + 'entity.type': 'container', + 'entity.displayName': 'Goldballs', + 'entity.id': '225', + }, + { + 'entity.lastSeenTimestamp': '2023-10-14T04:34:56.973Z', + 'entity.type': 'service', + 'entity.displayName': 'Magneto', + 'entity.id': '226', + }, + { + 'entity.lastSeenTimestamp': '2024-08-20T08:01:12.156Z', + 'entity.type': 'host', + 'entity.displayName': 'Juggernaut', + 'entity.id': '227', + }, + { + 'entity.lastSeenTimestamp': '2023-12-31T15:27:41.198Z', + 'entity.type': 'host', + 'entity.displayName': 'Mystique', + 'entity.id': '228', + }, + { + 'entity.lastSeenTimestamp': '2024-03-06T04:31:14.001Z', + 'entity.type': 'service', + 'entity.displayName': 'Sabretooth', + 'entity.id': '229', + }, + { + 'entity.lastSeenTimestamp': '2024-03-26T05:07:12.552Z', + 'entity.type': 'host', + 'entity.displayName': 'Toad', + 'entity.id': '230', + }, + { + 'entity.lastSeenTimestamp': '2024-05-20T17:34:56.098Z', + 'entity.type': 'service', + 'entity.displayName': 'Pyro', + 'entity.id': '231', + }, + { + 'entity.lastSeenTimestamp': '2023-04-12T16:53:27.530Z', + 'entity.type': 'host', + 'entity.displayName': 'Avalanche', + 'entity.id': '232', + }, + { + 'entity.lastSeenTimestamp': '2023-02-21T16:26:36.731Z', + 'entity.type': 'container', + 'entity.displayName': 'Blob', + 'entity.id': '233', + }, + { + 'entity.lastSeenTimestamp': '2023-03-23T03:52:18.017Z', + 'entity.type': 'host', + 'entity.displayName': 'Sauron', + 'entity.id': '234', + }, + { + 'entity.lastSeenTimestamp': '2024-04-10T21:31:37.929Z', + 'entity.type': 'container', + 'entity.displayName': 'Omega Red', + 'entity.id': '235', + }, + { + 'entity.lastSeenTimestamp': '2024-02-28T14:35:09.897Z', + 'entity.type': 'service', + 'entity.displayName': 'Mr. Sinister', + 'entity.id': '236', + }, + { + 'entity.lastSeenTimestamp': '2024-01-24T09:05:06.205Z', + 'entity.type': 'host', + 'entity.displayName': 'Apocalypse', + 'entity.id': '237', + }, + { + 'entity.lastSeenTimestamp': '2024-02-05T23:30:26.586Z', + 'entity.type': 'service', + 'entity.displayName': 'Genesis', + 'entity.id': '238', + }, + { + 'entity.lastSeenTimestamp': '2023-04-18T11:46:41.466Z', + 'entity.type': 'service', + 'entity.displayName': 'Archangel', + 'entity.id': '239', + }, + { + 'entity.lastSeenTimestamp': '2023-02-28T02:10:52.053Z', + 'entity.type': 'host', + 'entity.displayName': 'Holocaust', + 'entity.id': '240', + }, + { + 'entity.lastSeenTimestamp': '2024-04-03T12:19:13.947Z', + 'entity.type': 'host', + 'entity.displayName': 'Onslaught', + 'entity.id': '241', + }, + { + 'entity.lastSeenTimestamp': '2023-01-20T01:37:07.489Z', + 'entity.type': 'host', + 'entity.displayName': 'Exodus', + 'entity.id': '242', + }, + { + 'entity.lastSeenTimestamp': '2023-10-09T07:32:39.074Z', + 'entity.type': 'container', + 'entity.displayName': 'Gambit', + 'entity.id': '243', + }, + { + 'entity.lastSeenTimestamp': '2024-07-21T15:09:00.494Z', + 'entity.type': 'host', + 'entity.displayName': 'Rogue', + 'entity.id': '244', + }, + { + 'entity.lastSeenTimestamp': '2024-08-03T14:58:05.875Z', + 'entity.type': 'service', + 'entity.displayName': 'Magneto', + 'entity.id': '245', + }, + { + 'entity.lastSeenTimestamp': '2024-02-08T04:32:33.334Z', + 'entity.type': 'container', + 'entity.displayName': 'Longshot', + 'entity.id': '246', + }, + { + 'entity.lastSeenTimestamp': '2023-03-18T10:37:49.383Z', + 'entity.type': 'service', + 'entity.displayName': 'Dazzler', + 'entity.id': '247', + }, + { + 'entity.lastSeenTimestamp': '2024-07-11T17:35:31.669Z', + 'entity.type': 'service', + 'entity.displayName': 'Forge', + 'entity.id': '248', + }, + { + 'entity.lastSeenTimestamp': '2024-08-23T15:01:17.593Z', + 'entity.type': 'host', + 'entity.displayName': 'Mojo', + 'entity.id': '249', + }, + { + 'entity.lastSeenTimestamp': '2023-06-27T15:34:23.105Z', + 'entity.type': 'service', + 'entity.displayName': 'Spiral', + 'entity.id': '250', + }, + { + 'entity.lastSeenTimestamp': '2024-03-19T08:06:40.658Z', + 'entity.type': 'container', + 'entity.displayName': 'Warlock', + 'entity.id': '251', + }, + { + 'entity.lastSeenTimestamp': '2023-08-19T12:10:02.477Z', + 'entity.type': 'container', + 'entity.displayName': 'Magus', + 'entity.id': '252', + }, + { + 'entity.lastSeenTimestamp': '2024-05-30T12:20:06.653Z', + 'entity.type': 'service', + 'entity.displayName': 'Douglock', + 'entity.id': '253', + }, + { + 'entity.lastSeenTimestamp': '2023-05-31T17:48:07.719Z', + 'entity.type': 'service', + 'entity.displayName': 'Shatterstar', + 'entity.id': '254', + }, + { + 'entity.lastSeenTimestamp': '2023-12-10T05:35:40.666Z', + 'entity.type': 'service', + 'entity.displayName': 'Rictor', + 'entity.id': '255', + }, + { + 'entity.lastSeenTimestamp': '2024-02-02T22:18:47.168Z', + 'entity.type': 'host', + 'entity.displayName': 'Domino', + 'entity.id': '256', + }, + { + 'entity.lastSeenTimestamp': '2024-01-07T22:07:45.968Z', + 'entity.type': 'container', + 'entity.displayName': 'Cable', + 'entity.id': '257', + }, + { + 'entity.lastSeenTimestamp': '2023-01-15T11:22:54.155Z', + 'entity.type': 'host', + 'entity.displayName': 'Hope Summers', + 'entity.id': '258', + }, + { + 'entity.lastSeenTimestamp': '2023-03-26T13:56:10.553Z', + 'entity.type': 'service', + 'entity.displayName': 'Deadpool', + 'entity.id': '259', + }, + { + 'entity.lastSeenTimestamp': '2023-08-15T19:17:34.583Z', + 'entity.type': 'service', + 'entity.displayName': 'X-23', + 'entity.id': '260', + }, + { + 'entity.lastSeenTimestamp': '2024-06-26T09:02:40.512Z', + 'entity.type': 'host', + 'entity.displayName': 'Daken', + 'entity.id': '261', + }, + { + 'entity.lastSeenTimestamp': '2024-07-07T09:01:04.091Z', + 'entity.type': 'host', + 'entity.displayName': 'Wolverine', + 'entity.id': '262', + }, + { + 'entity.lastSeenTimestamp': '2023-10-15T22:25:29.643Z', + 'entity.type': 'service', + 'entity.displayName': 'Old Man Logan', + 'entity.id': '263', + }, + { + 'entity.lastSeenTimestamp': '2024-07-07T04:51:19.761Z', + 'entity.type': 'container', + 'entity.displayName': 'The Maker', + 'entity.id': '264', + }, + { + 'entity.lastSeenTimestamp': '2024-03-13T14:00:51.289Z', + 'entity.type': 'container', + 'entity.displayName': 'Ultimate Thor', + 'entity.id': '265', + }, + { + 'entity.lastSeenTimestamp': '2023-07-23T10:13:07.651Z', + 'entity.type': 'service', + 'entity.displayName': 'Ultimate Iron Man', + 'entity.id': '266', + }, + { + 'entity.lastSeenTimestamp': '2023-08-20T07:09:20.148Z', + 'entity.type': 'container', + 'entity.displayName': 'Ultimate Hulk', + 'entity.id': '267', + }, + { + 'entity.lastSeenTimestamp': '2024-09-08T10:53:13.256Z', + 'entity.type': 'service', + 'entity.displayName': 'Ultimate Captain America', + 'entity.id': '268', + }, + { + 'entity.lastSeenTimestamp': '2024-09-15T03:57:28.175Z', + 'entity.type': 'container', + 'entity.displayName': + 'Sed dignissim libero a diam sagittis, in convallis leo pellentesque. Cras ut sapien sed lacus scelerisque vehicula. Pellentesque at purus pulvinar, mollis justo hendrerit, pharetra purus. Morbi dapibus, augue et volutpat ultricies, neque quam sollicitudin mauris, vitae luctus ex libero id erat. Suspendisse risus lectus, scelerisque vel odio sed.', + 'entity.id': '269', + }, + { + 'entity.lastSeenTimestamp': '2023-10-22T13:49:53.092Z', + 'entity.type': 'host', + 'entity.displayName': 'Silk', + 'entity.id': '270', + }, + { + 'entity.lastSeenTimestamp': '2023-01-13T00:36:25.773Z', + 'entity.type': 'host', + 'entity.displayName': 'Scarlet Spider', + 'entity.id': '271', + }, + { + 'entity.lastSeenTimestamp': '2023-12-10T19:31:42.994Z', + 'entity.type': 'service', + 'entity.displayName': 'Ben Reilly', + 'entity.id': '272', + }, + { + 'entity.lastSeenTimestamp': '2023-01-17T09:49:30.447Z', + 'entity.type': 'service', + 'entity.displayName': 'Miles Morales', + 'entity.id': '273', + }, + { + 'entity.lastSeenTimestamp': '2023-01-02T18:45:44.012Z', + 'entity.type': 'container', + 'entity.displayName': 'Spider-Ham', + 'entity.id': '274', + }, + { + 'entity.lastSeenTimestamp': '2023-06-28T22:50:08.414Z', + 'entity.type': 'container', + 'entity.displayName': 'Agent Venom', + 'entity.id': '275', + }, + { + 'entity.lastSeenTimestamp': '2023-03-30T17:01:35.995Z', + 'entity.type': 'service', + 'entity.displayName': 'Anti-Venom', + 'entity.id': '276', + }, + { + 'entity.lastSeenTimestamp': '2023-06-11T05:23:11.367Z', + 'entity.type': 'host', + 'entity.displayName': 'Toxin', + 'entity.id': '277', + }, + { + 'entity.lastSeenTimestamp': '2023-07-22T15:27:17.077Z', + 'entity.type': 'service', + 'entity.displayName': 'Morbius', + 'entity.id': '278', + }, + { + 'entity.lastSeenTimestamp': '2024-01-26T11:19:34.147Z', + 'entity.type': 'host', + 'entity.displayName': 'Kraven the Hunter', + 'entity.id': '279', + }, + { + 'entity.lastSeenTimestamp': '2024-06-18T09:03:01.111Z', + 'entity.type': 'container', + 'entity.displayName': 'Doctor Octopus', + 'entity.id': '280', + }, + { + 'entity.lastSeenTimestamp': '2024-07-27T14:08:12.583Z', + 'entity.type': 'container', + 'entity.displayName': 'Green Goblin', + 'entity.id': '281', + }, + { + 'entity.lastSeenTimestamp': '2023-01-12T01:38:45.243Z', + 'entity.type': 'host', + 'entity.displayName': 'Electro', + 'entity.id': '282', + }, + { + 'entity.lastSeenTimestamp': '2024-04-19T05:33:59.289Z', + 'entity.type': 'container', + 'entity.displayName': 'Rhino', + 'entity.id': '283', + }, + { + 'entity.lastSeenTimestamp': '2023-04-13T22:06:02.389Z', + 'entity.type': 'service', + 'entity.displayName': 'Shocker', + 'entity.id': '284', + }, + { + 'entity.lastSeenTimestamp': '2023-01-26T15:36:08.782Z', + 'entity.type': 'host', + 'entity.displayName': 'Vulture', + 'entity.id': '285', + }, + { + 'entity.lastSeenTimestamp': '2023-11-11T19:54:14.523Z', + 'entity.type': 'container', + 'entity.displayName': 'Sandman', + 'entity.id': '286', + }, + { + 'entity.lastSeenTimestamp': '2023-12-06T06:20:06.995Z', + 'entity.type': 'host', + 'entity.displayName': 'Mysterio', + 'entity.id': '287', + }, + { + 'entity.lastSeenTimestamp': '2023-07-23T04:30:35.686Z', + 'entity.type': 'service', + 'entity.displayName': 'Black Cat', + 'entity.id': '288', + }, + { + 'entity.lastSeenTimestamp': '2023-01-18T03:09:26.047Z', + 'entity.type': 'host', + 'entity.displayName': 'Silver Sable', + 'entity.id': '289', + }, + { + 'entity.lastSeenTimestamp': '2024-06-08T12:42:08.485Z', + 'entity.type': 'service', + 'entity.displayName': 'Chameleon', + 'entity.id': '290', + }, + { + 'entity.lastSeenTimestamp': '2023-08-18T03:34:28.230Z', + 'entity.type': 'container', + 'entity.displayName': 'Hammerhead', + 'entity.id': '291', + }, + { + 'entity.lastSeenTimestamp': '2024-04-13T01:42:03.890Z', + 'entity.type': 'container', + 'entity.displayName': 'Tombstone', + 'entity.id': '292', + }, + { + 'entity.lastSeenTimestamp': '2023-11-21T17:39:56.066Z', + 'entity.type': 'container', + 'entity.displayName': 'Alistair Smythe', + 'entity.id': '293', + }, + { + 'entity.lastSeenTimestamp': '2024-02-29T04:45:41.113Z', + 'entity.type': 'host', + 'entity.displayName': 'The Beetle', + 'entity.id': '294', + }, + { + 'entity.lastSeenTimestamp': '2024-08-12T07:40:35.827Z', + 'entity.type': 'host', + 'entity.displayName': 'The Prowler', + 'entity.id': '295', + }, + { + 'entity.lastSeenTimestamp': '2023-11-27T23:09:49.629Z', + 'entity.type': 'service', + 'entity.displayName': 'Scorpion', + 'entity.id': '296', + }, + { + 'entity.lastSeenTimestamp': '2024-08-29T21:24:37.304Z', + 'entity.type': 'container', + 'entity.displayName': 'Jackal', + 'entity.id': '297', + }, + { + 'entity.lastSeenTimestamp': '2023-03-25T03:08:42.970Z', + 'entity.type': 'container', + 'entity.displayName': 'Morlun', + 'entity.id': '298', + }, + { + 'entity.lastSeenTimestamp': '2023-12-12T01:01:52.801Z', + 'entity.type': 'container', + 'entity.displayName': 'Lizard', + 'entity.id': '299', + }, + { + 'entity.lastSeenTimestamp': '2024-02-22T02:29:11.333Z', + 'entity.type': 'service', + 'entity.displayName': 'Kingpin', + 'entity.id': '300', + }, + { + 'entity.lastSeenTimestamp': '2024-09-03T19:31:38.700Z', + 'entity.type': 'host', + 'entity.displayName': 'Carnage', + 'entity.id': '301', + }, + { + 'entity.lastSeenTimestamp': '2023-04-09T17:55:20.565Z', + 'entity.type': 'container', + 'entity.displayName': 'Norman Osborn', + 'entity.id': '302', + }, + { + 'entity.lastSeenTimestamp': '2023-11-15T11:23:39.657Z', + 'entity.type': 'container', + 'entity.displayName': 'Harry Osborn', + 'entity.id': '303', + }, + { + 'entity.lastSeenTimestamp': '2024-08-16T08:14:11.415Z', + 'entity.type': 'service', + 'entity.displayName': 'Hobgoblin', + 'entity.id': '304', + }, + { + 'entity.lastSeenTimestamp': '2023-04-09T06:48:50.111Z', + 'entity.type': 'container', + 'entity.displayName': 'Phil Urich', + 'entity.id': '305', + }, + { + 'entity.lastSeenTimestamp': '2023-10-07T15:00:25.174Z', + 'entity.type': 'host', + 'entity.displayName': 'Demogoblin', + 'entity.id': '306', + }, + { + 'entity.lastSeenTimestamp': '2024-05-04T22:13:00.266Z', + 'entity.type': 'container', + 'entity.displayName': 'Red Goblin', + 'entity.id': '307', + }, + { + 'entity.lastSeenTimestamp': '2024-04-04T23:46:04.650Z', + 'entity.type': 'container', + 'entity.displayName': 'Doctor Octopus', + 'entity.id': '308', + }, + { + 'entity.lastSeenTimestamp': '2023-03-09T03:17:41.028Z', + 'entity.type': 'container', + 'entity.displayName': 'Otto Octavius', + 'entity.id': '309', + }, + { + 'entity.lastSeenTimestamp': '2023-02-15T01:52:08.165Z', + 'entity.type': 'service', + 'entity.displayName': 'Spider-Slayer', + 'entity.id': '310', + }, + { + 'entity.lastSeenTimestamp': '2024-05-18T16:03:17.334Z', + 'entity.type': 'container', + 'entity.displayName': 'The Spot', + 'entity.id': '311', + }, + { + 'entity.lastSeenTimestamp': '2023-10-24T01:14:40.519Z', + 'entity.type': 'host', + 'entity.displayName': 'White Tiger', + 'entity.id': '312', + }, + { + 'entity.lastSeenTimestamp': '2023-11-25T03:29:54.122Z', + 'entity.type': 'container', + 'entity.displayName': 'Kang', + 'entity.id': '313', + }, + { + 'entity.lastSeenTimestamp': '2023-03-10T14:39:44.761Z', + 'entity.type': 'container', + 'entity.displayName': 'Baron Zemo', + 'entity.id': '314', + }, + { + 'entity.lastSeenTimestamp': '2023-05-02T09:25:50.743Z', + 'entity.type': 'host', + 'entity.displayName': 'Red Skull', + 'entity.id': '315', + }, + { + 'entity.lastSeenTimestamp': '2023-04-09T14:57:15.653Z', + 'entity.type': 'container', + 'entity.displayName': 'MODOK', + 'entity.id': '316', + }, + { + 'entity.lastSeenTimestamp': '2023-12-02T10:21:33.045Z', + 'entity.type': 'service', + 'entity.displayName': 'Taskmaster', + 'entity.id': '317', + }, + { + 'entity.lastSeenTimestamp': '2023-09-26T12:18:47.857Z', + 'entity.type': 'service', + 'entity.displayName': 'Ultron', + 'entity.id': '318', + }, + { + 'entity.lastSeenTimestamp': '2023-06-29T22:13:32.744Z', + 'entity.type': 'container', + 'entity.displayName': 'Crossbones', + 'entity.id': '319', + }, + { + 'entity.lastSeenTimestamp': '2023-04-29T16:04:40.552Z', + 'entity.type': 'service', + 'entity.displayName': 'Madame Hydra', + 'entity.id': '320', + }, + { + 'entity.lastSeenTimestamp': '2023-07-26T05:34:55.857Z', + 'entity.type': 'host', + 'entity.displayName': 'The Leader', + 'entity.id': '321', + }, + { + 'entity.lastSeenTimestamp': '2023-05-23T13:21:34.771Z', + 'entity.type': 'service', + 'entity.displayName': 'Abomination', + 'entity.id': '322', + }, + { + 'entity.lastSeenTimestamp': '2024-05-06T22:15:26.389Z', + 'entity.type': 'container', + 'entity.displayName': 'The Mandarin', + 'entity.id': '323', + }, + { + 'entity.lastSeenTimestamp': '2024-01-08T09:12:59.615Z', + 'entity.type': 'service', + 'entity.displayName': 'Fin Fang Foom', + 'entity.id': '324', + }, + { + 'entity.lastSeenTimestamp': '2023-07-07T15:39:12.867Z', + 'entity.type': 'container', + 'entity.displayName': 'Killmonger', + 'entity.id': '325', + }, + { + 'entity.lastSeenTimestamp': '2023-12-04T02:42:55.907Z', + 'entity.type': 'container', + 'entity.displayName': 'Ulysses Klaue', + 'entity.id': '326', + }, + { + 'entity.lastSeenTimestamp': '2024-01-01T10:14:42.258Z', + 'entity.type': 'container', + 'entity.displayName': 'The Collector', + 'entity.id': '327', + }, + { + 'entity.lastSeenTimestamp': '2024-07-21T02:20:14.626Z', + 'entity.type': 'container', + 'entity.displayName': 'The Grandmaster', + 'entity.id': '328', + }, + { + 'entity.lastSeenTimestamp': '2024-04-19T01:54:14.317Z', + 'entity.type': 'service', + 'entity.displayName': 'Thanos', + 'entity.id': '329', + }, + { + 'entity.lastSeenTimestamp': '2023-12-15T04:43:05.141Z', + 'entity.type': 'host', + 'entity.displayName': 'Darkseid', + 'entity.id': '330', + }, + { + 'entity.lastSeenTimestamp': '2023-06-20T14:32:29.968Z', + 'entity.type': 'service', + 'entity.displayName': 'Lex Luthor', + 'entity.id': '331', + }, + { + 'entity.lastSeenTimestamp': '2023-11-02T15:33:40.790Z', + 'entity.type': 'container', + 'entity.displayName': 'Bane', + 'entity.id': '332', + }, + { + 'entity.lastSeenTimestamp': '2024-06-09T08:34:20.039Z', + 'entity.type': 'host', + 'entity.displayName': 'Brainiac', + 'entity.id': '333', + }, + { + 'entity.lastSeenTimestamp': '2024-08-30T14:00:25.077Z', + 'entity.type': 'container', + 'entity.displayName': 'Doomsday', + 'entity.id': '334', + }, + { + 'entity.lastSeenTimestamp': '2024-02-26T18:03:06.283Z', + 'entity.type': 'service', + 'entity.displayName': 'General Zod', + 'entity.id': '335', + }, + { + 'entity.lastSeenTimestamp': '2023-10-30T05:16:19.508Z', + 'entity.type': 'host', + 'entity.displayName': "Ra's al Ghul", + 'entity.id': '336', + }, + { + 'entity.lastSeenTimestamp': '2023-04-05T20:09:22.332Z', + 'entity.type': 'host', + 'entity.displayName': 'Scarecrow', + 'entity.id': '337', + }, + { + 'entity.lastSeenTimestamp': '2023-06-09T06:46:09.887Z', + 'entity.type': 'service', + 'entity.displayName': 'The Joker', + 'entity.id': '338', + }, + { + 'entity.lastSeenTimestamp': '2023-04-26T15:02:13.202Z', + 'entity.type': 'host', + 'entity.displayName': 'Harley Quinn', + 'entity.id': '339', + }, + { + 'entity.lastSeenTimestamp': '2024-04-09T05:21:09.975Z', + 'entity.type': 'service', + 'entity.displayName': 'Poison Ivy', + 'entity.id': '340', + }, + { + 'entity.lastSeenTimestamp': '2023-06-05T04:53:00.171Z', + 'entity.type': 'service', + 'entity.displayName': 'The Riddler', + 'entity.id': '341', + }, + { + 'entity.lastSeenTimestamp': '2024-03-07T01:23:08.698Z', + 'entity.type': 'host', + 'entity.displayName': 'Penguin', + 'entity.id': '342', + }, + { + 'entity.lastSeenTimestamp': '2024-05-17T13:08:12.434Z', + 'entity.type': 'container', + 'entity.displayName': 'Two-Face', + 'entity.id': '343', + }, + { + 'entity.lastSeenTimestamp': '2024-03-13T16:39:26.987Z', + 'entity.type': 'service', + 'entity.displayName': 'Mr. Freeze', + 'entity.id': '344', + }, + { + 'entity.lastSeenTimestamp': '2024-01-01T06:31:32.470Z', + 'entity.type': 'host', + 'entity.displayName': 'Clayface', + 'entity.id': '345', + }, + { + 'entity.lastSeenTimestamp': '2024-06-24T16:27:01.156Z', + 'entity.type': 'service', + 'entity.displayName': 'Hush', + 'entity.id': '346', + }, + { + 'entity.lastSeenTimestamp': '2023-10-19T14:35:47.544Z', + 'entity.type': 'host', + 'entity.displayName': 'Black Mask', + 'entity.id': '347', + }, + { + 'entity.lastSeenTimestamp': '2023-10-24T13:57:07.539Z', + 'entity.type': 'host', + 'entity.displayName': 'Killer Croc', + 'entity.id': '348', + }, + { + 'entity.lastSeenTimestamp': '2023-02-19T09:40:44.538Z', + 'entity.type': 'service', + 'entity.displayName': 'Deathstroke', + 'entity.id': '349', + }, + { + 'entity.lastSeenTimestamp': '2023-03-25T19:22:45.889Z', + 'entity.type': 'service', + 'entity.displayName': 'Deadshot', + 'entity.id': '350', + }, + { + 'entity.lastSeenTimestamp': '2024-06-08T03:10:02.475Z', + 'entity.type': 'container', + 'entity.displayName': 'Amanda Waller', + 'entity.id': '351', + }, + { + 'entity.lastSeenTimestamp': '2023-01-04T03:49:07.210Z', + 'entity.type': 'host', + 'entity.displayName': 'Captain Boomerang', + 'entity.id': '352', + }, + { + 'entity.lastSeenTimestamp': '2023-04-10T20:53:14.367Z', + 'entity.type': 'host', + 'entity.displayName': 'Katana', + 'entity.id': '353', + }, + { + 'entity.lastSeenTimestamp': '2024-04-25T09:42:55.170Z', + 'entity.type': 'host', + 'entity.displayName': 'El Diablo', + 'entity.id': '354', + }, + { + 'entity.lastSeenTimestamp': '2024-05-10T00:44:03.472Z', + 'entity.type': 'host', + 'entity.displayName': 'Enchantress', + 'entity.id': '355', + }, + { + 'entity.lastSeenTimestamp': '2024-02-16T03:47:56.021Z', + 'entity.type': 'service', + 'entity.displayName': 'Rick Flag', + 'entity.id': '356', + }, + { + 'entity.lastSeenTimestamp': '2023-09-30T16:45:27.670Z', + 'entity.type': 'host', + 'entity.displayName': 'King Shark', + 'entity.id': '357', + }, + { + 'entity.lastSeenTimestamp': '2023-10-14T03:04:21.380Z', + 'entity.type': 'host', + 'entity.displayName': 'Peacemaker', + 'entity.id': '358', + }, + { + 'entity.lastSeenTimestamp': '2023-06-27T20:42:18.732Z', + 'entity.type': 'host', + 'entity.displayName': 'Bloodsport', + 'entity.id': '359', + }, + { + 'entity.lastSeenTimestamp': '2024-05-25T22:56:14.675Z', + 'entity.type': 'container', + 'entity.displayName': 'Weasel', + 'entity.id': '360', + }, + { + 'entity.lastSeenTimestamp': '2024-05-15T05:34:39.704Z', + 'entity.type': 'container', + 'entity.displayName': 'Javelin', + 'entity.id': '361', + }, + { + 'entity.lastSeenTimestamp': '2024-07-18T13:40:24.040Z', + 'entity.type': 'container', + 'entity.displayName': 'Ratcatcher', + 'entity.id': '362', + }, + { + 'entity.lastSeenTimestamp': '2023-08-31T03:02:00.545Z', + 'entity.type': 'container', + 'entity.displayName': 'T.D.K.', + 'entity.id': '363', + }, + { + 'entity.lastSeenTimestamp': '2024-08-27T11:13:19.374Z', + 'entity.type': 'container', + 'entity.displayName': 'Doctor Fate', + 'entity.id': '364', + }, + { + 'entity.lastSeenTimestamp': '2023-08-29T06:47:41.545Z', + 'entity.type': 'container', + 'entity.displayName': 'Hawkman', + 'entity.id': '365', + }, + { + 'entity.lastSeenTimestamp': '2024-04-30T00:01:35.041Z', + 'entity.type': 'service', + 'entity.displayName': 'Hawkgirl', + 'entity.id': '366', + }, + { + 'entity.lastSeenTimestamp': '2024-01-24T01:02:59.317Z', + 'entity.type': 'container', + 'entity.displayName': 'Black Adam', + 'entity.id': '367', + }, + { + 'entity.lastSeenTimestamp': '2023-11-08T14:30:16.054Z', + 'entity.type': 'service', + 'entity.displayName': 'Atom Smasher', + 'entity.id': '368', + }, + { + 'entity.lastSeenTimestamp': '2024-08-02T05:40:07.271Z', + 'entity.type': 'host', + 'entity.displayName': 'Cyclone', + 'entity.id': '369', + }, + { + 'entity.lastSeenTimestamp': '2024-03-24T19:11:13.807Z', + 'entity.type': 'host', + 'entity.displayName': 'Stargirl', + 'entity.id': '370', + }, + { + 'entity.lastSeenTimestamp': '2024-01-25T19:31:31.536Z', + 'entity.type': 'host', + 'entity.displayName': 'Hourman', + 'entity.id': '371', + }, + { + 'entity.lastSeenTimestamp': '2024-05-20T22:09:46.339Z', + 'entity.type': 'service', + 'entity.displayName': 'Wildcat', + 'entity.id': '372', + }, + { + 'entity.lastSeenTimestamp': '2023-07-31T01:51:08.575Z', + 'entity.type': 'host', + 'entity.displayName': 'Green Arrow', + 'entity.id': '373', + }, + { + 'entity.lastSeenTimestamp': '2024-03-23T22:01:53.447Z', + 'entity.type': 'container', + 'entity.displayName': 'Speedy', + 'entity.id': '374', + }, + { + 'entity.lastSeenTimestamp': '2024-02-11T22:26:31.584Z', + 'entity.type': 'service', + 'entity.displayName': 'Arsenal', + 'entity.id': '375', + }, + { + 'entity.lastSeenTimestamp': '2024-04-06T12:30:22.601Z', + 'entity.type': 'service', + 'entity.displayName': 'Red Hood', + 'entity.id': '376', + }, + { + 'entity.lastSeenTimestamp': '2023-09-13T07:02:26.095Z', + 'entity.type': 'service', + 'entity.displayName': 'Batgirl', + 'entity.id': '377', + }, + { + 'entity.lastSeenTimestamp': '2024-07-07T22:22:48.331Z', + 'entity.type': 'container', + 'entity.displayName': 'Oracle', + 'entity.id': '378', + }, + { + 'entity.lastSeenTimestamp': '2024-08-09T21:51:59.774Z', + 'entity.type': 'host', + 'entity.displayName': 'Huntress', + 'entity.id': '379', + }, + { + 'entity.lastSeenTimestamp': '2024-02-04T21:15:45.848Z', + 'entity.type': 'service', + 'entity.displayName': 'Cassandra Cain', + 'entity.id': '380', + }, + { + 'entity.lastSeenTimestamp': '2023-07-23T14:22:33.033Z', + 'entity.type': 'host', + 'entity.displayName': 'Azrael', + 'entity.id': '381', + }, + { + 'entity.lastSeenTimestamp': '2024-09-04T05:28:23.197Z', + 'entity.type': 'container', + 'entity.displayName': 'Batwoman', + 'entity.id': '382', + }, + { + 'entity.lastSeenTimestamp': '2023-06-27T08:09:37.626Z', + 'entity.type': 'container', + 'entity.displayName': 'Stephanie Brown', + 'entity.id': '383', + }, + { + 'entity.lastSeenTimestamp': '2023-12-20T08:14:23.553Z', + 'entity.type': 'host', + 'entity.displayName': 'The Question', + 'entity.id': '384', + }, + { + 'entity.lastSeenTimestamp': '2024-03-17T00:19:48.826Z', + 'entity.type': 'container', + 'entity.displayName': 'Blue Beetle', + 'entity.id': '385', + }, + { + 'entity.lastSeenTimestamp': '2024-02-17T20:55:20.634Z', + 'entity.type': 'container', + 'entity.displayName': 'Booster Gold', + 'entity.id': '386', + }, + { + 'entity.lastSeenTimestamp': '2023-02-14T10:24:49.445Z', + 'entity.type': 'host', + 'entity.displayName': 'Plastic Man', + 'entity.id': '387', + }, + { + 'entity.lastSeenTimestamp': '2024-05-10T06:49:45.226Z', + 'entity.type': 'container', + 'entity.displayName': 'Metamorpho', + 'entity.id': '388', + }, + { + 'entity.lastSeenTimestamp': '2023-08-28T11:04:03.884Z', + 'entity.type': 'host', + 'entity.displayName': 'The Spectre', + 'entity.id': '389', + }, + { + 'entity.lastSeenTimestamp': '2023-06-03T09:16:22.294Z', + 'entity.type': 'service', + 'entity.displayName': 'Etrigan', + 'entity.id': '390', + }, + { + 'entity.lastSeenTimestamp': '2023-05-27T15:43:31.368Z', + 'entity.type': 'host', + 'entity.displayName': 'Swamp Thing', + 'entity.id': '391', + }, + { + 'entity.lastSeenTimestamp': '2024-01-23T00:27:36.339Z', + 'entity.type': 'service', + 'entity.displayName': 'Constantine', + 'entity.id': '392', + }, + { + 'entity.lastSeenTimestamp': '2023-12-19T09:00:36.251Z', + 'entity.type': 'host', + 'entity.displayName': 'Zatanna', + 'entity.id': '393', + }, + { + 'entity.lastSeenTimestamp': '2024-02-11T09:31:14.413Z', + 'entity.type': 'host', + 'entity.displayName': 'Doctor Fate', + 'entity.id': '394', + }, + { + 'entity.lastSeenTimestamp': '2024-08-15T14:04:15.345Z', + 'entity.type': 'service', + 'entity.displayName': 'Martian Manhunter', + 'entity.id': '395', + }, + { + 'entity.lastSeenTimestamp': '2024-03-23T06:41:28.527Z', + 'entity.type': 'container', + 'entity.displayName': 'Firestorm', + 'entity.id': '396', + }, + { + 'entity.lastSeenTimestamp': '2023-03-29T19:22:53.314Z', + 'entity.type': 'service', + 'entity.displayName': 'Captain Atom', + 'entity.id': '397', + }, + { + 'entity.lastSeenTimestamp': '2024-05-03T02:22:19.643Z', + 'entity.type': 'service', + 'entity.displayName': 'The Atom', + 'entity.id': '398', + }, + { + 'entity.lastSeenTimestamp': '2024-05-12T05:55:36.153Z', + 'entity.type': 'service', + 'entity.displayName': 'Vixen', + 'entity.id': '399', + }, + { + 'entity.lastSeenTimestamp': '2023-03-01T07:39:44.249Z', + 'entity.type': 'service', + 'entity.displayName': 'Animal Man', + 'entity.id': '400', + }, + { + 'entity.lastSeenTimestamp': '2023-05-20T14:24:33.191Z', + 'entity.type': 'host', + 'entity.displayName': 'Hawk', + 'entity.id': '401', + }, + { + 'entity.lastSeenTimestamp': '2023-06-24T16:44:21.444Z', + 'entity.type': 'host', + 'entity.displayName': 'Dove', + 'entity.id': '402', + }, + { + 'entity.lastSeenTimestamp': '2024-04-05T00:50:29.260Z', + 'entity.type': 'host', + 'entity.displayName': 'Steel', + 'entity.id': '403', + }, + { + 'entity.lastSeenTimestamp': '2024-05-01T07:44:47.694Z', + 'entity.type': 'host', + 'entity.displayName': 'Guardian', + 'entity.id': '404', + }, + { + 'entity.lastSeenTimestamp': '2024-08-10T20:46:37.204Z', + 'entity.type': 'container', + 'entity.displayName': 'The Phantom Stranger', + 'entity.id': '405', + }, + { + 'entity.lastSeenTimestamp': '2024-04-06T11:04:12.556Z', + 'entity.type': 'service', + 'entity.displayName': 'Lobo', + 'entity.id': '406', + }, + { + 'entity.lastSeenTimestamp': '2023-11-24T01:39:36.878Z', + 'entity.type': 'host', + 'entity.displayName': 'Red Tornado', + 'entity.id': '407', + }, + { + 'entity.lastSeenTimestamp': '2024-08-05T14:00:37.985Z', + 'entity.type': 'service', + 'entity.displayName': 'Miss Martian', + 'entity.id': '408', + }, + { + 'entity.lastSeenTimestamp': '2024-01-23T18:57:18.692Z', + 'entity.type': 'container', + 'entity.displayName': 'Bizarro', + 'entity.id': '409', + }, + { + 'entity.lastSeenTimestamp': '2023-01-29T08:35:22.194Z', + 'entity.type': 'service', + 'entity.displayName': 'Black Lightning', + 'entity.id': '410', + }, + { + 'entity.lastSeenTimestamp': '2024-04-03T21:32:10.035Z', + 'entity.type': 'container', + 'entity.displayName': 'Katana', + 'entity.id': '411', + }, + { + 'entity.lastSeenTimestamp': '2024-02-05T09:18:03.386Z', + 'entity.type': 'service', + 'entity.displayName': 'Mr. Terrific', + 'entity.id': '412', + }, + { + 'entity.lastSeenTimestamp': '2024-05-09T01:04:11.713Z', + 'entity.type': 'host', + 'entity.displayName': 'Plastic Man', + 'entity.id': '413', + }, + { + 'entity.lastSeenTimestamp': '2023-03-25T15:26:53.790Z', + 'entity.type': 'host', + 'entity.displayName': 'Shazam', + 'entity.id': '414', + }, + { + 'entity.lastSeenTimestamp': '2023-07-11T11:07:31.377Z', + 'entity.type': 'service', + 'entity.displayName': 'Spawn', + 'entity.id': '415', + }, + { + 'entity.lastSeenTimestamp': '2023-09-08T10:01:26.864Z', + 'entity.type': 'host', + 'entity.displayName': 'Invincible', + 'entity.id': '416', + }, + { + 'entity.lastSeenTimestamp': '2024-07-14T15:51:35.763Z', + 'entity.type': 'container', + 'entity.displayName': 'Atom Eve', + 'entity.id': '417', + }, + { + 'entity.lastSeenTimestamp': '2024-06-26T21:44:30.555Z', + 'entity.type': 'container', + 'entity.displayName': 'Rex Splode', + 'entity.id': '418', + }, + { + 'entity.lastSeenTimestamp': '2023-07-05T04:20:35.073Z', + 'entity.type': 'container', + 'entity.displayName': 'Allen the Alien', + 'entity.id': '419', + }, + { + 'entity.lastSeenTimestamp': '2024-05-31T19:57:53.543Z', + 'entity.type': 'service', + 'entity.displayName': 'Omni-Man', + 'entity.id': '420', + }, + { + 'entity.lastSeenTimestamp': '2023-02-19T17:22:07.379Z', + 'entity.type': 'service', + 'entity.displayName': 'The Tick', + 'entity.id': '421', + }, + { + 'entity.lastSeenTimestamp': '2023-12-17T22:51:04.060Z', + 'entity.type': 'host', + 'entity.displayName': 'Arthur', + 'entity.id': '422', + }, + { + 'entity.lastSeenTimestamp': '2024-03-09T23:54:47.229Z', + 'entity.type': 'service', + 'entity.displayName': 'Big Daddy', + 'entity.id': '423', + }, + { + 'entity.lastSeenTimestamp': '2024-07-14T11:52:37.828Z', + 'entity.type': 'service', + 'entity.displayName': 'Hit-Girl', + 'entity.id': '424', + }, + { + 'entity.lastSeenTimestamp': '2023-02-08T21:15:09.242Z', + 'entity.type': 'container', + 'entity.displayName': 'Kick-Ass', + 'entity.id': '425', + }, + { + 'entity.lastSeenTimestamp': '2024-03-01T17:58:53.274Z', + 'entity.type': 'host', + 'entity.displayName': 'Hellboy', + 'entity.id': '426', + }, + { + 'entity.lastSeenTimestamp': '2023-11-04T20:37:28.218Z', + 'entity.type': 'host', + 'entity.displayName': 'Abe Sapien', + 'entity.id': '427', + }, + { + 'entity.lastSeenTimestamp': '2024-05-16T15:38:01.584Z', + 'entity.type': 'service', + 'entity.displayName': 'Liz Sherman', + 'entity.id': '428', + }, + { + 'entity.lastSeenTimestamp': '2023-03-28T13:40:51.501Z', + 'entity.type': 'container', + 'entity.displayName': 'The Mask', + 'entity.id': '429', + }, + { + 'entity.lastSeenTimestamp': '2023-07-22T10:39:48.045Z', + 'entity.type': 'service', + 'entity.displayName': 'Judge Dredd', + 'entity.id': '430', + }, + { + 'entity.lastSeenTimestamp': '2023-11-10T02:21:09.389Z', + 'entity.type': 'service', + 'entity.displayName': 'Tank Girl', + 'entity.id': '431', + }, + { + 'entity.lastSeenTimestamp': '2024-04-21T16:23:33.730Z', + 'entity.type': 'container', + 'entity.displayName': 'Shadowman', + 'entity.id': '432', + }, + { + 'entity.lastSeenTimestamp': '2023-08-17T19:31:07.282Z', + 'entity.type': 'container', + 'entity.displayName': 'Bloodshot', + 'entity.id': '433', + }, + { + 'entity.lastSeenTimestamp': '2023-04-23T10:05:19.825Z', + 'entity.type': 'service', + 'entity.displayName': 'X-O Manowar', + 'entity.id': '434', + }, + { + 'entity.lastSeenTimestamp': '2024-04-30T21:58:46.410Z', + 'entity.type': 'host', + 'entity.displayName': 'Harbinger', + 'entity.id': '435', + }, + { + 'entity.lastSeenTimestamp': '2023-07-14T05:26:30.493Z', + 'entity.type': 'service', + 'entity.displayName': 'Ninjak', + 'entity.id': '436', + }, + { + 'entity.lastSeenTimestamp': '2024-01-30T09:21:55.939Z', + 'entity.type': 'host', + 'entity.displayName': 'Faith', + 'entity.id': '437', + }, + { + 'entity.lastSeenTimestamp': '2024-02-17T20:36:23.898Z', + 'entity.type': 'host', + 'entity.displayName': 'Archer', + 'entity.id': '438', + }, + { + 'entity.lastSeenTimestamp': '2023-04-04T15:08:08.423Z', + 'entity.type': 'container', + 'entity.displayName': 'Armstrong', + 'entity.id': '439', + }, + { + 'entity.lastSeenTimestamp': '2024-07-29T11:54:01.693Z', + 'entity.type': 'host', + 'entity.displayName': 'Eternal Warrior', + 'entity.id': '440', + }, + { + 'entity.lastSeenTimestamp': '2023-11-02T09:56:15.646Z', + 'entity.type': 'host', + 'entity.displayName': 'Quantum', + 'entity.id': '441', + }, + { + 'entity.lastSeenTimestamp': '2023-04-06T02:07:23.857Z', + 'entity.type': 'container', + 'entity.displayName': 'Woody', + 'entity.id': '442', + }, + { + 'entity.lastSeenTimestamp': '2023-05-20T10:33:26.328Z', + 'entity.type': 'host', + 'entity.displayName': 'The Darkness', + 'entity.id': '443', + }, + { + 'entity.lastSeenTimestamp': '2023-12-03T23:59:21.627Z', + 'entity.type': 'container', + 'entity.displayName': 'Witchblade', + 'entity.id': '444', + }, + { + 'entity.lastSeenTimestamp': '2023-05-31T10:56:01.829Z', + 'entity.type': 'container', + 'entity.displayName': 'Ripclaw', + 'entity.id': '445', + }, + { + 'entity.lastSeenTimestamp': '2024-07-28T11:56:20.407Z', + 'entity.type': 'host', + 'entity.displayName': 'Warblade', + 'entity.id': '446', + }, + { + 'entity.lastSeenTimestamp': '2023-05-03T18:24:08.227Z', + 'entity.type': 'host', + 'entity.displayName': 'Savage Dragon', + 'entity.id': '447', + }, + { + 'entity.lastSeenTimestamp': '2024-07-15T09:05:19.621Z', + 'entity.type': 'host', + 'entity.displayName': 'Spawn', + 'entity.id': '448', + }, + { + 'entity.lastSeenTimestamp': '2024-04-16T13:06:48.941Z', + 'entity.type': 'host', + 'entity.displayName': 'Witchblade', + 'entity.id': '449', + }, + { + 'entity.lastSeenTimestamp': '2024-04-22T12:52:06.912Z', + 'entity.type': 'container', + 'entity.displayName': 'Invincible', + 'entity.id': '450', + }, + { + 'entity.lastSeenTimestamp': '2023-02-23T23:57:49.389Z', + 'entity.type': 'host', + 'entity.displayName': 'The Maxx', + 'entity.id': '451', + }, + { + 'entity.lastSeenTimestamp': '2024-04-17T01:12:16.359Z', + 'entity.type': 'service', + 'entity.displayName': 'Lady Death', + 'entity.id': '452', + }, + { + 'entity.lastSeenTimestamp': '2024-05-07T14:14:02.286Z', + 'entity.type': 'container', + 'entity.displayName': 'The Shadow', + 'entity.id': '453', + }, + { + 'entity.lastSeenTimestamp': '2024-03-31T23:20:56.580Z', + 'entity.type': 'host', + 'entity.displayName': 'Doc Savage', + 'entity.id': '454', + }, + { + 'entity.lastSeenTimestamp': '2023-05-18T01:28:20.743Z', + 'entity.type': 'container', + 'entity.displayName': 'Zorro', + 'entity.id': '455', + }, + { + 'entity.lastSeenTimestamp': '2023-01-12T01:19:03.220Z', + 'entity.type': 'service', + 'entity.displayName': 'The Phantom', + 'entity.id': '456', + }, + { + 'entity.lastSeenTimestamp': '2023-10-10T20:35:47.302Z', + 'entity.type': 'container', + 'entity.displayName': 'Green Hornet', + 'entity.id': '457', + }, + { + 'entity.lastSeenTimestamp': '2023-05-09T19:35:59.568Z', + 'entity.type': 'service', + 'entity.displayName': 'Kato', + 'entity.id': '458', + }, + { + 'entity.lastSeenTimestamp': '2023-07-02T19:40:18.206Z', + 'entity.type': 'host', + 'entity.displayName': 'Red Sonja', + 'entity.id': '459', + }, + { + 'entity.lastSeenTimestamp': '2024-01-08T20:03:24.184Z', + 'entity.type': 'container', + 'entity.displayName': 'Conan the Barbarian', + 'entity.id': '460', + }, + { + 'entity.lastSeenTimestamp': '2024-03-13T05:26:16.730Z', + 'entity.type': 'service', + 'entity.displayName': 'Homer Simpson', + 'entity.id': '461', + }, + { + 'entity.lastSeenTimestamp': '2024-06-28T02:49:37.987Z', + 'entity.type': 'host', + 'entity.displayName': 'Marge Simpson', + 'entity.id': '462', + }, + { + 'entity.lastSeenTimestamp': '2024-06-17T21:16:08.180Z', + 'entity.type': 'host', + 'entity.displayName': 'Bart Simpson', + 'entity.id': '463', + }, + { + 'entity.lastSeenTimestamp': '2023-03-27T21:34:38.051Z', + 'entity.type': 'host', + 'entity.displayName': 'Lisa Simpson', + 'entity.id': '464', + }, + { + 'entity.lastSeenTimestamp': '2023-02-04T21:08:36.340Z', + 'entity.type': 'service', + 'entity.displayName': 'Maggie Simpson', + 'entity.id': '465', + }, + { + 'entity.lastSeenTimestamp': '2024-05-22T20:05:45.805Z', + 'entity.type': 'service', + 'entity.displayName': 'Abe Simpson', + 'entity.id': '466', + }, + { + 'entity.lastSeenTimestamp': '2023-04-02T23:57:33.378Z', + 'entity.type': 'container', + 'entity.displayName': 'Ned Flanders', + 'entity.id': '467', + }, + { + 'entity.lastSeenTimestamp': '2023-03-05T12:25:19.985Z', + 'entity.type': 'container', + 'entity.displayName': 'Maude Flanders', + 'entity.id': '468', + }, + { + 'entity.lastSeenTimestamp': '2024-05-31T22:44:52.035Z', + 'entity.type': 'container', + 'entity.displayName': 'Rod Flanders', + 'entity.id': '469', + }, + { + 'entity.lastSeenTimestamp': '2024-03-06T22:07:45.916Z', + 'entity.type': 'container', + 'entity.displayName': 'Todd Flanders', + 'entity.id': '470', + }, + { + 'entity.lastSeenTimestamp': '2023-09-29T20:39:30.536Z', + 'entity.type': 'service', + 'entity.displayName': 'Milhouse Van Houten', + 'entity.id': '471', + }, + { + 'entity.lastSeenTimestamp': '2023-07-13T22:08:03.669Z', + 'entity.type': 'host', + 'entity.displayName': 'Nelson Muntz', + 'entity.id': '472', + }, + { + 'entity.lastSeenTimestamp': '2024-01-11T11:44:27.608Z', + 'entity.type': 'service', + 'entity.displayName': 'Ralph Wiggum', + 'entity.id': '473', + }, + { + 'entity.lastSeenTimestamp': '2023-10-07T03:48:20.334Z', + 'entity.type': 'container', + 'entity.displayName': 'Chief Wiggum', + 'entity.id': '474', + }, + { + 'entity.lastSeenTimestamp': '2023-12-26T00:46:10.602Z', + 'entity.type': 'host', + 'entity.displayName': 'Clancy Wiggum', + 'entity.id': '475', + }, + { + 'entity.lastSeenTimestamp': '2023-03-24T03:32:51.643Z', + 'entity.type': 'host', + 'entity.displayName': 'Krusty the Clown', + 'entity.id': '476', + }, + { + 'entity.lastSeenTimestamp': '2023-01-19T18:15:10.942Z', + 'entity.type': 'container', + 'entity.displayName': 'Sideshow Bob', + 'entity.id': '477', + }, + { + 'entity.lastSeenTimestamp': '2023-02-05T23:13:30.639Z', + 'entity.type': 'service', + 'entity.displayName': 'Sideshow Mel', + 'entity.id': '478', + }, + { + 'entity.lastSeenTimestamp': '2024-03-06T07:02:19.760Z', + 'entity.type': 'host', + 'entity.displayName': 'Moe Szyslak', + 'entity.id': '479', + }, + { + 'entity.lastSeenTimestamp': '2024-08-26T17:28:47.162Z', + 'entity.type': 'service', + 'entity.displayName': 'Barney Gumble', + 'entity.id': '480', + }, + { + 'entity.lastSeenTimestamp': '2024-05-12T12:10:32.668Z', + 'entity.type': 'service', + 'entity.displayName': 'Lenny Leonard', + 'entity.id': '481', + }, + { + 'entity.lastSeenTimestamp': '2023-07-25T05:19:12.244Z', + 'entity.type': 'service', + 'entity.displayName': 'Carl Carlson', + 'entity.id': '482', + }, + { + 'entity.lastSeenTimestamp': '2023-09-14T19:23:00.311Z', + 'entity.type': 'container', + 'entity.displayName': 'Waylon Smithers', + 'entity.id': '483', + }, + { + 'entity.lastSeenTimestamp': '2023-07-06T12:21:13.655Z', + 'entity.type': 'service', + 'entity.displayName': 'Mr. Burns', + 'entity.id': '484', + }, + { + 'entity.lastSeenTimestamp': '2023-01-23T07:14:22.901Z', + 'entity.type': 'service', + 'entity.displayName': 'Principal Skinner', + 'entity.id': '485', + }, + { + 'entity.lastSeenTimestamp': '2024-05-07T18:03:19.312Z', + 'entity.type': 'service', + 'entity.displayName': 'Edna Krabappel', + 'entity.id': '486', + }, + { + 'entity.lastSeenTimestamp': '2023-02-14T07:33:02.981Z', + 'entity.type': 'service', + 'entity.displayName': 'Superintendent Chalmers', + 'entity.id': '487', + }, + { + 'entity.lastSeenTimestamp': '2024-01-21T22:32:55.738Z', + 'entity.type': 'container', + 'entity.displayName': 'Groundskeeper Willie', + 'entity.id': '488', + }, + { + 'entity.lastSeenTimestamp': '2024-03-31T13:42:07.765Z', + 'entity.type': 'service', + 'entity.displayName': 'Otto Mann', + 'entity.id': '489', + }, + { + 'entity.lastSeenTimestamp': '2023-08-23T18:26:32.084Z', + 'entity.type': 'container', + 'entity.displayName': 'Apu Nahasapeemapetilon', + 'entity.id': '490', + }, + { + 'entity.lastSeenTimestamp': '2024-02-14T04:17:17.737Z', + 'entity.type': 'container', + 'entity.displayName': 'Manjula Nahasapeemapetilon', + 'entity.id': '491', + }, + { + 'entity.lastSeenTimestamp': '2024-07-06T03:25:46.939Z', + 'entity.type': 'service', + 'entity.displayName': 'Kearney Zzyzwicz', + 'entity.id': '492', + }, + { + 'entity.lastSeenTimestamp': '2023-09-04T06:08:42.239Z', + 'entity.type': 'service', + 'entity.displayName': 'Jimbo Jones', + 'entity.id': '493', + }, + { + 'entity.lastSeenTimestamp': '2023-06-12T23:45:21.630Z', + 'entity.type': 'host', + 'entity.displayName': 'Dolph Starbeam', + 'entity.id': '494', + }, + { + 'entity.lastSeenTimestamp': '2023-11-18T18:43:41.585Z', + 'entity.type': 'container', + 'entity.displayName': 'Martin Prince', + 'entity.id': '495', + }, + { + 'entity.lastSeenTimestamp': '2024-07-29T01:12:36.480Z', + 'entity.type': 'container', + 'entity.displayName': 'Mrs. Prince', + 'entity.id': '496', + }, + { + 'entity.lastSeenTimestamp': '2023-09-25T18:32:05.791Z', + 'entity.type': 'container', + 'entity.displayName': 'Comic Book Guy', + 'entity.id': '497', + }, + { + 'entity.lastSeenTimestamp': '2023-04-05T12:49:08.814Z', + 'entity.type': 'host', + 'entity.displayName': 'Professor Frink', + 'entity.id': '498', + }, + { + 'entity.lastSeenTimestamp': '2023-04-07T20:07:02.744Z', + 'entity.type': 'host', + 'entity.displayName': 'Troy McClure', + 'entity.id': '499', + }, +]; diff --git a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx index 9389fdaca3ea0..e77b46b26dc79 100644 --- a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx @@ -5,12 +5,63 @@ * 2.0. */ import React from 'react'; +import { useAbortableAsync } from '@kbn/observability-utils/hooks/use_abortable_async'; +import { EuiDataGridSorting } from '@elastic/eui'; import { EntitiesGrid } from '../../components/entities_grid'; +import { useKibana } from '../../hooks/use_kibana'; +import { useInventoryParams } from '../../hooks/use_inventory_params'; +import { useInventoryRouter } from '../../hooks/use_inventory_router'; export function InventoryPage() { + const { + services: { inventoryAPIClient }, + } = useKibana(); + const { query } = useInventoryParams('/'); + const { sortDirection, sortField, pageIndex } = query; + const inventoryRoute = useInventoryRouter(); + + const { value = { entities: [] }, loading } = useAbortableAsync( + ({ signal }) => { + return inventoryAPIClient.fetch('GET /internal/inventory/entities', { + params: { + query: { + sortDirection, + sortField, + }, + }, + signal, + }); + }, + [inventoryAPIClient, sortDirection, sortField] + ); + + function handlePageChange(nextPage: number) { + inventoryRoute.push('/', { + path: {}, + query: { ...query, pageIndex: nextPage }, + }); + } + + function handleSortChange(sorting: EuiDataGridSorting['columns'][0]) { + inventoryRoute.push('/', { + path: {}, + query: { + ...query, + sortField: sorting.id, + sortDirection: sorting.direction, + }, + }); + } + return ( -
- -
+ ); } diff --git a/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx b/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx index 74eeaac220bc2..f0141a938e0bc 100644 --- a/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx @@ -7,8 +7,10 @@ import * as t from 'io-ts'; import { createRouter, Outlet } from '@kbn/typed-react-router-config'; import React from 'react'; +import { toNumberRt } from '@kbn/io-ts-utils'; import { InventoryPageTemplate } from '../components/inventory_page_template'; import { InventoryPage } from '../pages/inventory_page'; +import { ENTITY_LAST_SEEN } from '../../common/es_fields/entities'; /** * The array of route definitions to be used when the application @@ -21,6 +23,20 @@ const inventoryRoutes = { ), + params: t.type({ + query: t.type({ + sortField: t.string, + sortDirection: t.union([t.literal('asc'), t.literal('desc')]), + pageIndex: toNumberRt, + }), + }), + defaults: { + query: { + sortField: ENTITY_LAST_SEEN, + sortDirection: 'desc', + pageIndex: '0', + }, + }, children: { '/{type}': { element: <>, diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts index 4ddcaaf75c9a4..e286f0e2fac75 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts @@ -5,23 +5,63 @@ * 2.0. */ -import { LatestEntity } from '../../../common/entities'; -import { EntitiesESClient } from '../../lib/create_es_client/create_entities_es_client'; +import { ENTITY_LATEST, entitiesAliasPattern } from '@kbn/entities-schema'; +import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; +import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects'; +import { MAX_NUMBER_OF_ENTITIES, type EntityType } from '../../../common/entities'; +import { + ENTITY_DEFINITION_ID, + ENTITY_DISPLAY_NAME, + ENTITY_ID, + ENTITY_LAST_SEEN, + ENTITY_TYPE, +} from '../../../common/es_fields/entities'; -const MAX_NUMBER_OF_ENTITIES = 500; +const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({ + type: '*', + dataset: ENTITY_LATEST, +}); + +const BUILTIN_SERVICES_FROM_ECS_DATA = 'builtin_services_from_ecs_data'; +const BUILTIN_HOSTS_FROM_ECS_DATA = 'builtin_hosts_from_ecs_data'; +const BUILTIN_CONTAINERS_FROM_ECS_DATA = 'builtin_containers_from_ecs_data'; + +export interface LatestEntity { + [ENTITY_LAST_SEEN]: string; + [ENTITY_TYPE]: string; + [ENTITY_DISPLAY_NAME]: string; + [ENTITY_ID]: string; +} + +const DEFAULT_ENTITY_TYPES = ['service', 'host', 'container']; export async function getLatestEntities({ - entitiesESClient, + inventoryEsClient, + sortDirection, + sortField, + entityTypes, }: { - entitiesESClient: EntitiesESClient; + inventoryEsClient: ObservabilityElasticsearchClient; + sortDirection: 'asc' | 'desc'; + sortField: string; + entityTypes?: EntityType[]; }) { - const response = ( - await entitiesESClient.searchLatest('get_latest_entities', { - body: { - size: MAX_NUMBER_OF_ENTITIES, - }, - }) - ).hits.hits.map((hit) => hit._source); + const entityTypesFilter = entityTypes?.length ? entityTypes : DEFAULT_ENTITY_TYPES; + const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', { + query: `FROM ${ENTITIES_LATEST_ALIAS} + | WHERE ${ENTITY_TYPE} IN (${entityTypesFilter.map((entityType) => `"${entityType}"`).join()}) + | WHERE ${ENTITY_DEFINITION_ID} IN (${[ + BUILTIN_SERVICES_FROM_ECS_DATA, + BUILTIN_HOSTS_FROM_ECS_DATA, + BUILTIN_CONTAINERS_FROM_ECS_DATA, + ] + .map((buildin) => `"${buildin}"`) + .join()}) + | SORT ${sortField} ${sortDirection} + | LIMIT ${MAX_NUMBER_OF_ENTITIES} + | KEEP ${ENTITY_LAST_SEEN}, ${ENTITY_TYPE}, ${ENTITY_DISPLAY_NAME}, ${ENTITY_ID} + `, + }); - return response; + return esqlResultToPlainObjects(latestEntitiesEsqlResponse); } diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts index 093e5ff399ed1..e77dccb8b8cdb 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts @@ -4,23 +4,46 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { jsonRt } from '@kbn/io-ts-utils'; +import { createObservabilityEsClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; +import * as t from 'io-ts'; +import { INVENTORY_APP_ID } from '@kbn/deeplinks-observability/constants'; +import { entityTypeRt } from '../../../common/entities'; import { createInventoryServerRoute } from '../create_inventory_server_route'; -import { createEntitiesESClient } from '../../lib/create_es_client/create_entities_es_client'; import { getLatestEntities } from './get_latest_entities'; export const listLatestEntitiesRoute = createInventoryServerRoute({ endpoint: 'GET /internal/inventory/entities', + params: t.type({ + query: t.intersection([ + t.type({ + sortField: t.string, + sortDirection: t.union([t.literal('asc'), t.literal('desc')]), + }), + t.partial({ + entityTypes: jsonRt.pipe(t.array(entityTypeRt)), + }), + ]), + }), options: { tags: ['access:inventory'], }, - handler: async ({ plugins, request, context }) => { + handler: async ({ params, context, logger }) => { const coreContext = await context.core; - const entitiesESClient = createEntitiesESClient({ - esClient: coreContext.elasticsearch.client.asCurrentUser, - request, + const inventoryEsClient = createObservabilityEsClient({ + client: coreContext.elasticsearch.client.asCurrentUser, + logger, + plugin: `@kbn/${INVENTORY_APP_ID}-plugin`, }); - const latestEntities = await getLatestEntities({ entitiesESClient }); + const { sortDirection, sortField, entityTypes } = params.query; + + const latestEntities = await getLatestEntities({ + inventoryEsClient, + sortDirection, + sortField, + entityTypes, + }); return { entities: latestEntities }; }, diff --git a/x-pack/plugins/observability_solution/inventory/server/utils/with_apm_span.ts b/x-pack/plugins/observability_solution/inventory/server/utils/with_apm_span.ts deleted file mode 100644 index b9e79df6cb0d7..0000000000000 --- a/x-pack/plugins/observability_solution/inventory/server/utils/with_apm_span.ts +++ /dev/null @@ -1,7 +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 { withApmSpan } from '@kbn/apm-data-access-plugin/server/utils'; diff --git a/x-pack/plugins/observability_solution/inventory/tsconfig.json b/x-pack/plugins/observability_solution/inventory/tsconfig.json index c0fc7c2692fde..e5e530ce1233f 100644 --- a/x-pack/plugins/observability_solution/inventory/tsconfig.json +++ b/x-pack/plugins/observability_solution/inventory/tsconfig.json @@ -35,6 +35,8 @@ "@kbn/server-route-repository-client", "@kbn/react-kibana-context-render", "@kbn/es-types", - "@kbn/entities-schema" + "@kbn/entities-schema", + "@kbn/i18n-react", + "@kbn/io-ts-utils" ] } From 5bf4501f5f39f05c3586083e867679d9952eda9d Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 18 Sep 2024 08:19:05 -0700 Subject: [PATCH 04/11] [Spaces Management] Ensure current badge can only appear for single entry (#193195) ## Summary Closes #192811 ### Checklist Delete any items that are not applicable to this PR. - [X] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or --- .../spaces_grid/spaces_grid_page.test.tsx | 45 +++++++++++++------ .../spaces_grid/spaces_grid_page.tsx | 2 +- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx index e8239e85f9ec3..091057a2f4a4c 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx @@ -19,7 +19,6 @@ import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers'; import { SpacesGridPage } from './spaces_grid_page'; import { SpaceAvatarInternal } from '../../space_avatar/space_avatar_internal'; -import type { SpacesManager } from '../../spaces_manager'; import { spacesManagerMock } from '../../spaces_manager/mocks'; const spaces = [ @@ -73,7 +72,7 @@ describe('SpacesGridPage', () => { const wrapper = shallowWithIntl( { const wrapper = shallowWithIntl( { }); it('renders a "current" badge for the current space', async () => { - spacesManager.getActiveSpace.mockResolvedValue(spaces[2]); - const current = await spacesManager.getActiveSpace(); - expect(current.id).toBe('custom-2'); + const spacesWithCurrent = [ + { id: 'default', name: 'Default', disabledFeatures: [], _reserved: true }, + { id: 'test-1', name: 'Test', disabledFeatures: [] }, + { id: 'test-2', name: 'Test', disabledFeatures: [] }, + ]; + const spacesManagerWithCurrent = spacesManagerMock.create(); + spacesManagerWithCurrent.getSpaces = jest.fn().mockResolvedValue(spacesWithCurrent); + spacesManagerWithCurrent.getActiveSpace.mockResolvedValue(spacesWithCurrent[2]); + + const current = await spacesManagerWithCurrent.getActiveSpace(); + expect(current.id).toBe('test-2'); const wrapper = mountWithIntl( { await act(async () => {}); wrapper.update(); - const activeRow = wrapper.find('[data-test-subj="spacesListTableRow-custom-2"]'); + const activeRow = wrapper.find('[data-test-subj="spacesListTableRow-test-2"]'); const nameCell = activeRow.find('[data-test-subj="spacesListTableRowNameCell"]'); const activeBadge = nameCell.find('EuiBadge'); expect(activeBadge.text()).toBe('current'); + + // ensure that current badge appears only once + const currentBadges = wrapper.findWhere((node) => { + return ( + node.type() === 'span' && + node.prop('data-test-subj') && + node.prop('data-test-subj').includes('spacesListCurrentBadge') + ); + }); + expect(currentBadges.length).toBe(1); }); it('renders a non-clickable "switch" action for the current space', async () => { @@ -202,7 +219,7 @@ describe('SpacesGridPage', () => { const wrapper = mountWithIntl( { const wrapper = mountWithIntl( { const wrapper = mountWithIntl( { const wrapper = mountWithIntl( { const wrapper = shallowWithIntl( { const wrapper = shallowWithIntl( Promise.reject(error)} notifications={notifications} getUrlForApp={getUrlForApp} diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx index 95ef9a563162f..2bbcc290a8a83 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx @@ -284,7 +284,7 @@ export class SpacesGridPage extends Component { {value} - {this.state.activeSpace?.name === rowRecord.name && ( + {this.state.activeSpace?.id === rowRecord.id && ( {i18n.translate('xpack.spaces.management.spacesGridPage.currentSpaceMarkerText', { From bfbcf6291edcb82448e99f4d5a479fecf7f58211 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 18 Sep 2024 11:25:42 -0400 Subject: [PATCH 05/11] chore(rca): show full name in notes and store profile id in model (#193211) --- .../public/hooks/query_key_factory.ts | 2 + .../public/hooks/use_fetch_user_profiles.tsx | 63 ++++++++++++ .../investigation_notes.tsx | 10 +- .../components/investigation_notes/note.tsx | 95 +++++++++++-------- .../list/components/investigation_list.tsx | 27 ++++++ .../server/services/create_investigation.ts | 2 +- .../services/create_investigation_item.ts | 2 +- .../services/create_investigation_note.ts | 2 +- .../services/delete_investigation_item.ts | 2 +- .../services/delete_investigation_note.ts | 2 +- .../services/update_investigation_item.ts | 2 +- .../services/update_investigation_note.ts | 2 +- 12 files changed, 163 insertions(+), 48 deletions(-) create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_user_profiles.tsx diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts index 85a8c35b63a5e..44352e46997ea 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts @@ -9,6 +9,8 @@ export const investigationKeys = { all: ['investigations'] as const, + userProfiles: (profileIds: Set) => + [...investigationKeys.all, 'userProfiles', ...profileIds] as const, tags: () => [...investigationKeys.all, 'tags'] as const, stats: () => [...investigationKeys.all, 'stats'] as const, lists: () => [...investigationKeys.all, 'list'] as const, diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_user_profiles.tsx b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_user_profiles.tsx new file mode 100644 index 0000000000000..80a5017fe479d --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_user_profiles.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { UserProfile } from '@kbn/security-plugin/common'; +import { useQuery } from '@tanstack/react-query'; +import { Dictionary, keyBy } from 'lodash'; +import { investigationKeys } from './query_key_factory'; +import { useKibana } from './use_kibana'; + +export interface Params { + profileIds: Set; +} + +export interface Response { + isInitialLoading: boolean; + isLoading: boolean; + isRefetching: boolean; + isSuccess: boolean; + isError: boolean; + data: Dictionary | undefined; +} + +export function useFetchUserProfiles({ profileIds }: Params) { + const { + core: { + notifications: { toasts }, + userProfile, + }, + } = useKibana(); + + const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({ + queryKey: investigationKeys.userProfiles(profileIds), + queryFn: async () => { + const userProfiles = await userProfile.bulkGet({ uids: profileIds }); + return keyBy(userProfiles, 'uid'); + }, + enabled: profileIds.size > 0, + retry: false, + cacheTime: Infinity, + staleTime: Infinity, + onError: (error: Error) => { + toasts.addError(error, { + title: i18n.translate('xpack.investigateApp.useFetchUserProfiles.errorTitle', { + defaultMessage: 'Something went wrong while fetching user profiles', + }), + }); + }, + }); + + return { + data, + isInitialLoading, + isLoading, + isRefetching, + isSuccess, + isError, + }; +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx index 9e1741c1afd12..ec63b09358159 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx @@ -18,6 +18,7 @@ import { i18n } from '@kbn/i18n'; import { InvestigationNoteResponse } from '@kbn/investigation-shared'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; import React, { useState } from 'react'; +import { useFetchUserProfiles } from '../../../../hooks/use_fetch_user_profiles'; import { useTheme } from '../../../../hooks/use_theme'; import { useInvestigation } from '../../contexts/investigation_context'; import { Note } from './note'; @@ -30,8 +31,11 @@ export interface Props { export function InvestigationNotes({ user }: Props) { const theme = useTheme(); const { investigation, addNote, isAddingNote } = useInvestigation(); - const [noteInput, setNoteInput] = useState(''); + const { data: userProfiles, isLoading: isLoadingUserProfiles } = useFetchUserProfiles({ + profileIds: new Set(investigation?.notes.map((note) => note.createdBy)), + }); + const [noteInput, setNoteInput] = useState(''); const onAddNote = async (content: string) => { await addNote(content); setNoteInput(''); @@ -59,7 +63,9 @@ export function InvestigationNotes({ user }: Props) { ); })} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/note.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/note.tsx index 4db3237b9a90a..8bd0f14c9d892 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/note.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/note.tsx @@ -5,15 +5,16 @@ * 2.0. */ import { - EuiAvatar, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, + EuiLoadingSpinner, EuiMarkdownFormat, EuiText, } from '@elastic/eui'; import { css } from '@emotion/css'; import { InvestigationNoteResponse } from '@kbn/investigation-shared'; +import { UserProfile } from '@kbn/security-plugin/common'; // eslint-disable-next-line import/no-extraneous-dependencies import { formatDistance } from 'date-fns'; import React, { useState } from 'react'; @@ -27,14 +28,16 @@ const textContainerClassName = css` interface Props { note: InvestigationNoteResponse; - disabled: boolean; + isOwner: boolean; + userProfile?: UserProfile; + userProfileLoading: boolean; } -export function Note({ note, disabled }: Props) { +export function Note({ note, isOwner, userProfile, userProfileLoading }: Props) { + const theme = useTheme(); const [isEditing, setIsEditing] = useState(false); const { deleteNote, isDeletingNote } = useInvestigation(); - const theme = useTheme(); const timelineContainerClassName = css` padding-bottom: 16px; border-bottom: 1px solid ${theme.colors.lightShade}; @@ -43,51 +46,65 @@ export function Note({ note, disabled }: Props) { } `; + const actionButtonClassname = css` + color: ${theme.colors.mediumShade}; + :hover { + color: ${theme.colors.darkShade}; + } + `; + + const timestampClassName = css` + color: ${theme.colors.darkShade}; + `; + return ( - - + + - + {userProfileLoading ? ( + + ) : ( + + {userProfile?.user.full_name ?? userProfile?.user.username ?? note?.createdBy} + + )} - + {formatDistance(new Date(note.createdAt), new Date(), { addSuffix: true })} - - - { - setIsEditing(!isEditing); - }} - /> - - - await deleteNote(note.id)} - data-test-subj="deleteInvestigationNoteButton" - /> - - + {isOwner && ( + + + { + setIsEditing(!isEditing); + }} + className={actionButtonClassname} + /> + + + await deleteNote(note.id)} + data-test-subj="deleteInvestigationNoteButton" + className={actionButtonClassname} + /> + + + )} {isEditing ? ( diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx index d75710f817703..a65eb12001342 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx @@ -6,6 +6,7 @@ */ import { Criteria, + EuiAvatar, EuiBadge, EuiBasicTable, EuiBasicTableColumn, @@ -22,6 +23,7 @@ import React, { useState } from 'react'; import { paths } from '../../../../common/paths'; import { InvestigationStatusBadge } from '../../../components/investigation_status_badge/investigation_status_badge'; import { useFetchInvestigationList } from '../../../hooks/use_fetch_investigation_list'; +import { useFetchUserProfiles } from '../../../hooks/use_fetch_user_profiles'; import { useKibana } from '../../../hooks/use_kibana'; import { InvestigationListActions } from './investigation_list_actions'; import { InvestigationStats } from './investigation_stats'; @@ -51,6 +53,10 @@ export function InvestigationList() { filter: toFilter(status, tags), }); + const { data: userProfiles, isLoading: isUserProfilesLoading } = useFetchUserProfiles({ + profileIds: new Set(data?.results.map((i) => i.createdBy)), + }); + const investigations = data?.results ?? []; const totalItemCount = data?.total ?? 0; @@ -77,6 +83,27 @@ export function InvestigationList() { defaultMessage: 'Created by', }), truncateText: true, + render: (value: InvestigationResponse['createdBy']) => { + return isUserProfilesLoading ? ( + + ) : ( + + + + {userProfiles?.[value]?.user.full_name ?? + userProfiles?.[value]?.user.username ?? + value} + + + ); + }, }, { field: 'tags', diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts index eb8277d7d6f83..6a7355c0ef875 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts @@ -23,7 +23,7 @@ export async function createInvestigation( ...params, updatedAt: now, createdAt: now, - createdBy: user.username, + createdBy: user.profile_uid!, status: 'triage', notes: [], items: [], diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts index cf77887aab0a3..548912b576618 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_item.ts @@ -23,7 +23,7 @@ export async function createInvestigationItem( const now = Date.now(); const investigationItem = { id: v4(), - createdBy: user.username, + createdBy: user.profile_uid!, createdAt: now, updatedAt: now, ...params, diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts index 2f74123b6f269..f2fe766cdf52d 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts @@ -24,7 +24,7 @@ export async function createInvestigationNote( const investigationNote = { id: v4(), content: params.content, - createdBy: user.username, + createdBy: user.profile_uid!, updatedAt: now, createdAt: now, }; diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_item.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_item.ts index d40938804badc..a9856cc0eaa99 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_item.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_item.ts @@ -19,7 +19,7 @@ export async function deleteInvestigationItem( throw new Error('Note not found'); } - if (item.createdBy !== user.username) { + if (item.createdBy !== user.profile_uid) { throw new Error('User does not have permission to delete note'); } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_note.ts index b0a9e7adf8492..fabbd6f0cdb9c 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_note.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_note.ts @@ -19,7 +19,7 @@ export async function deleteInvestigationNote( throw new Error('Note not found'); } - if (note.createdBy !== user.username) { + if (note.createdBy !== user.profile_uid) { throw new Error('User does not have permission to delete note'); } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_item.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_item.ts index 7e7f03bc7f12c..f95950560ca08 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_item.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_item.ts @@ -25,7 +25,7 @@ export async function updateInvestigationItem( throw new Error('Cannot change item type'); } - if (item.createdBy !== user.username) { + if (item.createdBy !== user.profile_uid) { throw new Error('User does not have permission to update item'); } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts index fc4c5a2c0b1fc..9113be5fedf4d 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation_note.ts @@ -21,7 +21,7 @@ export async function updateInvestigationNote( throw new Error('Note not found'); } - if (note.createdBy !== user.username) { + if (note.createdBy !== user.profile_uid) { throw new Error('User does not have permission to update note'); } From 78b21cd9f98f65d93bfba4b3a2d0e5b33f481c26 Mon Sep 17 00:00:00 2001 From: Tre Date: Wed, 18 Sep 2024 16:31:11 +0100 Subject: [PATCH 06/11] [Unskip] x-pack/.../summary_actions.ts (#193120) ## Summary Use retryForTime instead. Test against local (fake mki) and mki; both were security projects. Tested against `x-pack/test_serverless/api_integration/test_suites/security/common_configs/config.group1.ts` Resolves: https://github.com/elastic/kibana/issues/193061 --------- Co-authored-by: Elastic Machine --- .../common/alerting/summary_actions.ts | 10 ++--- .../shared/services/alerting_api.ts | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts b/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts index ec63653bef7c7..d5f04e692acfe 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts @@ -39,8 +39,7 @@ export default function ({ getService }: FtrProviderContext) { const alertingApi = getService('alertingApi'); let roleAdmin: RoleCredentials; - // Failing: See https://github.com/elastic/kibana/issues/193061 - describe.skip('Summary actions', function () { + describe('Summary actions', function () { const RULE_TYPE_ID = '.es-query'; const ALERT_ACTION_INDEX = 'alert-action-es-query'; const ALERT_INDEX = '.alerts-stack.alerts-default'; @@ -491,16 +490,13 @@ export default function ({ getService }: FtrProviderContext) { }); ruleId = createdRule.id; - const resp = await alertingApi.helpers.waitForDocumentInIndex({ + const resp = await alertingApi.helpers.waitForDocumentInIndexForTime({ esClient, indexName: ALERT_ACTION_INDEX, ruleId, num: 2, sort: 'asc', - retryOptions: { - retryCount: 20, - retryDelay: 10_000, - }, + timeout: 180_000, }); const resp2 = await alertingApi.helpers.waitForAlertInIndex({ diff --git a/x-pack/test_serverless/shared/services/alerting_api.ts b/x-pack/test_serverless/shared/services/alerting_api.ts index afed22fbe2c9a..86fcad5060cc6 100644 --- a/x-pack/test_serverless/shared/services/alerting_api.ts +++ b/x-pack/test_serverless/shared/services/alerting_api.ts @@ -102,6 +102,46 @@ export function AlertingApiProvider({ getService }: FtrProviderContext) { ); }, + async waitForDocumentInIndexForTime({ + esClient, + indexName, + ruleId, + num = 1, + sort = 'desc', + timeout = 1000, + }: { + esClient: Client; + indexName: string; + ruleId: string; + num?: number; + sort?: 'asc' | 'desc'; + timeout?: number; + }): Promise { + return await retry.tryForTime(timeout, async () => { + const response = await esClient.search({ + index: indexName, + sort: `date:${sort}`, + body: { + query: { + bool: { + must: [ + { + term: { + 'ruleId.keyword': ruleId, + }, + }, + ], + }, + }, + }, + }); + if (response.hits.hits.length < num) { + throw new Error(`Only found ${response.hits.hits.length} / ${num} documents`); + } + return response; + }); + }, + async waitForDocumentInIndex({ esClient, indexName, From 38d6143f722459002f5556a9f7888ada4193b12d Mon Sep 17 00:00:00 2001 From: Elena Stoeva <59341489+ElenaStoeva@users.noreply.github.com> Date: Wed, 18 Sep 2024 16:45:59 +0100 Subject: [PATCH 07/11] [Index Management] Restrict dot-prefixed index patterns in template form (#193196) Closes https://github.com/elastic/kibana/issues/190251 ## Summary This PR adds validation that restricts creating a template with a dot-prefixed index pattern. Screenshot 2024-09-18 at 10 49 47 Note: I tried adding tests for this validation [here](https://github.com/elastic/kibana/blob/6a3adf73dacaeda073674ac4a10e8a2597e67739/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx#L163), but it didn't work because the index pattern field is mocked in the tests and errors are not triggered from invalid values. --- .../template_form/template_form_schemas.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx index dec31faad794a..5448c932d65e0 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx @@ -129,6 +129,18 @@ export const schemas: Record = { type: VALIDATION_TYPES.ARRAY_ITEM, isBlocking: false, }, + { + validator: startsWithField({ + char: '.', + message: i18n.translate( + 'xpack.idxMgmt.templateValidation.indexPatternDotPrefixedError', + { + defaultMessage: 'Index patterns cannot match dot-prefixed indices.', + } + ), + }), + type: VALIDATION_TYPES.ARRAY_ITEM, + }, ], }, doCreateDataStream: { From 02ce1b91014eb59f019854b01325bbdf983d2218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 18 Sep 2024 18:02:55 +0200 Subject: [PATCH 08/11] =?UTF-8?q?[Feature=20Flags=20Service]=20Hello=20wor?= =?UTF-8?q?ld=20=F0=9F=91=8B=20(#188562)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Jean-Louis Leysens --- .github/CODEOWNERS | 7 + dev_docs/nav-kibana-dev.docnav.json | 10 +- docs/developer/plugin-list.asciidoc | 4 +- examples/feature_flags_example/README.md | 5 + .../common/feature_flags.ts | 12 + .../feature_flags_example/common/index.ts | 11 + examples/feature_flags_example/kibana.jsonc | 13 + .../public/application.tsx | 33 ++ .../public/components/app.tsx | 91 ++++++ .../feature_flags_example/public/index.ts | 14 + .../feature_flags_example/public/plugin.ts | 40 +++ .../feature_flags_example/public/types.ts | 14 + .../feature_flags_example/server/index.ts | 77 +++++ .../feature_flags_example/server/plugin.ts | 69 +++++ .../server/routes/index.ts | 44 +++ examples/feature_flags_example/tsconfig.json | 24 ++ package.json | 13 +- .../elasticsearch_route_handler_context.ts | 2 +- packages/core/feature-flags/README.mdx | 158 ++++++++++ .../README.md | 5 + .../index.ts | 10 + .../jest.config.js | 14 + .../kibana.jsonc | 5 + .../package.json | 6 + .../src/feature_flags_service.test.ts | 292 ++++++++++++++++++ .../src/feature_flags_service.ts | 203 ++++++++++++ .../tsconfig.json | 26 ++ .../README.md | 3 + .../core-feature-flags-browser-mocks/index.ts | 58 ++++ .../jest.config.js | 14 + .../kibana.jsonc | 5 + .../package.json | 6 + .../tsconfig.json | 23 ++ .../core-feature-flags-browser/README.md | 3 + .../core-feature-flags-browser/index.ts | 16 + .../core-feature-flags-browser/kibana.jsonc | 5 + .../core-feature-flags-browser/package.json | 6 + .../core-feature-flags-browser/src/types.ts | 165 ++++++++++ .../core-feature-flags-browser/tsconfig.json | 18 ++ .../README.md | 5 + .../index.ts | 12 + .../jest.config.js | 14 + .../kibana.jsonc | 5 + .../package.json | 6 + .../src/feature_flags_config.ts | 42 +++ .../feature_flags_request_handler_context.ts | 33 ++ .../src/feature_flags_service.test.ts | 260 ++++++++++++++++ .../src/feature_flags_service.ts | 196 ++++++++++++ .../tsconfig.json | 24 ++ .../core-feature-flags-server-mocks/README.md | 3 + .../core-feature-flags-server-mocks/index.ts | 88 ++++++ .../jest.config.js | 14 + .../kibana.jsonc | 5 + .../package.json | 6 + .../tsconfig.json | 21 ++ .../core-feature-flags-server/README.md | 3 + .../core-feature-flags-server/index.ts | 18 ++ .../core-feature-flags-server/kibana.jsonc | 5 + .../core-feature-flags-server/package.json | 6 + .../src/contracts.ts | 165 ++++++++++ .../src/feature_flag_definition.ts | 60 ++++ .../src/request_handler_context.ts | 18 ++ .../core-feature-flags-server/tsconfig.json | 16 + .../src/core_route_handler_context.ts | 11 + .../core_route_handler_context_params.mock.ts | 2 + .../tsconfig.json | 3 + .../src/preboot_request_handler_context.ts | 12 + .../src/request_handler_context.ts | 26 ++ .../tsconfig.json | 3 +- .../src/injected_metadata_service.test.ts | 27 ++ .../src/injected_metadata_service.ts | 4 + .../src/types.ts | 5 + .../src/injected_metadata_service.mock.ts | 1 + .../src/types.ts | 3 + .../src/internal_core_setup.ts | 2 + .../src/internal_core_start.ts | 2 + .../tsconfig.json | 3 +- .../src/core_setup.mock.ts | 2 + .../src/core_start.mock.ts | 2 + .../tsconfig.json | 3 +- .../core-lifecycle-browser/src/core_setup.ts | 3 + .../core-lifecycle-browser/src/core_start.ts | 3 + .../core-lifecycle-browser/tsconfig.json | 3 +- .../src/internal_core_setup.ts | 2 + .../src/internal_core_start.ts | 2 + .../tsconfig.json | 4 +- .../src/core_setup.mock.ts | 2 + .../src/core_start.mock.ts | 2 + .../src/internal_core_setup.mock.ts | 2 + .../src/internal_core_start.mock.ts | 2 + .../core-lifecycle-server-mocks/tsconfig.json | 1 + .../core-lifecycle-server/src/core_setup.ts | 3 + .../core-lifecycle-server/src/core_start.ts | 3 + .../core-lifecycle-server/tsconfig.json | 3 +- .../src/plugin_context.ts | 2 + .../src/plugin_context.ts | 13 + .../rendering_service.test.ts.snap | 217 +++++++++++++ .../src/rendering_service.test.ts | 17 + .../src/rendering_service.tsx | 18 +- .../src/test_helpers/params.ts | 2 + .../src/types.ts | 2 + .../tsconfig.json | 2 + .../src/core_system.ts | 9 + .../core-root-browser-internal/tsconfig.json | 1 + .../src/register_service_config.ts | 2 + .../core-root-server-internal/src/server.ts | 10 + .../core-root-server-internal/tsconfig.json | 1 + packages/kbn-optimizer/limits.yml | 6 +- renovate.json | 17 +- src/core/public/index.ts | 5 + src/core/public/mocks.ts | 1 + src/core/server/index.ts | 5 + .../config/check_dynamic_config.test.ts | 2 + src/core/server/mocks.ts | 3 + src/core/tsconfig.json | 4 + src/plugins/home/kibana.jsonc | 3 +- .../test_suites/core_plugins/rendering.ts | 2 - tsconfig.base.json | 14 + x-pack/plugins/cloud/server/mocks.ts | 2 + .../cloud_chat/kibana.jsonc | 1 - .../cloud_chat/server/plugin.ts | 31 +- .../cloud_chat/server/routes/chat.test.ts | 26 +- .../cloud_chat/server/routes/chat.ts | 18 +- .../cloud_chat/tsconfig.json | 1 - .../cloud_experiments/README.mdx | 187 ++--------- .../common/constants.test.ts | 25 -- .../cloud_experiments/common/constants.ts | 51 --- .../cloud_experiments/common/index.ts | 13 - .../common/metadata_service/index.ts | 1 + .../metadata_service/initialize_metadata.ts | 63 ++++ .../metadata_service/metadata_service.test.ts | 85 +++-- .../metadata_service/metadata_service.ts | 129 ++++++-- .../metadata_service/remove_undefined.ts | 16 + .../cloud_experiments/common/mocks.ts | 19 -- .../cloud_experiments/common/types.ts | 74 ----- .../cloud_experiments/kibana.jsonc | 4 +- .../public/launch_darkly_client/index.ts | 12 - .../launch_darkly_client.test.mock.ts | 27 -- .../launch_darkly_client.test.ts | 195 ------------ .../launch_darkly_client.ts | 104 ------- .../cloud_experiments/public/plugin.test.ts | 254 ++------------- .../cloud_experiments/public/plugin.ts | 147 ++++----- .../cloud_experiments/server/config.test.ts | 81 +---- .../cloud_experiments/server/config.ts | 2 - .../server/launch_darkly_client/index.ts | 8 - .../launch_darkly_client.test.mock.ts | 25 -- .../launch_darkly_client.test.ts | 217 ------------- .../launch_darkly_client.ts | 97 ------ .../server/launch_darkly_client/mocks.ts | 26 -- .../cloud_experiments/server/plugin.test.ts | 250 +++------------ .../cloud_experiments/server/plugin.ts | 130 +++----- .../usage/register_usage_collector.test.ts | 22 +- .../server/usage/register_usage_collector.ts | 23 +- .../cloud_experiments/tsconfig.json | 6 +- .../fleet/.storybook/context/index.tsx | 2 + .../observability_onboarding/README.md | 11 +- .../observability_onboarding/kibana.jsonc | 2 +- .../observability_onboarding/public/plugin.ts | 2 - .../observability_onboarding/tsconfig.json | 1 - x-pack/plugins/security_solution/kibana.jsonc | 1 - .../common/lib/kibana/kibana_react.mock.ts | 3 - .../plugins/security_solution/public/types.ts | 2 - .../plugins/security_solution/tsconfig.json | 1 - yarn.lock | 100 +++--- 164 files changed, 3607 insertions(+), 1943 deletions(-) create mode 100755 examples/feature_flags_example/README.md create mode 100644 examples/feature_flags_example/common/feature_flags.ts create mode 100644 examples/feature_flags_example/common/index.ts create mode 100644 examples/feature_flags_example/kibana.jsonc create mode 100644 examples/feature_flags_example/public/application.tsx create mode 100644 examples/feature_flags_example/public/components/app.tsx create mode 100644 examples/feature_flags_example/public/index.ts create mode 100644 examples/feature_flags_example/public/plugin.ts create mode 100644 examples/feature_flags_example/public/types.ts create mode 100644 examples/feature_flags_example/server/index.ts create mode 100644 examples/feature_flags_example/server/plugin.ts create mode 100644 examples/feature_flags_example/server/routes/index.ts create mode 100644 examples/feature_flags_example/tsconfig.json create mode 100644 packages/core/feature-flags/README.mdx create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/jest.config.js create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.test.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/jest.config.js create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/tsconfig.json create mode 100644 packages/core/feature-flags/core-feature-flags-browser/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-browser/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-browser/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-browser/src/types.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser/tsconfig.json create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/jest.config.js create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_config.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_request_handler_context.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.test.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/jest.config.js create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/tsconfig.json create mode 100644 packages/core/feature-flags/core-feature-flags-server/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-server/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-server/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-server/src/contracts.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server/src/feature_flag_definition.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server/src/request_handler_context.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server/tsconfig.json delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.test.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts delete mode 100755 x-pack/plugins/cloud_integrations/cloud_experiments/common/index.ts create mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/initialize_metadata.ts create mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/remove_undefined.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/common/mocks.ts delete mode 100755 x-pack/plugins/cloud_integrations/cloud_experiments/common/types.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/index.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.mock.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/index.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.mock.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/mocks.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 76dccf659fb54..9284e5ba821f9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -183,6 +183,12 @@ packages/core/execution-context/core-execution-context-server-mocks @elastic/kib packages/core/fatal-errors/core-fatal-errors-browser @elastic/kibana-core packages/core/fatal-errors/core-fatal-errors-browser-internal @elastic/kibana-core packages/core/fatal-errors/core-fatal-errors-browser-mocks @elastic/kibana-core +packages/core/feature-flags/core-feature-flags-browser @elastic/kibana-core +packages/core/feature-flags/core-feature-flags-browser-internal @elastic/kibana-core +packages/core/feature-flags/core-feature-flags-browser-mocks @elastic/kibana-core +packages/core/feature-flags/core-feature-flags-server @elastic/kibana-core +packages/core/feature-flags/core-feature-flags-server-internal @elastic/kibana-core +packages/core/feature-flags/core-feature-flags-server-mocks @elastic/kibana-core test/plugin_functional/plugins/core_history_block @elastic/kibana-core packages/core/http/core-http-browser @elastic/kibana-core packages/core/http/core-http-browser-internal @elastic/kibana-core @@ -453,6 +459,7 @@ examples/expressions_explorer @elastic/kibana-visualizations src/plugins/expressions @elastic/kibana-visualizations packages/kbn-failed-test-reporter-cli @elastic/kibana-operations @elastic/appex-qa examples/feature_control_examples @elastic/kibana-security +examples/feature_flags_example @elastic/kibana-core x-pack/test/plugin_api_integration/plugins/feature_usage_test @elastic/kibana-security x-pack/plugins/features @elastic/kibana-core x-pack/test/functional_execution_context/plugins/alerts @elastic/kibana-core diff --git a/dev_docs/nav-kibana-dev.docnav.json b/dev_docs/nav-kibana-dev.docnav.json index 2a9893898e8c5..dc8a4373f5450 100644 --- a/dev_docs/nav-kibana-dev.docnav.json +++ b/dev_docs/nav-kibana-dev.docnav.json @@ -136,10 +136,6 @@ }, { "id": "kibDevDocsEmbeddables" - }, - { - "id": "kibCloudExperimentsPlugin", - "label": "A/B testing on Elastic Cloud" } ] }, @@ -205,6 +201,10 @@ }, { "id": "kibDevTutorialCcsSetup" + }, + { + "id": "kibFeatureFlagsService", + "label": "Feature Flags" } ] }, @@ -646,4 +646,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 7e31cd50aeb81..38fc136ef1fac 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -499,8 +499,8 @@ The plugin exposes the static DefaultEditorController class to consume. |{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx[cloudExperiments] -|[!WARNING] -These APIs are deprecated and should not be used as we're working on a replacement Core Feature Flags Service that will arrive soon. +|[!NOTE] +This plugin no-longer exposes any evaluation APIs. Refer to for more information about how to interact with feature flags. |{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_full_story/README.md[cloudFullStory] diff --git a/examples/feature_flags_example/README.md b/examples/feature_flags_example/README.md new file mode 100755 index 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/feature_flags.ts b/examples/feature_flags_example/common/feature_flags.ts new file mode 100644 index 0000000000000..fcff25bbd2c42 --- /dev/null +++ b/examples/feature_flags_example/common/feature_flags.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export const FeatureFlagExampleBoolean = 'example-boolean'; +export const FeatureFlagExampleString = 'example-string'; +export const FeatureFlagExampleNumber = 'example-number'; diff --git a/examples/feature_flags_example/common/index.ts b/examples/feature_flags_example/common/index.ts new file mode 100644 index 0000000000000..37bde8e9843e1 --- /dev/null +++ b/examples/feature_flags_example/common/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export const PLUGIN_ID = 'featureFlagsExample'; +export const PLUGIN_NAME = 'Feature Flags Example'; diff --git a/examples/feature_flags_example/kibana.jsonc b/examples/feature_flags_example/kibana.jsonc new file mode 100644 index 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..eab558d9301bd --- /dev/null +++ b/examples/feature_flags_example/public/application.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, CoreStart } from '@kbn/core/public'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root'; +import { FeatureFlagsExampleApp } from './components/app'; + +export const renderApp = (coreStart: CoreStart, { element }: AppMountParameters) => { + const { notifications, http, featureFlags } = coreStart; + ReactDOM.render( + + + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/feature_flags_example/public/components/app.tsx b/examples/feature_flags_example/public/components/app.tsx new file mode 100644 index 0000000000000..432e7dc348abc --- /dev/null +++ b/examples/feature_flags_example/public/components/app.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { + EuiHorizontalRule, + EuiPageTemplate, + EuiTitle, + EuiText, + EuiLink, + EuiListGroup, + EuiListGroupItem, +} from '@elastic/eui'; +import type { CoreStart, FeatureFlagsStart } from '@kbn/core/public'; + +import useObservable from 'react-use/lib/useObservable'; +import { + FeatureFlagExampleBoolean, + FeatureFlagExampleNumber, + FeatureFlagExampleString, +} from '../../common/feature_flags'; +import { PLUGIN_NAME } from '../../common'; + +interface FeatureFlagsExampleAppDeps { + featureFlags: FeatureFlagsStart; + notifications: CoreStart['notifications']; + http: CoreStart['http']; +} + +export const FeatureFlagsExampleApp = ({ featureFlags }: FeatureFlagsExampleAppDeps) => { + // Fetching the feature flags synchronously + const bool = featureFlags.getBooleanValue(FeatureFlagExampleBoolean, false); + const str = featureFlags.getStringValue(FeatureFlagExampleString, 'red'); + const num = featureFlags.getNumberValue(FeatureFlagExampleNumber, 1); + + // Use React Hooks to observe feature flags changes + const bool$ = useObservable(featureFlags.getBooleanValue$(FeatureFlagExampleBoolean, false)); + const str$ = useObservable(featureFlags.getStringValue$(FeatureFlagExampleString, 'red')); + const num$ = useObservable(featureFlags.getNumberValue$(FeatureFlagExampleNumber, 1)); + + return ( + <> + + + +

{PLUGIN_NAME}

+
+
+ + +

Demo of the feature flags service

+
+ +

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

+ + +

+ The feature flags are: + + + +

+
+ +

+ The observed feature flags are: + + + +

+
+
+
+
+ + ); +}; diff --git a/examples/feature_flags_example/public/index.ts b/examples/feature_flags_example/public/index.ts new file mode 100644 index 0000000000000..9324fbb56bc2e --- /dev/null +++ b/examples/feature_flags_example/public/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { FeatureFlagsExamplePlugin } from './plugin'; + +export function plugin() { + return new FeatureFlagsExamplePlugin(); +} diff --git a/examples/feature_flags_example/public/plugin.ts b/examples/feature_flags_example/public/plugin.ts new file mode 100644 index 0000000000000..915c40dcaafe8 --- /dev/null +++ b/examples/feature_flags_example/public/plugin.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { AppPluginSetupDependencies } from './types'; +import { PLUGIN_NAME } from '../common'; + +export class FeatureFlagsExamplePlugin implements Plugin { + public setup(core: CoreSetup, deps: AppPluginSetupDependencies) { + // Register an application into the side navigation menu + core.application.register({ + id: 'featureFlagsExample', + title: PLUGIN_NAME, + async mount(params: AppMountParameters) { + // Load application bundle + const { renderApp } = await import('./application'); + // Get start services as specified in kibana.json + const [coreStart] = await core.getStartServices(); + // Render the application + return renderApp(coreStart, params); + }, + }); + + deps.developerExamples.register({ + appId: 'featureFlagsExample', + title: PLUGIN_NAME, + description: 'Plugin that shows how to make use of the feature flags core service.', + }); + } + + public start(core: CoreStart) {} + + public stop() {} +} diff --git a/examples/feature_flags_example/public/types.ts b/examples/feature_flags_example/public/types.ts new file mode 100644 index 0000000000000..7f3f7107a1385 --- /dev/null +++ b/examples/feature_flags_example/public/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public'; + +export interface AppPluginSetupDependencies { + developerExamples: DeveloperExamplesSetup; +} diff --git a/examples/feature_flags_example/server/index.ts b/examples/feature_flags_example/server/index.ts new file mode 100644 index 0000000000000..ad88372a7e11e --- /dev/null +++ b/examples/feature_flags_example/server/index.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { FeatureFlagDefinitions } from '@kbn/core-feature-flags-server'; +import type { PluginInitializerContext } from '@kbn/core-plugins-server'; +import { + FeatureFlagExampleBoolean, + FeatureFlagExampleNumber, + FeatureFlagExampleString, +} from '../common/feature_flags'; + +export const featureFlags: FeatureFlagDefinitions = [ + { + key: FeatureFlagExampleBoolean, + name: 'Example boolean', + description: 'This is a demo of a boolean flag', + tags: ['example', 'my-plugin'], + variationType: 'boolean', + variations: [ + { + name: 'On', + description: 'Auto-hides the bar', + value: true, + }, + { + name: 'Off', + description: 'Static always-on', + value: false, + }, + ], + }, + { + key: FeatureFlagExampleString, + name: 'Example string', + description: 'This is a demo of a string flag', + tags: ['example', 'my-plugin'], + variationType: 'string', + variations: [ + { + name: 'Pink', + value: '#D75489', + }, + { + name: 'Turquoise', + value: '#65BAAF', + }, + ], + }, + { + key: FeatureFlagExampleNumber, + name: 'Example Number', + description: 'This is a demo of a number flag', + tags: ['example', 'my-plugin'], + variationType: 'number', + variations: [ + { + name: 'Five', + value: 5, + }, + { + name: 'Ten', + value: 10, + }, + ], + }, +]; + +export async function plugin(initializerContext: PluginInitializerContext) { + const { FeatureFlagsExamplePlugin } = await import('./plugin'); + return new FeatureFlagsExamplePlugin(initializerContext); +} diff --git a/examples/feature_flags_example/server/plugin.ts b/examples/feature_flags_example/server/plugin.ts new file mode 100644 index 0000000000000..3abd4554eb335 --- /dev/null +++ b/examples/feature_flags_example/server/plugin.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '@kbn/core/server'; +import { combineLatest } from 'rxjs'; + +import { + FeatureFlagExampleBoolean, + FeatureFlagExampleNumber, + FeatureFlagExampleString, +} from '../common/feature_flags'; +import { defineRoutes } from './routes'; + +export class FeatureFlagsExamplePlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + + // Register server side APIs + defineRoutes(router); + } + + public start(core: CoreStart) { + // Promise form: when we need to fetch it once, like in an HTTP request + void Promise.all([ + core.featureFlags.getBooleanValue(FeatureFlagExampleBoolean, false), + core.featureFlags.getStringValue(FeatureFlagExampleString, 'white'), + core.featureFlags.getNumberValue(FeatureFlagExampleNumber, 1), + ]).then(([bool, str, num]) => { + this.logger.info(`The feature flags are: + - ${FeatureFlagExampleBoolean}: ${bool} + - ${FeatureFlagExampleString}: ${str} + - ${FeatureFlagExampleNumber}: ${num} + `); + }); + + // Observable form: when we need to react to the changes + combineLatest([ + core.featureFlags.getBooleanValue$(FeatureFlagExampleBoolean, false), + core.featureFlags.getStringValue$(FeatureFlagExampleString, 'red'), + core.featureFlags.getNumberValue$(FeatureFlagExampleNumber, 1), + ]).subscribe(([bool, str, num]) => { + this.logger.info(`The observed feature flags are: + - ${FeatureFlagExampleBoolean}: ${bool} + - ${FeatureFlagExampleString}: ${str} + - ${FeatureFlagExampleNumber}: ${num} + `); + }); + } + + public stop() {} +} diff --git a/examples/feature_flags_example/server/routes/index.ts b/examples/feature_flags_example/server/routes/index.ts new file mode 100644 index 0000000000000..97ce19ec9981b --- /dev/null +++ b/examples/feature_flags_example/server/routes/index.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { IRouter } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; +import { FeatureFlagExampleNumber } from '../../common/feature_flags'; + +export function defineRoutes(router: IRouter) { + router.versioned + .get({ + path: '/api/feature_flags_example/example', + access: 'public', + }) + .addVersion( + { + version: '2023-10-31', + validate: { + response: { + 200: { + body: () => + schema.object({ + number: schema.number(), + }), + }, + }, + }, + }, + async (context, request, response) => { + const { featureFlags } = await context.core; + + return response.ok({ + body: { + number: await featureFlags.getNumberValue(FeatureFlagExampleNumber, 1), + }, + }); + } + ); +} diff --git a/examples/feature_flags_example/tsconfig.json b/examples/feature_flags_example/tsconfig.json new file mode 100644 index 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/package.json b/package.json index 49b45c263c020..d675146586e1f 100644 --- a/package.json +++ b/package.json @@ -287,6 +287,12 @@ "@kbn/core-execution-context-server-internal": "link:packages/core/execution-context/core-execution-context-server-internal", "@kbn/core-fatal-errors-browser": "link:packages/core/fatal-errors/core-fatal-errors-browser", "@kbn/core-fatal-errors-browser-internal": "link:packages/core/fatal-errors/core-fatal-errors-browser-internal", + "@kbn/core-feature-flags-browser": "link:packages/core/feature-flags/core-feature-flags-browser", + "@kbn/core-feature-flags-browser-internal": "link:packages/core/feature-flags/core-feature-flags-browser-internal", + "@kbn/core-feature-flags-browser-mocks": "link:packages/core/feature-flags/core-feature-flags-browser-mocks", + "@kbn/core-feature-flags-server": "link:packages/core/feature-flags/core-feature-flags-server", + "@kbn/core-feature-flags-server-internal": "link:packages/core/feature-flags/core-feature-flags-server-internal", + "@kbn/core-feature-flags-server-mocks": "link:packages/core/feature-flags/core-feature-flags-server-mocks", "@kbn/core-history-block-plugin": "link:test/plugin_functional/plugins/core_history_block", "@kbn/core-http-browser": "link:packages/core/http/core-http-browser", "@kbn/core-http-browser-internal": "link:packages/core/http/core-http-browser-internal", @@ -505,6 +511,7 @@ "@kbn/expressions-explorer-plugin": "link:examples/expressions_explorer", "@kbn/expressions-plugin": "link:src/plugins/expressions", "@kbn/feature-controls-examples-plugin": "link:examples/feature_control_examples", + "@kbn/feature-flags-example-plugin": "link:examples/feature_flags_example", "@kbn/feature-usage-test-plugin": "link:x-pack/test/plugin_api_integration/plugins/feature_usage_test", "@kbn/features-plugin": "link:x-pack/plugins/features", "@kbn/fec-alerts-test-plugin": "link:x-pack/test/functional_execution_context/plugins/alerts", @@ -988,6 +995,7 @@ "@langchain/openai": "^0.1.3", "@langtrase/trace-attributes": "^3.0.8", "@launchdarkly/node-server-sdk": "^9.5.4", + "@launchdarkly/openfeature-node-server": "^1.0.0", "@loaders.gl/core": "^3.4.7", "@loaders.gl/json": "^3.4.7", "@loaders.gl/shapefile": "^3.4.7", @@ -996,6 +1004,10 @@ "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mapbox/mapbox-gl-supported": "2.0.1", "@mapbox/vector-tile": "1.3.1", + "@openfeature/core": "^1.3.0", + "@openfeature/launchdarkly-client-provider": "^0.3.0", + "@openfeature/server-sdk": "^1.15.0", + "@openfeature/web-sdk": "^1.2.1", "@opentelemetry/api": "^1.1.0", "@opentelemetry/api-metrics": "^0.31.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.34.0", @@ -1129,7 +1141,6 @@ "langchain": "^0.2.11", "langsmith": "^0.1.39", "launchdarkly-js-client-sdk": "^3.4.0", - "launchdarkly-node-server-sdk": "^7.0.3", "load-json-file": "^6.2.0", "lodash": "^4.17.21", "lru-cache": "^4.1.5", diff --git a/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_route_handler_context.ts b/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_route_handler_context.ts index b4be404f137af..ce45e5c11833c 100644 --- a/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_route_handler_context.ts +++ b/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_route_handler_context.ts @@ -15,7 +15,7 @@ import type { import type { InternalElasticsearchServiceStart } from './types'; /** - * The {@link UiSettingsRequestHandlerContext} implementation. + * The {@link ElasticsearchRequestHandlerContext} implementation. * @internal */ export class CoreElasticsearchRouteHandlerContext implements ElasticsearchRequestHandlerContext { diff --git a/packages/core/feature-flags/README.mdx b/packages/core/feature-flags/README.mdx new file mode 100644 index 0000000000000..d1e3583aaf2b8 --- /dev/null +++ b/packages/core/feature-flags/README.mdx @@ -0,0 +1,158 @@ +--- +id: kibFeatureFlagsService +slug: /kibana-dev-docs/tutorials/feature-flags-service +title: Feature Flags service +description: The Feature Flags service provides the necessary APIs to evaluate dynamic feature flags. +date: 2024-07-26 +tags: ['kibana', 'dev', 'contributor', 'api docs', 'a/b testing', 'feature flags', 'flags'] +--- + +# Feature Flags Service + +The Feature Flags service provides the necessary APIs to evaluate dynamic feature flags. + +The service is always enabled, however, it will return the fallback value if a feature flags provider hasn't been attached. +Kibana only registers a provider when running on Elastic Cloud Hosted/Serverless. + +For a code example, refer to the [Feature Flags Example plugin](../../../examples/feature_flags_example) + +## Registering a feature flag + +Kibana follows a _gitops_ approach when managing feature flags. To declare a feature flag, add your flags definitions in +your plugin's `server/index.ts` file: + +```typescript +// /server/index.ts +import type { FeatureFlagDefinitions } from '@kbn/core-feature-flags-server'; +import type { PluginInitializerContext } from '@kbn/core-plugins-server'; + +export const featureFlags: FeatureFlagDefinitions = [ + { + key: 'my-cool-feature', + name: 'My cool feature', + description: 'Enables the cool feature to auto-hide the navigation bar', + tags: ['my-plugin', 'my-service', 'ui'], + variationType: 'boolean', + variations: [ + { + name: 'On', + description: 'Auto-hides the bar', + value: true, + }, + { + name: 'Off', + description: 'Static always-on', + value: false, + }, + ], + }, + {...}, +]; + +export async function plugin(initializerContext: PluginInitializerContext) { + const { FeatureFlagsExamplePlugin } = await import('./plugin'); + return new FeatureFlagsExamplePlugin(initializerContext); +} +``` + +After merging your PR, the CI will create/update the flags in our third-party feature flags provider. + +### Deprecation/removal strategy + +When your code doesn't use the feature flag anymore, it is recommended to clean up the feature flags when possible. +There are a few considerations to take into account when performing this clean-up: + +1. Always deprecate first, remove after +2. When to remove? + +#### Always deprecate first, remove after + +Just because the CI syncs the state of `main` to our feature flag provider, there is a high probability that the +previous version of the code that still relied on the feature flag is still running out there. + +For that reason, the recommendation is to always deprecate before removing the flags. This will keep evaluating the flags, +according to the segmentation rules configured for the flag. + +#### When to remove? + +After deprecation, we need to consider when it's safe to remove the flag. There are different scenarios that come with +different recommendations: + +* The segmentation rules of my flag are set up to return the fallback value 100% of the time: it should be safe to +remove the flag at any time. +* My flag only made it to Serverless (it never made it to Elastic Cloud Hosted): it should be safe to remove the flag +after 2 releases have been rolled out (roughly 2-3 weeks later). This is to ensure that all Serverless projects have +been upgraded and that we won't need to rollback to the previous version. +* My flag made it to Elastic Cloud Hosted: if we want to remove the flag, we should approach the affected customers to +fix the expected values via [config overrides](#config-overrides). + +In general, the recommendation is to check our telemetry to validate the usage of our flags. + +## Evaluating feature flags + +This service provides 2 ways to evaluate your feature flags, depending on the use case: + +1. **Single evaluation**: performs the evaluation once, and doesn't react to updates. These APIs are synchronous in the +browser, and asynchronous in the server. +2. **Observed evaluation**: observes the flag for any changes so that the code can adapt. These APIs return an RxJS observable. + +Also, the APIs are typed, so you need to use the appropriate API depending on the `variationType` you defined your flag: + +| Type | Single evaluation | Observed evaluation | +|:-------:|:--------------------------------------------------------|:---------------------------------------------------------| +| Boolean | `core.featureFlags.getBooleanValue(flagName, fallback)` | `core.featureFlags.getBooleanValue$(flagName, fallback)` | +| String | `core.featureFlags.getStringValue(flagName, fallback)` | `core.featureFlags.getStringValue$(flagName, fallback)` | +| Number | `core.featureFlags.getNumberValue(flagName, fallback)` | `core.featureFlags.getNumberValue$(flagName, fallback)` | + +### Request handler context + +Additionally, to make things easier in our HTTP handlers, the _Single evaluation_ APIs are available as part of the core +context provided to the handlers: + +```typescript +async (context, request, response) => { + const { featureFlags } = await context.core; + return response.ok({ + body: { + number: await featureFlags.getNumberValue('example-number', 1), + }, + }); +} +``` + +## Extending the evaluation context + +The should have +enough information to declare the segmentation rules for your feature flags. However, if your use case requires additional +context, feel free to call the API `core.featureFlags.setContext()` from your plugin. + +At the moment, we use 2 levels of context: `kibana` and `organization` that we can use for segmentation purposes at +different levels. By default, the API appends the context to the `kibana` scope. If you need to extend the `organization` +scope, make sure to add `kind: 'organization'` to the object provided to the `setContext` API. + +## Config overrides + +To help with testing, and to provide an escape hatch in cases where the flag evaluation is not behaving as intended, +the Feature Flags Service provides a way to force the values of a feature flag without attempting to resolve it via the +provider. In the `kibana.yml`, the following config sets the overrides: + +```yaml +feature_flags.overrides: + my-feature-flag: 'my-forced-value' +``` + +> [!WARNING] +> There is no validation regarding the variations nor the type of the flags. Use these overrides with caution. + +### Dynamic config + +When running in our test environments, the overrides can be updated without restarting Kibana via the HTTP `PUT /internal/core/_settings`: + +``` +PUT /internal/core/_settings +{ + "feature_flags.overrides": { + "my-feature-flag": "my-forced-value" + } +} +``` diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/README.md b/packages/core/feature-flags/core-feature-flags-browser-internal/README.md new file mode 100644 index 0000000000000..f5696d4530483 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/README.md @@ -0,0 +1,5 @@ +# @kbn/core-feature-flags-browser-internal + +Internal implementation of the browser-side Feature Flags Service. + +It should only be imported by _Core_ packages. \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/index.ts b/packages/core/feature-flags/core-feature-flags-browser-internal/index.ts new file mode 100644 index 0000000000000..e22aeeecd35fb --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { FeatureFlagsService, type FeatureFlagsSetupDeps } from './src/feature_flags_service'; diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/jest.config.js b/packages/core/feature-flags/core-feature-flags-browser-internal/jest.config.js new file mode 100644 index 0000000000000..be4a9c1b14073 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/core/feature-flags/core-feature-flags-browser-internal'], +}; diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-browser-internal/kibana.jsonc new file mode 100644 index 0000000000000..150509b99f519 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-feature-flags-browser-internal", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/package.json b/packages/core/feature-flags/core-feature-flags-browser-internal/package.json new file mode 100644 index 0000000000000..de82d53b1c964 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-feature-flags-browser-internal", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.test.ts b/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.test.ts new file mode 100644 index 0000000000000..596d64c7b77ae --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.test.ts @@ -0,0 +1,292 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { firstValueFrom } from 'rxjs'; +import { apm } from '@elastic/apm-rum'; +import { type Client, OpenFeature, type Provider } from '@openfeature/web-sdk'; +import { coreContextMock } from '@kbn/core-base-browser-mocks'; +import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser'; +import { injectedMetadataServiceMock } from '@kbn/core-injected-metadata-browser-mocks'; +import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal'; +import { FeatureFlagsService } from '..'; + +async function isSettledPromise(p: Promise) { + const immediateValue = {}; + const result = await Promise.race([p, immediateValue]); + return result !== immediateValue; +} + +describe('FeatureFlagsService Browser', () => { + let featureFlagsService: FeatureFlagsService; + let featureFlagsClient: Client; + let injectedMetadata: jest.Mocked; + + beforeEach(() => { + const getClientSpy = jest.spyOn(OpenFeature, 'getClient'); + featureFlagsService = new FeatureFlagsService(coreContextMock.create()); + featureFlagsClient = getClientSpy.mock.results[0].value; + injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + }); + + afterEach(async () => { + await featureFlagsService.stop(); + jest.clearAllMocks(); + await OpenFeature.clearProviders(); + await OpenFeature.clearContexts(); + }); + + describe('provider handling', () => { + test('appends a provider (without awaiting)', () => { + expect.assertions(1); + const { setProvider } = featureFlagsService.setup({ injectedMetadata }); + const spy = jest.spyOn(OpenFeature, 'setProviderAndWait'); + const fakeProvider = { metadata: { name: 'fake provider' } } as Provider; + setProvider(fakeProvider); + expect(spy).toHaveBeenCalledWith(fakeProvider); + }); + + test('throws an error if called twice', () => { + const { setProvider } = featureFlagsService.setup({ injectedMetadata }); + const fakeProvider = { metadata: { name: 'fake provider' } } as Provider; + setProvider(fakeProvider); + expect(() => setProvider(fakeProvider)).toThrowErrorMatchingInlineSnapshot( + `"A provider has already been set. This API cannot be called twice."` + ); + }); + + test('awaits initialization in the start context', async () => { + const { setProvider } = featureFlagsService.setup({ injectedMetadata }); + let externalResolve: Function = () => void 0; + const spy = jest.spyOn(OpenFeature, 'setProviderAndWait').mockImplementation(async () => { + await new Promise((resolve) => { + externalResolve = resolve; + }); + }); + const fakeProvider = {} as Provider; + setProvider(fakeProvider); + expect(spy).toHaveBeenCalledWith(fakeProvider); + const startPromise = featureFlagsService.start(); + await expect(isSettledPromise(startPromise)).resolves.toBe(false); + externalResolve(); + await new Promise((resolve) => process.nextTick(resolve)); // Wait for the promise resolution to spread + await expect(isSettledPromise(startPromise)).resolves.toBe(true); + }); + + test('do not hold for too long during initialization', async () => { + const { setProvider } = featureFlagsService.setup({ injectedMetadata }); + const spy = jest.spyOn(OpenFeature, 'setProviderAndWait').mockImplementation(async () => { + await new Promise(() => {}); // never resolves + }); + const apmCaptureErrorSpy = jest.spyOn(apm, 'captureError'); + const fakeProvider = {} as Provider; + setProvider(fakeProvider); + expect(spy).toHaveBeenCalledWith(fakeProvider); + const startPromise = featureFlagsService.start(); + await expect(isSettledPromise(startPromise)).resolves.toBe(false); + await new Promise((resolve) => setTimeout(resolve, 2100)); // A bit longer than 2 seconds + await expect(isSettledPromise(startPromise)).resolves.toBe(true); + expect(apmCaptureErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('The feature flags provider took too long to initialize.') + ); + }); + }); + + describe('context handling', () => { + let setContextSpy: jest.SpyInstance; + + beforeEach(() => { + setContextSpy = jest.spyOn(OpenFeature, 'setContext'); + }); + + test('appends context to the provider', async () => { + const { appendContext } = featureFlagsService.setup({ injectedMetadata }); + await appendContext({ kind: 'multi' }); + expect(setContextSpy).toHaveBeenCalledWith({ kind: 'multi' }); + }); + + test('appends context to the provider (start method)', async () => { + featureFlagsService.setup({ injectedMetadata }); + const { appendContext } = await featureFlagsService.start(); + await appendContext({ kind: 'multi' }); + expect(setContextSpy).toHaveBeenCalledWith({ kind: 'multi' }); + }); + + test('full multi context pass-through', async () => { + const { appendContext } = featureFlagsService.setup({ injectedMetadata }); + const context = { + kind: 'multi' as const, + kibana: { + key: 'kibana-1', + }, + organization: { + key: 'organization-1', + }, + }; + await appendContext(context); + expect(setContextSpy).toHaveBeenCalledWith(context); + }); + + test('appends to the existing context', async () => { + const { appendContext } = featureFlagsService.setup({ injectedMetadata }); + const initialContext = { + kind: 'multi' as const, + kibana: { + key: 'kibana-1', + }, + organization: { + key: 'organization-1', + }, + }; + await appendContext(initialContext); + expect(setContextSpy).toHaveBeenCalledWith(initialContext); + + await appendContext({ kind: 'multi', kibana: { has_data: true } }); + expect(setContextSpy).toHaveBeenCalledWith({ + ...initialContext, + kibana: { + ...initialContext.kibana, + has_data: true, + }, + }); + }); + + test('converts single-contexts to multi-context', async () => { + const { appendContext } = featureFlagsService.setup({ injectedMetadata }); + await appendContext({ kind: 'organization', key: 'organization-1' }); + expect(setContextSpy).toHaveBeenCalledWith({ + kind: 'multi', + organization: { + key: 'organization-1', + }, + }); + }); + + test('if no `kind` provided, it defaults to the kibana context', async () => { + const { appendContext } = featureFlagsService.setup({ injectedMetadata }); + await appendContext({ key: 'key-1', has_data: false }); + expect(setContextSpy).toHaveBeenCalledWith({ + kind: 'multi', + kibana: { + key: 'key-1', + has_data: false, + }, + }); + }); + }); + + describe('flag evaluation', () => { + let startContract: FeatureFlagsStart; + let apmSpy: jest.SpyInstance; + let addHandlerSpy: jest.SpyInstance; + + beforeEach(async () => { + addHandlerSpy = jest.spyOn(featureFlagsClient, 'addHandler'); + injectedMetadata.getFeatureFlags.mockReturnValue({ + overrides: { 'my-overridden-flag': true }, + }); + featureFlagsService.setup({ injectedMetadata }); + startContract = await featureFlagsService.start(); + apmSpy = jest.spyOn(apm, 'addLabels'); + }); + + // We don't need to test the client, just our APIs, so testing that it returns the fallback value should be enough. + test('get boolean flag', () => { + const value = false; + expect(startContract.getBooleanValue('my-flag', value)).toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + }); + + test('get string flag', () => { + const value = 'my-default'; + expect(startContract.getStringValue('my-flag', value)).toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + }); + + test('get number flag', () => { + const value = 42; + expect(startContract.getNumberValue('my-flag', value)).toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + }); + + test('observe a boolean flag', async () => { + const value = false; + const flag$ = startContract.getBooleanValue$('my-flag', value); + const observedValues: boolean[] = []; + flag$.subscribe((v) => observedValues.push(v)); + // Initial emission + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + expect(observedValues).toHaveLength(1); + + // Does not reevaluate and emit if the other flags are changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(1); // still 1 + + // Reevaluates and emits when the observed flag is changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(2); + }); + + test('observe a string flag', async () => { + const value = 'my-value'; + const flag$ = startContract.getStringValue$('my-flag', value); + const observedValues: string[] = []; + flag$.subscribe((v) => observedValues.push(v)); + // Initial emission + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + expect(observedValues).toHaveLength(1); + + // Does not reevaluate and emit if the other flags are changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(1); // still 1 + + // Reevaluates and emits when the observed flag is changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(2); + }); + + test('observe a number flag', async () => { + const value = 42; + const flag$ = startContract.getNumberValue$('my-flag', value); + const observedValues: number[] = []; + flag$.subscribe((v) => observedValues.push(v)); + // Initial emission + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + expect(observedValues).toHaveLength(1); + + // Does not reevaluate and emit if the other flags are changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(1); // still 1 + + // Reevaluates and emits when the observed flag is changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(2); + }); + + test('with overrides', async () => { + const getBooleanValueSpy = jest.spyOn(featureFlagsClient, 'getBooleanValue'); + expect(startContract.getBooleanValue('my-overridden-flag', false)).toEqual(true); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-overridden-flag': true }); + expect(getBooleanValueSpy).not.toHaveBeenCalled(); + + // Only to prove the spy works + expect(startContract.getBooleanValue('another-flag', false)).toEqual(false); + expect(getBooleanValueSpy).toHaveBeenCalledTimes(1); + expect(getBooleanValueSpy).toHaveBeenCalledWith('another-flag', false); + }); + }); +}); diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.ts b/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.ts new file mode 100644 index 0000000000000..0f7e572ef5ce0 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.ts @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { CoreContext } from '@kbn/core-base-browser-internal'; +import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal'; +import type { Logger } from '@kbn/logging'; +import type { + EvaluationContext, + FeatureFlagsSetup, + FeatureFlagsStart, + MultiContextEvaluationContext, +} from '@kbn/core-feature-flags-browser'; +import { apm } from '@elastic/apm-rum'; +import { type Client, ClientProviderEvents, OpenFeature } from '@openfeature/web-sdk'; +import deepMerge from 'deepmerge'; +import { filter, map, startWith, Subject } from 'rxjs'; + +/** + * setup method dependencies + * @private + */ +export interface FeatureFlagsSetupDeps { + /** + * Used to read the flag overrides set up in the configuration file. + */ + injectedMetadata: InternalInjectedMetadataSetup; +} + +/** + * The browser-side Feature Flags Service + * @private + */ +export class FeatureFlagsService { + private readonly featureFlagsClient: Client; + private readonly logger: Logger; + private isProviderReadyPromise?: Promise; + private context: MultiContextEvaluationContext = { kind: 'multi' }; + private overrides: Record = {}; + + /** + * The core service's constructor + * @param core {@link CoreContext} + */ + constructor(core: CoreContext) { + this.logger = core.logger.get('feature-flags-service'); + this.featureFlagsClient = OpenFeature.getClient(); + OpenFeature.setLogger(this.logger.get('open-feature')); + } + + /** + * Setup lifecycle method + * @param deps {@link FeatureFlagsSetup} including the {@link InternalInjectedMetadataSetup} used to retrieve the feature flags. + */ + public setup(deps: FeatureFlagsSetupDeps): FeatureFlagsSetup { + const featureFlagsInjectedMetadata = deps.injectedMetadata.getFeatureFlags(); + if (featureFlagsInjectedMetadata) { + this.overrides = featureFlagsInjectedMetadata.overrides; + } + return { + setProvider: (provider) => { + if (this.isProviderReadyPromise) { + throw new Error('A provider has already been set. This API cannot be called twice.'); + } + this.isProviderReadyPromise = OpenFeature.setProviderAndWait(provider); + }, + appendContext: (contextToAppend) => this.appendContext(contextToAppend), + }; + } + + /** + * Start lifecycle method + */ + public async start(): Promise { + const featureFlagsChanged$ = new Subject(); + this.featureFlagsClient.addHandler(ClientProviderEvents.ConfigurationChanged, (event) => { + if (event?.flagsChanged) { + featureFlagsChanged$.next(event.flagsChanged); + } + }); + const observeFeatureFlag$ = (flagName: string) => + featureFlagsChanged$.pipe( + filter((flagNames) => flagNames.includes(flagName)), + startWith([flagName]) // only to emit on the first call + ); + + await this.waitForProviderInitialization(); + + return { + appendContext: (contextToAppend) => this.appendContext(contextToAppend), + getBooleanValue: (flagName: string, fallbackValue: boolean) => + this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue), + getStringValue: (flagName: string, fallbackValue: Value) => + this.evaluateFlag(this.featureFlagsClient.getStringValue, flagName, fallbackValue), + getNumberValue: (flagName: string, fallbackValue: Value) => + this.evaluateFlag(this.featureFlagsClient.getNumberValue, flagName, fallbackValue), + getBooleanValue$: (flagName, fallbackValue) => { + return observeFeatureFlag$(flagName).pipe( + map(() => + this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue) + ) + ); + }, + getStringValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + map(() => + this.evaluateFlag( + this.featureFlagsClient.getStringValue, + flagName, + fallbackValue + ) + ) + ); + }, + getNumberValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + map(() => + this.evaluateFlag( + this.featureFlagsClient.getNumberValue, + flagName, + fallbackValue + ) + ) + ); + }, + }; + } + + /** + * Stop lifecycle method + */ + public async stop() { + await OpenFeature.close(); + } + + /** + * Waits for the provider initialization with a timeout to avoid holding the page load for too long + * @private + */ + private async waitForProviderInitialization() { + // Adding a timeout here to avoid hanging the start for too long if the provider is unresponsive + let timeoutId: NodeJS.Timeout | undefined; + await Promise.race([ + this.isProviderReadyPromise, + new Promise((resolve) => { + timeoutId = setTimeout(resolve, 2 * 1000); + }).then(() => { + const msg = `The feature flags provider took too long to initialize. + Won't hold the page load any longer. + Feature flags will return the provided fallbacks until the provider is eventually initialized.`; + this.logger.warn(msg); + apm.captureError(msg); + }), + ]); + clearTimeout(timeoutId); + } + + /** + * Wrapper to evaluate flags with the common config overrides interceptions + APM and counters reporting + * @param evaluationFn The actual evaluation API + * @param flagName The name of the flag to evaluate + * @param fallbackValue The fallback value + * @private + */ + private evaluateFlag( + evaluationFn: (flagName: string, fallbackValue: T) => T, + flagName: string, + fallbackValue: T + ): T { + const value = + typeof this.overrides[flagName] !== 'undefined' + ? (this.overrides[flagName] as T) + : // We have to bind the evaluation or the client will lose its internal context + evaluationFn.bind(this.featureFlagsClient)(flagName, fallbackValue); + apm.addLabels({ [`flag_${flagName}`]: value }); + // TODO: increment usage counter + return value; + } + + /** + * Formats the provided context to fulfill the expected multi-context structure. + * @param contextToAppend The {@link EvaluationContext} to append. + * @private + */ + private async appendContext(contextToAppend: EvaluationContext): Promise { + // If no kind provided, default to the project|deployment level. + const { kind = 'kibana', ...rest } = contextToAppend; + // Format the context to fulfill the expected multi-context structure + const formattedContextToAppend: MultiContextEvaluationContext = + kind === 'multi' + ? (contextToAppend as MultiContextEvaluationContext) + : { kind: 'multi', [kind]: rest }; + + // Merge the formatted context to append to the global context, and set it in the OpenFeature client. + this.context = deepMerge(this.context, formattedContextToAppend); + await OpenFeature.setContext(this.context); + } +} diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json b/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json new file mode 100644 index 0000000000000..3ed73d73e75a3 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/core-base-browser-internal", + "@kbn/core-feature-flags-browser", + "@kbn/logging", + "@kbn/core-base-browser-mocks", + "@kbn/core-injected-metadata-browser-internal", + "@kbn/core-injected-metadata-browser-mocks", + ] +} diff --git a/packages/core/feature-flags/core-feature-flags-browser-mocks/README.md b/packages/core/feature-flags/core-feature-flags-browser-mocks/README.md new file mode 100644 index 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..ad8cdae6a5ef1 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { FeatureFlagsSetup, FeatureFlagsStart } from '@kbn/core-feature-flags-browser'; +import type { FeatureFlagsService } from '@kbn/core-feature-flags-browser-internal'; +import type { PublicMethodsOf } from '@kbn/utility-types'; +import { of } from 'rxjs'; + +const createFeatureFlagsSetup = (): jest.Mocked => { + return { + setProvider: jest.fn(), + appendContext: jest.fn().mockImplementation(Promise.resolve), + }; +}; + +const createFeatureFlagsStart = (): jest.Mocked => { + return { + appendContext: jest.fn().mockImplementation(Promise.resolve), + getBooleanValue: jest.fn().mockImplementation(async (_, fallback) => fallback), + getNumberValue: jest.fn().mockImplementation(async (_, fallback) => fallback), + getStringValue: jest.fn().mockImplementation(async (_, fallback) => fallback), + getBooleanValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)), + getStringValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)), + getNumberValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)), + }; +}; + +const createFeatureFlagsServiceMock = (): jest.Mocked> => { + return { + setup: jest.fn().mockImplementation(createFeatureFlagsSetup), + start: jest.fn().mockImplementation(async () => createFeatureFlagsStart()), + stop: jest.fn().mockImplementation(Promise.resolve), + }; +}; + +/** + * Mocks for the Feature Flags service (browser-side) + */ +export const coreFeatureFlagsMock = { + /** + * Mocks the entire feature flags service + */ + create: createFeatureFlagsServiceMock, + /** + * Mocks the setup contract + */ + createSetup: createFeatureFlagsSetup, + /** + * Mocks the start contract + */ + createStart: createFeatureFlagsStart, +}; diff --git a/packages/core/feature-flags/core-feature-flags-browser-mocks/jest.config.js b/packages/core/feature-flags/core-feature-flags-browser-mocks/jest.config.js new file mode 100644 index 0000000000000..f259faecb6046 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/core/feature-flags/core-feature-flags-browser-mocks'], +}; diff --git a/packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc new file mode 100644 index 0000000000000..0917a098841c4 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-feature-flags-browser-mocks", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-browser-mocks/package.json b/packages/core/feature-flags/core-feature-flags-browser-mocks/package.json new file mode 100644 index 0000000000000..77e9150ce7834 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-feature-flags-browser-mocks", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-browser-mocks/tsconfig.json b/packages/core/feature-flags/core-feature-flags-browser-mocks/tsconfig.json new file mode 100644 index 0000000000000..b7d1b3ca28cbb --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/core-feature-flags-browser", + "@kbn/core-feature-flags-browser-internal", + "@kbn/utility-types", + ] +} diff --git a/packages/core/feature-flags/core-feature-flags-browser/README.md b/packages/core/feature-flags/core-feature-flags-browser/README.md new file mode 100644 index 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..6c79c96f01878 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { + EvaluationContext, + MultiContextEvaluationContext, + SingleContextEvaluationContext, + FeatureFlagsSetup, + FeatureFlagsStart, +} from './src/types'; diff --git a/packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc new file mode 100644 index 0000000000000..56187119509b9 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-feature-flags-browser", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-browser/package.json b/packages/core/feature-flags/core-feature-flags-browser/package.json new file mode 100644 index 0000000000000..235f52c0521f1 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-feature-flags-browser", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-browser/src/types.ts b/packages/core/feature-flags/core-feature-flags-browser/src/types.ts new file mode 100644 index 0000000000000..844675aab4603 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/src/types.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Provider } from '@openfeature/web-sdk'; +import { type EvaluationContext as OpenFeatureEvaluationContext } from '@openfeature/core'; +import type { Observable } from 'rxjs'; + +/** + * The evaluation context to use when retrieving the flags. + * + * We use multi-context so that we can apply segmentation rules at different levels (`organization`/`kibana`). + * * `organization` includes any information that is common to all the projects/deployments in an organization. An example is the in_trial status. + * * The `kibana` context includes all the information that identifies a project/deployment. Examples are version, offering, and has_data. + * Kind helps us specify which sub-context should receive the new attributes. + * If no `kind` is provided, it defaults to `kibana`. + * + * @example Providing properties for both contexts + * { + * kind: 'multi', + * organization: { + * key: 1234, + * in_trial: true, + * }, + * kibana: { + * key: 12345567890, + * version: 8.15.0, + * buildHash: 'ffffffffaaaaaaaa', + * }, + * } + * + * @example Appending context to the organization sub-context + * { + * kind: 'organization', + * key: 1234, + * in_trial: true, + * } + * + * @example Appending context to the `kibana` sub-context + * { + * key: 12345567890, + * version: 8.15.0, + * buildHash: 'ffffffffaaaaaaaa', + * } + * } + * + * @public + */ +export type EvaluationContext = MultiContextEvaluationContext | SingleContextEvaluationContext; + +/** + * Multi-context format. The sub-contexts are provided in their nested properties. + * @public + */ +export type MultiContextEvaluationContext = OpenFeatureEvaluationContext & { + /** + * Static `multi` string + */ + kind: 'multi'; + /** + * The Elastic Cloud organization-specific context. + */ + organization?: OpenFeatureEvaluationContext; + /** + * The deployment/project-specific context. + */ + kibana?: OpenFeatureEvaluationContext; +}; + +/** + * Single Context format. If `kind` is not specified, it applies to the `kibana` sub-context. + */ +export type SingleContextEvaluationContext = OpenFeatureEvaluationContext & { + /** + * The sub-context that it's updated. Defaults to `kibana`. + */ + kind?: 'organization' | 'kibana'; +}; + +/** + * Setup contract of the Feature Flags Service + * @public + */ +export interface FeatureFlagsSetup { + /** + * Registers an OpenFeature provider to talk to the + * 3rd-party service that manages the Feature Flags. + * @param provider The {@link Provider | OpenFeature Provider} to handle the communication with the feature flags management system. + * @public + */ + setProvider(provider: Provider): void; + + /** + * Appends new keys to the evaluation context. + * @param contextToAppend The additional keys that should be appended/modified in the evaluation context. + * @public + */ + appendContext(contextToAppend: EvaluationContext): Promise; +} + +/** + * Setup contract of the Feature Flags Service + * @public + */ +export interface FeatureFlagsStart { + /** + * Appends new keys to the evaluation context. + * @param contextToAppend The additional keys that should be appended/modified in the evaluation context. + * @public + */ + appendContext(contextToAppend: EvaluationContext): Promise; + + /** + * Evaluates a boolean flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getBooleanValue(flagName: string, fallbackValue: boolean): boolean; + + /** + * Evaluates a string flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getStringValue(flagName: string, fallbackValue: Value): Value; + + /** + * Evaluates a number flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getNumberValue(flagName: string, fallbackValue: Value): Value; + + /** + * Returns an observable of a boolean flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getBooleanValue$(flagName: string, fallbackValue: boolean): Observable; + + /** + * Returns an observable of a string flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getStringValue$(flagName: string, fallbackValue: Value): Observable; + + /** + * Returns an observable of a number flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getNumberValue$(flagName: string, fallbackValue: Value): Observable; +} diff --git a/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json b/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json new file mode 100644 index 0000000000000..9fa73d55be770 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/README.md b/packages/core/feature-flags/core-feature-flags-server-internal/README.md new file mode 100644 index 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..97083327e609d --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { featureFlagsConfig } from './src/feature_flags_config'; +export { FeatureFlagsService, type InternalFeatureFlagsSetup } from './src/feature_flags_service'; +export { CoreFeatureFlagsRouteHandlerContext } from './src/feature_flags_request_handler_context'; diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/jest.config.js b/packages/core/feature-flags/core-feature-flags-server-internal/jest.config.js new file mode 100644 index 0000000000000..67b65d2040c54 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../../../..', + roots: ['/packages/core/feature-flags/core-feature-flags-server-internal'], +}; diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-server-internal/kibana.jsonc new file mode 100644 index 0000000000000..60a01597c0454 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-server", + "id": "@kbn/core-feature-flags-server-internal", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/package.json b/packages/core/feature-flags/core-feature-flags-server-internal/package.json new file mode 100644 index 0000000000000..33383b043fa5c --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-feature-flags-server-internal", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_config.ts b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_config.ts new file mode 100644 index 0000000000000..fe6725456806b --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_config.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ServiceConfigDescriptor } from '@kbn/core-base-server-internal'; +import { schema } from '@kbn/config-schema'; + +/** + * The definition of the validation config schema + * @private + */ +const configSchema = schema.object({ + overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())), +}); + +/** + * Type definition of the Feature Flags configuration + * @private + */ +export interface FeatureFlagsConfig { + overrides?: Record; +} + +/** + * Config descriptor for the feature flags service + * @private + */ +export const featureFlagsConfig: ServiceConfigDescriptor = { + /** + * All config is prefixed by `feature_flags` + */ + path: 'feature_flags', + /** + * The definition of the validation config schema + */ + schema: configSchema, +}; diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_request_handler_context.ts b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_request_handler_context.ts new file mode 100644 index 0000000000000..f0ac4da69b1a9 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_request_handler_context.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { + FeatureFlagsRequestHandlerContext, + FeatureFlagsStart, +} from '@kbn/core-feature-flags-server'; + +/** + * The {@link FeatureFlagsRequestHandlerContext} implementation. + * @internal + */ +export class CoreFeatureFlagsRouteHandlerContext implements FeatureFlagsRequestHandlerContext { + constructor(private readonly featureFlags: FeatureFlagsStart) {} + + public getBooleanValue(flagName: string, fallback: boolean): Promise { + return this.featureFlags.getBooleanValue(flagName, fallback); + } + + public getStringValue(flagName: string, fallback: Value): Promise { + return this.featureFlags.getStringValue(flagName, fallback); + } + + public getNumberValue(flagName: string, fallback: Value): Promise { + return this.featureFlags.getNumberValue(flagName, fallback); + } +} diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.test.ts b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.test.ts new file mode 100644 index 0000000000000..7bad676b9528b --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.test.ts @@ -0,0 +1,260 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { firstValueFrom } from 'rxjs'; +import apm from 'elastic-apm-node'; +import { type Client, OpenFeature, type Provider } from '@openfeature/server-sdk'; +import { mockCoreContext } from '@kbn/core-base-server-mocks'; +import { configServiceMock } from '@kbn/config-mocks'; +import type { FeatureFlagsStart } from '@kbn/core-feature-flags-server'; +import { FeatureFlagsService } from '..'; + +describe('FeatureFlagsService Server', () => { + let featureFlagsService: FeatureFlagsService; + let featureFlagsClient: Client; + + beforeEach(() => { + const getClientSpy = jest.spyOn(OpenFeature, 'getClient'); + featureFlagsService = new FeatureFlagsService( + mockCoreContext.create({ + configService: configServiceMock.create({ + atPath: { + overrides: { + 'my-overridden-flag': true, + }, + }, + }), + }) + ); + featureFlagsClient = getClientSpy.mock.results[0].value; + }); + + afterEach(async () => { + await featureFlagsService.stop(); + jest.clearAllMocks(); + await OpenFeature.clearProviders(); + }); + + describe('provider handling', () => { + test('appends a provider (no async operation)', () => { + expect.assertions(1); + const { setProvider } = featureFlagsService.setup(); + const spy = jest.spyOn(OpenFeature, 'setProvider'); + const fakeProvider = { metadata: { name: 'fake provider' } } as Provider; + setProvider(fakeProvider); + expect(spy).toHaveBeenCalledWith(fakeProvider); + }); + + test('throws an error if called twice', () => { + const { setProvider } = featureFlagsService.setup(); + const fakeProvider = { metadata: { name: 'fake provider' } } as Provider; + setProvider(fakeProvider); + expect(() => setProvider(fakeProvider)).toThrowErrorMatchingInlineSnapshot( + `"A provider has already been set. This API cannot be called twice."` + ); + }); + }); + + describe('context handling', () => { + let setContextSpy: jest.SpyInstance; + + beforeEach(() => { + setContextSpy = jest.spyOn(OpenFeature, 'setContext'); + }); + + test('appends context to the provider', () => { + const { appendContext } = featureFlagsService.setup(); + appendContext({ kind: 'multi' }); + expect(setContextSpy).toHaveBeenCalledWith({ kind: 'multi' }); + }); + + test('appends context to the provider (start method)', () => { + featureFlagsService.setup(); + const { appendContext } = featureFlagsService.start(); + appendContext({ kind: 'multi' }); + expect(setContextSpy).toHaveBeenCalledWith({ kind: 'multi' }); + }); + + test('full multi context pass-through', () => { + const { appendContext } = featureFlagsService.setup(); + const context = { + kind: 'multi' as const, + kibana: { + key: 'kibana-1', + }, + organization: { + key: 'organization-1', + }, + }; + appendContext(context); + expect(setContextSpy).toHaveBeenCalledWith(context); + }); + + test('appends to the existing context', () => { + const { appendContext } = featureFlagsService.setup(); + const initialContext = { + kind: 'multi' as const, + kibana: { + key: 'kibana-1', + }, + organization: { + key: 'organization-1', + }, + }; + appendContext(initialContext); + expect(setContextSpy).toHaveBeenCalledWith(initialContext); + + appendContext({ kind: 'multi', kibana: { has_data: true } }); + expect(setContextSpy).toHaveBeenCalledWith({ + ...initialContext, + kibana: { + ...initialContext.kibana, + has_data: true, + }, + }); + }); + + test('converts single-contexts to multi-context', () => { + const { appendContext } = featureFlagsService.setup(); + appendContext({ kind: 'organization', key: 'organization-1' }); + expect(setContextSpy).toHaveBeenCalledWith({ + kind: 'multi', + organization: { + key: 'organization-1', + }, + }); + }); + + test('if no `kind` provided, it defaults to the kibana context', () => { + const { appendContext } = featureFlagsService.setup(); + appendContext({ key: 'key-1', has_data: false }); + expect(setContextSpy).toHaveBeenCalledWith({ + kind: 'multi', + kibana: { + key: 'key-1', + has_data: false, + }, + }); + }); + }); + + describe('flag evaluation', () => { + let startContract: FeatureFlagsStart; + let apmSpy: jest.SpyInstance; + let addHandlerSpy: jest.SpyInstance; + + beforeEach(() => { + addHandlerSpy = jest.spyOn(featureFlagsClient, 'addHandler'); + featureFlagsService.setup(); + startContract = featureFlagsService.start(); + apmSpy = jest.spyOn(apm, 'addLabels'); + }); + + // We don't need to test the client, just our APIs, so testing that it returns the fallback value should be enough. + test('get boolean flag', async () => { + const value = false; + await expect(startContract.getBooleanValue('my-flag', value)).resolves.toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + }); + + test('get string flag', async () => { + const value = 'my-default'; + await expect(startContract.getStringValue('my-flag', value)).resolves.toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + }); + + test('get number flag', async () => { + const value = 42; + await expect(startContract.getNumberValue('my-flag', value)).resolves.toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + }); + + test('observe a boolean flag', async () => { + const value = false; + const flag$ = startContract.getBooleanValue$('my-flag', value); + const observedValues: boolean[] = []; + flag$.subscribe((v) => observedValues.push(v)); + // Initial emission + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + expect(observedValues).toHaveLength(1); + + // Does not reevaluate and emit if the other flags are changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(1); // still 1 + + // Reevaluates and emits when the observed flag is changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(2); + }); + + test('observe a string flag', async () => { + const value = 'my-value'; + const flag$ = startContract.getStringValue$('my-flag', value); + const observedValues: string[] = []; + flag$.subscribe((v) => observedValues.push(v)); + // Initial emission + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + expect(observedValues).toHaveLength(1); + + // Does not reevaluate and emit if the other flags are changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(1); // still 1 + + // Reevaluates and emits when the observed flag is changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(2); + }); + + test('observe a number flag', async () => { + const value = 42; + const flag$ = startContract.getNumberValue$('my-flag', value); + const observedValues: number[] = []; + flag$.subscribe((v) => observedValues.push(v)); + // Initial emission + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value }); + expect(observedValues).toHaveLength(1); + + // Does not reevaluate and emit if the other flags are changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(1); // still 1 + + // Reevaluates and emits when the observed flag is changed + addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] }); + await expect(firstValueFrom(flag$)).resolves.toEqual(value); + expect(observedValues).toHaveLength(2); + }); + + test('with overrides', async () => { + const getBooleanValueSpy = jest.spyOn(featureFlagsClient, 'getBooleanValue'); + await expect(startContract.getBooleanValue('my-overridden-flag', false)).resolves.toEqual( + true + ); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-overridden-flag': true }); + expect(getBooleanValueSpy).not.toHaveBeenCalled(); + + // Only to prove the spy works + await expect(startContract.getBooleanValue('another-flag', false)).resolves.toEqual(false); + expect(getBooleanValueSpy).toHaveBeenCalledTimes(1); + expect(getBooleanValueSpy).toHaveBeenCalledWith('another-flag', false); + }); + }); + + test('returns overrides', () => { + const { getOverrides } = featureFlagsService.setup(); + expect(getOverrides()).toStrictEqual({ 'my-overridden-flag': true }); + }); +}); diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.ts b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.ts new file mode 100644 index 0000000000000..7b01ebde731fe --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { CoreContext } from '@kbn/core-base-server-internal'; +import type { + EvaluationContext, + FeatureFlagsSetup, + FeatureFlagsStart, + MultiContextEvaluationContext, +} from '@kbn/core-feature-flags-server'; +import type { Logger } from '@kbn/logging'; +import apm from 'elastic-apm-node'; +import { + type Client, + OpenFeature, + ServerProviderEvents, + NOOP_PROVIDER, +} from '@openfeature/server-sdk'; +import deepMerge from 'deepmerge'; +import { filter, switchMap, startWith, Subject } from 'rxjs'; +import { type FeatureFlagsConfig, featureFlagsConfig } from './feature_flags_config'; + +/** + * Core-internal contract for the setup lifecycle step. + * @private + */ +export interface InternalFeatureFlagsSetup extends FeatureFlagsSetup { + /** + * Used by the rendering service to share the overrides with the service on the browser side. + */ + getOverrides: () => Record; +} + +/** + * The server-side Feature Flags Service + * @private + */ +export class FeatureFlagsService { + private readonly featureFlagsClient: Client; + private readonly logger: Logger; + private overrides: Record = {}; + private context: MultiContextEvaluationContext = { kind: 'multi' }; + + /** + * The core service's constructor + * @param core {@link CoreContext} + */ + constructor(private readonly core: CoreContext) { + this.logger = core.logger.get('feature-flags-service'); + this.featureFlagsClient = OpenFeature.getClient(); + OpenFeature.setLogger(this.logger.get('open-feature')); + } + + /** + * Setup lifecycle method + */ + public setup(): InternalFeatureFlagsSetup { + // Register "overrides" to be changed via the dynamic config endpoint (enabled in test environments only) + this.core.configService.addDynamicConfigPaths(featureFlagsConfig.path, ['overrides']); + + this.core.configService + .atPath(featureFlagsConfig.path) + .subscribe(({ overrides = {} }) => { + this.overrides = overrides; + }); + + return { + getOverrides: () => this.overrides, + setProvider: (provider) => { + if (OpenFeature.providerMetadata !== NOOP_PROVIDER.metadata) { + throw new Error('A provider has already been set. This API cannot be called twice.'); + } + OpenFeature.setProvider(provider); + }, + appendContext: (contextToAppend) => this.appendContext(contextToAppend), + }; + } + + /** + * Start lifecycle method + */ + public start(): FeatureFlagsStart { + const featureFlagsChanged$ = new Subject(); + this.featureFlagsClient.addHandler(ServerProviderEvents.ConfigurationChanged, (event) => { + if (event?.flagsChanged) { + featureFlagsChanged$.next(event.flagsChanged); + } + }); + const observeFeatureFlag$ = (flagName: string) => + featureFlagsChanged$.pipe( + filter((flagNames) => flagNames.includes(flagName)), + startWith([flagName]) // only to emit on the first call + ); + + return { + appendContext: (contextToAppend) => this.appendContext(contextToAppend), + getBooleanValue: async (flagName, fallbackValue) => + this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue), + getStringValue: async (flagName: string, fallbackValue: Value) => + await this.evaluateFlag( + this.featureFlagsClient.getStringValue, + flagName, + fallbackValue + ), + getNumberValue: async (flagName: string, fallbackValue: Value) => + await this.evaluateFlag( + this.featureFlagsClient.getNumberValue, + flagName, + fallbackValue + ), + getBooleanValue$: (flagName, fallbackValue) => { + return observeFeatureFlag$(flagName).pipe( + switchMap(() => + this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue) + ) + ); + }, + getStringValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + switchMap(() => + this.evaluateFlag( + this.featureFlagsClient.getStringValue, + flagName, + fallbackValue + ) + ) + ); + }, + getNumberValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + switchMap(() => + this.evaluateFlag( + this.featureFlagsClient.getNumberValue, + flagName, + fallbackValue + ) + ) + ); + }, + }; + } + + /** + * Stop lifecycle method + */ + public async stop() { + await OpenFeature.close(); + } + + /** + * Wrapper to evaluate flags with the common config overrides interceptions + APM and counters reporting + * @param evaluationFn The actual evaluation API + * @param flagName The name of the flag to evaluate + * @param fallbackValue The fallback value + * @private + */ + private async evaluateFlag( + evaluationFn: (flagName: string, fallbackValue: T) => Promise, + flagName: string, + fallbackValue: T + ): Promise { + const value = + typeof this.overrides[flagName] !== 'undefined' + ? (this.overrides[flagName] as T) + : // We have to bind the evaluation or the client will lose its internal context + await evaluationFn.bind(this.featureFlagsClient)(flagName, fallbackValue); + apm.addLabels({ [`flag_${flagName}`]: value }); + // TODO: increment usage counter + return value; + } + + /** + * Formats the provided context to fulfill the expected multi-context structure. + * @param contextToAppend The {@link EvaluationContext} to append. + * @private + */ + private appendContext(contextToAppend: EvaluationContext): void { + // If no kind provided, default to the project|deployment level. + const { kind = 'kibana', ...rest } = contextToAppend; + // Format the context to fulfill the expected multi-context structure + const formattedContextToAppend: MultiContextEvaluationContext = + kind === 'multi' + ? (contextToAppend as MultiContextEvaluationContext) + : { kind: 'multi', [kind]: rest }; + + // Merge the formatted context to append to the global context, and set it in the OpenFeature client. + this.context = deepMerge(this.context, formattedContextToAppend); + OpenFeature.setContext(this.context); + } +} diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json b/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json new file mode 100644 index 0000000000000..72a97ef56eb4f --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/core-base-server-internal", + "@kbn/core-feature-flags-server", + "@kbn/logging", + "@kbn/core-base-server-mocks", + "@kbn/config-schema", + "@kbn/config-mocks", + ] +} diff --git a/packages/core/feature-flags/core-feature-flags-server-mocks/README.md b/packages/core/feature-flags/core-feature-flags-server-mocks/README.md new file mode 100644 index 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..182f6dbc21102 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { + FeatureFlagsRequestHandlerContext, + FeatureFlagsSetup, + FeatureFlagsStart, +} from '@kbn/core-feature-flags-server'; +import type { + FeatureFlagsService, + InternalFeatureFlagsSetup, +} from '@kbn/core-feature-flags-server-internal'; +import { of } from 'rxjs'; + +const createFeatureFlagsInternalSetup = (): jest.Mocked => { + return { + ...createFeatureFlagsSetup(), + getOverrides: jest.fn().mockReturnValue({}), + }; +}; + +const createFeatureFlagsSetup = (): jest.Mocked => { + return { + setProvider: jest.fn(), + appendContext: jest.fn(), + }; +}; + +const createFeatureFlagsStart = (): jest.Mocked => { + return { + appendContext: jest.fn(), + getBooleanValue: jest.fn().mockImplementation(async (_, fallback) => fallback), + getNumberValue: jest.fn().mockImplementation(async (_, fallback) => fallback), + getStringValue: jest.fn().mockImplementation(async (_, fallback) => fallback), + getBooleanValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)), + getStringValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)), + getNumberValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)), + }; +}; + +const createRequestHandlerContext = (): jest.Mocked => { + return { + getBooleanValue: jest.fn().mockImplementation(async (_, fallback) => fallback), + getNumberValue: jest.fn().mockImplementation(async (_, fallback) => fallback), + getStringValue: jest.fn().mockImplementation(async (_, fallback) => fallback), + }; +}; + +const createFeatureFlagsServiceMock = (): jest.Mocked> => { + return { + setup: jest.fn().mockImplementation(createFeatureFlagsInternalSetup), + start: jest.fn().mockImplementation(createFeatureFlagsStart), + stop: jest.fn().mockImplementation(Promise.resolve), + }; +}; + +/** + * Mocks for the Feature Flags service (browser-side) + */ +export const coreFeatureFlagsMock = { + /** + * Mocks the entire feature flags service + */ + create: createFeatureFlagsServiceMock, + /** + * Mocks the core-internal setup contract + */ + createInternalSetup: createFeatureFlagsInternalSetup, + /** + * Mocks the setup contract + */ + createSetup: createFeatureFlagsSetup, + /** + * Mocks the start contract + */ + createStart: createFeatureFlagsStart, + /** + * Mocks the request handler context contract + */ + createRequestHandlerContext, +}; diff --git a/packages/core/feature-flags/core-feature-flags-server-mocks/jest.config.js b/packages/core/feature-flags/core-feature-flags-server-mocks/jest.config.js new file mode 100644 index 0000000000000..bc50c37548c95 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../../../..', + roots: ['/packages/core/feature-flags/core-feature-flags-server-mocks'], +}; diff --git a/packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc new file mode 100644 index 0000000000000..69b03f0badbdc --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-server", + "id": "@kbn/core-feature-flags-server-mocks", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-server-mocks/package.json b/packages/core/feature-flags/core-feature-flags-server-mocks/package.json new file mode 100644 index 0000000000000..f009e55f76a8e --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-feature-flags-server-mocks", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-server-mocks/tsconfig.json b/packages/core/feature-flags/core-feature-flags-server-mocks/tsconfig.json new file mode 100644 index 0000000000000..c672eb28c83a9 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/utility-types", + "@kbn/core-feature-flags-server", + "@kbn/core-feature-flags-server-internal", + ] +} diff --git a/packages/core/feature-flags/core-feature-flags-server/README.md b/packages/core/feature-flags/core-feature-flags-server/README.md new file mode 100644 index 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..7538b68686cd9 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { + EvaluationContext, + MultiContextEvaluationContext, + SingleContextEvaluationContext, + FeatureFlagsSetup, + FeatureFlagsStart, +} from './src/contracts'; +export type { FeatureFlagDefinition, FeatureFlagDefinitions } from './src/feature_flag_definition'; +export type { FeatureFlagsRequestHandlerContext } from './src/request_handler_context'; diff --git a/packages/core/feature-flags/core-feature-flags-server/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-server/kibana.jsonc new file mode 100644 index 0000000000000..dc896ed83b97b --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-server", + "id": "@kbn/core-feature-flags-server", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-server/package.json b/packages/core/feature-flags/core-feature-flags-server/package.json new file mode 100644 index 0000000000000..d1f18a98a3840 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-feature-flags-server", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-server/src/contracts.ts b/packages/core/feature-flags/core-feature-flags-server/src/contracts.ts new file mode 100644 index 0000000000000..34fc3a3a73383 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/src/contracts.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Provider } from '@openfeature/server-sdk'; +import { type EvaluationContext as OpenFeatureEvaluationContext } from '@openfeature/core'; +import type { Observable } from 'rxjs'; + +/** + * The evaluation context to use when retrieving the flags. + * + * We use multi-context so that we can apply segmentation rules at different levels (`organization`/`kibana`). + * * `organization` includes any information that is common to all the projects/deployments in an organization. An example is the in_trial status. + * * The `kibana` context includes all the information that identifies a project/deployment. Examples are version, offering, and has_data. + * Kind helps us specify which sub-context should receive the new attributes. + * If no `kind` is provided, it defaults to `kibana`. + * + * @example Providing properties for both contexts + * { + * kind: 'multi', + * organization: { + * key: 1234, + * in_trial: true, + * }, + * kibana: { + * key: 12345567890, + * version: 8.15.0, + * buildHash: 'ffffffffaaaaaaaa', + * }, + * } + * + * @example Appending context to the organization sub-context + * { + * kind: 'organization', + * key: 1234, + * in_trial: true, + * } + * + * @example Appending context to the `kibana` sub-context + * { + * key: 12345567890, + * version: 8.15.0, + * buildHash: 'ffffffffaaaaaaaa', + * } + * } + * + * @public + */ +export type EvaluationContext = MultiContextEvaluationContext | SingleContextEvaluationContext; + +/** + * Multi-context format. The sub-contexts are provided in their nested properties. + * @public + */ +export type MultiContextEvaluationContext = OpenFeatureEvaluationContext & { + /** + * Static `multi` string + */ + kind: 'multi'; + /** + * The Elastic Cloud organization-specific context. + */ + organization?: OpenFeatureEvaluationContext; + /** + * The deployment/project-specific context. + */ + kibana?: OpenFeatureEvaluationContext; +}; + +/** + * Single Context format. If `kind` is not specified, it applies to the `kibana` sub-context. + */ +export type SingleContextEvaluationContext = OpenFeatureEvaluationContext & { + /** + * The sub-context that it's updated. Defaults to `kibana`. + */ + kind?: 'organization' | 'kibana'; +}; + +/** + * Setup contract of the Feature Flags Service + * @public + */ +export interface FeatureFlagsSetup { + /** + * Registers an OpenFeature provider to talk to the + * 3rd-party service that manages the Feature Flags. + * @param provider The {@link Provider | OpenFeature Provider} to handle the communication with the feature flags management system. + * @public + */ + setProvider(provider: Provider): void; + + /** + * Appends new keys to the evaluation context. + * @param contextToAppend The additional keys that should be appended/modified in the evaluation context. + * @public + */ + appendContext(contextToAppend: EvaluationContext): void; +} + +/** + * Setup contract of the Feature Flags Service + * @public + */ +export interface FeatureFlagsStart { + /** + * Appends new keys to the evaluation context. + * @param contextToAppend The additional keys that should be appended/modified in the evaluation context. + * @public + */ + appendContext(contextToAppend: EvaluationContext): void; + + /** + * Evaluates a boolean flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getBooleanValue(flagName: string, fallbackValue: boolean): Promise; + + /** + * Evaluates a string flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getStringValue(flagName: string, fallbackValue: Value): Promise; + + /** + * Evaluates a number flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getNumberValue(flagName: string, fallbackValue: Value): Promise; + + /** + * Returns an observable of a boolean flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getBooleanValue$(flagName: string, fallbackValue: boolean): Observable; + + /** + * Returns an observable of a string flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getStringValue$(flagName: string, fallbackValue: Value): Observable; + + /** + * Returns an observable of a number flag + * @param flagName The flag ID to evaluate + * @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided. + * @public + */ + getNumberValue$(flagName: string, fallbackValue: Value): Observable; +} diff --git a/packages/core/feature-flags/core-feature-flags-server/src/feature_flag_definition.ts b/packages/core/feature-flags/core-feature-flags-server/src/feature_flag_definition.ts new file mode 100644 index 0000000000000..3ea761484fc2a --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/src/feature_flag_definition.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * List of {@link FeatureFlagDefinition} + */ +export type FeatureFlagDefinitions = Array< + | FeatureFlagDefinition<'boolean'> + | FeatureFlagDefinition<'string'> + | FeatureFlagDefinition<'number'> +>; + +/** + * Definition of a feature flag + */ +export interface FeatureFlagDefinition { + /** + * The ID of the feature flag. Used to reference it when evaluating the flag. + */ + key: string; + /** + * Human friendly name. + */ + name: string; + /** + * Description of the purpose of the feature flag. + */ + description?: string; + /** + * Tags to apply to the feature flag for easier categorizing. It may include the plugin, the solution, the team. + */ + tags: string[]; + /** + * The type of the values returned by the feature flag ("string", "boolean", or "number"). + */ + variationType: ValueType; + /** + * List of variations of the feature flags. + */ + variations: Array<{ + /** + * Human friendly name of the variation. + */ + name: string; + /** + * Description of the variation. + */ + description?: string; + /** + * The value of the variation. + */ + value: ValueType extends 'string' ? string : ValueType extends 'boolean' ? boolean : number; + }>; +} diff --git a/packages/core/feature-flags/core-feature-flags-server/src/request_handler_context.ts b/packages/core/feature-flags/core-feature-flags-server/src/request_handler_context.ts new file mode 100644 index 0000000000000..25f521e18f1c9 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/src/request_handler_context.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { FeatureFlagsStart } from '..'; + +/** + * The HTTP request handler context for evaluating feature flags + */ +export type FeatureFlagsRequestHandlerContext = Pick< + FeatureFlagsStart, + 'getBooleanValue' | 'getStringValue' | 'getNumberValue' +>; diff --git a/packages/core/feature-flags/core-feature-flags-server/tsconfig.json b/packages/core/feature-flags/core-feature-flags-server/tsconfig.json new file mode 100644 index 0000000000000..f5bb1b00512e4 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/packages/core/http/core-http-request-handler-context-server-internal/src/core_route_handler_context.ts b/packages/core/http/core-http-request-handler-context-server-internal/src/core_route_handler_context.ts index 2e1e3b790b55a..376eb5a2bd24f 100644 --- a/packages/core/http/core-http-request-handler-context-server-internal/src/core_route_handler_context.ts +++ b/packages/core/http/core-http-request-handler-context-server-internal/src/core_route_handler_context.ts @@ -33,12 +33,15 @@ import { CoreUserProfileRouteHandlerContext, type InternalUserProfileServiceStart, } from '@kbn/core-user-profile-server-internal'; +import { CoreFeatureFlagsRouteHandlerContext } from '@kbn/core-feature-flags-server-internal'; +import type { FeatureFlagsStart } from '@kbn/core-feature-flags-server'; /** * Subset of `InternalCoreStart` used by {@link CoreRouteHandlerContext} * @internal */ export interface CoreRouteHandlerContextParams { + featureFlags: FeatureFlagsStart; elasticsearch: InternalElasticsearchServiceStart; savedObjects: InternalSavedObjectsServiceStart; uiSettings: InternalUiSettingsServiceStart; @@ -53,6 +56,7 @@ export interface CoreRouteHandlerContextParams { * @internal */ export class CoreRouteHandlerContext implements CoreRequestHandlerContext { + #featureFlags?: CoreFeatureFlagsRouteHandlerContext; #elasticsearch?: CoreElasticsearchRouteHandlerContext; #savedObjects?: CoreSavedObjectsRouteHandlerContext; #uiSettings?: CoreUiSettingsRouteHandlerContext; @@ -65,6 +69,13 @@ export class CoreRouteHandlerContext implements CoreRequestHandlerContext { private readonly request: KibanaRequest ) {} + public get featureFlags() { + if (!this.#featureFlags) { + this.#featureFlags = new CoreFeatureFlagsRouteHandlerContext(this.coreStart.featureFlags); + } + return this.#featureFlags; + } + public get elasticsearch() { if (!this.#elasticsearch) { this.#elasticsearch = new CoreElasticsearchRouteHandlerContext( diff --git a/packages/core/http/core-http-request-handler-context-server-internal/src/test_helpers/core_route_handler_context_params.mock.ts b/packages/core/http/core-http-request-handler-context-server-internal/src/test_helpers/core_route_handler_context_params.mock.ts index 1839f51a68c62..01b3c7aee4656 100644 --- a/packages/core/http/core-http-request-handler-context-server-internal/src/test_helpers/core_route_handler_context_params.mock.ts +++ b/packages/core/http/core-http-request-handler-context-server-internal/src/test_helpers/core_route_handler_context_params.mock.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { savedObjectsServiceMock } from '@kbn/core-saved-objects-server-mocks'; import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks'; @@ -16,6 +17,7 @@ import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks'; export const createCoreRouteHandlerContextParamsMock = () => { return { + featureFlags: coreFeatureFlagsMock.createStart(), elasticsearch: elasticsearchServiceMock.createInternalStart(), savedObjects: savedObjectsServiceMock.createInternalStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), diff --git a/packages/core/http/core-http-request-handler-context-server-internal/tsconfig.json b/packages/core/http/core-http-request-handler-context-server-internal/tsconfig.json index 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/preboot_request_handler_context.ts b/packages/core/http/core-http-request-handler-context-server/src/preboot_request_handler_context.ts index 8f2755403e1d8..294adf380d8e1 100644 --- a/packages/core/http/core-http-request-handler-context-server/src/preboot_request_handler_context.ts +++ b/packages/core/http/core-http-request-handler-context-server/src/preboot_request_handler_context.ts @@ -11,22 +11,34 @@ import type { RequestHandlerContextBase } from '@kbn/core-http-server'; import type { IUiSettingsClient } from '@kbn/core-ui-settings-server'; /** + * `uiSettings` http request context provider during the preboot phase. * @public */ export interface PrebootUiSettingsRequestHandlerContext { + /** + * The {@link IUiSettingsClient | UI Settings client}. + */ client: IUiSettingsClient; } /** + * The `core` context provided to route handler during the preboot phase. * @public */ export interface PrebootCoreRequestHandlerContext { + /** + * {@link PrebootUiSettingsRequestHandlerContext} + */ uiSettings: PrebootUiSettingsRequestHandlerContext; } /** + * Base context passed to a route handler during the preboot phase, containing the `core` context part. * @public */ export interface PrebootRequestHandlerContext extends RequestHandlerContextBase { + /** + * Promise that resolves the {@link PrebootCoreRequestHandlerContext} + */ core: Promise; } diff --git a/packages/core/http/core-http-request-handler-context-server/src/request_handler_context.ts b/packages/core/http/core-http-request-handler-context-server/src/request_handler_context.ts index 0dc5655c820fa..1d77e033b8e62 100644 --- a/packages/core/http/core-http-request-handler-context-server/src/request_handler_context.ts +++ b/packages/core/http/core-http-request-handler-context-server/src/request_handler_context.ts @@ -14,6 +14,7 @@ import type { DeprecationsRequestHandlerContext } from '@kbn/core-deprecations-s import type { UiSettingsRequestHandlerContext } from '@kbn/core-ui-settings-server'; import type { SecurityRequestHandlerContext } from '@kbn/core-security-server'; import type { UserProfileRequestHandlerContext } from '@kbn/core-user-profile-server'; +import type { FeatureFlagsRequestHandlerContext } from '@kbn/core-feature-flags-server'; /** * The `core` context provided to route handler. @@ -30,11 +31,33 @@ import type { UserProfileRequestHandlerContext } from '@kbn/core-user-profile-se * @public */ export interface CoreRequestHandlerContext { + /** + * {@link SavedObjectsRequestHandlerContext} + */ savedObjects: SavedObjectsRequestHandlerContext; + /** + * {@link ElasticsearchRequestHandlerContext} + */ elasticsearch: ElasticsearchRequestHandlerContext; + /** + * {@link FeatureFlagsRequestHandlerContext} + */ + featureFlags: FeatureFlagsRequestHandlerContext; + /** + * {@link UiSettingsRequestHandlerContext} + */ uiSettings: UiSettingsRequestHandlerContext; + /** + * {@link DeprecationsRequestHandlerContext} + */ deprecations: DeprecationsRequestHandlerContext; + /** + * {@link SecurityRequestHandlerContext} + */ security: SecurityRequestHandlerContext; + /** + * {@link UserProfileRequestHandlerContext} + */ userProfile: UserProfileRequestHandlerContext; } @@ -44,6 +67,9 @@ export interface CoreRequestHandlerContext { * @public */ export interface RequestHandlerContext extends RequestHandlerContextBase { + /** + * Promise that resolves the {@link CoreRequestHandlerContext} + */ core: Promise; } diff --git a/packages/core/http/core-http-request-handler-context-server/tsconfig.json b/packages/core/http/core-http-request-handler-context-server/tsconfig.json index 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/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.test.ts b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.test.ts index dd1e768a64709..dd315b38fe3c2 100644 --- a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.test.ts +++ b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.test.ts @@ -9,6 +9,7 @@ import type { DiscoveredPlugin } from '@kbn/core-base-common'; import { InjectedMetadataService } from './injected_metadata_service'; +import type { InjectedMetadataParams } from '..'; describe('setup.getElasticsearchInfo()', () => { it('returns elasticsearch info from injectedMetadata', () => { @@ -160,3 +161,29 @@ describe('setup.getLegacyMetadata()', () => { }).toThrowError(); }); }); + +describe('setup.getFeatureFlags()', () => { + it('returns injectedMetadata.featureFlags', () => { + const injectedMetadata = new InjectedMetadataService({ + injectedMetadata: { + featureFlags: { + overrides: { + 'my-overridden-flag': 1234, + }, + }, + }, + } as unknown as InjectedMetadataParams); + + const contract = injectedMetadata.setup(); + expect(contract.getFeatureFlags()).toStrictEqual({ overrides: { 'my-overridden-flag': 1234 } }); + }); + + it('returns empty injectedMetadata.featureFlags', () => { + const injectedMetadata = new InjectedMetadataService({ + injectedMetadata: {}, + } as unknown as InjectedMetadataParams); + + const contract = injectedMetadata.setup(); + expect(contract.getFeatureFlags()).toBeUndefined(); + }); +}); diff --git a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.ts b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.ts index 624c213ce11bc..b9594b9f042e3 100644 --- a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.ts +++ b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.ts @@ -95,6 +95,10 @@ export class InjectedMetadataService { getCustomBranding: () => { return this.state.customBranding; }, + + getFeatureFlags: () => { + return this.state.featureFlags; + }, }; } } diff --git a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts index f8c730414746d..244b99da0c207 100644 --- a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts +++ b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts @@ -58,6 +58,11 @@ export interface InternalInjectedMetadataSetup { }; }; getCustomBranding: () => CustomBranding; + getFeatureFlags: () => + | { + overrides: Record; + } + | undefined; } /** @internal */ diff --git a/packages/core/injected-metadata/core-injected-metadata-browser-mocks/src/injected_metadata_service.mock.ts b/packages/core/injected-metadata/core-injected-metadata-browser-mocks/src/injected_metadata_service.mock.ts index 9ee48eda09210..804134cabd4b9 100644 --- a/packages/core/injected-metadata/core-injected-metadata-browser-mocks/src/injected_metadata_service.mock.ts +++ b/packages/core/injected-metadata/core-injected-metadata-browser-mocks/src/injected_metadata_service.mock.ts @@ -30,6 +30,7 @@ const createSetupContractMock = () => { getPlugins: jest.fn(), getKibanaBuildNumber: jest.fn(), getCustomBranding: jest.fn(), + getFeatureFlags: jest.fn(), }; setupContract.getBasePath.mockReturnValue('/base-path'); setupContract.getServerBasePath.mockReturnValue('/server-base-path'); diff --git a/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts b/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts index 498e1ff0f15e9..1ee75dbfc0d5d 100644 --- a/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts +++ b/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts @@ -63,6 +63,9 @@ export interface InjectedMetadata { mode: EnvironmentMode; packageInfo: PackageInfo; }; + featureFlags?: { + overrides: Record; + }; anonymousStatusPage: boolean; i18n: { translationsUrl: string; diff --git a/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_setup.ts b/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_setup.ts index 9730936cedb93..07695d37bea69 100644 --- a/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_setup.ts +++ b/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_setup.ts @@ -13,6 +13,7 @@ import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata- import type { InternalHttpSetup } from '@kbn/core-http-browser-internal'; import type { InternalSecurityServiceSetup } from '@kbn/core-security-browser-internal'; import type { InternalUserProfileServiceSetup } from '@kbn/core-user-profile-browser-internal'; +import type { FeatureFlagsSetup } from '@kbn/core-feature-flags-browser'; /** @internal */ export interface InternalCoreSetup @@ -21,6 +22,7 @@ export interface InternalCoreSetup 'application' | 'plugins' | 'getStartServices' | 'http' | 'security' | 'userProfile' > { application: InternalApplicationSetup; + featureFlags: FeatureFlagsSetup; injectedMetadata: InternalInjectedMetadataSetup; http: InternalHttpSetup; security: InternalSecurityServiceSetup; diff --git a/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_start.ts b/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_start.ts index 0571ca18955b1..f422275f53019 100644 --- a/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_start.ts +++ b/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_start.ts @@ -13,11 +13,13 @@ import type { InternalInjectedMetadataStart } from '@kbn/core-injected-metadata- import type { InternalHttpStart } from '@kbn/core-http-browser-internal'; import type { InternalSecurityServiceStart } from '@kbn/core-security-browser-internal'; import type { InternalUserProfileServiceStart } from '@kbn/core-user-profile-browser-internal'; +import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser'; /** @internal */ export interface InternalCoreStart extends Omit { application: InternalApplicationStart; + featureFlags: FeatureFlagsStart; injectedMetadata: InternalInjectedMetadataStart; http: InternalHttpStart; security: InternalSecurityServiceStart; diff --git a/packages/core/lifecycle/core-lifecycle-browser-internal/tsconfig.json b/packages/core/lifecycle/core-lifecycle-browser-internal/tsconfig.json index 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/src/core_setup.mock.ts b/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_setup.mock.ts index 1a3f6e22ff39a..9937286104efe 100644 --- a/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_setup.mock.ts +++ b/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_setup.mock.ts @@ -21,6 +21,7 @@ import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-moc import { securityServiceMock } from '@kbn/core-security-browser-mocks'; import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks'; import { createCoreStartMock } from './core_start.mock'; +import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-browser-mocks'; export function createCoreSetupMock({ basePath = '', @@ -38,6 +39,7 @@ export function createCoreSetupMock({ docLinks: docLinksServiceMock.createSetupContract(), executionContext: executionContextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), + featureFlags: coreFeatureFlagsMock.createSetup(), getStartServices: jest.fn, any, any]>, []>(() => Promise.resolve([createCoreStartMock({ basePath }), pluginStartDeps, pluginStartContract]) ), diff --git a/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_start.mock.ts b/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_start.mock.ts index 71f5dd0db3958..f6f5aa5493f73 100644 --- a/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_start.mock.ts +++ b/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_start.mock.ts @@ -24,6 +24,7 @@ import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks'; import { securityServiceMock } from '@kbn/core-security-browser-mocks'; import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks'; +import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-browser-mocks'; export function createCoreStartMock({ basePath = '' } = {}) { const mock = { @@ -33,6 +34,7 @@ export function createCoreStartMock({ basePath = '' } = {}) { customBranding: customBrandingServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), executionContext: executionContextServiceMock.createStartContract(), + featureFlags: coreFeatureFlagsMock.createStart(), http: httpServiceMock.createStartContract({ basePath }), i18n: i18nServiceMock.createStartContract(), notifications: notificationServiceMock.createStartContract(), diff --git a/packages/core/lifecycle/core-lifecycle-browser-mocks/tsconfig.json b/packages/core/lifecycle/core-lifecycle-browser-mocks/tsconfig.json index 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/src/core_setup.ts b/packages/core/lifecycle/core-lifecycle-browser/src/core_setup.ts index a989bdda60426..bef46aa4b84c1 100644 --- a/packages/core/lifecycle/core-lifecycle-browser/src/core_setup.ts +++ b/packages/core/lifecycle/core-lifecycle-browser/src/core_setup.ts @@ -10,6 +10,7 @@ import type { ThemeServiceSetup } from '@kbn/core-theme-browser'; import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser'; import type { ExecutionContextSetup } from '@kbn/core-execution-context-browser'; +import type { FeatureFlagsSetup } from '@kbn/core-feature-flags-browser'; import type { HttpSetup } from '@kbn/core-http-browser'; import type { FatalErrorsSetup } from '@kbn/core-fatal-errors-browser'; import type { IUiSettingsClient, SettingsStart } from '@kbn/core-ui-settings-browser'; @@ -44,6 +45,8 @@ export interface CoreSetup & { elasticsearch: ReturnType; @@ -61,6 +62,7 @@ export function createCoreSetupMock({ userSettings: userSettingsServiceMock.createSetupContract(), docLinks: docLinksServiceMock.createSetupContract(), elasticsearch: elasticsearchServiceMock.createSetup(), + featureFlags: coreFeatureFlagsMock.createSetup(), http: httpMock, i18n: i18nServiceMock.createSetupContract(), savedObjects: savedObjectsServiceMock.createInternalSetupContract(), diff --git a/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_start.mock.ts b/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_start.mock.ts index 8bbdb322bb14a..d4b341bcf2f54 100644 --- a/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_start.mock.ts +++ b/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_start.mock.ts @@ -22,6 +22,7 @@ import { coreUsageDataServiceMock } from '@kbn/core-usage-data-server-mocks'; import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mocks'; import { securityServiceMock } from '@kbn/core-security-server-mocks'; import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks'; +import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; export function createCoreStartMock() { const mock: MockedKeys = { @@ -29,6 +30,7 @@ export function createCoreStartMock() { capabilities: capabilitiesServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), elasticsearch: elasticsearchServiceMock.createStart(), + featureFlags: coreFeatureFlagsMock.createStart(), http: httpServiceMock.createStartContract(), metrics: metricsServiceMock.createStartContract(), savedObjects: savedObjectsServiceMock.createStartContract(), diff --git a/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_setup.mock.ts b/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_setup.mock.ts index 4919fa0c65eb5..4e6ca5b75059f 100644 --- a/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_setup.mock.ts +++ b/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_setup.mock.ts @@ -29,6 +29,7 @@ import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mock import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks'; import { securityServiceMock } from '@kbn/core-security-server-mocks'; import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks'; +import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; export function createInternalCoreSetupMock() { const setupDeps = { @@ -37,6 +38,7 @@ export function createInternalCoreSetupMock() { context: contextServiceMock.createSetupContract(), docLinks: docLinksServiceMock.createSetupContract(), elasticsearch: elasticsearchServiceMock.createInternalSetup(), + featureFlags: coreFeatureFlagsMock.createInternalSetup(), http: httpServiceMock.createInternalSetupContract(), savedObjects: savedObjectsServiceMock.createInternalSetupContract(), status: statusServiceMock.createInternalSetupContract(), diff --git a/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_start.mock.ts b/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_start.mock.ts index 4e0d63f654516..248a9712057f4 100644 --- a/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_start.mock.ts +++ b/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_start.mock.ts @@ -21,6 +21,7 @@ import { coreUsageDataServiceMock } from '@kbn/core-usage-data-server-mocks'; import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mocks'; import { securityServiceMock } from '@kbn/core-security-server-mocks'; import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks'; +import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; export function createInternalCoreStartMock() { const startDeps = { @@ -28,6 +29,7 @@ export function createInternalCoreStartMock() { capabilities: capabilitiesServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), elasticsearch: elasticsearchServiceMock.createInternalStart(), + featureFlags: coreFeatureFlagsMock.createStart(), http: httpServiceMock.createInternalStartContract(), metrics: metricsServiceMock.createInternalStartContract(), savedObjects: savedObjectsServiceMock.createInternalStartContract(), diff --git a/packages/core/lifecycle/core-lifecycle-server-mocks/tsconfig.json b/packages/core/lifecycle/core-lifecycle-server-mocks/tsconfig.json index 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/src/core_setup.ts b/packages/core/lifecycle/core-lifecycle-server/src/core_setup.ts index 63f56f22cc145..59616a5d65ecd 100644 --- a/packages/core/lifecycle/core-lifecycle-server/src/core_setup.ts +++ b/packages/core/lifecycle/core-lifecycle-server/src/core_setup.ts @@ -13,6 +13,7 @@ import type { DeprecationsServiceSetup } from '@kbn/core-deprecations-server'; import type { DocLinksServiceSetup } from '@kbn/core-doc-links-server'; import type { ElasticsearchServiceSetup } from '@kbn/core-elasticsearch-server'; import type { ExecutionContextSetup } from '@kbn/core-execution-context-server'; +import type { FeatureFlagsSetup } from '@kbn/core-feature-flags-server'; import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; import type { HttpResources } from '@kbn/core-http-resources-server'; import type { HttpServiceSetup } from '@kbn/core-http-server'; @@ -52,6 +53,8 @@ export interface CoreSetup & { /** {@link HttpResources} */ diff --git a/packages/core/lifecycle/core-lifecycle-server/src/core_start.ts b/packages/core/lifecycle/core-lifecycle-server/src/core_start.ts index ae6135c55800f..13d871df8b759 100644 --- a/packages/core/lifecycle/core-lifecycle-server/src/core_start.ts +++ b/packages/core/lifecycle/core-lifecycle-server/src/core_start.ts @@ -12,6 +12,7 @@ import type { CapabilitiesStart } from '@kbn/core-capabilities-server'; import type { DocLinksServiceStart } from '@kbn/core-doc-links-server'; import type { ElasticsearchServiceStart } from '@kbn/core-elasticsearch-server'; import type { ExecutionContextStart } from '@kbn/core-execution-context-server'; +import type { FeatureFlagsStart } from '@kbn/core-feature-flags-server'; import type { HttpServiceStart } from '@kbn/core-http-server'; import type { MetricsServiceStart } from '@kbn/core-metrics-server'; import type { SavedObjectsServiceStart } from '@kbn/core-saved-objects-server'; @@ -40,6 +41,8 @@ export interface CoreStart { elasticsearch: ElasticsearchServiceStart; /** {@link ExecutionContextStart} */ executionContext: ExecutionContextStart; + /** {@link FeatureFlagsStart} */ + featureFlags: FeatureFlagsStart; /** {@link HttpServiceStart} */ http: HttpServiceStart; /** {@link MetricsServiceStart} */ diff --git a/packages/core/lifecycle/core-lifecycle-server/tsconfig.json b/packages/core/lifecycle/core-lifecycle-server/tsconfig.json index 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/plugins/core-plugins-browser-internal/src/plugin_context.ts b/packages/core/plugins/core-plugins-browser-internal/src/plugin_context.ts index ad242905f7751..b78e5cec0b276 100644 --- a/packages/core/plugins/core-plugins-browser-internal/src/plugin_context.ts +++ b/packages/core/plugins/core-plugins-browser-internal/src/plugin_context.ts @@ -82,6 +82,7 @@ export function createPluginSetupContext< }, customBranding: deps.customBranding, fatalErrors: deps.fatalErrors, + featureFlags: deps.featureFlags, executionContext: deps.executionContext, http: { ...deps.http, @@ -147,6 +148,7 @@ export function createPluginStartContext< customBranding: deps.customBranding, docLinks: deps.docLinks, executionContext: deps.executionContext, + featureFlags: deps.featureFlags, http: { ...deps.http, staticAssets: { diff --git a/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts b/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts index 76306751427cf..539b629974982 100644 --- a/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts +++ b/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts @@ -218,6 +218,10 @@ export function createPluginSetupContext({ withContext: deps.executionContext.withContext, getAsLabels: deps.executionContext.getAsLabels, }, + featureFlags: { + setProvider: deps.featureFlags.setProvider, + appendContext: deps.featureFlags.appendContext, + }, http: { createCookieSessionStorageFactory: deps.http.createCookieSessionStorageFactory, registerRouteHandlerContext: < @@ -332,6 +336,15 @@ export function createPluginStartContext({ getCapabilities: deps.elasticsearch.getCapabilities, }, executionContext: deps.executionContext, + featureFlags: { + appendContext: deps.featureFlags.appendContext, + getBooleanValue: deps.featureFlags.getBooleanValue, + getStringValue: deps.featureFlags.getStringValue, + getNumberValue: deps.featureFlags.getNumberValue, + getBooleanValue$: deps.featureFlags.getBooleanValue$, + getStringValue$: deps.featureFlags.getStringValue$, + getNumberValue$: deps.featureFlags.getNumberValue$, + }, http: { auth: deps.http.auth, basePath: deps.http.basePath, diff --git a/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap b/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap index 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 1a746e1dbd784..b22697a494788 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts @@ -82,6 +82,10 @@ function renderTestCases( }); }); + afterEach(() => { + mockRenderingSetupDeps.featureFlags.getOverrides.mockReset(); + }); + it('renders "core" page', async () => { const [render] = await getRender(); const content = await render(createKibanaRequest(), uiSettings); @@ -245,6 +249,19 @@ function renderTestCases( expect(data).toMatchSnapshot(INJECTED_METADATA); }); + it('renders feature flags overrides', async () => { + mockRenderingSetupDeps.featureFlags.getOverrides.mockReturnValueOnce({ + 'my-overridden-flag': 1234, + }); + const [render] = await getRender(); + const content = await render(createKibanaRequest(), uiSettings, { + isAnonymousPage: false, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + it('renders "core" with logging config injected', async () => { const loggingConfig = { root: { diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx index 25a10be54f5bd..a696328475853 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx @@ -51,6 +51,7 @@ type RenderOptions = | (RenderingPrebootDeps & { status?: never; elasticsearch?: never; + featureFlags?: never; customBranding?: never; userSettings?: never; }); @@ -85,6 +86,7 @@ export class RenderingService { public async setup({ elasticsearch, + featureFlags, http, status, uiPlugins, @@ -106,6 +108,7 @@ export class RenderingService { return { render: this.render.bind(this, { elasticsearch, + featureFlags, http, uiPlugins, status, @@ -125,8 +128,16 @@ export class RenderingService { }, { isAnonymousPage = false, includeExposedConfigKeys }: IRenderOptions = {} ) { - const { elasticsearch, http, uiPlugins, status, customBranding, userSettings, i18n } = - renderOptions; + const { + elasticsearch, + featureFlags, + http, + uiPlugins, + status, + customBranding, + userSettings, + i18n, + } = renderOptions; const env = { mode: this.coreContext.env.mode, @@ -251,6 +262,9 @@ export class RenderingService { assetsHrefBase: staticAssetsHrefBase, logging: loggingConfig, env, + featureFlags: { + overrides: featureFlags?.getOverrides() || {}, + }, clusterInfo, apmConfig, anonymousStatusPage: status?.isStatusPageAnonymous() ?? false, diff --git a/packages/core/rendering/core-rendering-server-internal/src/test_helpers/params.ts b/packages/core/rendering/core-rendering-server-internal/src/test_helpers/params.ts index b8a3f1fe0c35b..735358f5aa92f 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/test_helpers/params.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/test_helpers/params.ts @@ -14,6 +14,7 @@ import { statusServiceMock } from '@kbn/core-status-server-mocks'; import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mocks'; import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks'; import { i18nServiceMock } from '@kbn/core-i18n-server-mocks'; +import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; const context = mockCoreContext.create(); const httpPreboot = httpServiceMock.createInternalPrebootContract(); @@ -39,6 +40,7 @@ export const mockRenderingPrebootDeps = { }; export const mockRenderingSetupDeps = { elasticsearch, + featureFlags: coreFeatureFlagsMock.createInternalSetup(), http: httpSetup, uiPlugins: createUiPlugins(), customBranding, diff --git a/packages/core/rendering/core-rendering-server-internal/src/types.ts b/packages/core/rendering/core-rendering-server-internal/src/types.ts index 57fee5e26cf48..1897ffdc08eb3 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/types.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/types.ts @@ -24,6 +24,7 @@ import type { CustomBranding } from '@kbn/core-custom-branding-common'; import type { InternalUserSettingsServiceSetup } from '@kbn/core-user-settings-server-internal'; import type { I18nServiceSetup } from '@kbn/core-i18n-server'; import type { InternalI18nServicePreboot } from '@kbn/core-i18n-server-internal'; +import type { InternalFeatureFlagsSetup } from '@kbn/core-feature-flags-server-internal'; /** @internal */ export interface RenderingMetadata { @@ -49,6 +50,7 @@ export interface RenderingPrebootDeps { /** @internal */ export interface RenderingSetupDeps { elasticsearch: InternalElasticsearchServiceSetup; + featureFlags: InternalFeatureFlagsSetup; http: InternalHttpServiceSetup; status: InternalStatusServiceSetup; uiPlugins: UiPlugins; diff --git a/packages/core/rendering/core-rendering-server-internal/tsconfig.json b/packages/core/rendering/core-rendering-server-internal/tsconfig.json index 2689069f79d79..28a22d3d51ca9 100644 --- a/packages/core/rendering/core-rendering-server-internal/tsconfig.json +++ b/packages/core/rendering/core-rendering-server-internal/tsconfig.json @@ -45,6 +45,8 @@ "@kbn/core-i18n-server-internal", "@kbn/core-i18n-server-mocks", "@kbn/apm-config-loader", + "@kbn/core-feature-flags-server-internal", + "@kbn/core-feature-flags-server-mocks", ], "exclude": [ "target/**/*", diff --git a/packages/core/root/core-root-browser-internal/src/core_system.ts b/packages/core/root/core-root-browser-internal/src/core_system.ts index 8428333b96686..44e25b257e32c 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.ts @@ -22,6 +22,7 @@ import { I18nService } from '@kbn/core-i18n-browser-internal'; import { ExecutionContextService } from '@kbn/core-execution-context-browser-internal'; import type { FatalErrorsSetup } from '@kbn/core-fatal-errors-browser'; import { FatalErrorsService } from '@kbn/core-fatal-errors-browser-internal'; +import { FeatureFlagsService } from '@kbn/core-feature-flags-browser-internal'; import { HttpService } from '@kbn/core-http-browser-internal'; import { SettingsService, UiSettingsService } from '@kbn/core-ui-settings-browser-internal'; import { DeprecationsService } from '@kbn/core-deprecations-browser-internal'; @@ -85,6 +86,7 @@ export class CoreSystem { private readonly loggingSystem: BrowserLoggingSystem; private readonly analytics: AnalyticsService; private readonly fatalErrors: FatalErrorsService; + private readonly featureFlags: FeatureFlagsService; private readonly injectedMetadata: InjectedMetadataService; private readonly notifications: NotificationsService; private readonly http: HttpService; @@ -132,6 +134,7 @@ export class CoreSystem { // Stop Core before rendering any fatal errors into the DOM this.stop(); }); + this.featureFlags = new FeatureFlagsService(this.coreContext); this.security = new SecurityService(this.coreContext); this.userProfile = new UserProfileService(this.coreContext); this.theme = new ThemeService(); @@ -251,11 +254,13 @@ export class CoreSystem { const application = this.application.setup({ http, analytics }); this.coreApp.setup({ application, http, injectedMetadata, notifications }); + const featureFlags = this.featureFlags.setup({ injectedMetadata }); const core: InternalCoreSetup = { analytics, application, fatalErrors: this.fatalErrorsSetup, + featureFlags, http, injectedMetadata, notifications, @@ -357,12 +362,15 @@ export class CoreSystem { theme, }); + const featureFlags = await this.featureFlags.start(); + const core: InternalCoreStart = { analytics, application, chrome, docLinks, executionContext, + featureFlags, http, theme, savedObjects, @@ -439,6 +447,7 @@ export class CoreSystem { this.deprecations.stop(); this.theme.stop(); this.analytics.stop(); + this.featureFlags.stop(); this.security.stop(); this.userProfile.stop(); this.rootDomElement.textContent = ''; diff --git a/packages/core/root/core-root-browser-internal/tsconfig.json b/packages/core/root/core-root-browser-internal/tsconfig.json index 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/src/register_service_config.ts b/packages/core/root/core-root-server-internal/src/register_service_config.ts index 3b131d721b4e8..ae38eba4c9ddc 100644 --- a/packages/core/root/core-root-server-internal/src/register_service_config.ts +++ b/packages/core/root/core-root-server-internal/src/register_service_config.ts @@ -33,6 +33,7 @@ import { config as deprecationConfig } from '@kbn/core-deprecations-server-inter import { statusConfig } from '@kbn/core-status-server-internal'; import { uiSettingsConfig } from '@kbn/core-ui-settings-server-internal'; import { config as pluginsConfig } from '@kbn/core-plugins-server-internal'; +import { featureFlagsConfig } from '@kbn/core-feature-flags-server-internal'; import { elasticApmConfig } from './root/elastic_config'; import { serverlessConfig } from './root/serverless_config'; import { coreConfig } from './core_config'; @@ -48,6 +49,7 @@ export function registerServiceConfig(configService: ConfigService) { coreAppConfig, elasticApmConfig, executionContextConfig, + featureFlagsConfig, externalUrlConfig, httpConfig, i18nConfig, diff --git a/packages/core/root/core-root-server-internal/src/server.ts b/packages/core/root/core-root-server-internal/src/server.ts index 64cf2c936e3be..447db192c3048 100644 --- a/packages/core/root/core-root-server-internal/src/server.ts +++ b/packages/core/root/core-root-server-internal/src/server.ts @@ -20,6 +20,7 @@ import { NodeService } from '@kbn/core-node-server-internal'; import { AnalyticsService } from '@kbn/core-analytics-server-internal'; import { EnvironmentService } from '@kbn/core-environment-server-internal'; import { ExecutionContextService } from '@kbn/core-execution-context-server-internal'; +import { FeatureFlagsService } from '@kbn/core-feature-flags-server-internal'; import { PrebootService } from '@kbn/core-preboot-server-internal'; import { ContextService } from '@kbn/core-http-context-server-internal'; import { HttpService } from '@kbn/core-http-server-internal'; @@ -69,6 +70,7 @@ export class Server { private readonly capabilities: CapabilitiesService; private readonly context: ContextService; private readonly elasticsearch: ElasticsearchService; + private readonly featureFlags: FeatureFlagsService; private readonly http: HttpService; private readonly rendering: RenderingService; private readonly log: Logger; @@ -118,6 +120,7 @@ export class Server { const core = { coreId, configService: this.configService, env, logger: this.logger }; this.analytics = new AnalyticsService(core); this.context = new ContextService(core); + this.featureFlags = new FeatureFlagsService(core); this.http = new HttpService(core); this.rendering = new RenderingService(core); this.plugins = new PluginsService(core); @@ -325,9 +328,11 @@ export class Server { const customBrandingSetup = this.customBranding.setup(); const userSettingsServiceSetup = this.userSettingsService.setup(); + const featureFlagsSetup = this.featureFlags.setup(); const renderingSetup = await this.rendering.setup({ elasticsearch: elasticsearchServiceSetup, + featureFlags: featureFlagsSetup, http: httpSetup, status: statusSetup, uiPlugins, @@ -352,6 +357,7 @@ export class Server { elasticsearch: elasticsearchServiceSetup, environment: environmentSetup, executionContext: executionContextSetup, + featureFlags: featureFlagsSetup, http: httpSetup, i18n: i18nServiceSetup, savedObjects: savedObjectsSetup, @@ -432,6 +438,8 @@ export class Server { exposedConfigsToUsage: this.plugins.getExposedPluginConfigsToUsage(), }); + const featureFlagsStart = this.featureFlags.start(); + this.status.start(); this.coreStart = { @@ -441,6 +449,7 @@ export class Server { docLinks: docLinkStart, elasticsearch: elasticsearchStart, executionContext: executionContextStart, + featureFlags: featureFlagsStart, http: httpStart, metrics: metricsStart, savedObjects: savedObjectsStart, @@ -484,6 +493,7 @@ export class Server { await this.status.stop(); await this.logging.stop(); await this.customBranding.stop(); + await this.featureFlags.stop(); this.node.stop(); this.deprecations.stop(); this.security.stop(); diff --git a/packages/core/root/core-root-server-internal/tsconfig.json b/packages/core/root/core-root-server-internal/tsconfig.json index 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/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index a7d94a423d606..116a087af89f6 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -14,14 +14,14 @@ pageLoadAssetSize: cloudChat: 19894 cloudDataMigration: 19170 cloudDefend: 18697 - cloudExperiments: 59358 + cloudExperiments: 109746 cloudFullStory: 18493 cloudLinks: 55984 cloudSecurityPosture: 19109 console: 46091 contentManagement: 16254 controls: 60000 - core: 435325 + core: 564663 crossClusterReplication: 65408 customIntegrations: 22034 dashboard: 52967 @@ -159,7 +159,7 @@ pageLoadAssetSize: spaces: 57868 stackAlerts: 58316 stackConnectors: 67227 - synthetics: 40958 + synthetics: 55971 telemetry: 51957 telemetryManagementSection: 38586 threatIntelligence: 44299 diff --git a/renovate.json b/renovate.json index d013a49ea37bd..eeb91efd871bf 100644 --- a/renovate.json +++ b/renovate.json @@ -78,7 +78,22 @@ }, { "groupName": "LaunchDarkly", - "matchDepNames": ["launchdarkly-js-client-sdk", "@launchdarkly/node-server-sdk", "launchdarkly/find-code-references"], + "matchDepNames": [ + "launchdarkly-js-client-sdk", + "@openfeature/launchdarkly-client-provider", + "@launchdarkly/node-server-sdk", + "@launchdarkly/openfeature-node-server", + "launchdarkly/find-code-references" + ], + "reviewers": ["team:kibana-security", "team:kibana-core"], + "matchBaseBranches": ["main"], + "labels": ["release_note:skip", "Team:Security", "Team:Core", "backport:prev-minor"], + "minimumReleaseAge": "7 days", + "enabled": true + }, + { + "groupName": "OpenFeature", + "matchDepNames": ["@openfeature/core", "@openfeature/server-sdk", "@openfeature/web-sdk"], "reviewers": ["team:kibana-security", "team:kibana-core"], "matchBaseBranches": ["main"], "labels": ["release_note:skip", "Team:Security", "Team:Core", "backport:prev-minor"], diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 91320b8ade383..ecce03d0b5092 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -37,6 +37,11 @@ export type { FatalErrorsStart, FatalErrorInfo, } from '@kbn/core-fatal-errors-browser'; +export type { + EvaluationContext, + FeatureFlagsSetup, + FeatureFlagsStart, +} from '@kbn/core-feature-flags-browser'; export type { UiSettingsState, IUiSettingsClient, diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index dcd30a738d3d1..61b9f7759c50f 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -21,6 +21,7 @@ export { themeServiceMock } from '@kbn/core-theme-browser-mocks'; export { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks'; export { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; export { executionContextServiceMock } from '@kbn/core-execution-context-browser-mocks'; +export { coreFeatureFlagsMock } from '@kbn/core-feature-flags-browser-mocks'; export { fatalErrorsServiceMock } from '@kbn/core-fatal-errors-browser-mocks'; export { httpServiceMock } from '@kbn/core-http-browser-mocks'; export { i18nServiceMock } from '@kbn/core-i18n-browser-mocks'; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index e684c9565d9ed..5282f2048dd06 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -71,6 +71,11 @@ export type { export type { KibanaExecutionContext } from '@kbn/core-execution-context-common'; export type { IExecutionContextContainer } from '@kbn/core-execution-context-server'; +export type { + EvaluationContext, + FeatureFlagsStart, + FeatureFlagsSetup, +} from '@kbn/core-feature-flags-server'; export type { Capabilities } from '@kbn/core-capabilities-common'; export type { CapabilitiesProvider, diff --git a/src/core/server/integration_tests/config/check_dynamic_config.test.ts b/src/core/server/integration_tests/config/check_dynamic_config.test.ts index 8cb9ac2466b62..eaffd56ed1b16 100644 --- a/src/core/server/integration_tests/config/check_dynamic_config.test.ts +++ b/src/core/server/integration_tests/config/check_dynamic_config.test.ts @@ -129,6 +129,8 @@ describe('checking all opted-in dynamic config settings', () => { */ test('detecting all the settings that have opted-in for dynamic in-memory updates', () => { expect(getListOfDynamicConfigPaths()).toStrictEqual([ + // Making testing easier by having the ability of overriding the feature flags without the need to restart + 'feature_flags.overrides', // We need this for enriching our Perf tests with more valuable data regarding the steps of the test // Also helpful in Cloud & Serverless testing because we can't control the labels in those offerings 'telemetry.labels', diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 0356d5e483103..0467b9c660db6 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -23,6 +23,7 @@ import { coreLifecycleMock, coreInternalLifecycleMock } from '@kbn/core-lifecycl import { securityServiceMock } from '@kbn/core-security-server-mocks'; import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks'; import type { SharedGlobalConfig, PluginInitializerContext } from '@kbn/core-plugins-server'; +import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; export { configServiceMock, configDeprecationsMock } from '@kbn/config-mocks'; export { loggingSystemMock } from '@kbn/core-logging-server-mocks'; @@ -46,6 +47,7 @@ export { deprecationsServiceMock } from '@kbn/core-deprecations-server-mocks'; export { coreUsageDataServiceMock } from '@kbn/core-usage-data-server-mocks'; export { i18nServiceMock } from '@kbn/core-i18n-server-mocks'; export { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks'; +export { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; export { docLinksServiceMock } from '@kbn/core-doc-links-server-mocks'; export { analyticsServiceMock } from '@kbn/core-analytics-server-mocks'; export { securityServiceMock } from '@kbn/core-security-server-mocks'; @@ -120,6 +122,7 @@ function pluginInitializerContextMock(config: T = {} as T) { function createCoreRequestHandlerContextMock() { return { + featureFlags: coreFeatureFlagsMock.createRequestHandlerContext(), savedObjects: { client: savedObjectsClientMock.create(), typeRegistry: savedObjectsTypeRegistryMock.create(), diff --git a/src/core/tsconfig.json b/src/core/tsconfig.json index 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/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/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index b9d2f51cc8c16..f1922eee52380 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -235,8 +235,6 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.cloud.trial_end_date (string?)', 'xpack.cloud_integrations.chat.chatURL (string?)', 'xpack.cloud_integrations.chat.trialBuffer (number?)', - // No PII. This is an escape patch to override LaunchDarkly's flag resolution mechanism for testing or quick fix. - 'xpack.cloud_integrations.experiments.flag_overrides (record?)', // Commented because it's inside a schema conditional, and the test is not able to resolve it. But it's shared. // Added here for documentation purposes. // 'xpack.cloud_integrations.experiments.launch_darkly.client_id (string)', diff --git a/tsconfig.base.json b/tsconfig.base.json index ae8b6c2c2a95f..1dd8335471582 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -360,6 +360,18 @@ "@kbn/core-fatal-errors-browser-internal/*": ["packages/core/fatal-errors/core-fatal-errors-browser-internal/*"], "@kbn/core-fatal-errors-browser-mocks": ["packages/core/fatal-errors/core-fatal-errors-browser-mocks"], "@kbn/core-fatal-errors-browser-mocks/*": ["packages/core/fatal-errors/core-fatal-errors-browser-mocks/*"], + "@kbn/core-feature-flags-browser": ["packages/core/feature-flags/core-feature-flags-browser"], + "@kbn/core-feature-flags-browser/*": ["packages/core/feature-flags/core-feature-flags-browser/*"], + "@kbn/core-feature-flags-browser-internal": ["packages/core/feature-flags/core-feature-flags-browser-internal"], + "@kbn/core-feature-flags-browser-internal/*": ["packages/core/feature-flags/core-feature-flags-browser-internal/*"], + "@kbn/core-feature-flags-browser-mocks": ["packages/core/feature-flags/core-feature-flags-browser-mocks"], + "@kbn/core-feature-flags-browser-mocks/*": ["packages/core/feature-flags/core-feature-flags-browser-mocks/*"], + "@kbn/core-feature-flags-server": ["packages/core/feature-flags/core-feature-flags-server"], + "@kbn/core-feature-flags-server/*": ["packages/core/feature-flags/core-feature-flags-server/*"], + "@kbn/core-feature-flags-server-internal": ["packages/core/feature-flags/core-feature-flags-server-internal"], + "@kbn/core-feature-flags-server-internal/*": ["packages/core/feature-flags/core-feature-flags-server-internal/*"], + "@kbn/core-feature-flags-server-mocks": ["packages/core/feature-flags/core-feature-flags-server-mocks"], + "@kbn/core-feature-flags-server-mocks/*": ["packages/core/feature-flags/core-feature-flags-server-mocks/*"], "@kbn/core-history-block-plugin": ["test/plugin_functional/plugins/core_history_block"], "@kbn/core-history-block-plugin/*": ["test/plugin_functional/plugins/core_history_block/*"], "@kbn/core-http-browser": ["packages/core/http/core-http-browser"], @@ -900,6 +912,8 @@ "@kbn/failed-test-reporter-cli/*": ["packages/kbn-failed-test-reporter-cli/*"], "@kbn/feature-controls-examples-plugin": ["examples/feature_control_examples"], "@kbn/feature-controls-examples-plugin/*": ["examples/feature_control_examples/*"], + "@kbn/feature-flags-example-plugin": ["examples/feature_flags_example"], + "@kbn/feature-flags-example-plugin/*": ["examples/feature_flags_example/*"], "@kbn/feature-usage-test-plugin": ["x-pack/test/plugin_api_integration/plugins/feature_usage_test"], "@kbn/feature-usage-test-plugin/*": ["x-pack/test/plugin_api_integration/plugins/feature_usage_test/*"], "@kbn/features-plugin": ["x-pack/plugins/features"], diff --git a/x-pack/plugins/cloud/server/mocks.ts b/x-pack/plugins/cloud/server/mocks.ts index 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_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..a2762b89e124a 100755 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts @@ -8,20 +8,14 @@ import { PluginInitializerContext, CoreSetup, Plugin } from '@kbn/core/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; -import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import { registerChatRoute } from './routes'; import type { CloudChatConfigType } from './config'; -import type { ChatVariant } from '../common/types'; interface CloudChatSetupDeps { cloud: CloudSetup; } -interface CloudChatStartDeps { - cloudExperiments?: CloudExperimentsPluginStart; -} - -export class CloudChatPlugin implements Plugin { +export class CloudChatPlugin implements Plugin { private readonly config: CloudChatConfigType; private readonly isDev: boolean; @@ -30,7 +24,7 @@ export class CloudChatPlugin implements Plugin, { cloud }: CloudChatSetupDeps) { + public setup(core: CoreSetup, { cloud }: CloudChatSetupDeps) { const { chatIdentitySecret, trialBuffer } = this.config; const { isCloudEnabled, trialEndDate } = cloud; @@ -41,27 +35,6 @@ export class CloudChatPlugin implements Plugin - core.getStartServices().then(([_, { cloudExperiments }]) => { - if (!cloudExperiments) { - return 'header'; - } else { - return cloudExperiments - .getVariation('cloud-chat.chat-variant', 'header') - .catch(() => 'header'); - } - }), - getChatDisabledThroughExperiments: () => - core.getStartServices().then(([_, { cloudExperiments }]) => { - if (!cloudExperiments) { - return false; - } else { - return cloudExperiments - .getVariation('cloud-chat.enabled', true) - .then((enabled) => !enabled) - .catch(() => false); - } - }), }); } } diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts index 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 }); } 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/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/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 4efbca83ce2cc..0000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/** - * List of feature flag names used in Kibana. - * - * Feel free to add/remove entries if needed. - * - * As a convention, the key and the value have the same string. - * - * @remarks Kept centralized in this place to serve as a repository - * to help devs understand if there is someone else already using it. - */ -export enum FEATURE_FLAG_NAMES { - /** - * Used in the Security Solutions onboarding page. - * It resolves the URL that the button "Add Integrations" will point to. - */ - 'security-solutions.add-integrations-url' = 'security-solutions.add-integrations-url', - /** - * Used in cloud chat plugin to enable/disable the chat. - * The expectation that the chat is enabled by default and the flag is used as a runtime kill switch. - */ - 'cloud-chat.enabled' = 'cloud-chat.enabled', - /** - * Used in cloud chat plugin to switch between the chat variants. - * Options are: 'header' (the chat button appears as part of the kibana header) and 'bubble' (floating chat button at the bottom of the screen). - */ - 'cloud-chat.chat-variant' = 'cloud-chat.chat-variant', - /** - * Used in observability onboarding plugin to enable/disable the experimental onboarding flow. - * Options are: `true` and `false`. - */ - 'observability_onboarding.experimental_onboarding_flow_enabled' = 'observability_onboarding.experimental_onboarding_flow_enabled', -} - -/** - * List of LaunchDarkly metric names used in Kibana. - * - * Feel free to add/remove entries if needed. - * - * As a convention, the key and the value have the same string. - * - * @remarks Kept centralized in this place to serve as a repository - * to help devs understand if there is someone else already using it. - */ -export enum METRIC_NAMES {} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/index.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/index.ts deleted file mode 100755 index 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/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..ff9d8b9715ce1 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/initialize_metadata.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { concatMap } from 'rxjs'; +import type { CloudSetup as CloudSetupBrowser } from '@kbn/cloud-plugin/public'; +import type { CloudSetup as CloudSetupServer } from '@kbn/cloud-plugin/server'; +import type { PluginInitializerContext as PluginInitializerContextBrowser } from '@kbn/core-plugins-browser'; +import type { PluginInitializerContext as PluginInitializerContextServer } from '@kbn/core-plugins-server'; +import type { FeatureFlagsSetup as FeatureFlagsSetupBrowser } from '@kbn/core-feature-flags-browser'; +import type { FeatureFlagsSetup as FeatureFlagsSetupServer } from '@kbn/core-feature-flags-server'; +import type { Logger } from '@kbn/logging'; +import type { MetadataService } from './metadata_service'; + +/** + * @private + */ +export function initializeMetadata({ + metadataService, + initializerContext, + featureFlags, + cloud, + logger, +}: { + metadataService: MetadataService; + initializerContext: PluginInitializerContextBrowser | PluginInitializerContextServer; + featureFlags: FeatureFlagsSetupBrowser | FeatureFlagsSetupServer; + cloud: CloudSetupBrowser | CloudSetupServer; + logger: Logger; +}) { + const offering = initializerContext.env.packageInfo.buildFlavor; + + metadataService.setup({ + instanceKey: cloud.serverless?.projectId || cloud.deploymentId, + offering, + version: initializerContext.env.packageInfo.version, + build_num: initializerContext.env.packageInfo.buildNum, + build_sha: initializerContext.env.packageInfo.buildSha, + build_sha_short: initializerContext.env.packageInfo.buildShaShort, + project_type: cloud.serverless.projectType, + orchestrator_target: cloud.serverless.orchestratorTarget, + organizationKey: cloud.organizationId, + trial_end_date: cloud.trialEndDate, + is_elastic_staff: cloud.isElasticStaffOwned, + }); + + // Update the client's contexts when we get any updates in the metadata. + metadataService.userMetadata$ + .pipe( + // Using concatMap to ensure we call the promised update in an orderly manner to avoid concurrency issues + concatMap(async (userMetadata) => { + try { + await featureFlags.appendContext(userMetadata); + } catch (err) { + logger.warn(`Failed to set the feature flags context ${err}`); + } + }) + ) + .subscribe(); // This subscription will stop when the metadataService stops because it completes the Observable +} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.test.ts index 0c0f5f5127f0f..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 @@ -8,8 +8,8 @@ import moment from 'moment'; import { fakeSchedulers } from 'rxjs-marbles/jest'; import { firstValueFrom } from 'rxjs'; -import { MetadataService } from './metadata_service'; import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; +import { type FlatMetadata, MetadataService } from './metadata_service'; jest.mock('rxjs', () => { const RxJs = jest.requireActual('rxjs'); @@ -22,7 +22,6 @@ jest.mock('rxjs', () => { describe('MetadataService', () => { jest.useFakeTimers({ legacyFakeTimers: true }); - let metadataService: MetadataService; let logger: MockedLogger; @@ -39,43 +38,73 @@ describe('MetadataService', () => { jest.clearAllMocks(); }); + const initialMetadata: FlatMetadata = { + instanceKey: 'project-id', + offering: 'serverless', + version: '1.2.3', + build_num: 123, + build_sha: 'abcdefghijklmnopqrstux', + build_sha_short: 'abcde', + project_type: 'project-type', + organizationKey: 'organization-id', + is_elastic_staff: true, + }; + + const multiContextFormat = { + kind: 'multi', + kibana: { + key: 'project-id', + offering: 'serverless', + version: '1.2.3', + build_num: 123, + build_sha: 'abcdefghijklmnopqrstux', + build_sha_short: 'abcde', + project_type: 'project-type', + }, + organization: { + key: 'organization-id', + is_elastic_staff: true, + }, + }; + describe('setup', () => { test('emits the initial metadata', async () => { - const initialMetadata = { userId: 'fake-user-id', kibanaVersion: 'version' }; metadataService.setup(initialMetadata); await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual( - initialMetadata + multiContextFormat ); }); test( 'emits inTrial when trialEndDate is provided', fakeSchedulers(async (advance) => { - const initialMetadata = { - userId: 'fake-user-id', - kibanaVersion: 'version', - trialEndDate: new Date(0).toISOString(), - }; - metadataService.setup(initialMetadata); + metadataService.setup({ ...initialMetadata, trial_end_date: new Date(0) }); // Still equals initialMetadata - await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual( - initialMetadata - ); + await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual({ + ...multiContextFormat, + organization: { + ...multiContextFormat.organization, + trial_end_date: new Date(0), + }, + }); // After scheduler kicks in... advance(1); // The timer kicks in first on 0 (but let's give us 1ms so the trial is expired) await new Promise((resolve) => process.nextTick(resolve)); // The timer triggers a promise, so we need to skip to the next tick await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual({ - ...initialMetadata, - inTrial: false, + ...multiContextFormat, + organization: { + ...multiContextFormat.organization, + trial_end_date: new Date(0), + in_trial: false, + }, }); }) ); }); describe('start', () => { - const initialMetadata = { userId: 'fake-user-id', kibanaVersion: 'version' }; beforeEach(() => { metadataService.setup(initialMetadata); }); @@ -83,19 +112,22 @@ describe('MetadataService', () => { test( 'emits hasData after resolving the `hasUserDataView`', fakeSchedulers(async (advance) => { - metadataService.start({ hasDataFetcher: async () => ({ hasData: true }) }); + metadataService.start({ hasDataFetcher: async () => ({ has_data: true }) }); // Still equals initialMetadata await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual( - initialMetadata + multiContextFormat ); // After scheduler kicks in... advance(1); // The timer kicks in first on 0 (but let's give us 1ms so the trial is expired) await new Promise((resolve) => process.nextTick(resolve)); // The timer triggers a promise, so we need to skip to the next tick await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual({ - ...initialMetadata, - hasData: true, + ...multiContextFormat, + kibana: { + ...multiContextFormat.kibana, + has_data: true, + }, }); }) ); @@ -107,7 +139,7 @@ describe('MetadataService', () => { metadataService.start({ hasDataFetcher: async () => { if (count++ > 0) { - return { hasData: true }; + return { has_data: true }; } else { throw new Error('Something went wrong'); } @@ -116,7 +148,7 @@ describe('MetadataService', () => { // Still equals initialMetadata await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual( - initialMetadata + multiContextFormat ); // After scheduler kicks in... @@ -125,7 +157,7 @@ describe('MetadataService', () => { // Still equals initialMetadata await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual( - initialMetadata + multiContextFormat ); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -136,8 +168,11 @@ describe('MetadataService', () => { advance(1_001); await new Promise((resolve) => process.nextTick(resolve)); // The timer triggers a promise, so we need to skip to the next tick await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual({ - ...initialMetadata, - hasData: true, + ...multiContextFormat, + kibana: { + ...multiContextFormat.kibana, + has_data: true, + }, }); }) ); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.ts index 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/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/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/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 bc2064ec6bcf0..0000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - type LDClient, - type LDSingleKindContext, - type LDLogLevel, -} from 'launchdarkly-js-client-sdk'; -import { BehaviorSubject, filter, firstValueFrom, switchMap } from 'rxjs'; -import type { Logger } from '@kbn/logging'; - -export interface LaunchDarklyClientConfig { - client_id: string; - client_log_level: LDLogLevel; -} - -export interface LaunchDarklyUserMetadata - extends Record { - userId: string; -} - -export class LaunchDarklyClient { - private initialized = false; - private canceled = false; - private launchDarklyClientSub$ = new BehaviorSubject(null); - private loadingClient$ = new BehaviorSubject(true); - private launchDarklyClient$ = this.loadingClient$.pipe( - // To avoid a racing condition when trying to get a variation before the client is ready - // we use the `switchMap` operator to ensure we only return the client when it has been initialized. - filter((loading) => !loading), - switchMap(() => this.launchDarklyClientSub$) - ); - - constructor( - private readonly ldConfig: LaunchDarklyClientConfig, - private readonly kibanaVersion: string, - private readonly logger: Logger - ) {} - - public async updateUserMetadata(userMetadata: LaunchDarklyUserMetadata) { - if (this.canceled) return; - - const { userId, ...userMetadataWithoutUserId } = userMetadata; - const launchDarklyUser: LDSingleKindContext = { - ...userMetadataWithoutUserId, - kind: 'user', - key: userId, - }; - - let launchDarklyClient: LDClient | null = null; - if (this.initialized) { - launchDarklyClient = await this.getClient(); - } - - if (launchDarklyClient) { - await launchDarklyClient.identify(launchDarklyUser); - } else { - this.initialized = true; - const { initialize, basicLogger } = await import('launchdarkly-js-client-sdk'); - launchDarklyClient = initialize(this.ldConfig.client_id, launchDarklyUser, { - application: { id: 'kibana-browser', version: this.kibanaVersion }, - logger: basicLogger({ level: this.ldConfig.client_log_level }), - }); - this.launchDarklyClientSub$.next(launchDarklyClient); - this.loadingClient$.next(false); - } - } - - public async getVariation(configKey: string, defaultValue: Data): Promise { - const launchDarklyClient = await this.getClient(); - if (!launchDarklyClient) return defaultValue; // Skip any action if no LD User is defined - await launchDarklyClient.waitForInitialization(); - return await launchDarklyClient.variation(configKey, defaultValue); - } - - public reportMetric(metricName: string, meta?: unknown, value?: number): void { - this.getClient().then((launchDarklyClient) => { - if (!launchDarklyClient) return; // Skip any action if no LD User is defined - launchDarklyClient.track(metricName, meta, value); - }); - } - - public stop() { - this.getClient().then((launchDarklyClient) => { - launchDarklyClient?.flush().catch((err) => { - this.logger.warn(err); - }); - }); - } - - public cancel() { - this.initialized = true; - this.canceled = true; - this.loadingClient$.next(false); - } - - private getClient(): Promise { - return firstValueFrom(this.launchDarklyClient$, { defaultValue: null }); - } -} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.test.ts index 7c945afcf53f3..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 @@ -9,20 +9,8 @@ import { duration } from 'moment'; import { coreMock } from '@kbn/core/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; -import { CloudExperimentsPluginStart } from '../common'; -import { FEATURE_FLAG_NAMES } from '../common/constants'; import { CloudExperimentsPlugin } from './plugin'; -import { LaunchDarklyClient } from './launch_darkly_client'; import { MetadataService } from '../common/metadata_service'; -jest.mock('./launch_darkly_client'); - -function getLaunchDarklyClientInstanceMock() { - const launchDarklyClientInstanceMock = ( - LaunchDarklyClient as jest.MockedClass - ).mock.instances[0] as jest.Mocked; - - return launchDarklyClientInstanceMock; -} describe('Cloud Experiments public plugin', () => { jest.spyOn(console, 'debug').mockImplementation(); // silence console.debug logs @@ -34,59 +22,40 @@ describe('Cloud Experiments public plugin', () => { describe('constructor', () => { test('successfully creates a new plugin if provided an empty configuration', () => { const initializerContext = coreMock.createPluginInitializerContext(); - // @ts-expect-error it's defined as readonly but the mock is not. - initializerContext.env.mode.dev = true; // ensure it's true + initializerContext.env.mode = { + name: 'development', + dev: true, // ensure it's true + prod: false, + }; const plugin = new CloudExperimentsPlugin(initializerContext); expect(plugin).toHaveProperty('setup'); expect(plugin).toHaveProperty('start'); expect(plugin).toHaveProperty('stop'); - expect(plugin).toHaveProperty('flagOverrides', undefined); - expect(plugin).toHaveProperty('launchDarklyClient', undefined); expect(plugin).toHaveProperty('metadataService', expect.any(MetadataService)); }); test('fails if launch_darkly is not provided in the config and it is a non-dev environment', () => { const initializerContext = coreMock.createPluginInitializerContext(); - // @ts-expect-error it's defined as readonly but the mock is not. - initializerContext.env.mode.dev = false; + initializerContext.env.mode = { + name: 'production', + dev: false, + prod: true, // ensure it's true + }; expect(() => new CloudExperimentsPlugin(initializerContext)).toThrowError( 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); }); - - test('it initializes the flagOverrides property', () => { - const initializerContext = coreMock.createPluginInitializerContext({ - flag_overrides: { my_flag: '1234' }, - }); - // @ts-expect-error it's defined as readonly but the mock is not. - initializerContext.env.mode.dev = true; // ensure it's true - const plugin = new CloudExperimentsPlugin(initializerContext); - expect(plugin).toHaveProperty('flagOverrides', { my_flag: '1234' }); - }); - - test('it initializes the LaunchDarkly client', () => { - const initializerContext = coreMock.createPluginInitializerContext({ - launch_darkly: { client_id: 'sdk-1234' }, - }); - const plugin = new CloudExperimentsPlugin(initializerContext); - expect(LaunchDarklyClient).toHaveBeenCalledTimes(1); - expect(plugin).toHaveProperty('launchDarklyClient', expect.any(LaunchDarklyClient)); - }); }); describe('setup', () => { let plugin: CloudExperimentsPlugin; - let metadataServiceSetupSpy: jest.SpyInstance; beforeEach(() => { const initializerContext = coreMock.createPluginInitializerContext({ launch_darkly: { client_id: '1234' }, - flag_overrides: { my_flag: '1234' }, metadata_refresh_interval: duration(1, 'h'), }); plugin = new CloudExperimentsPlugin(initializerContext); - // eslint-disable-next-line dot-notation - metadataServiceSetupSpy = jest.spyOn(plugin['metadataService'], 'setup'); }); afterEach(() => { @@ -100,60 +69,16 @@ describe('Cloud Experiments public plugin', () => { }) ).toBeUndefined(); }); - - describe('identifyUser', () => { - test('it skips creating the client if no client id provided in the config', () => { - const initializerContext = coreMock.createPluginInitializerContext({ - flag_overrides: { my_flag: '1234' }, - metadata_refresh_interval: duration(1, 'h'), - }); - const customPlugin = new CloudExperimentsPlugin(initializerContext); - customPlugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - expect(customPlugin).toHaveProperty('launchDarklyClient', undefined); - }); - - test('it skips identifying the user if cloud is not enabled and cancels loading the LDclient', () => { - const ldClientCancelSpy = jest.spyOn(LaunchDarklyClient.prototype, 'cancel'); - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: false }, - }); - - expect(metadataServiceSetupSpy).not.toHaveBeenCalled(); - expect(ldClientCancelSpy).toHaveBeenCalled(); // Cancel loading the client - }); - - test('it initializes the LaunchDarkly client', async () => { - const ldClientCancelSpy = jest.spyOn(LaunchDarklyClient.prototype, 'cancel'); - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - - expect(metadataServiceSetupSpy).toHaveBeenCalledWith({ - isElasticStaff: true, - kibanaVersion: 'version', - trialEndDate: '2020-10-01T14:13:12.000Z', - userId: 'mock-deployment-id', - }); - expect(ldClientCancelSpy).not.toHaveBeenCalled(); - }); - }); }); describe('start', () => { let plugin: CloudExperimentsPlugin; - let launchDarklyInstanceMock: jest.Mocked; - - const firstKnownFlag = Object.keys(FEATURE_FLAG_NAMES)[0] as keyof typeof FEATURE_FLAG_NAMES; beforeEach(() => { const initializerContext = coreMock.createPluginInitializerContext({ launch_darkly: { client_id: '1234' }, - flag_overrides: { [firstKnownFlag]: '1234' }, }); plugin = new CloudExperimentsPlugin(initializerContext); - launchDarklyInstanceMock = getLaunchDarklyClientInstanceMock(); }); afterEach(() => { @@ -163,168 +88,35 @@ describe('Cloud Experiments public plugin', () => { test('returns the contract', () => { plugin.setup(coreMock.createSetup(), { cloud: cloudMock.createSetup() }); const startContract = plugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), dataViews: dataViewPluginMocks.createStartContract(), }); - expect(startContract).toStrictEqual( - expect.objectContaining({ - getVariation: expect.any(Function), - reportMetric: expect.any(Function), - }) - ); + expect(startContract).toBeUndefined(); }); - test('triggers a userMetadataUpdate for `hasData`', async () => { - plugin.setup(coreMock.createSetup(), { + test('updates the context with `has_data`', async () => { + const coreSetup = coreMock.createSetup(); + plugin.setup(coreSetup, { cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, }); const dataViews = dataViewPluginMocks.createStartContract(); - plugin.start(coreMock.createStart(), { cloud: cloudMock.createStart(), dataViews }); + plugin.start(coreMock.createStart(), { dataViews }); // After scheduler kicks in... await new Promise((resolve) => setTimeout(resolve, 200)); - // Using a timeout of 0ms to let the `timer` kick in. - // For some reason, fakeSchedulers is not working on browser-side tests :shrug: - expect(launchDarklyInstanceMock.updateUserMetadata).toHaveBeenCalledWith( + expect(coreSetup.featureFlags.appendContext).toHaveBeenCalledWith( expect.objectContaining({ - hasData: true, + kind: 'multi', + kibana: expect.objectContaining({ + has_data: true, + }), }) ); }); - - describe('getVariation', () => { - let startContract: CloudExperimentsPluginStart; - describe('with the client created', () => { - beforeEach(() => { - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - startContract = plugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), - dataViews: dataViewPluginMocks.createStartContract(), - }); - }); - - test('uses the flag overrides to respond early', async () => { - await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual( - '1234' - ); - }); - - test('calls the client', async () => { - launchDarklyInstanceMock.getVariation.mockResolvedValue('12345'); - await expect( - startContract.getVariation( - // @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES - 'some-random-flag', - 123 - ) - ).resolves.toStrictEqual('12345'); - expect(launchDarklyInstanceMock.getVariation).toHaveBeenCalledWith( - undefined, // it couldn't find it in FEATURE_FLAG_NAMES - 123 - ); - }); - }); - - describe('with the client not created', () => { - beforeEach(() => { - const initializerContext = coreMock.createPluginInitializerContext({ - flag_overrides: { [firstKnownFlag]: '1234' }, - metadata_refresh_interval: duration(1, 'h'), - }); - const customPlugin = new CloudExperimentsPlugin(initializerContext); - customPlugin.setup(coreMock.createSetup(), { - cloud: cloudMock.createSetup(), - }); - expect(customPlugin).toHaveProperty('launchDarklyClient', undefined); - startContract = customPlugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), - dataViews: dataViewPluginMocks.createStartContract(), - }); - }); - - test('uses the flag overrides to respond early', async () => { - await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual( - '1234' - ); - }); - - test('returns the default value without calling the client', async () => { - await expect( - startContract.getVariation( - // @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES - 'some-random-flag', - 123 - ) - ).resolves.toStrictEqual(123); - expect(launchDarklyInstanceMock.getVariation).not.toHaveBeenCalled(); - }); - }); - }); - - describe('reportMetric', () => { - let startContract: CloudExperimentsPluginStart; - describe('with the client created', () => { - beforeEach(() => { - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - startContract = plugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), - dataViews: dataViewPluginMocks.createStartContract(), - }); - }); - - test('calls the track API', () => { - startContract.reportMetric({ - // @ts-expect-error We only allow existing flags in METRIC_NAMES - name: 'my-flag', - meta: {}, - value: 1, - }); - expect(launchDarklyInstanceMock.reportMetric).toHaveBeenCalledWith( - undefined, // it couldn't find it in METRIC_NAMES - {}, - 1 - ); - }); - }); - - describe('with the client not created', () => { - beforeEach(() => { - const initializerContext = coreMock.createPluginInitializerContext({ - flag_overrides: { [firstKnownFlag]: '1234' }, - metadata_refresh_interval: duration(1, 'h'), - }); - const customPlugin = new CloudExperimentsPlugin(initializerContext); - customPlugin.setup(coreMock.createSetup(), { - cloud: cloudMock.createSetup(), - }); - expect(customPlugin).toHaveProperty('launchDarklyClient', undefined); - startContract = customPlugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), - dataViews: dataViewPluginMocks.createStartContract(), - }); - }); - - test('calls the track API', () => { - startContract.reportMetric({ - // @ts-expect-error We only allow existing flags in METRIC_NAMES - name: 'my-flag', - meta: {}, - value: 1, - }); - expect(launchDarklyInstanceMock.reportMetric).not.toHaveBeenCalled(); - }); - }); - }); }); describe('stop', () => { let plugin: CloudExperimentsPlugin; - let launchDarklyInstanceMock: jest.Mocked; beforeEach(() => { const initializerContext = coreMock.createPluginInitializerContext({ @@ -333,19 +125,19 @@ describe('Cloud Experiments public plugin', () => { metadata_refresh_interval: duration(1, 'h'), }); plugin = new CloudExperimentsPlugin(initializerContext); - launchDarklyInstanceMock = getLaunchDarklyClientInstanceMock(); plugin.setup(coreMock.createSetup(), { cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, }); plugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), dataViews: dataViewPluginMocks.createStartContract(), }); }); test('flushes the events on stop', () => { + // eslint-disable-next-line dot-notation + const metadataServiceStopSpy = jest.spyOn(plugin['metadataService'], 'stop'); expect(() => plugin.stop()).not.toThrow(); - expect(launchDarklyInstanceMock.stop).toHaveBeenCalledTimes(1); + expect(metadataServiceStopSpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts index a201c98df1ea3..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,53 +5,42 @@ * 2.0. */ -import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; -import { get, has } from 'lodash'; import { duration } from 'moment'; -import { concatMap } from 'rxjs'; -import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; -import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { Logger } from '@kbn/logging'; - -import { LaunchDarklyClient, type LaunchDarklyClientConfig } from './launch_darkly_client'; -import type { - CloudExperimentsFeatureFlagNames, - CloudExperimentsMetric, - CloudExperimentsPluginStart, -} from '../common'; -import { MetadataService } from '../common/metadata_service'; -import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants'; +import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import type { CloudSetup } from '@kbn/cloud-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { LaunchDarklyClientProvider } from '@openfeature/launchdarkly-client-provider'; +import { type LDLogLevel, basicLogger } from 'launchdarkly-js-client-sdk'; +import { initializeMetadata, MetadataService } from '../common/metadata_service'; interface CloudExperimentsPluginSetupDeps { cloud: CloudSetup; } interface CloudExperimentsPluginStartDeps { - cloud: CloudStart; dataViews: DataViewsPublicPluginStart; } +interface LaunchDarklyClientConfig { + client_id: string; + client_log_level: LDLogLevel; +} + /** * Browser-side implementation of the Cloud Experiments plugin */ export class CloudExperimentsPlugin - implements Plugin + implements Plugin { private readonly logger: Logger; private readonly metadataService: MetadataService; - private readonly launchDarklyClient?: LaunchDarklyClient; - private readonly kibanaVersion: string; - private readonly flagOverrides?: Record; - private readonly isDev: boolean; /** Constructor of the plugin **/ - constructor(initializerContext: PluginInitializerContext) { + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); - this.isDev = initializerContext.env.mode.dev; - this.kibanaVersion = initializerContext.env.packageInfo.version; const config = initializerContext.config.get<{ launch_darkly?: LaunchDarklyClientConfig; - flag_overrides?: Record; metadata_refresh_interval: string; }>(); @@ -60,9 +49,6 @@ export class CloudExperimentsPlugin this.logger.get('metadata') ); - if (config.flag_overrides) { - this.flagOverrides = config.flag_overrides; - } const ldConfig = config.launch_darkly; if (!ldConfig?.client_id && !initializerContext.env.mode.dev) { // If the plugin is enabled, and it's in prod mode, launch_darkly must exist @@ -71,9 +57,6 @@ export class CloudExperimentsPlugin 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); } - if (ldConfig?.client_id) { - this.launchDarklyClient = new LaunchDarklyClient(ldConfig, this.kibanaVersion, this.logger); - } } /** @@ -82,83 +65,61 @@ export class CloudExperimentsPlugin * @param deps {@link CloudExperimentsPluginSetupDeps} */ public setup(core: CoreSetup, deps: CloudExperimentsPluginSetupDeps) { - if (deps.cloud.isCloudEnabled && deps.cloud.deploymentId && this.launchDarklyClient) { - this.metadataService.setup({ - userId: deps.cloud.deploymentId, - kibanaVersion: this.kibanaVersion, - trialEndDate: deps.cloud.trialEndDate?.toISOString(), - isElasticStaff: deps.cloud.isElasticStaffOwned, - }); - } else { - this.launchDarklyClient?.cancel(); + initializeMetadata({ + metadataService: this.metadataService, + initializerContext: this.initializerContext, + cloud: deps.cloud, + featureFlags: core.featureFlags, + logger: this.logger, + }); + + const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(); + if (launchDarklyOpenFeatureProvider) { + core.featureFlags.setProvider(launchDarklyOpenFeatureProvider); } } /** - * Returns the contract {@link CloudExperimentsPluginStart} + * Sets the metadata service update hooks * @param core {@link CoreStart} + * @param deps {@link CloudExperimentsPluginStartDeps} */ - public start( - core: CoreStart, - { cloud, dataViews }: CloudExperimentsPluginStartDeps - ): CloudExperimentsPluginStart { - if (cloud.isCloudEnabled) { - this.metadataService.start({ - hasDataFetcher: async () => ({ hasData: await dataViews.hasData.hasUserDataView() }), - }); - - // We only subscribe to the user metadata updates if Cloud is enabled. - // This way, since the user is not identified, it cannot retrieve Feature Flags from LaunchDarkly when not running on Cloud. - this.metadataService.userMetadata$ - .pipe( - // Using concatMap to ensure we call the promised update in an orderly manner to avoid concurrency issues - concatMap( - async (userMetadata) => await this.launchDarklyClient?.updateUserMetadata(userMetadata) - ) - ) - .subscribe(); // This subscription will stop on when the metadataService stops because it completes the Observable - } - return { - getVariation: this.getVariation, - reportMetric: this.reportMetric, - }; + public start(core: CoreStart, { dataViews }: CloudExperimentsPluginStartDeps) { + this.metadataService.start({ + hasDataFetcher: async () => ({ has_data: await dataViews.hasData.hasUserDataView() }), + }); } /** * Cleans up and flush the sending queues. */ public stop() { - this.launchDarklyClient?.stop(); this.metadataService.stop(); } - private getVariation = async ( - featureFlagName: CloudExperimentsFeatureFlagNames, - defaultValue: Data - ): Promise => { - const configKey = FEATURE_FLAG_NAMES[featureFlagName]; - - // Apply overrides if they exist without asking LaunchDarkly. - if (this.flagOverrides && has(this.flagOverrides, configKey)) { - return get(this.flagOverrides, configKey, defaultValue) as Data; - } - - // Skip any action if no LD Client is defined - if (!this.launchDarklyClient) { - return defaultValue; - } - - return await this.launchDarklyClient.getVariation(configKey, defaultValue); - }; + /** + * Sets up the OpenFeature LaunchDarkly provider + * @private + */ + private createOpenFeatureProvider() { + const { launch_darkly: ldConfig } = this.initializerContext.config.get<{ + launch_darkly?: LaunchDarklyClientConfig; + }>(); - private reportMetric = ({ name, meta, value }: CloudExperimentsMetric): void => { - const metricName = METRIC_NAMES[name]; - this.launchDarklyClient?.reportMetric(metricName, meta, value); - if (this.isDev) { - // eslint-disable-next-line no-console - console.debug(`Reported experimentation metric ${metricName}`, { - experimentationMetric: { name, meta, value }, - }); - } - }; + if (!ldConfig) return; + + return new LaunchDarklyClientProvider(ldConfig.client_id, { + // logger: this.logger.get('launch-darkly'), + // Using basicLogger for now because we can't limit the level for now if we're using core's logger. + logger: basicLogger({ level: ldConfig.client_log_level }), + streaming: true, // Necessary to react to flag changes + application: { + id: 'kibana-browser', + version: + this.initializerContext.env.packageInfo.buildFlavor === 'serverless' + ? this.initializerContext.env.packageInfo.buildSha + : this.initializerContext.env.packageInfo.version, + }, + }); + } } diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.test.ts index 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 c6511302eb7b1..0000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - type LDClient, - type LDFlagSet, - type LDLogLevel, - type LDSingleKindContext, -} from '@launchdarkly/node-server-sdk'; -import { init, basicLogger } from '@launchdarkly/node-server-sdk'; -import type { Logger } from '@kbn/core/server'; - -export interface LaunchDarklyClientConfig { - sdk_key: string; - client_id: string; - client_log_level: LDLogLevel; - kibana_version: string; -} - -export interface LaunchDarklyUserMetadata - extends Record { - userId: string; - // We are not collecting any of the above, but this is to match the LDUser first-level definition - name?: string; - firstName?: string; - lastName?: string; - email?: string; - avatar?: string; - ip?: string; - country?: string; -} - -export interface LaunchDarklyGetAllFlags { - initialized: boolean; - flags: LDFlagSet; - flagNames: string[]; -} - -export class LaunchDarklyClient { - private readonly launchDarklyClient: LDClient; - private launchDarklyUser?: LDSingleKindContext; - - constructor(ldConfig: LaunchDarklyClientConfig, private readonly logger: Logger) { - this.launchDarklyClient = init(ldConfig.sdk_key, { - application: { id: `kibana-server`, version: ldConfig.kibana_version }, - logger: basicLogger({ level: ldConfig.client_log_level }), - // For some reason, the stream API does not work in Kibana. `.waitForInitialization()` hangs forever (doesn't throw, neither logs any errors). - // Using polling for now until we resolve that issue. - // Relevant issue: https://github.com/launchdarkly/node-server-sdk/issues/132 - stream: false, - }); - this.launchDarklyClient.waitForInitialization().then( - () => this.logger.debug('LaunchDarkly is initialized!'), - (err) => this.logger.warn(`Error initializing LaunchDarkly: ${err}`) - ); - } - - public updateUserMetadata(userMetadata: LaunchDarklyUserMetadata) { - const { userId, ...userMetadataWithoutUserId } = userMetadata; - this.launchDarklyUser = { - ...userMetadataWithoutUserId, - kind: 'user', - key: userId, - }; - } - - public async getVariation(configKey: string, defaultValue: Data): Promise { - if (!this.launchDarklyUser) return defaultValue; // Skip any action if no LD User is defined - await this.launchDarklyClient.waitForInitialization(); - return await this.launchDarklyClient.variation(configKey, this.launchDarklyUser, defaultValue); - } - - public reportMetric(metricName: string, meta?: unknown, value?: number): void { - if (!this.launchDarklyUser) return; // Skip any action if no LD User is defined - this.launchDarklyClient.track(metricName, this.launchDarklyUser, meta, value); - } - - public async getAllFlags(): Promise { - if (!this.launchDarklyUser) return { initialized: false, flagNames: [], flags: {} }; - // According to the docs, this method does not send analytics back to LaunchDarkly, so it does not provide false results - const flagsState = await this.launchDarklyClient.allFlagsState(this.launchDarklyUser); - const flags = flagsState.allValues(); - return { - initialized: flagsState.valid, - flags, - flagNames: Object.keys(flags), - }; - } - - public stop() { - this.launchDarklyClient?.flush().catch((err) => this.logger.error(err)); - } -} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/mocks.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/mocks.ts deleted file mode 100644 index 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..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 @@ -5,7 +5,6 @@ * 2.0. */ -import { fakeSchedulers } from 'rxjs-marbles/jest'; import { coreMock } from '@kbn/core/server/mocks'; import { cloudMock } from '@kbn/cloud-plugin/server/mocks'; import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks'; @@ -16,9 +15,6 @@ import { import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server'; import { config } from './config'; import { CloudExperimentsPlugin } from './plugin'; -import { FEATURE_FLAG_NAMES } from '../common/constants'; -import { LaunchDarklyClient } from './launch_darkly_client'; -jest.mock('./launch_darkly_client'); describe('Cloud Experiments server plugin', () => { jest.useFakeTimers(); @@ -29,15 +25,13 @@ describe('Cloud Experiments server plugin', () => { }); describe('constructor', () => { - test('successfully creates a new plugin if provided an empty configuration', () => { + test('successfully creates a new when in dev mode plugin if provided an empty configuration', () => { const initializerContext = coreMock.createPluginInitializerContext(); initializerContext.env.mode.dev = true; // ensure it's true const plugin = new CloudExperimentsPlugin(initializerContext); expect(plugin).toHaveProperty('setup'); expect(plugin).toHaveProperty('start'); expect(plugin).toHaveProperty('stop'); - expect(plugin).toHaveProperty('flagOverrides', undefined); - expect(plugin).toHaveProperty('launchDarklyClient', undefined); }); test('fails if launch_darkly is not provided in the config and it is a non-dev environment', () => { @@ -47,24 +41,6 @@ describe('Cloud Experiments server plugin', () => { 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); }); - - test('it initializes the LaunchDarkly client', () => { - const initializerContext = coreMock.createPluginInitializerContext({ - launch_darkly: { sdk_key: 'sdk-1234' }, - }); - const plugin = new CloudExperimentsPlugin(initializerContext); - expect(LaunchDarklyClient).toHaveBeenCalledTimes(1); - expect(plugin).toHaveProperty('launchDarklyClient', expect.any(LaunchDarklyClient)); - }); - - test('it initializes the flagOverrides property', () => { - const initializerContext = coreMock.createPluginInitializerContext({ - flag_overrides: { my_flag: '1234' }, - }); - initializerContext.env.mode.dev = true; // ensure it's true - const plugin = new CloudExperimentsPlugin(initializerContext); - expect(plugin).toHaveProperty('flagOverrides', { my_flag: '1234' }); - }); }); describe('setup', () => { @@ -75,7 +51,6 @@ describe('Cloud Experiments server plugin', () => { config.schema.validate( { launch_darkly: { sdk_key: 'sdk-1234', client_id: 'fake-client-id' }, - flag_overrides: { my_flag: '1234' }, }, { dev: true } ) @@ -87,221 +62,98 @@ describe('Cloud Experiments server plugin', () => { plugin.stop(); }); - test('returns the contract', () => { + test('registers the usage collector when available', () => { + const usageCollection = usageCollectionPluginMock.createSetupContract(); expect( plugin.setup(coreMock.createSetup(), { cloud: cloudMock.createSetup(), + usageCollection, }) ).toBeUndefined(); - }); - - test('registers the usage collector when available', () => { - const usageCollection = usageCollectionPluginMock.createSetupContract(); - plugin.setup(coreMock.createSetup(), { - cloud: cloudMock.createSetup(), - usageCollection, - }); expect(usageCollection.makeUsageCollector).toHaveBeenCalledTimes(1); expect(usageCollection.registerCollector).toHaveBeenCalledTimes(1); }); - test( - 'updates the user metadata on setup', - fakeSchedulers((advance) => { - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - const launchDarklyInstanceMock = ( - LaunchDarklyClient as jest.MockedClass - ).mock.instances[0]; - advance(100); // Remove the debounceTime effect - expect(launchDarklyInstanceMock.updateUserMetadata).toHaveBeenCalledWith({ - userId: 'deployment-id', - kibanaVersion: coreMock.createPluginInitializerContext().env.packageInfo.version, - isElasticStaff: true, - trialEndDate: expect.any(String), - }); - }) - ); + test('updates the user metadata on setup', async () => { + const coreSetupMock = coreMock.createSetup(); + plugin.setup(coreSetupMock, { + cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, + usageCollection: usageCollectionPluginMock.createSetupContract(), + }); + + const initializerContext = coreMock.createPluginInitializerContext(); + + await jest.advanceTimersByTimeAsync(100); // Remove the debounceTime effect + expect(coreSetupMock.featureFlags.appendContext).toHaveBeenCalledWith({ + kind: 'multi', + kibana: { + key: 'deployment-id', + offering: 'traditional', + version: initializerContext.env.packageInfo.version, + build_num: initializerContext.env.packageInfo.buildNum, + build_sha: initializerContext.env.packageInfo.buildSha, + build_sha_short: initializerContext.env.packageInfo.buildShaShort, + }, + organization: { + key: 'organization-id', + trial_end_date: expect.any(Date), + in_trial: false, + is_elastic_staff: true, + }, + }); + }); }); describe('start', () => { let plugin: CloudExperimentsPlugin; let dataViews: jest.Mocked; - let launchDarklyInstanceMock: jest.Mocked; - - const firstKnownFlag = Object.keys(FEATURE_FLAG_NAMES)[0] as keyof typeof FEATURE_FLAG_NAMES; beforeEach(() => { - jest.useRealTimers(); const initializerContext = coreMock.createPluginInitializerContext( config.schema.validate( { launch_darkly: { sdk_key: 'sdk-1234', client_id: 'fake-client-id' }, - flag_overrides: { [firstKnownFlag]: '1234' }, }, { dev: true } ) ); plugin = new CloudExperimentsPlugin(initializerContext); dataViews = createIndexPatternsStartMock(); - launchDarklyInstanceMock = (LaunchDarklyClient as jest.MockedClass) - .mock.instances[0] as jest.Mocked; }); afterEach(() => { plugin.stop(); - jest.useFakeTimers(); }); test('returns the contract', () => { - plugin.setup(coreMock.createSetup(), { cloud: cloudMock.createSetup() }); - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - expect(startContract).toStrictEqual( - expect.objectContaining({ - getVariation: expect.any(Function), - reportMetric: expect.any(Function), - }) - ); + plugin.setup(coreMock.createSetup(), { + cloud: cloudMock.createSetup(), + usageCollection: usageCollectionPluginMock.createSetupContract(), + }); + expect(plugin.start(coreMock.createStart(), { dataViews })).toBeUndefined(); }); test('triggers a userMetadataUpdate for `hasData`', async () => { - plugin.setup(coreMock.createSetup(), { + const coreSetup = coreMock.createSetup(); + plugin.setup(coreSetup, { cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, + usageCollection: usageCollectionPluginMock.createSetupContract(), }); dataViews.dataViewsServiceFactory.mockResolvedValue(dataViewsService); dataViewsService.hasUserDataView.mockResolvedValue(true); plugin.start(coreMock.createStart(), { dataViews }); // After scheduler kicks in... - await new Promise((resolve) => setTimeout(resolve, 200)); // Waiting for scheduler and debounceTime to complete (don't know why fakeScheduler didn't work here). - expect(launchDarklyInstanceMock.updateUserMetadata).toHaveBeenCalledWith( + await jest.advanceTimersByTimeAsync(100); + expect(coreSetup.featureFlags.appendContext).toHaveBeenCalledWith( expect.objectContaining({ - hasData: true, + kind: 'multi', + kibana: expect.objectContaining({ + has_data: true, + }), }) ); }); - - describe('getVariation', () => { - describe('with the client created', () => { - beforeEach(() => { - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - }); - - test('uses the flag overrides to respond early', async () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual( - '1234' - ); - }); - - test('calls the client', async () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - launchDarklyInstanceMock.getVariation.mockResolvedValue('12345'); - await expect( - startContract.getVariation( - // @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES - 'some-random-flag', - 123 - ) - ).resolves.toStrictEqual('12345'); - expect(launchDarklyInstanceMock.getVariation).toHaveBeenCalledWith( - undefined, // it couldn't find it in FEATURE_FLAG_NAMES - 123 - ); - }); - }); - - describe('with the client not created (missing LD settings)', () => { - beforeEach(() => { - const initializerContext = coreMock.createPluginInitializerContext( - config.schema.validate( - { - flag_overrides: { [firstKnownFlag]: '1234' }, - }, - { dev: true } - ) - ); - plugin = new CloudExperimentsPlugin(initializerContext); - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: false }, - }); - }); - - test('uses the flag overrides to respond early', async () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual( - '1234' - ); - }); - - test('returns the default value without calling the client', async () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - await expect( - startContract.getVariation( - // @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES - 'some-random-flag', - 123 - ) - ).resolves.toStrictEqual(123); - }); - }); - }); - - describe('reportMetric', () => { - describe('with the client created', () => { - beforeEach(() => { - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - }); - - test('calls LaunchDarklyClient.reportMetric', () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - startContract.reportMetric({ - // @ts-expect-error We only allow existing flags in METRIC_NAMES - name: 'my-flag', - meta: {}, - value: 1, - }); - expect(launchDarklyInstanceMock.reportMetric).toHaveBeenCalledWith( - undefined, // it couldn't find it in METRIC_NAMES - {}, - 1 - ); - }); - }); - - describe('with the client not created (missing LD settings)', () => { - beforeEach(() => { - const initializerContext = coreMock.createPluginInitializerContext( - config.schema.validate( - { - flag_overrides: { [firstKnownFlag]: '1234' }, - }, - { dev: true } - ) - ); - plugin = new CloudExperimentsPlugin(initializerContext); - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: false }, - }); - }); - - test('does not call LaunchDarklyClient.reportMetric because the client is not there', () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - startContract.reportMetric({ - // @ts-expect-error We only allow existing flags in METRIC_NAMES - name: 'my-flag', - meta: {}, - value: 1, - }); - expect(plugin).toHaveProperty('launchDarklyClient', undefined); - }); - }); - }); }); describe('stop', () => { @@ -312,7 +164,6 @@ describe('Cloud Experiments server plugin', () => { config.schema.validate( { launch_darkly: { sdk_key: 'sdk-1234', client_id: 'fake-client-id' }, - flag_overrides: { my_flag: '1234' }, }, { dev: true } ) @@ -321,18 +172,11 @@ describe('Cloud Experiments server plugin', () => { const dataViews = createIndexPatternsStartMock(); plugin.setup(coreMock.createSetup(), { cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, + usageCollection: usageCollectionPluginMock.createSetupContract(), }); plugin.start(coreMock.createStart(), { dataViews }); }); - test('stops the LaunchDarkly client', () => { - plugin.stop(); - const launchDarklyInstanceMock = ( - LaunchDarklyClient as jest.MockedClass - ).mock.instances[0] as jest.Mocked; - expect(launchDarklyInstanceMock.stop).toHaveBeenCalledTimes(1); - }); - test('stops the Metadata Service', () => { // eslint-disable-next-line dot-notation const metadataServiceStopSpy = jest.spyOn(plugin['metadataService'], 'stop'); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts index 834784a11f2c5..fa9de11b0dfc9 100755 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts @@ -12,26 +12,20 @@ import type { Plugin, Logger, } from '@kbn/core/server'; -import { get, has } from 'lodash'; -import type { LogMeta } from '@kbn/logging'; +import { map } from 'rxjs'; +import { OpenFeature } from '@openfeature/server-sdk'; +import { LaunchDarklyProvider } from '@launchdarkly/openfeature-node-server'; +import type { LogLevelId } from '@kbn/logging'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server/types'; -import { filter, map } from 'rxjs'; -import { MetadataService } from '../common/metadata_service'; -import { LaunchDarklyClient } from './launch_darkly_client'; +import { initializeMetadata, MetadataService } from '../common/metadata_service'; import { registerUsageCollector } from './usage'; import type { CloudExperimentsConfigType } from './config'; -import type { - CloudExperimentsFeatureFlagNames, - CloudExperimentsMetric, - CloudExperimentsPluginStart, -} from '../common'; -import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants'; interface CloudExperimentsPluginSetupDeps { cloud: CloudSetup; - usageCollection?: UsageCollectionSetup; + usageCollection: UsageCollectionSetup; } interface CloudExperimentsPluginStartDeps { @@ -39,11 +33,9 @@ interface CloudExperimentsPluginStartDeps { } export class CloudExperimentsPlugin - implements Plugin + implements Plugin { private readonly logger: Logger; - private readonly launchDarklyClient?: LaunchDarklyClient; - private readonly flagOverrides?: Record; private readonly metadataService: MetadataService; constructor(private readonly initializerContext: PluginInitializerContext) { @@ -55,9 +47,6 @@ export class CloudExperimentsPlugin this.logger.get('metadata') ); - if (config.flag_overrides) { - this.flagOverrides = config.flag_overrides; - } const ldConfig = config.launch_darkly; // If the plugin is enabled and no flag_overrides are provided (dev mode only), launch_darkly must exist if (!ldConfig && !initializerContext.env.mode.dev) { // If the plugin is enabled, and it's in prod mode, launch_darkly must exist @@ -66,87 +55,70 @@ export class CloudExperimentsPlugin 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); } - if (ldConfig) { - this.launchDarklyClient = new LaunchDarklyClient( - { - ...ldConfig, - kibana_version: initializerContext.env.packageInfo.version, - }, - this.logger.get('launch_darkly') - ); - } } public setup(core: CoreSetup, deps: CloudExperimentsPluginSetupDeps) { - if (deps.usageCollection) { - registerUsageCollector(deps.usageCollection, () => ({ - launchDarklyClient: this.launchDarklyClient, - })); - } + // Ideally we should have something like this for the browser as well. + core.logging.configure( + this.initializerContext.config.create().pipe( + map(({ launch_darkly: { client_log_level: clientLogLevel = 'none' } = {} }) => { + const logLevel = clientLogLevel.replace('none', 'off') as LogLevelId; + return { loggers: [{ name: 'launch-darkly', level: logLevel, appenders: [] }] }; + }) + ) + ); + + initializeMetadata({ + metadataService: this.metadataService, + initializerContext: this.initializerContext, + cloud: deps.cloud, + featureFlags: core.featureFlags, + logger: this.logger, + }); - if (deps.cloud.isCloudEnabled && deps.cloud.deploymentId) { - this.metadataService.setup({ - // We use the Cloud Deployment ID as the userId in the Cloud Experiments - userId: deps.cloud.deploymentId, - kibanaVersion: this.initializerContext.env.packageInfo.version, - trialEndDate: deps.cloud.trialEndDate?.toISOString(), - isElasticStaff: deps.cloud.isElasticStaffOwned, - }); - - // We only subscribe to the user metadata updates if Cloud is enabled. - // This way, since the user is not identified, it cannot retrieve Feature Flags from LaunchDarkly when not running on Cloud. - this.metadataService.userMetadata$ - .pipe( - filter(Boolean), // Filter out undefined - map((userMetadata) => this.launchDarklyClient?.updateUserMetadata(userMetadata)) - ) - .subscribe(); // This subscription will stop on when the metadataService stops because it completes the Observable + const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(); + if (launchDarklyOpenFeatureProvider) { + core.featureFlags.setProvider(launchDarklyOpenFeatureProvider); } + + registerUsageCollector(deps.usageCollection, () => ({ + launchDarklyClient: launchDarklyOpenFeatureProvider?.getClient(), + currentContext: OpenFeature.getContext(), + })); } public start(core: CoreStart, deps: CloudExperimentsPluginStartDeps) { this.metadataService.start({ hasDataFetcher: async () => await this.addHasDataMetadata(core, deps.dataViews), }); - return { - getVariation: this.getVariation, - reportMetric: this.reportMetric, - }; } public stop() { - this.launchDarklyClient?.stop(); this.metadataService.stop(); } - private getVariation = async ( - featureFlagName: CloudExperimentsFeatureFlagNames, - defaultValue: Data - ): Promise => { - const configKey = FEATURE_FLAG_NAMES[featureFlagName]; - // Apply overrides if they exist without asking LaunchDarkly. - if (this.flagOverrides && has(this.flagOverrides, configKey)) { - return get(this.flagOverrides, configKey, defaultValue) as Data; - } - if (!this.launchDarklyClient) return defaultValue; - return await this.launchDarklyClient.getVariation(configKey, defaultValue); - }; - - private reportMetric = ({ name, meta, value }: CloudExperimentsMetric): void => { - const metricName = METRIC_NAMES[name]; - this.launchDarklyClient?.reportMetric(metricName, meta, value); - this.logger.debug<{ experimentationMetric: CloudExperimentsMetric } & LogMeta>( - `Reported experimentation metric ${metricName}`, - { - experimentationMetric: { name, meta, value }, - } - ); - }; + private createOpenFeatureProvider() { + const { launch_darkly: ldConfig } = + this.initializerContext.config.get(); + + if (!ldConfig) return; + + return new LaunchDarklyProvider(ldConfig.sdk_key, { + logger: this.logger.get('launch-darkly'), + application: { + id: 'kibana-server', + version: + this.initializerContext.env.packageInfo.buildFlavor === 'serverless' + ? this.initializerContext.env.packageInfo.buildSha + : this.initializerContext.env.packageInfo.version, + }, + }); + } private async addHasDataMetadata( core: CoreStart, dataViews: DataViewsServerPluginStart - ): Promise<{ hasData: boolean }> { + ): Promise<{ has_data: boolean }> { const dataViewsService = await dataViews.dataViewsServiceFactory( core.savedObjects.createInternalRepository(), core.elasticsearch.client.asInternalUser, @@ -154,7 +126,7 @@ export class CloudExperimentsPlugin true // Ignore capabilities checks ); return { - hasData: await dataViewsService.hasUserDataView(), + has_data: await dataViewsService.hasUserDataView(), }; } } diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.test.ts index 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/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/cloud_integrations/cloud_experiments/tsconfig.json b/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json index e1c6ed7b04539..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,11 @@ "@kbn/config-schema", "@kbn/logging", "@kbn/logging-mocks", - "@kbn/utility-types", + "@kbn/core-plugins-browser", + "@kbn/core-plugins-server", + "@kbn/core-feature-flags-browser", + "@kbn/core-feature-flags-server", + "@kbn/config", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/fleet/.storybook/context/index.tsx b/x-pack/plugins/fleet/.storybook/context/index.tsx index 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 }), 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/plugins/observability_solution/observability_onboarding/kibana.jsonc b/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc index fd4e955b9bd95..859f9539bd9fa 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc @@ -16,7 +16,7 @@ "fleet", "customIntegrations" ], - "optionalPlugins": ["cloud", "cloudExperiments", "usageCollection"], + "optionalPlugins": ["cloud", "usageCollection"], "requiredBundles": ["kibanaReact"], "extraPublicDirs": ["common"] } diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts index 514a4fcc94049..97850fc5ff47b 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts @@ -21,7 +21,6 @@ import { Plugin, PluginInitializerContext, } from '@kbn/core/public'; -import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; import { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; import { DiscoverSetup, DiscoverStart } from '@kbn/discover-plugin/public'; @@ -62,7 +61,6 @@ export interface ObservabilityOnboardingPluginStartDeps { fleet: FleetStart; cloud?: CloudStart; usageCollection?: UsageCollectionStart; - cloudExperiments?: CloudExperimentsPluginStart; } export type ObservabilityOnboardingContextValue = CoreStart & diff --git a/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json b/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json index 12a908624cfd9..8730f85b5943f 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 e5840a6662e79..075da90b44a0f 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -57,7 +57,6 @@ "entityManager" ], "optionalPlugins": [ - "cloudExperiments", "encryptedSavedObjects", "fleet", "ml", diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 27a6d28418e9e..07a9220b3dfa4 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -44,7 +44,6 @@ import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; import { noCasesPermissions } from '../../../cases_test_utils'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; import { mockApm } from '../apm/service.mock'; -import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks'; import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; @@ -124,7 +123,6 @@ export const createStartServicesMock = ( const dataViewServiceMock = dataViewPluginMocks.createStartContract(); cases.helpers.canUseCases.mockReturnValue(noCasesPermissions()); const triggersActionsUi = triggersActionsUiMock.createStart(); - const cloudExperiments = cloudExperimentsMock.createStartMock(); const guidedOnboarding = guidedOnboardingMock.createStart(); const cloud = cloudMock.createStart(); const mockSetHeaderActionMenu = jest.fn(); @@ -238,7 +236,6 @@ export const createStartServicesMock = ( fetchAllLiveQueries: jest.fn().mockReturnValue({ data: { data: { items: [] } } }), }, triggersActionsUi, - cloudExperiments, guidedOnboarding, cloud: { ...cloud, diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 42c332d5f8449..ca144c21d7847 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -45,7 +45,6 @@ import type { SavedObjectTaggingOssPluginStart, } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { ThreatIntelligencePluginStart } from '@kbn/threat-intelligence-plugin/public'; -import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; import type { DataViewsServicePublic } from '@kbn/data-views-plugin/public'; import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; @@ -143,7 +142,6 @@ export interface StartPlugins { cloudDefend: CloudDefendPluginStart; cloudSecurityPosture: CspClientPluginStart; threatIntelligence: ThreatIntelligencePluginStart; - cloudExperiments?: CloudExperimentsPluginStart; dataViews: DataViewsServicePublic; fieldFormats: FieldFormatsStartCommon; discover: DiscoverStart; diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index b2df523f67d6b..e6ec61c44d89e 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -39,7 +39,6 @@ "@kbn/actions-plugin", "@kbn/alerting-plugin", "@kbn/cases-plugin", - "@kbn/cloud-experiments-plugin", "@kbn/cloud-security-posture-plugin", "@kbn/encrypted-saved-objects-plugin", "@kbn/features-plugin", diff --git a/yarn.lock b/yarn.lock index 13852c94782c1..9ef2287fc16df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3994,6 +3994,30 @@ version "0.0.0" uid "" +"@kbn/core-feature-flags-browser-internal@link:packages/core/feature-flags/core-feature-flags-browser-internal": + version "0.0.0" + uid "" + +"@kbn/core-feature-flags-browser-mocks@link:packages/core/feature-flags/core-feature-flags-browser-mocks": + version "0.0.0" + uid "" + +"@kbn/core-feature-flags-browser@link:packages/core/feature-flags/core-feature-flags-browser": + version "0.0.0" + uid "" + +"@kbn/core-feature-flags-server-internal@link:packages/core/feature-flags/core-feature-flags-server-internal": + version "0.0.0" + uid "" + +"@kbn/core-feature-flags-server-mocks@link:packages/core/feature-flags/core-feature-flags-server-mocks": + version "0.0.0" + uid "" + +"@kbn/core-feature-flags-server@link:packages/core/feature-flags/core-feature-flags-server": + version "0.0.0" + uid "" + "@kbn/core-history-block-plugin@link:test/plugin_functional/plugins/core_history_block": version "0.0.0" uid "" @@ -5078,6 +5102,10 @@ version "0.0.0" uid "" +"@kbn/feature-flags-example-plugin@link:examples/feature_flags_example": + version "0.0.0" + uid "" + "@kbn/feature-usage-test-plugin@link:x-pack/test/plugin_api_integration/plugins/feature_usage_test": version "0.0.0" uid "" @@ -7331,6 +7359,11 @@ https-proxy-agent "^5.0.1" launchdarkly-eventsource "2.0.3" +"@launchdarkly/openfeature-node-server@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@launchdarkly/openfeature-node-server/-/openfeature-node-server-1.0.0.tgz#09abebb56608e729049c3ebbd2373ce0ea25121d" + integrity sha512-4O4bQSqM+9BUZo8L+rQkxUdrv3sqC8vGcC0U0yBvELXmd9Q8jJZkY+7+idcx/zJsInYwnfmS0TUA4YeOyQw89A== + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" @@ -7978,6 +8011,28 @@ resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== +"@openfeature/core@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@openfeature/core/-/core-1.3.0.tgz#59e98813fa3878402de7b9529cec1734597f9be7" + integrity sha512-Z2TiqfC4zoiCB/JMzIrzRrdDYdfOCGjI2MDgNHDEwA/k3y5IZANFkNAc/nhfof/QrmOy0HjQtvjRLnEW8urqJQ== + +"@openfeature/launchdarkly-client-provider@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@openfeature/launchdarkly-client-provider/-/launchdarkly-client-provider-0.3.0.tgz#47ad29671529595314fdb9497d078be0a744e006" + integrity sha512-iFe27RbuUxv4hDGJDmWJnxs5gpzU2d1xTxrGu/8z0gcbtXUAaYM6s4kglf63V2QzWV/Grot6P6bwSLlqeSDwMw== + dependencies: + lodash.isempty "4.4.0" + +"@openfeature/server-sdk@^1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@openfeature/server-sdk/-/server-sdk-1.15.0.tgz#f10e8284e6fbc010d40cc9515227456eb3a1620f" + integrity sha512-NEdVg5YuUNrCTNtLOg2f37QHCCGmKtfPEkFCsbwnJ3PQ5Gkii3Qufhr17LhbRqKOahqMBuNiMebQ3n1p8ty6Sg== + +"@openfeature/web-sdk@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@openfeature/web-sdk/-/web-sdk-1.2.1.tgz#6069cedfd1ba7bd88ea47e7afb0d2892d1c891e5" + integrity sha512-4Yz6zQA8/zwFUjKhvgyhIscywkLuDLOpzy//+GdMpSgvC1VsyifFf0p0ISMMLXlQmYZxVLamhL6jAnVge8VyEw== + "@opentelemetry/api-metrics@0.31.0", "@opentelemetry/api-metrics@^0.31.0": version "0.31.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api-metrics/-/api-metrics-0.31.0.tgz#0ed4cf4d7c731f968721c2b303eaf5e9fd42f736" @@ -12843,9 +12898,9 @@ async@^1.4.2: integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= async@^3.2.0, async@^3.2.3, async@^3.2.4: - version "3.2.4" - resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" - integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== asynckit@^0.4.0: version "0.4.0" @@ -14417,16 +14472,16 @@ clone-stats@^1.0.0: resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680" integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA= -clone@2.x, clone@^2.1.1, clone@^2.1.2, clone@~2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" - integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= - clone@^1.0.2, clone@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= +clone@^2.1.1, clone@^2.1.2, clone@~2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= + cloneable-readable@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.2.tgz#d591dee4a8f8bc15da43ce97dceeba13d43e2a65" @@ -22079,11 +22134,6 @@ language-tags@=1.0.5: dependencies: language-subtag-registry "~0.3.2" -launchdarkly-eventsource@1.4.4: - version "1.4.4" - resolved "https://registry.yarnpkg.com/launchdarkly-eventsource/-/launchdarkly-eventsource-1.4.4.tgz#fa595af8602e487c61520787170376c6a1104459" - integrity sha512-GL+r2Y3WccJlhFyL2buNKel+9VaMnYpbE/FfCkOST5jSNSFodahlxtGyrE8o7R+Qhobyq0Ree4a7iafJDQi9VQ== - launchdarkly-eventsource@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/launchdarkly-eventsource/-/launchdarkly-eventsource-2.0.3.tgz#8a7b8da5538153f438f7d452b1c87643d900f984" @@ -22106,19 +22156,6 @@ launchdarkly-js-sdk-common@5.3.0: fast-deep-equal "^2.0.1" uuid "^8.0.0" -launchdarkly-node-server-sdk@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/launchdarkly-node-server-sdk/-/launchdarkly-node-server-sdk-7.0.3.tgz#d7a8b996d992b0ca5d4972db5df1ae49332b094c" - integrity sha512-uSkBezAiQ9nwv8N6CmI7OmyJ9e3xpueJzYOso8+5vMf7VtBtPjz6RRsUkUsSzUDo7siclmW8USjCwqn9aX2EbQ== - dependencies: - async "^3.2.4" - launchdarkly-eventsource "1.4.4" - lru-cache "^6.0.0" - node-cache "^5.1.0" - semver "^7.5.4" - tunnel "0.0.6" - uuid "^8.3.2" - lazy-ass@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513" @@ -22406,7 +22443,7 @@ lodash.isboolean@^3.0.3: resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== -lodash.isempty@^4.4.0: +lodash.isempty@4.4.0, lodash.isempty@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" integrity sha1-b4bL7di+TsmHvpqvM8loTbGzHn4= @@ -24044,13 +24081,6 @@ node-addon-api@^6.1.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-6.1.0.tgz#ac8470034e58e67d0c6f1204a18ae6995d9c0d76" integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== -node-cache@^5.1.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" - integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== - dependencies: - clone "2.x" - node-diff3@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/node-diff3/-/node-diff3-3.1.2.tgz#49df8d821dc9cbab87bfd6182171d90169613a97" @@ -30757,7 +30787,7 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" -tunnel@0.0.6, tunnel@^0.0.6: +tunnel@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== From 70b7d263352a73569f2809bb0b4d1e2624b43b28 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 18 Sep 2024 18:21:00 +0200 Subject: [PATCH 09/11] [Security Solution] ThreeWayDiff UI: Migrate to using `DiffableRule` TS type in `FieldReadOnly` component (#192342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Partially addresses: https://github.com/elastic/kibana/issues/171520** **Is a follow-up PR to: https://github.com/elastic/kibana/pull/191499** This is the 2nd of the 3 PRs for `FieldReadOnly`. - The 1st [PR](https://github.com/elastic/kibana/pull/191499) added the `FieldReadOnly` and a bunch of field components. - This (2nd) PR moves away from using `DiffableAllFields` type in favour of `DiffableRule` and splits the large `FieldReadOnly` component into smaller ones for readability. - Next (3rd) PR will add the remaining field components. ## Summary This PR changes the TS type (`DiffableAllFields` -> `DiffableRule`) used by the `FieldReadOnly` component. This component displays a read-only view of a particular rule field, similar to how fields are shown on the Rule Details page. Using `DiffableRule` type makes the component compatible with the flyout context and is safer to use than `DiffableAllFields`. ### Changes - TS type used in the `FieldReadOnly` component and Storybook stories changed to `DiffableRule`. - `FieldReadOnly` field rendering was split into multiple files by rule type to make it more readable. - Added rule-mocking functions to Storybook to allow creation of `DiffableRule` mocks. - Added field components for `name`, `description` and `tags` fields. - Rewrote type narrowing for `Filters` component to a type guard (`isFilters`). - Fixed a couple of outdated code comments. ### Running `FinalReadOnly` and its field components are not yet integrated into the flyout, but you can view components in Storybook. 1. Run Storybook: `yarn storybook security_solution` 2. Go to `http://localhost:9001` in browser. Scherm­afbeelding 2024-09-03 om 13 05 11 --- .../diff/three_way_diff/three_way_diff.ts | 1 + .../three_way_diff/three_way_diff_conflict.ts | 4 +- .../components/rule_details/helpers.ts | 15 +- .../rule_details/rule_about_section.tsx | 2 +- .../common_rule_field_readonly.tsx | 91 +++++++++ .../custom_query_rule_field_readonly.tsx | 36 ++++ .../eql_rule_field_readonly.tsx | 34 ++++ .../esql_rule_field_readonly.tsx | 29 +++ .../final_readonly/field_readonly.tsx | 134 +++++++------ .../data_source/data_source.stories.tsx | 18 +- .../fields/data_source/data_source.tsx | 4 +- .../description/description.stories.tsx | 35 ++++ .../fields/description/description.tsx | 36 ++++ .../fields/eql_query/eql_query.stories.tsx | 18 +- .../fields/eql_query/eql_query.tsx | 17 +- .../fields/esql_query/esql_query.stories.tsx | 16 +- .../fields/esql_query/esql_query.tsx | 4 +- .../final_readonly/fields/kql_query/index.tsx | 7 +- .../fields/kql_query/inline_kql_query.tsx | 12 +- .../fields/kql_query/kql_query.stories.tsx | 34 ++-- .../fields/kql_query/saved_kql_query.tsx | 8 +- .../machine_learning_job_id.stories.tsx | 11 +- .../machine_learning_job_id.tsx | 4 +- .../fields/name/name.stories.tsx | 34 ++++ .../final_readonly/fields/name/name.tsx | 36 ++++ .../related_integrations.stories.tsx | 11 +- .../required_fields.stories.tsx | 16 +- .../risk_score_mapping.stories.tsx | 14 +- .../severity_mapping.stories.tsx | 16 +- .../fields/tags/tags.stories.tsx | 34 ++++ .../final_readonly/fields/tags/tags.tsx | 29 +++ .../fields/threat/threat.stories.tsx | 16 +- .../threat_index/threat_index.stories.tsx | 16 +- .../threat_indicator_path.stories.tsx | 14 +- .../threat_mapping/threat_mapping.stories.tsx | 16 +- .../threat_query/threat_query.stories.tsx | 18 +- .../fields/threat_query/threat_query.tsx | 4 +- .../machine_learning_rule_field_readonly.tsx | 33 ++++ .../new_terms_rule_field_readonly.tsx | 38 ++++ .../saved_query_rule_field_readonly.tsx | 38 ++++ .../final_readonly/storybook/mocks.ts | 181 +++++++++++++++++- .../threat_match_rule_field_readonly.tsx | 59 ++++++ .../threshold_rule_field_readonly.tsx | 38 ++++ 43 files changed, 992 insertions(+), 239 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/common_rule_field_readonly.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/custom_query_rule_field_readonly.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/eql_rule_field_readonly.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/esql_rule_field_readonly.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/description/description.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/description/description.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/name/name.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/name/name.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tags/tags.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tags/tags.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/machine_learning_rule_field_readonly.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/new_terms_rule_field_readonly.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/saved_query_rule_field_readonly.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/threat_match_rule_field_readonly.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/threshold_rule_field_readonly.tsx diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff.ts index 7b71bb6f16c7a..c8c4238709849 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff.ts @@ -131,6 +131,7 @@ export interface ThreeWayDiff { * True if: * - base=A, current=A, target=B * - base=A, current=B, target=C + * - base=, current=A, target=B */ has_update: boolean; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff_conflict.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff_conflict.ts index f33c52d33abfc..541c51d4f571f 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff_conflict.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/three_way_diff/three_way_diff_conflict.ts @@ -7,8 +7,8 @@ /** * Enum of possible conflict outcomes of a three-way diff: - * - NON_SOLVABLE_CONFLICT: current != target and we couldn't automatically resolve the conflict between them - * - SOLVABLE_CONFLICT: current != target and we automatically resolved the conflict between them + * - NON_SOLVABLE: current != target and we couldn't automatically resolve the conflict between them + * - SOLVABLE: current != target and we automatically resolved the conflict between them * - NO_CONFLICT: * - current == target (value won't change) * - current != target && current == base (stock rule will get a new value) diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/helpers.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/helpers.ts index 9810cd4882cc7..08e4c9535ae91 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/helpers.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/helpers.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { isPlainObject } from 'lodash'; import type { Filter } from '@kbn/es-query'; import type { DiffableAllFields, @@ -77,16 +78,12 @@ export function getQueryLanguageLabel(language: string) { } /** - * Assigns type `Filter` to items that have a `meta` property. Removes any other items. + * Assigns type `Filter[]` to an array if every item in it has a `meta` property. */ -export function typeCheckFilters(filters: unknown[]): Filter[] { - return filters.filter((f) => { - if (typeof f === 'object' && f !== null && 'meta' in f) { - return true; - } - - return false; - }) as Filter[]; +export function isFilters(maybeFilters: unknown[]): maybeFilters is Filter[] { + return maybeFilters.every( + (f) => typeof f === 'object' && f !== null && 'meta' in f && isPlainObject(f.meta) + ); } type DataSourceProps = diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx index c7c9ec3ca732d..1d2e93df8e7f7 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx @@ -256,7 +256,7 @@ interface TagsProps { tags: string[]; } -const Tags = ({ tags }: TagsProps) => ( +export const Tags = ({ tags }: TagsProps) => ( ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/common_rule_field_readonly.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/common_rule_field_readonly.tsx new file mode 100644 index 0000000000000..35bde351bbeb6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/common_rule_field_readonly.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { + DiffableCommonFields, + DiffableRule, +} from '../../../../../../../common/api/detection_engine'; +import { RelatedIntegrationsReadOnly } from './fields/related_integrations/related_integrations'; +import { RequiredFieldsReadOnly } from './fields/required_fields/required_fields'; +import { SeverityMappingReadOnly } from './fields/severity_mapping/severity_mapping'; +import { RiskScoreMappingReadOnly } from './fields/risk_score_mapping/risk_score_mapping'; +import { ThreatReadOnly } from './fields/threat/threat'; +import { NameReadOnly } from './fields/name/name'; +import { TagsReadOnly } from './fields/tags/tags'; +import { DescriptionReadOnly } from './fields/description/description'; +import { assertUnreachable } from '../../../../../../../common/utility_types'; + +interface CommonRuleFieldReadOnlyProps { + fieldName: keyof DiffableCommonFields; + finalDiffableRule: DiffableRule; +} + +// eslint-disable-next-line complexity +export function CommonRuleFieldReadOnly({ + fieldName, + finalDiffableRule, +}: CommonRuleFieldReadOnlyProps) { + switch (fieldName) { + case 'author': + return null; + case 'building_block': + return null; + case 'description': + return ; + case 'exceptions_list': + return null; + case 'investigation_fields': + return null; + case 'false_positives': + return null; + case 'license': + return null; + case 'max_signals': + return null; + case 'name': + return ; + case 'note': + return null; + case 'related_integrations': + return ( + + ); + case 'required_fields': + return ; + case 'risk_score_mapping': + return ; + case 'rule_schedule': + return null; + case 'severity_mapping': + return ; + case 'tags': + return ; + case 'threat': + return ; + case 'references': + return null; + case 'risk_score': + return null; + case 'rule_id': + return null; + case 'rule_name_override': + return null; + case 'setup': + return null; + case 'severity': + return null; + case 'timestamp_override': + return null; + case 'timeline_template': + return null; + case 'version': + return null; + default: + return assertUnreachable(fieldName); + } +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/custom_query_rule_field_readonly.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/custom_query_rule_field_readonly.tsx new file mode 100644 index 0000000000000..3d22d268438c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/custom_query_rule_field_readonly.tsx @@ -0,0 +1,36 @@ +/* + * 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 React from 'react'; +import type { DiffableCustomQueryFields } from '../../../../../../../common/api/detection_engine'; +import { DataSourceReadOnly } from './fields/data_source/data_source'; +import { KqlQueryReadOnly } from './fields/kql_query'; + +interface CustomQueryRuleFieldReadOnlyProps { + fieldName: keyof DiffableCustomQueryFields; + finalDiffableRule: DiffableCustomQueryFields; +} + +export function CustomQueryRuleFieldReadOnly({ + fieldName, + finalDiffableRule, +}: CustomQueryRuleFieldReadOnlyProps) { + switch (fieldName) { + case 'data_source': + return ; + case 'kql_query': + return ( + + ); + default: + return null; // Will replace with `assertUnreachable(fieldName)` once all fields are implemented + } +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/eql_rule_field_readonly.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/eql_rule_field_readonly.tsx new file mode 100644 index 0000000000000..126775965724b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/eql_rule_field_readonly.tsx @@ -0,0 +1,34 @@ +/* + * 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 React from 'react'; +import type { DiffableEqlFields } from '../../../../../../../common/api/detection_engine'; +import { DataSourceReadOnly } from './fields/data_source/data_source'; +import { EqlQueryReadOnly } from './fields/eql_query/eql_query'; + +interface EqlRuleFieldReadOnlyProps { + fieldName: keyof DiffableEqlFields; + finalDiffableRule: DiffableEqlFields; +} + +export function EqlRuleFieldReadOnly({ fieldName, finalDiffableRule }: EqlRuleFieldReadOnlyProps) { + switch (fieldName) { + case 'data_source': + return ; + case 'eql_query': + return ( + + ); + case 'type': + return null; + default: + return null; // Will replace with `assertUnreachable(fieldName)` once all fields are implemented + } +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/esql_rule_field_readonly.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/esql_rule_field_readonly.tsx new file mode 100644 index 0000000000000..755ad6b1b4789 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/esql_rule_field_readonly.tsx @@ -0,0 +1,29 @@ +/* + * 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 React from 'react'; +import type { DiffableEsqlFields } from '../../../../../../../common/api/detection_engine'; +import { EsqlQueryReadOnly } from './fields/esql_query/esql_query'; + +interface EsqlRuleFieldReadOnlyProps { + fieldName: keyof DiffableEsqlFields; + finalDiffableRule: DiffableEsqlFields; +} + +export function EsqlRuleFieldReadOnly({ + fieldName, + finalDiffableRule, +}: EsqlRuleFieldReadOnlyProps) { + switch (fieldName) { + case 'esql_query': + return ; + case 'type': + return null; + default: + return null; // Will replace with `assertUnreachable(fieldName)` once all fields are implemented + } +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/field_readonly.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/field_readonly.tsx index f3d436ad7a26a..d8d31e343278b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/field_readonly.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/field_readonly.tsx @@ -5,85 +5,105 @@ * 2.0. */ -import React from 'react'; -import type { DiffableAllFields } from '../../../../../../../common/api/detection_engine'; -import { KqlQueryReadOnly } from './fields/kql_query'; -import { DataSourceReadOnly } from './fields/data_source/data_source'; -import { EqlQueryReadOnly } from './fields/eql_query/eql_query'; -import { EsqlQueryReadOnly } from './fields/esql_query/esql_query'; -import { MachineLearningJobIdReadOnly } from './fields/machine_learning_job_id/machine_learning_job_id'; -import { RelatedIntegrationsReadOnly } from './fields/related_integrations/related_integrations'; -import { RequiredFieldsReadOnly } from './fields/required_fields/required_fields'; -import { SeverityMappingReadOnly } from './fields/severity_mapping/severity_mapping'; -import { RiskScoreMappingReadOnly } from './fields/risk_score_mapping/risk_score_mapping'; -import { ThreatMappingReadOnly } from './fields/threat_mapping/threat_mapping'; -import { ThreatReadOnly } from './fields/threat/threat'; -import { ThreatIndexReadOnly } from './fields/threat_index/threat_index'; -import { ThreatIndicatorPathReadOnly } from './fields/threat_indicator_path/threat_indicator_path'; -import { ThreatQueryReadOnly } from './fields/threat_query/threat_query'; +import React, { useMemo } from 'react'; +import { DiffableCommonFields } from '../../../../../../../common/api/detection_engine'; +import type { + DiffableRule, + DiffableCustomQueryFields, + DiffableSavedQueryFields, + DiffableEqlFields, + DiffableThreatMatchFields, + DiffableThresholdFields, + DiffableNewTermsFields, + DiffableEsqlFields, + DiffableMachineLearningFields, +} from '../../../../../../../common/api/detection_engine'; +import { assertUnreachable } from '../../../../../../../common/utility_types'; +import { CustomQueryRuleFieldReadOnly } from './custom_query_rule_field_readonly'; +import { SavedQueryRuleFieldReadOnly } from './saved_query_rule_field_readonly'; +import { EqlRuleFieldReadOnly } from './eql_rule_field_readonly'; +import { EsqlRuleFieldReadOnly } from './esql_rule_field_readonly'; +import { ThreatMatchRuleFieldReadOnly } from './threat_match_rule_field_readonly'; +import { ThresholdRuleFieldReadOnly } from './threshold_rule_field_readonly'; +import { MachineLearningRuleFieldReadOnly } from './machine_learning_rule_field_readonly'; +import { NewTermsRuleFieldReadOnly } from './new_terms_rule_field_readonly'; +import { CommonRuleFieldReadOnly } from './common_rule_field_readonly'; interface FieldReadOnlyProps { - fieldName: keyof DiffableAllFields; - finalDiffableRule: DiffableAllFields; + fieldName: string; + finalDiffableRule: DiffableRule; } export function FieldReadOnly({ fieldName, finalDiffableRule }: FieldReadOnlyProps) { - switch (fieldName) { - case 'data_source': - return ; - case 'eql_query': + const { data: commonField } = useMemo( + () => DiffableCommonFields.keyof().safeParse(fieldName), + [fieldName] + ); + + if (commonField) { + return ( + + ); + } + + switch (finalDiffableRule.type) { + case 'query': + return ( + + ); + case 'saved_query': return ( - ); - case 'esql_query': - return ; - case 'kql_query': + case 'eql': return ( - ); - case 'machine_learning_job_id': + case 'esql': return ( - ); - case 'related_integrations': + case 'threat_match': return ( - + + ); + case 'threshold': + return ( + ); - case 'required_fields': - return ; - case 'risk_score_mapping': - return ; - case 'severity_mapping': - return ; - case 'threat': - return ; - case 'threat_index': - return ; - case 'threat_indicator_path': + case 'machine_learning': return ( - ); - case 'threat_mapping': - return ; - case 'threat_query': + case 'new_terms': return ( - ); default: - return null; + return assertUnreachable(finalDiffableRule); } } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/data_source/data_source.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/data_source/data_source.stories.tsx index 6ffe47a254de9..9deebf794c241 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/data_source/data_source.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/data_source/data_source.stories.tsx @@ -8,12 +8,13 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers'; import { dataSourceWithDataView, dataSourceWithIndexPatterns, mockDataView, + mockCustomQueryRule, } from '../../storybook/mocks'; export default { @@ -22,17 +23,14 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; kibanaServicesMock?: Record; } const Template: Story = (args) => { return ( - + ); }; @@ -40,17 +38,17 @@ const Template: Story = (args) => { export const DataSourceWithIndexPatterns = Template.bind({}); DataSourceWithIndexPatterns.args = { - finalDiffableRule: { + finalDiffableRule: mockCustomQueryRule({ data_source: dataSourceWithIndexPatterns, - }, + }), }; export const DataSourceWithDataView = Template.bind({}); DataSourceWithDataView.args = { - finalDiffableRule: { + finalDiffableRule: mockCustomQueryRule({ data_source: dataSourceWithDataView, - }, + }), kibanaServicesMock: { data: { dataViews: { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/data_source/data_source.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/data_source/data_source.tsx index 29518981011fa..2a7bff6ff8b2d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/data_source/data_source.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/data_source/data_source.tsx @@ -8,13 +8,13 @@ import React from 'react'; import { EuiDescriptionList } from '@elastic/eui'; import { DataSourceType } from '../../../../../../../../../common/api/detection_engine'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { RuleDataSource } from '../../../../../../../../../common/api/detection_engine'; import { Index, DataViewId, DataViewIndexPattern } from '../../../../rule_definition_section'; import * as ruleDetailsI18n from '../../../../translations'; import { assertUnreachable } from '../../../../../../../../../common/utility_types'; interface DataSourceReadOnlyProps { - dataSource: DiffableAllFields['data_source']; + dataSource?: RuleDataSource; } export function DataSourceReadOnly({ dataSource }: DataSourceReadOnlyProps) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/description/description.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/description/description.stories.tsx new file mode 100644 index 0000000000000..ba094ecfe54f9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/description/description.stories.tsx @@ -0,0 +1,35 @@ +/* + * 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 React from 'react'; +import type { Story } from '@storybook/react'; +import { DescriptionReadOnly } from './description'; +import { FieldReadOnly } from '../../field_readonly'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; +import { mockCustomQueryRule } from '../../storybook/mocks'; + +export default { + component: DescriptionReadOnly, + title: 'Rule Management/Prebuilt Rules/Upgrade Flyout/ThreeWayDiff/FieldReadOnly/description', +}; + +interface TemplateProps { + finalDiffableRule: DiffableRule; +} + +const Template: Story = (args) => { + return ; +}; + +export const Default = Template.bind({}); + +Default.args = { + finalDiffableRule: mockCustomQueryRule({ + description: + "Identifies the occurrence of a security alert from the Google Workspace alerts center. Google Workspace's security alert center provides an overview of actionable alerts that may be affecting an organization's domain. An alert is a warning of a potential security issue that Google has detected.", + }), +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/description/description.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/description/description.tsx new file mode 100644 index 0000000000000..add624ba15a6d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/description/description.tsx @@ -0,0 +1,36 @@ +/* + * 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 React from 'react'; +import { EuiDescriptionList } from '@elastic/eui'; +import * as ruleDetailsI18n from '../../../../translations'; +import type { RuleDescription } from '../../../../../../../../../common/api/detection_engine'; + +interface DescriptionReadOnlyProps { + description: RuleDescription; +} + +export function DescriptionReadOnly({ description }: DescriptionReadOnlyProps) { + return ( + , + }, + ]} + /> + ); +} + +interface DescriptionProps { + description: RuleDescription; +} + +function Description({ description }: DescriptionProps) { + return <>{description}; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.stories.tsx index 205e87e209068..af835c5a92779 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.stories.tsx @@ -8,7 +8,7 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers'; import { EqlQueryReadOnly } from './eql_query'; import { @@ -16,6 +16,7 @@ import { dataSourceWithIndexPatterns, eqlQuery, mockDataView, + mockEqlRule, } from '../../storybook/mocks'; export default { @@ -24,17 +25,14 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; kibanaServicesMock?: Record; } const Template: Story = (args) => { return ( - + ); }; @@ -42,10 +40,10 @@ const Template: Story = (args) => { export const EqlQueryWithIndexPatterns = Template.bind({}); EqlQueryWithIndexPatterns.args = { - finalDiffableRule: { + finalDiffableRule: mockEqlRule({ eql_query: eqlQuery, data_source: dataSourceWithIndexPatterns, - }, + }), kibanaServicesMock: { data: { dataViews: { @@ -58,10 +56,10 @@ EqlQueryWithIndexPatterns.args = { export const EqlQueryWithDataView = Template.bind({}); EqlQueryWithDataView.args = { - finalDiffableRule: { + finalDiffableRule: mockEqlRule({ eql_query: eqlQuery, data_source: dataSourceWithDataView, - }, + }), kibanaServicesMock: { data: { dataViews: { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.tsx index 3238e7bb7eeef..f94f0bbfbe6c8 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.tsx @@ -8,14 +8,17 @@ import React from 'react'; import { EuiDescriptionList } from '@elastic/eui'; import type { EuiDescriptionListProps } from '@elastic/eui'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { + RuleDataSource, + RuleEqlQuery, +} from '../../../../../../../../../common/api/detection_engine'; import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations'; import { Query, Filters } from '../../../../rule_definition_section'; -import { getDataSourceProps, typeCheckFilters } from '../../../../helpers'; +import { getDataSourceProps, isFilters } from '../../../../helpers'; interface EqlQueryReadOnlyProps { - eqlQuery: DiffableAllFields['eql_query']; - dataSource: DiffableAllFields['data_source']; + eqlQuery: RuleEqlQuery; + dataSource?: RuleDataSource; } export function EqlQueryReadOnly({ eqlQuery, dataSource }: EqlQueryReadOnlyProps) { @@ -26,14 +29,12 @@ export function EqlQueryReadOnly({ eqlQuery, dataSource }: EqlQueryReadOnlyProps }, ]; - const filters = typeCheckFilters(eqlQuery.filters); - - if (filters.length > 0) { + if (isFilters(eqlQuery.filters) && eqlQuery.filters.length > 0) { const dataSourceProps = getDataSourceProps(dataSource); listItems.push({ title: descriptionStepI18n.FILTERS_LABEL, - description: , + description: , }); } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/esql_query/esql_query.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/esql_query/esql_query.stories.tsx index cda1c99a218bb..664294dbe768c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/esql_query/esql_query.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/esql_query/esql_query.stories.tsx @@ -8,7 +8,8 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; +import { mockEsqlRule } from '../../storybook/mocks'; export default { component: FieldReadOnly, @@ -16,25 +17,20 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; } const Template: Story = (args) => { - return ( - - ); + return ; }; export const Default = Template.bind({}); Default.args = { - finalDiffableRule: { + finalDiffableRule: mockEsqlRule({ esql_query: { query: `SELECT user.name, source.ip FROM "logs-*" WHERE event.action = 'user_login' AND event.outcome = 'failure'`, language: 'esql', }, - }, + }), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/esql_query/esql_query.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/esql_query/esql_query.tsx index ec110bd034b67..9277d2ded7a7d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/esql_query/esql_query.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/esql_query/esql_query.tsx @@ -9,10 +9,10 @@ import React from 'react'; import { EuiDescriptionList } from '@elastic/eui'; import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations'; import { Query } from '../../../../rule_definition_section'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { RuleEsqlQuery } from '../../../../../../../../../common/api/detection_engine'; interface EsqlQueryReadonlyProps { - esqlQuery: DiffableAllFields['esql_query']; + esqlQuery: RuleEsqlQuery; } export function EsqlQueryReadOnly({ esqlQuery }: EsqlQueryReadonlyProps) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/index.tsx index a2dd75e188a0c..d5bea0866c22f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/index.tsx @@ -8,7 +8,8 @@ import React from 'react'; import { KqlQueryType } from '../../../../../../../../../common/api/detection_engine'; import type { - DiffableAllFields, + DiffableRuleTypes, + RuleDataSource, RuleKqlQuery, } from '../../../../../../../../../common/api/detection_engine'; import { InlineKqlQueryReadOnly } from './inline_kql_query'; @@ -17,8 +18,8 @@ import { assertUnreachable } from '../../../../../../../../../common/utility_typ interface KqlQueryReadOnlyProps { kqlQuery: RuleKqlQuery; - dataSource: DiffableAllFields['data_source']; - ruleType: DiffableAllFields['type']; + dataSource?: RuleDataSource; + ruleType: DiffableRuleTypes; } export function KqlQueryReadOnly({ kqlQuery, dataSource, ruleType }: KqlQueryReadOnlyProps) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/inline_kql_query.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/inline_kql_query.tsx index a70909745d8dc..54d3836573ab5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/inline_kql_query.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/inline_kql_query.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { EuiDescriptionList } from '@elastic/eui'; import type { EuiDescriptionListProps } from '@elastic/eui'; import type { - DiffableAllFields, InlineKqlQuery, + RuleDataSource, } from '../../../../../../../../../common/api/detection_engine'; import { Query, Filters } from '../../../../rule_definition_section'; import * as ruleDetailsI18n from '../../../../translations'; import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations'; -import { getDataSourceProps, getQueryLanguageLabel, typeCheckFilters } from '../../../../helpers'; +import { getDataSourceProps, getQueryLanguageLabel, isFilters } from '../../../../helpers'; const defaultI18nLabels = { query: descriptionStepI18n.QUERY_LABEL, @@ -25,7 +25,7 @@ const defaultI18nLabels = { interface InlineQueryProps { kqlQuery: InlineKqlQuery; - dataSource?: DiffableAllFields['data_source']; + dataSource?: RuleDataSource; i18nLabels?: { query: string; language: string; @@ -49,14 +49,12 @@ export function InlineKqlQueryReadOnly({ }, ]; - const filters = typeCheckFilters(kqlQuery.filters); - - if (filters.length > 0) { + if (isFilters(kqlQuery.filters) && kqlQuery.filters.length > 0) { const dataSourceProps = getDataSourceProps(dataSource); listItems.push({ title: i18nLabels.filters, - description: , + description: , }); } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/kql_query.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/kql_query.stories.tsx index 61d31d983b183..2b4844ceac5d5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/kql_query.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/kql_query.stories.tsx @@ -8,18 +8,17 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { FieldReadOnly } from '../../field_readonly'; -import type { - DiffableAllFields, - RuleKqlQuery, -} from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers'; import { dataSourceWithDataView, dataSourceWithIndexPatterns, inlineKqlQuery, mockDataView, + mockCustomQueryRule, savedKqlQuery, savedQueryResponse, + mockSavedQueryRule, } from '../../storybook/mocks'; export default { @@ -28,17 +27,14 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial | { kql_query: RuleKqlQuery }; + finalDiffableRule: DiffableRule; kibanaServicesMock?: Record; } const Template: Story = (args) => { return ( - + ); }; @@ -46,10 +42,10 @@ const Template: Story = (args) => { export const InlineKqlQueryWithIndexPatterns = Template.bind({}); InlineKqlQueryWithIndexPatterns.args = { - finalDiffableRule: { + finalDiffableRule: mockCustomQueryRule({ kql_query: inlineKqlQuery, data_source: dataSourceWithIndexPatterns, - }, + }), kibanaServicesMock: { data: { dataViews: { @@ -62,10 +58,10 @@ InlineKqlQueryWithIndexPatterns.args = { export const InlineKqlQueryWithDataView = Template.bind({}); InlineKqlQueryWithDataView.args = { - finalDiffableRule: { + finalDiffableRule: mockCustomQueryRule({ kql_query: inlineKqlQuery, data_source: dataSourceWithDataView, - }, + }), kibanaServicesMock: { data: { dataViews: { @@ -82,9 +78,9 @@ export const InlineKqlQueryWithoutDataSource = Template.bind({}); Component would fall back to the default index pattern in such case. */ InlineKqlQueryWithoutDataSource.args = { - finalDiffableRule: { + finalDiffableRule: mockCustomQueryRule({ kql_query: inlineKqlQuery, - }, + }), kibanaServicesMock: { data: { dataViews: { @@ -97,11 +93,11 @@ InlineKqlQueryWithoutDataSource.args = { export const SavedKqlQueryWithIndexPatterns = Template.bind({}); SavedKqlQueryWithIndexPatterns.args = { - finalDiffableRule: { + finalDiffableRule: mockSavedQueryRule({ kql_query: savedKqlQuery, data_source: dataSourceWithIndexPatterns, type: 'saved_query', - }, + }), kibanaServicesMock: { data: { dataViews: { @@ -117,11 +113,11 @@ SavedKqlQueryWithIndexPatterns.args = { export const SavedKqlQueryWithDataView = Template.bind({}); SavedKqlQueryWithDataView.args = { - finalDiffableRule: { + finalDiffableRule: mockSavedQueryRule({ kql_query: savedKqlQuery, data_source: dataSourceWithDataView, type: 'saved_query', - }, + }), kibanaServicesMock: { data: { dataViews: { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/saved_kql_query.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/saved_kql_query.tsx index d7741f41af820..5f50a9c1b9eae 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/saved_kql_query.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/kql_query/saved_kql_query.tsx @@ -10,8 +10,8 @@ import { EuiDescriptionList } from '@elastic/eui'; import type { EuiDescriptionListProps } from '@elastic/eui'; import type { SavedKqlQuery, - DiffableRule, - DiffableAllFields, + RuleDataSource, + DiffableRuleTypes, } from '../../../../../../../../../common/api/detection_engine'; import { Query, SavedQueryName, Filters } from '../../../../rule_definition_section'; import * as ruleDetailsI18n from '../../../../translations'; @@ -21,8 +21,8 @@ import { getDataSourceProps, getQueryLanguageLabel } from '../../../../helpers'; interface SavedQueryProps { kqlQuery: SavedKqlQuery; - dataSource?: DiffableAllFields['data_source']; - ruleType: DiffableRule['type']; + dataSource?: RuleDataSource; + ruleType: DiffableRuleTypes; } export function SavedKqlQueryReadOnly({ kqlQuery, dataSource, ruleType }: SavedQueryProps) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/machine_learning_job_id/machine_learning_job_id.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/machine_learning_job_id/machine_learning_job_id.stories.tsx index 4d334f3aa57ec..8dc504b737e00 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/machine_learning_job_id/machine_learning_job_id.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/machine_learning_job_id/machine_learning_job_id.stories.tsx @@ -9,12 +9,13 @@ import React from 'react'; import { useQueryClient } from '@tanstack/react-query'; import type { Story } from '@storybook/react'; import { MachineLearningJobIdReadOnly } from './machine_learning_job_id'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; import { FieldReadOnly } from '../../field_readonly'; import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers'; import { GET_MODULES_QUERY_KEY } from '../../../../../../../../common/components/ml_popover/hooks/use_fetch_modules_query'; import { GET_RECOGNIZER_QUERY_KEY } from '../../../../../../../../common/components/ml_popover/hooks/use_fetch_recognizer_query'; import { GET_JOBS_SUMMARY_QUERY_KEY } from '../../../../../../../../common/components/ml/hooks/use_fetch_jobs_summary_query'; +import { mockMachineLearningRule } from '../../storybook/mocks'; export default { component: MachineLearningJobIdReadOnly, @@ -58,7 +59,7 @@ function MockMlData({ children }: { children: React.ReactNode }) { } interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; } const Template: Story = (args) => { @@ -67,7 +68,7 @@ const Template: Story = (args) => { @@ -77,7 +78,7 @@ const Template: Story = (args) => { export const Default = Template.bind({}); Default.args = { - finalDiffableRule: { + finalDiffableRule: mockMachineLearningRule({ machine_learning_job_id: 'auth_high_count_logon_events', - }, + }), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/machine_learning_job_id/machine_learning_job_id.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/machine_learning_job_id/machine_learning_job_id.tsx index b177da1467910..570c58539ea84 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/machine_learning_job_id/machine_learning_job_id.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/machine_learning_job_id/machine_learning_job_id.tsx @@ -8,11 +8,11 @@ import React from 'react'; import { EuiDescriptionList } from '@elastic/eui'; import { MachineLearningJobList } from '../../../../rule_definition_section'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { MachineLearningJobId } from '../../../../../../../../../common/api/detection_engine'; import * as ruleDetailsI18n from '../../../../translations'; interface MachineLearningJobIdProps { - machineLearningJobId: DiffableAllFields['machine_learning_job_id']; + machineLearningJobId: MachineLearningJobId; } export function MachineLearningJobIdReadOnly({ machineLearningJobId }: MachineLearningJobIdProps) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/name/name.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/name/name.stories.tsx new file mode 100644 index 0000000000000..1451d3b70ef8b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/name/name.stories.tsx @@ -0,0 +1,34 @@ +/* + * 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 React from 'react'; +import type { Story } from '@storybook/react'; +import { NameReadOnly } from './name'; +import { FieldReadOnly } from '../../field_readonly'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; +import { mockCustomQueryRule } from '../../storybook/mocks'; + +export default { + component: NameReadOnly, + title: 'Rule Management/Prebuilt Rules/Upgrade Flyout/ThreeWayDiff/FieldReadOnly/name', +}; + +interface TemplateProps { + finalDiffableRule: DiffableRule; +} + +const Template: Story = (args) => { + return ; +}; + +export const Default = Template.bind({}); + +Default.args = { + finalDiffableRule: mockCustomQueryRule({ + name: 'Forwarded Google Workspace Security Alert', + }), +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/name/name.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/name/name.tsx new file mode 100644 index 0000000000000..a611f9821d54e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/name/name.tsx @@ -0,0 +1,36 @@ +/* + * 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 React from 'react'; +import { EuiDescriptionList } from '@elastic/eui'; +import * as ruleDetailsI18n from '../../../../translations'; +import type { RuleName } from '../../../../../../../../../common/api/detection_engine'; + +interface NameReadOnlyProps { + name: RuleName; +} + +export function NameReadOnly({ name }: NameReadOnlyProps) { + return ( + , + }, + ]} + /> + ); +} + +interface NameProps { + name: RuleName; +} + +function Name({ name }: NameProps) { + return <>{name}; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/related_integrations/related_integrations.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/related_integrations/related_integrations.stories.tsx index 9855e2f974096..4657b21b0c64e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/related_integrations/related_integrations.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/related_integrations/related_integrations.stories.tsx @@ -11,7 +11,8 @@ import type { Story } from '@storybook/react'; import { RelatedIntegrationsReadOnly } from './related_integrations'; import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; +import { mockCustomQueryRule } from '../../storybook/mocks'; export default { component: RelatedIntegrationsReadOnly, @@ -39,7 +40,7 @@ function MockRelatedIntegrationsData({ children }: { children: React.ReactNode } } interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; } const Template: Story = (args) => { @@ -48,7 +49,7 @@ const Template: Story = (args) => { @@ -58,7 +59,7 @@ const Template: Story = (args) => { export const Default = Template.bind({}); Default.args = { - finalDiffableRule: { + finalDiffableRule: mockCustomQueryRule({ related_integrations: [{ package: 'endpoint', version: '^8.2.0' }], - }, + }), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/required_fields/required_fields.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/required_fields/required_fields.stories.tsx index 2957d8ff7ceaf..44d3383ae8227 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/required_fields/required_fields.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/required_fields/required_fields.stories.tsx @@ -8,7 +8,8 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { RequiredFieldsReadOnly } from './required_fields'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; +import { mockCustomQueryRule } from '../../storybook/mocks'; export default { component: RequiredFieldsReadOnly, @@ -16,25 +17,20 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; } const Template: Story = (args) => { - return ( - - ); + return ; }; export const Default = Template.bind({}); Default.args = { - finalDiffableRule: { + finalDiffableRule: mockCustomQueryRule({ required_fields: [ { name: 'event.kind', type: 'keyword', ecs: true }, { name: 'event.module', type: 'keyword', ecs: true }, ], - }, + }), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/risk_score_mapping/risk_score_mapping.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/risk_score_mapping/risk_score_mapping.stories.tsx index 76775d6da4586..40c8644ffd3ea 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/risk_score_mapping/risk_score_mapping.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/risk_score_mapping/risk_score_mapping.stories.tsx @@ -8,8 +8,9 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; import { RiskScoreMappingReadOnly } from './risk_score_mapping'; +import { mockCustomQueryRule } from '../../storybook/mocks'; export default { component: RiskScoreMappingReadOnly, @@ -18,22 +19,19 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; } const Template: Story = (args) => { return ( - + ); }; export const Default = Template.bind({}); Default.args = { - finalDiffableRule: { + finalDiffableRule: mockCustomQueryRule({ risk_score_mapping: [{ field: 'event.risk_score', operator: 'equals', value: '' }], - }, + }), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/severity_mapping/severity_mapping.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/severity_mapping/severity_mapping.stories.tsx index 92d7b95b17c2b..6a4b365a86db5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/severity_mapping/severity_mapping.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/severity_mapping/severity_mapping.stories.tsx @@ -8,8 +8,9 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; import { SeverityMappingReadOnly } from './severity_mapping'; +import { mockCustomQueryRule } from '../../storybook/mocks'; export default { component: SeverityMappingReadOnly, @@ -18,22 +19,17 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; } const Template: Story = (args) => { - return ( - - ); + return ; }; export const Default = Template.bind({}); Default.args = { - finalDiffableRule: { + finalDiffableRule: mockCustomQueryRule({ severity_mapping: [ { field: 'event.severity', @@ -48,5 +44,5 @@ Default.args = { value: 'VERY HIGH', }, ], - }, + }), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tags/tags.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tags/tags.stories.tsx new file mode 100644 index 0000000000000..b2c483cbc8c97 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tags/tags.stories.tsx @@ -0,0 +1,34 @@ +/* + * 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 React from 'react'; +import type { Story } from '@storybook/react'; +import { TagsReadOnly } from './tags'; +import { FieldReadOnly } from '../../field_readonly'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; +import { mockCustomQueryRule } from '../../storybook/mocks'; + +export default { + component: TagsReadOnly, + title: 'Rule Management/Prebuilt Rules/Upgrade Flyout/ThreeWayDiff/FieldReadOnly/tags', +}; + +interface TemplateProps { + finalDiffableRule: DiffableRule; +} + +const Template: Story = (args) => { + return ; +}; + +export const Default = Template.bind({}); + +Default.args = { + finalDiffableRule: mockCustomQueryRule({ + tags: ['Elastic', 'Cloud', 'Google Workspace', 'Log Auditing', 'Threat Detection'], + }), +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tags/tags.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tags/tags.tsx new file mode 100644 index 0000000000000..dbb7928b5f22e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tags/tags.tsx @@ -0,0 +1,29 @@ +/* + * 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 React from 'react'; +import { EuiDescriptionList } from '@elastic/eui'; +import * as ruleDetailsI18n from '../../../../translations'; +import type { RuleTagArray } from '../../../../../../../../../common/api/detection_engine'; +import { Tags } from '../../../../rule_about_section'; + +interface TagsReadOnlyProps { + tags: RuleTagArray; +} + +export function TagsReadOnly({ tags }: TagsReadOnlyProps) { + return ( + , + }, + ]} + /> + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat/threat.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat/threat.stories.tsx index 4909174dcb577..aa95633349260 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat/threat.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat/threat.stories.tsx @@ -8,8 +8,9 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; import { ThreatReadOnly } from './threat'; +import { mockCustomQueryRule } from '../../storybook/mocks'; export default { component: ThreatReadOnly, @@ -17,22 +18,17 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; } const Template: Story = (args) => { - return ( - - ); + return ; }; export const Default = Template.bind({}); Default.args = { - finalDiffableRule: { + finalDiffableRule: mockCustomQueryRule({ threat: [ { framework: 'MITRE ATT&CK', @@ -57,5 +53,5 @@ Default.args = { ], }, ], - }, + }), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_index/threat_index.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_index/threat_index.stories.tsx index c42fa2e890c52..4f10cb8932ab1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_index/threat_index.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_index/threat_index.stories.tsx @@ -8,8 +8,9 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; import { ThreatIndexReadOnly } from './threat_index'; +import { mockThreatMatchRule } from '../../storybook/mocks'; export default { component: ThreatIndexReadOnly, @@ -17,22 +18,17 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; } const Template: Story = (args) => { - return ( - - ); + return ; }; export const Default = Template.bind({}); Default.args = { - finalDiffableRule: { + finalDiffableRule: mockThreatMatchRule({ threat_index: ['logs-ti_*', 'logs-defend_*'], - }, + }), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_indicator_path/threat_indicator_path.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_indicator_path/threat_indicator_path.stories.tsx index 34f0cdbcac317..be8906d76eb6b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_indicator_path/threat_indicator_path.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_indicator_path/threat_indicator_path.stories.tsx @@ -8,8 +8,9 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; import { ThreatIndicatorPathReadOnly } from './threat_indicator_path'; +import { mockThreatMatchRule } from '../../storybook/mocks'; export default { component: ThreatIndicatorPathReadOnly, @@ -18,22 +19,19 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; } const Template: Story = (args) => { return ( - + ); }; export const Default = Template.bind({}); Default.args = { - finalDiffableRule: { + finalDiffableRule: mockThreatMatchRule({ threat_indicator_path: 'threat.indicator', - }, + }), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_mapping/threat_mapping.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_mapping/threat_mapping.stories.tsx index 05d1aaa6cb028..4f5b8f608c4bf 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_mapping/threat_mapping.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_mapping/threat_mapping.stories.tsx @@ -8,8 +8,9 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; import { ThreatMappingReadOnly } from './threat_mapping'; +import { mockThreatMatchRule } from '../../storybook/mocks'; export default { component: ThreatMappingReadOnly, @@ -17,22 +18,17 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; } const Template: Story = (args) => { - return ( - - ); + return ; }; export const Default = Template.bind({}); Default.args = { - finalDiffableRule: { + finalDiffableRule: mockThreatMatchRule({ threat_mapping: [ { entries: [ @@ -44,5 +40,5 @@ Default.args = { ], }, ], - }, + }), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_query/threat_query.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_query/threat_query.stories.tsx index 5b59287255bc9..28b4cd65ba78b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_query/threat_query.stories.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_query/threat_query.stories.tsx @@ -8,13 +8,14 @@ import React from 'react'; import type { Story } from '@storybook/react'; import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableAllFields } from '../../../../../../../../../common/api/detection_engine'; +import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; import { ThreatQueryReadOnly } from './threat_query'; import { dataSourceWithDataView, dataSourceWithIndexPatterns, inlineKqlQuery, mockDataView, + mockThreatMatchRule, } from '../../storybook/mocks'; import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers'; @@ -24,17 +25,14 @@ export default { }; interface TemplateProps { - finalDiffableRule: Partial; + finalDiffableRule: DiffableRule; kibanaServicesMock?: Record; } const Template: Story = (args) => { return ( - + ); }; @@ -42,10 +40,10 @@ const Template: Story = (args) => { export const ThreatQueryWithIndexPatterns = Template.bind({}); ThreatQueryWithIndexPatterns.args = { - finalDiffableRule: { + finalDiffableRule: mockThreatMatchRule({ threat_query: inlineKqlQuery, data_source: dataSourceWithIndexPatterns, - }, + }), kibanaServicesMock: { data: { dataViews: { @@ -58,10 +56,10 @@ ThreatQueryWithIndexPatterns.args = { export const ThreatQueryWithDataView = Template.bind({}); ThreatQueryWithDataView.args = { - finalDiffableRule: { + finalDiffableRule: mockThreatMatchRule({ threat_query: inlineKqlQuery, data_source: dataSourceWithDataView, - }, + }), kibanaServicesMock: { data: { dataViews: { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_query/threat_query.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_query/threat_query.tsx index 6092fd8a0e0ce..b321213b75ad5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_query/threat_query.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/threat_query/threat_query.tsx @@ -7,8 +7,8 @@ import React from 'react'; import type { - DiffableAllFields, InlineKqlQuery, + RuleDataSource, } from '../../../../../../../../../common/api/detection_engine'; import * as ruleDetailsI18n from '../../../../translations'; import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations'; @@ -22,7 +22,7 @@ const i18nLabels = { export interface ThreatQueryReadOnlyProps { threatQuery: InlineKqlQuery; - dataSource: DiffableAllFields['data_source']; + dataSource?: RuleDataSource; } export const ThreatQueryReadOnly = ({ threatQuery, dataSource }: ThreatQueryReadOnlyProps) => { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/machine_learning_rule_field_readonly.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/machine_learning_rule_field_readonly.tsx new file mode 100644 index 0000000000000..5ebc6f80b13f2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/machine_learning_rule_field_readonly.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { DiffableMachineLearningFields } from '../../../../../../../common/api/detection_engine'; +import { MachineLearningJobIdReadOnly } from './fields/machine_learning_job_id/machine_learning_job_id'; + +interface MachineLearningRuleFieldReadOnlyProps { + fieldName: keyof DiffableMachineLearningFields; + finalDiffableRule: DiffableMachineLearningFields; +} + +export function MachineLearningRuleFieldReadOnly({ + fieldName, + finalDiffableRule, +}: MachineLearningRuleFieldReadOnlyProps) { + switch (fieldName) { + case 'machine_learning_job_id': + return ( + + ); + case 'type': + return null; + default: + return null; // Will replace with `assertUnreachable(fieldName)` once all fields are implemented + } +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/new_terms_rule_field_readonly.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/new_terms_rule_field_readonly.tsx new file mode 100644 index 0000000000000..0e2b52c71ba2b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/new_terms_rule_field_readonly.tsx @@ -0,0 +1,38 @@ +/* + * 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 React from 'react'; +import type { DiffableNewTermsFields } from '../../../../../../../common/api/detection_engine'; +import { DataSourceReadOnly } from './fields/data_source/data_source'; +import { KqlQueryReadOnly } from './fields/kql_query'; + +interface NewTermsRuleFieldReadOnlyProps { + fieldName: keyof DiffableNewTermsFields; + finalDiffableRule: DiffableNewTermsFields; +} + +export function NewTermsRuleFieldReadOnly({ + fieldName, + finalDiffableRule, +}: NewTermsRuleFieldReadOnlyProps) { + switch (fieldName) { + case 'data_source': + return ; + case 'kql_query': + return ( + + ); + case 'type': + return null; + default: + return null; // Will replace with `assertUnreachable(fieldName)` once all fields are implemented + } +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/saved_query_rule_field_readonly.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/saved_query_rule_field_readonly.tsx new file mode 100644 index 0000000000000..41e2e0c32108b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/saved_query_rule_field_readonly.tsx @@ -0,0 +1,38 @@ +/* + * 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 React from 'react'; +import type { DiffableSavedQueryFields } from '../../../../../../../common/api/detection_engine'; +import { DataSourceReadOnly } from './fields/data_source/data_source'; +import { KqlQueryReadOnly } from './fields/kql_query'; + +interface SavedQueryRuleFieldReadOnlyProps { + fieldName: keyof DiffableSavedQueryFields; + finalDiffableRule: DiffableSavedQueryFields; +} + +export function SavedQueryRuleFieldReadOnly({ + fieldName, + finalDiffableRule, +}: SavedQueryRuleFieldReadOnlyProps) { + switch (fieldName) { + case 'data_source': + return ; + case 'kql_query': + return ( + + ); + case 'type': + return null; + default: + return null; // Will replace with `assertUnreachable(fieldName)` once all fields are implemented + } +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/storybook/mocks.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/storybook/mocks.ts index 2b21558e9b0ca..854251450809f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/storybook/mocks.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/storybook/mocks.ts @@ -9,9 +9,21 @@ import type { FieldFormatsStartCommon } from '@kbn/field-formats-plugin/common'; import { DataView } from '@kbn/data-views-plugin/common'; import { DataSourceType, KqlQueryType } from '../../../../../../../../common/api/detection_engine'; import type { - DiffableAllFields, + DataSourceDataView, + DataSourceIndexPatterns, + DiffableCommonFields, + DiffableCustomQueryFields, + DiffableEqlFields, + DiffableEsqlFields, + DiffableMachineLearningFields, + DiffableRule, + DiffableSavedQueryFields, + DiffableThreatMatchFields, + InlineKqlQuery, + RuleEqlQuery, SavedKqlQuery, } from '../../../../../../../../common/api/detection_engine'; +import { DEFAULT_MAX_SIGNALS } from '../../../../../../../../common/constants'; export const filters = [ { @@ -50,7 +62,7 @@ export const savedQueryResponse = { namespaces: ['default'], }; -export const inlineKqlQuery: DiffableAllFields['kql_query'] = { +export const inlineKqlQuery: InlineKqlQuery = { type: KqlQueryType.inline_query, query: 'event.action: "user_login" and source.ip: "192.168.1.100"', language: 'kuery', @@ -62,18 +74,18 @@ export const savedKqlQuery: SavedKqlQuery = { saved_query_id: 'fake-saved-query-id', }; -export const eqlQuery: DiffableAllFields['eql_query'] = { +export const eqlQuery: RuleEqlQuery = { query: 'process where process.name == "powershell.exe" and process.args : "* -EncodedCommand *"', language: 'eql', filters, }; -export const dataSourceWithIndexPatterns: DiffableAllFields['data_source'] = { +export const dataSourceWithIndexPatterns: DataSourceIndexPatterns = { type: DataSourceType.index_patterns, index_patterns: ['logs-*'], }; -export const dataSourceWithDataView: DiffableAllFields['data_source'] = { +export const dataSourceWithDataView: DataSourceDataView = { type: DataSourceType.data_view, data_view_id: 'logs-*', }; @@ -103,3 +115,162 @@ export function mockDataView(spec: Partial = {}): DataView return dataView; } + +const commonDiffableRuleFields: DiffableCommonFields = { + rule_id: 'some-rule-id', + version: 1, + + name: 'Some Rule Name', + tags: [], + description: 'Some rule description', + severity: 'low', + severity_mapping: [], + risk_score: 1, + risk_score_mapping: [], + + references: [], + false_positives: [], + threat: [], + note: '', + setup: '', + related_integrations: [], + required_fields: [], + author: [], + license: '', + + rule_schedule: { + interval: '5m', + lookback: '360s', + }, + exceptions_list: [], + max_signals: DEFAULT_MAX_SIGNALS, +}; + +const customQueryDiffableRuleFields: DiffableCustomQueryFields = { + type: 'query', + kql_query: { + type: KqlQueryType.inline_query, + query: '*', + language: 'kuery', + filters: [], + }, +}; + +export function mockCustomQueryRule( + overrides: Partial +): DiffableRule { + return { + ...commonDiffableRuleFields, + ...customQueryDiffableRuleFields, + ...overrides, + }; +} + +const savedQueryDiffableRuleFields: DiffableSavedQueryFields = { + type: 'saved_query', + kql_query: { + type: KqlQueryType.saved_query, + saved_query_id: 'some-saved-query-id', + }, +}; + +export function mockSavedQueryRule( + overrides: Partial +): DiffableRule { + return { + ...commonDiffableRuleFields, + ...savedQueryDiffableRuleFields, + ...overrides, + }; +} + +const eqlDiffableRuleFields: DiffableEqlFields = { + type: 'eql', + eql_query: { + query: 'any where true', + language: 'eql', + filters: [], + }, +}; + +export function mockEqlRule( + overrides: Partial +): DiffableRule { + return { + ...commonDiffableRuleFields, + ...eqlDiffableRuleFields, + ...overrides, + }; +} + +const esqlDiffableRuleFields: DiffableEsqlFields = { + type: 'esql', + esql_query: { + query: 'SELECT * FROM any', + language: 'esql', + }, +}; + +export function mockEsqlRule( + overrides: Partial +): DiffableRule { + return { + ...commonDiffableRuleFields, + ...esqlDiffableRuleFields, + ...overrides, + }; +} + +const machineLearningDiffableRuleFields: DiffableMachineLearningFields = { + type: 'machine_learning', + machine_learning_job_id: 'ml-job-id-123', + anomaly_threshold: 0, +}; + +export function mockMachineLearningRule( + overrides: Partial +): DiffableRule { + return { + ...commonDiffableRuleFields, + ...machineLearningDiffableRuleFields, + ...overrides, + }; +} + +const threatMatchDiffableRuleFields: DiffableThreatMatchFields = { + type: 'threat_match', + kql_query: { + type: KqlQueryType.inline_query, + query: '*', + language: 'kuery', + filters: [], + }, + threat_query: { + type: KqlQueryType.inline_query, + query: '*', + language: 'kuery', + filters: [], + }, + threat_index: [], + threat_mapping: [ + { + entries: [ + { + field: 'abc', + type: 'mapping', + value: 'xyz', + }, + ], + }, + ], +}; + +export function mockThreatMatchRule( + overrides: Partial +): DiffableRule { + return { + ...commonDiffableRuleFields, + ...threatMatchDiffableRuleFields, + ...overrides, + }; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/threat_match_rule_field_readonly.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/threat_match_rule_field_readonly.tsx new file mode 100644 index 0000000000000..11fd941601922 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/threat_match_rule_field_readonly.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { DiffableThreatMatchFields } from '../../../../../../../common/api/detection_engine'; +import { DataSourceReadOnly } from './fields/data_source/data_source'; +import { KqlQueryReadOnly } from './fields/kql_query'; +import { ThreatIndexReadOnly } from './fields/threat_index/threat_index'; +import { ThreatIndicatorPathReadOnly } from './fields/threat_indicator_path/threat_indicator_path'; +import { ThreatMappingReadOnly } from './fields/threat_mapping/threat_mapping'; +import { ThreatQueryReadOnly } from './fields/threat_query/threat_query'; + +interface ThreatMatchRuleFieldReadOnlyProps { + fieldName: keyof DiffableThreatMatchFields; + finalDiffableRule: DiffableThreatMatchFields; +} + +export function ThreatMatchRuleFieldReadOnly({ + fieldName, + finalDiffableRule, +}: ThreatMatchRuleFieldReadOnlyProps) { + switch (fieldName) { + case 'data_source': + return ; + case 'kql_query': + return ( + + ); + case 'threat_index': + return ; + case 'threat_indicator_path': + return ( + + ); + case 'threat_mapping': + return ; + case 'threat_query': + return ( + + ); + case 'type': + return null; + default: + return null; // Will replace with `assertUnreachable(fieldName)` once all fields are implemented + } +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/threshold_rule_field_readonly.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/threshold_rule_field_readonly.tsx new file mode 100644 index 0000000000000..da7d3984d7ccb --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/threshold_rule_field_readonly.tsx @@ -0,0 +1,38 @@ +/* + * 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 React from 'react'; +import type { DiffableThresholdFields } from '../../../../../../../common/api/detection_engine'; +import { DataSourceReadOnly } from './fields/data_source/data_source'; +import { KqlQueryReadOnly } from './fields/kql_query'; + +interface ThresholdRuleFieldReadOnlyProps { + fieldName: keyof DiffableThresholdFields; + finalDiffableRule: DiffableThresholdFields; +} + +export function ThresholdRuleFieldReadOnly({ + fieldName, + finalDiffableRule, +}: ThresholdRuleFieldReadOnlyProps) { + switch (fieldName) { + case 'data_source': + return ; + case 'kql_query': + return ( + + ); + case 'type': + return null; + default: + return null; // Will replace with `assertUnreachable(fieldName)` once all fields are implemented + } +} From 004631b6c229d9d87e43c1dc73321c90efb857dc Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Wed, 18 Sep 2024 18:56:06 +0200 Subject: [PATCH 10/11] [EDR Workflows] Automated Actions in more rule types (#191874) --- ...s_upgrade_and_rollback_checks.test.ts.snap | 338 ++++++++++++++++++ .../model/rule_schema/rule_schemas.gen.ts | 3 + .../rule_schema/rule_schemas.schema.yaml | 12 + .../common/detection_engine/utils.ts | 11 + .../common/experimental_features.ts | 5 + ...ections_api_2023_10_31.bundled.schema.yaml | 12 + ...ections_api_2023_10_31.bundled.schema.yaml | 12 + .../components/step_rule_actions/index.tsx | 10 +- .../e2e/automated_response_actions/form.cy.ts | 20 ++ .../cypress/tasks/response_actions.ts | 26 ++ .../rule_assets/prebuilt_rule_asset.test.ts | 1 + .../model/rule_assets/prebuilt_rule_asset.ts | 6 +- .../api/rules/create_rule/route.test.ts | 40 ++- .../api/rules/update_rule/route.test.ts | 37 +- .../convert_rule_response_to_alerting_rule.ts | 12 +- .../type_specific_camel_to_snake.ts | 5 +- .../mergers/apply_rule_defaults.ts | 3 + .../mergers/apply_rule_patch.ts | 3 + .../rule_management/utils/validate.ts | 32 +- .../endpoint_response_action.ts | 5 +- ...dule_notification_response_actions.test.ts | 59 ++- .../schedule_notification_response_actions.ts | 14 +- .../rule_response_actions/types.ts | 8 + .../rule_schema/model/rule_schemas.ts | 3 + .../rule_types/eql/create_eql_alert_type.ts | 13 +- .../rule_types/eql/eql.test.ts | 36 ++ .../detection_engine/rule_types/eql/eql.ts | 11 + .../rule_types/esql/create_esql_alert_type.ts | 16 +- .../detection_engine/rule_types/esql/esql.ts | 12 +- .../new_terms/create_new_terms_alert_type.ts | 16 +- .../rule_types/query/query.ts | 11 +- .../lib/detection_engine/rule_types/types.ts | 10 +- .../security_solution/server/plugin.ts | 20 +- 33 files changed, 737 insertions(+), 85 deletions(-) diff --git a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/serverless_upgrade_and_rollback_checks.test.ts.snap b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/serverless_upgrade_and_rollback_checks.test.ts.snap index c84a3565d48f6..4dc2abbc5f6a8 100644 --- a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/serverless_upgrade_and_rollback_checks.test.ts.snap +++ b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/serverless_upgrade_and_rollback_checks.test.ts.snap @@ -6135,6 +6135,175 @@ Object { "query": Object { "type": "string", }, + "responseActions": Object { + "items": Object { + "anyOf": Array [ + Object { + "additionalProperties": false, + "properties": Object { + "actionTypeId": Object { + "const": ".osquery", + "type": "string", + }, + "params": Object { + "additionalProperties": false, + "properties": Object { + "ecsMapping": Object { + "additionalProperties": Object { + "additionalProperties": false, + "properties": Object { + "field": Object { + "type": "string", + }, + "value": Object { + "anyOf": Array [ + Object { + "type": "string", + }, + Object { + "items": Object { + "type": "string", + }, + "type": "array", + }, + ], + }, + }, + "type": "object", + }, + "properties": Object {}, + "type": "object", + }, + "packId": Object { + "type": "string", + }, + "queries": Object { + "items": Object { + "additionalProperties": false, + "properties": Object { + "ecs_mapping": Object { + "$ref": "#/allOf/1/properties/responseActions/items/anyOf/0/properties/params/properties/ecsMapping", + }, + "id": Object { + "type": "string", + }, + "platform": Object { + "type": "string", + }, + "query": Object { + "type": "string", + }, + "removed": Object { + "type": "boolean", + }, + "snapshot": Object { + "type": "boolean", + }, + "version": Object { + "type": "string", + }, + }, + "required": Array [ + "id", + "query", + ], + "type": "object", + }, + "type": "array", + }, + "query": Object { + "type": "string", + }, + "savedQueryId": Object { + "type": "string", + }, + "timeout": Object { + "type": "number", + }, + }, + "type": "object", + }, + }, + "required": Array [ + "actionTypeId", + "params", + ], + "type": "object", + }, + Object { + "additionalProperties": false, + "properties": Object { + "actionTypeId": Object { + "const": ".endpoint", + "type": "string", + }, + "params": Object { + "anyOf": Array [ + Object { + "additionalProperties": false, + "properties": Object { + "command": Object { + "const": "isolate", + "type": "string", + }, + "comment": Object { + "type": "string", + }, + }, + "required": Array [ + "command", + ], + "type": "object", + }, + Object { + "additionalProperties": false, + "properties": Object { + "command": Object { + "enum": Array [ + "kill-process", + "suspend-process", + ], + "type": "string", + }, + "comment": Object { + "type": "string", + }, + "config": Object { + "additionalProperties": false, + "properties": Object { + "field": Object { + "type": "string", + }, + "overwrite": Object { + "default": true, + "type": "boolean", + }, + }, + "required": Array [ + "field", + ], + "type": "object", + }, + }, + "required": Array [ + "command", + "config", + ], + "type": "object", + }, + ], + }, + }, + "required": Array [ + "actionTypeId", + "params", + ], + "type": "object", + }, + ], + }, + "type": "array", + }, "tiebreakerField": Object { "type": "string", }, @@ -7687,6 +7856,175 @@ Object { "query": Object { "type": "string", }, + "responseActions": Object { + "items": Object { + "anyOf": Array [ + Object { + "additionalProperties": false, + "properties": Object { + "actionTypeId": Object { + "const": ".osquery", + "type": "string", + }, + "params": Object { + "additionalProperties": false, + "properties": Object { + "ecsMapping": Object { + "additionalProperties": Object { + "additionalProperties": false, + "properties": Object { + "field": Object { + "type": "string", + }, + "value": Object { + "anyOf": Array [ + Object { + "type": "string", + }, + Object { + "items": Object { + "type": "string", + }, + "type": "array", + }, + ], + }, + }, + "type": "object", + }, + "properties": Object {}, + "type": "object", + }, + "packId": Object { + "type": "string", + }, + "queries": Object { + "items": Object { + "additionalProperties": false, + "properties": Object { + "ecs_mapping": Object { + "$ref": "#/allOf/1/properties/responseActions/items/anyOf/0/properties/params/properties/ecsMapping", + }, + "id": Object { + "type": "string", + }, + "platform": Object { + "type": "string", + }, + "query": Object { + "type": "string", + }, + "removed": Object { + "type": "boolean", + }, + "snapshot": Object { + "type": "boolean", + }, + "version": Object { + "type": "string", + }, + }, + "required": Array [ + "id", + "query", + ], + "type": "object", + }, + "type": "array", + }, + "query": Object { + "type": "string", + }, + "savedQueryId": Object { + "type": "string", + }, + "timeout": Object { + "type": "number", + }, + }, + "type": "object", + }, + }, + "required": Array [ + "actionTypeId", + "params", + ], + "type": "object", + }, + Object { + "additionalProperties": false, + "properties": Object { + "actionTypeId": Object { + "const": ".endpoint", + "type": "string", + }, + "params": Object { + "anyOf": Array [ + Object { + "additionalProperties": false, + "properties": Object { + "command": Object { + "const": "isolate", + "type": "string", + }, + "comment": Object { + "type": "string", + }, + }, + "required": Array [ + "command", + ], + "type": "object", + }, + Object { + "additionalProperties": false, + "properties": Object { + "command": Object { + "enum": Array [ + "kill-process", + "suspend-process", + ], + "type": "string", + }, + "comment": Object { + "type": "string", + }, + "config": Object { + "additionalProperties": false, + "properties": Object { + "field": Object { + "type": "string", + }, + "overwrite": Object { + "default": true, + "type": "boolean", + }, + }, + "required": Array [ + "field", + ], + "type": "object", + }, + }, + "required": Array [ + "command", + "config", + ], + "type": "object", + }, + ], + }, + }, + "required": Array [ + "actionTypeId", + "params", + ], + "type": "object", + }, + ], + }, + "type": "array", + }, "type": Object { "const": "new_terms", "type": "string", diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts index 2d3dbcd3f436f..a723eb8e7da89 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts @@ -224,6 +224,7 @@ export const EqlOptionalFields = z.object({ tiebreaker_field: TiebreakerField.optional(), timestamp_field: TimestampField.optional(), alert_suppression: AlertSuppression.optional(), + response_actions: z.array(ResponseAction).optional(), }); export type EqlRuleCreateFields = z.infer; @@ -521,6 +522,7 @@ export const NewTermsRuleOptionalFields = z.object({ data_view_id: DataViewId.optional(), filters: RuleFilterArray.optional(), alert_suppression: AlertSuppression.optional(), + response_actions: z.array(ResponseAction).optional(), }); export type NewTermsRuleDefaultableFields = z.infer; @@ -574,6 +576,7 @@ export const EsqlRuleRequiredFields = z.object({ export type EsqlRuleOptionalFields = z.infer; export const EsqlRuleOptionalFields = z.object({ alert_suppression: AlertSuppression.optional(), + response_actions: z.array(ResponseAction).optional(), }); export type EsqlRulePatchFields = z.infer; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml index 4ade72c15fbb9..ca2f325c8f713 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml @@ -292,6 +292,10 @@ components: $ref: './specific_attributes/eql_attributes.schema.yaml#/components/schemas/TimestampField' alert_suppression: $ref: './common_attributes.schema.yaml#/components/schemas/AlertSuppression' + response_actions: + type: array + items: + $ref: '../rule_response_actions/response_actions.schema.yaml#/components/schemas/ResponseAction' EqlRuleCreateFields: allOf: @@ -762,6 +766,10 @@ components: $ref: './common_attributes.schema.yaml#/components/schemas/RuleFilterArray' alert_suppression: $ref: './common_attributes.schema.yaml#/components/schemas/AlertSuppression' + response_actions: + type: array + items: + $ref: '../rule_response_actions/response_actions.schema.yaml#/components/schemas/ResponseAction' NewTermsRuleDefaultableFields: type: object @@ -840,6 +848,10 @@ components: properties: alert_suppression: $ref: './common_attributes.schema.yaml#/components/schemas/AlertSuppression' + response_actions: + type: array + items: + $ref: '../rule_response_actions/response_actions.schema.yaml#/components/schemas/ResponseAction' EsqlRulePatchFields: allOf: diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts index e0cefdebecd93..503e0c58ff46e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -93,3 +93,14 @@ export const isSuppressionRuleConfiguredWithMissingFields = (ruleType: Type) => export const isSuppressionRuleInGA = (ruleType: Type): boolean => { return isSuppressibleAlertRule(ruleType) && SUPPRESSIBLE_ALERT_RULES_GA.includes(ruleType); }; + +export const shouldShowResponseActions = ( + ruleType: Type | undefined, + automatedResponseActionsForMoreRulesEnabled: boolean +) => { + return ( + isQueryRule(ruleType) || + (automatedResponseActionsForMoreRulesEnabled && + (isEsqlRule(ruleType) || isEqlRule(ruleType) || isNewTermsRule(ruleType))) + ); +}; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index e11965653526f..121c8d6a97a1a 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -52,6 +52,11 @@ export const allowedExperimentalValues = Object.freeze({ */ automatedProcessActionsEnabled: true, + /** + * Temporary feature flag to enable the Response Actions in Rules UI - intermediate release + */ + automatedResponseActionsForMoreRulesEnabled: false, + /** * Enables the ability to send Response actions to SentinelOne and persist the results * in ES. Adds API changes to support `agentType` and supports `isolate` and `release` diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml index dcee1694a4aeb..8642113778fe0 100644 --- a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml @@ -2042,6 +2042,10 @@ components: $ref: '#/components/schemas/RuleFilterArray' index: $ref: '#/components/schemas/IndexPatternArray' + response_actions: + items: + $ref: '#/components/schemas/ResponseAction' + type: array tiebreaker_field: $ref: '#/components/schemas/TiebreakerField' timestamp_field: @@ -2729,6 +2733,10 @@ components: properties: alert_suppression: $ref: '#/components/schemas/AlertSuppression' + response_actions: + items: + $ref: '#/components/schemas/ResponseAction' + type: array EsqlRulePatchProps: allOf: - type: object @@ -3873,6 +3881,10 @@ components: $ref: '#/components/schemas/RuleFilterArray' index: $ref: '#/components/schemas/IndexPatternArray' + response_actions: + items: + $ref: '#/components/schemas/ResponseAction' + type: array NewTermsRulePatchFields: allOf: - type: object diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml index e3a294c9f92a5..514c4c87405cd 100644 --- a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml @@ -1316,6 +1316,10 @@ components: $ref: '#/components/schemas/RuleFilterArray' index: $ref: '#/components/schemas/IndexPatternArray' + response_actions: + items: + $ref: '#/components/schemas/ResponseAction' + type: array tiebreaker_field: $ref: '#/components/schemas/TiebreakerField' timestamp_field: @@ -2003,6 +2007,10 @@ components: properties: alert_suppression: $ref: '#/components/schemas/AlertSuppression' + response_actions: + items: + $ref: '#/components/schemas/ResponseAction' + type: array EsqlRulePatchProps: allOf: - type: object @@ -3026,6 +3034,10 @@ components: $ref: '#/components/schemas/RuleFilterArray' index: $ref: '#/components/schemas/IndexPatternArray' + response_actions: + items: + $ref: '#/components/schemas/ResponseAction' + type: array NewTermsRulePatchFields: allOf: - type: object diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/step_rule_actions/index.tsx index b555054a75e0c..5838c85281123 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/step_rule_actions/index.tsx @@ -16,8 +16,9 @@ import type { } from '@kbn/triggers-actions-ui-plugin/public'; import { UseArray } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { shouldShowResponseActions } from '../../../../../common/detection_engine/utils'; import type { RuleObjectId } from '../../../../../common/api/detection_engine/model/rule_schema'; -import { isQueryRule } from '../../../../../common/detection_engine/utils'; import { ResponseActionsForm } from '../../../rule_response_actions/response_actions_form'; import type { RuleStepProps, @@ -84,6 +85,9 @@ const StepRuleActionsComponent: FC = ({ const { services: { application }, } = useKibana(); + const automatedResponseActionsForMoreRulesEnabled = useIsExperimentalFeatureEnabled( + 'automatedResponseActionsForMoreRulesEnabled' + ); const displayActionsOptions = useMemo( () => ( <> @@ -101,7 +105,7 @@ const StepRuleActionsComponent: FC = ({ [actionMessageParams, summaryActionMessageParams] ); const displayResponseActionsOptions = useMemo(() => { - if (isQueryRule(ruleType)) { + if (shouldShowResponseActions(ruleType, automatedResponseActionsForMoreRulesEnabled)) { return ( {ResponseActionsForm} @@ -109,7 +113,7 @@ const StepRuleActionsComponent: FC = ({ ); } return null; - }, [ruleType]); + }, [ruleType, automatedResponseActionsForMoreRulesEnabled]); // only display the actions dropdown if the user has "read" privileges for actions const displayActionsDropDown = useMemo(() => { return application.capabilities.actions.show ? ( diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts index bc909bb62a30e..4b1b8e728e8c2 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts @@ -12,6 +12,8 @@ import { tryAddingDisabledResponseAction, validateAvailableCommands, visitRuleActions, + selectIsolateAndSaveWithoutEnabling, + fillUpNewEsqlRule, } from '../../tasks/response_actions'; import { cleanupRule, generateRandomStringName, loadRule } from '../../tasks/api_fixtures'; import { ResponseActionTypesEnum } from '../../../../../common/api/detection_engine'; @@ -28,6 +30,7 @@ describe( kbnServerArgs: [ `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'automatedProcessActionsEnabled', + 'automatedResponseActionsForMoreRulesEnabled', ])}`, ], }, @@ -202,6 +205,23 @@ describe( }); }); + describe('User should be able to add response action to ESQL rule', () => { + const [ruleName, ruleDescription] = generateRandomStringName(2); + + beforeEach(() => { + login(ROLE.soc_manager); + }); + + it('create and save endpoint response action inside of a rule', () => { + const query = 'FROM * METADATA _index, _id'; + fillUpNewEsqlRule(ruleName, ruleDescription, query); + addEndpointResponseAction(); + focusAndOpenCommandDropdown(); + validateAvailableCommands(); + selectIsolateAndSaveWithoutEnabling(ruleName); + }); + }); + describe('User should not see endpoint action when no rbac', () => { const [ruleName, ruleDescription] = generateRandomStringName(2); diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts index 0e46b99c40d72..715f8adc972f5 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts @@ -42,6 +42,12 @@ export const validateAvailableCommands = () => { cy.getByTestSubj(`command-type-${command}`); }); }; +export const selectIsolateAndSaveWithoutEnabling = (ruleName: string) => { + cy.getByTestSubj(`command-type-isolate`).click(); + cy.getByTestSubj('create-enabled-false').click(); + cy.contains(`${ruleName} was created`); +}; + export const addEndpointResponseAction = () => { cy.getByTestSubj('response-actions-wrapper').within(() => { cy.getByTestSubj('Elastic Defend-response-action-type-selection-option').click(); @@ -69,6 +75,26 @@ export const fillUpNewRule = (name = 'Test', description = 'Test') => { cy.getByTestSubj('about-continue').click(); cy.getByTestSubj('schedule-continue').click(); }; +export const fillUpNewEsqlRule = (name = 'Test', description = 'Test', query: string) => { + loadPage('app/security/rules/management'); + cy.getByTestSubj('create-new-rule').click(); + cy.getByTestSubj('stepDefineRule').within(() => { + cy.getByTestSubj('esqlRuleType').click(); + cy.getByTestSubj('detectionEngineStepDefineRuleEsqlQueryBar').within(() => { + cy.getByTestSubj('globalQueryBar').click(); + cy.getByTestSubj('kibanaCodeEditor').type(query); + }); + }); + cy.getByTestSubj('define-continue').click(); + cy.getByTestSubj('detectionEngineStepAboutRuleName').within(() => { + cy.getByTestSubj('input').type(name); + }); + cy.getByTestSubj('detectionEngineStepAboutRuleDescription').within(() => { + cy.getByTestSubj('input').type(description); + }); + cy.getByTestSubj('about-continue').click(); + cy.getByTestSubj('schedule-continue').click(); +}; export const visitRuleActions = (ruleId: string) => { loadPage(`app/security/rules/id/${ruleId}/edit`); cy.getByTestSubj('edit-rule-actions-tab').should('exist'); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.test.ts index ee028cfc7f203..45a561996e0a9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.test.ts @@ -51,6 +51,7 @@ describe('Prebuilt rule asset schema', () => { // See: detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts const omittedBaseFields = [ 'actions', + 'response_actions', 'throttle', 'meta', 'output_index', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts index 6267be09652e8..2d7b056f86248 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts @@ -63,14 +63,14 @@ const TYPE_SPECIFIC_FIELDS_TO_OMIT_FROM_SAVED_QUERY_RULES = export type TypeSpecificFields = z.infer; export const TypeSpecificFields = z.discriminatedUnion('type', [ - EqlRuleCreateFields, + EqlRuleCreateFields.omit(TYPE_SPECIFIC_FIELDS_TO_OMIT_FROM_QUERY_RULES), QueryRuleCreateFields.omit(TYPE_SPECIFIC_FIELDS_TO_OMIT_FROM_QUERY_RULES), SavedQueryRuleCreateFields.omit(TYPE_SPECIFIC_FIELDS_TO_OMIT_FROM_SAVED_QUERY_RULES), ThresholdRuleCreateFields, ThreatMatchRuleCreateFields, MachineLearningRuleCreateFields, - NewTermsRuleCreateFields, - EsqlRuleCreateFields, + NewTermsRuleCreateFields.omit(TYPE_SPECIFIC_FIELDS_TO_OMIT_FROM_QUERY_RULES), + EsqlRuleCreateFields.omit(TYPE_SPECIFIC_FIELDS_TO_OMIT_FROM_QUERY_RULES), ]); // Make sure the type-specific fields contain all the same rule types as the type-specific rule params. diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/create_rule/route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/create_rule/route.test.ts index 7441aec8c8fa5..b0d0b202341d3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/create_rule/route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/create_rule/route.test.ts @@ -16,7 +16,12 @@ import { } from '../../../../routes/__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../../../../routes/__mocks__'; import { createRuleRoute } from './route'; -import { getCreateRulesSchemaMock } from '../../../../../../../common/api/detection_engine/model/rule_schema/mocks'; +import { + getCreateEqlRuleSchemaMock, + getCreateEsqlRulesSchemaMock, + getCreateNewTermsRulesSchemaMock, + getCreateRulesSchemaMock, +} from '../../../../../../../common/api/detection_engine/model/rule_schema/mocks'; import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import { getQueryRuleParams } from '../../../../rule_schema/mocks'; import { HttpAuthzError } from '../../../../../machine_learning/validation'; @@ -181,20 +186,29 @@ describe('Create rule route', () => { }, }); const defaultAction = getResponseAction(); + const ruleTypes: Array<[string, () => object]> = [ + ['query', getCreateRulesSchemaMock], + ['esql', getCreateEsqlRulesSchemaMock], + ['eql', getCreateEqlRuleSchemaMock], + ['new_terms', getCreateNewTermsRulesSchemaMock], + ]; - test('is successful', async () => { - const request = requestMock.create({ - method: 'post', - path: DETECTION_ENGINE_RULES_URL, - body: { - ...getCreateRulesSchemaMock(), - response_actions: [defaultAction], - }, - }); + test.each(ruleTypes)( + 'is successful for %s rule', + async (ruleType: string, schemaMock: (ruleId: string) => object) => { + const request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: { + ...schemaMock(`rule-${ruleType}`), + response_actions: [defaultAction], + }, + }); - const response = await server.inject(request, requestContextMock.convertContext(context)); - expect(response.status).toEqual(200); - }); + const response = await server.inject(request, requestContextMock.convertContext(context)); + expect(response.status).toEqual(200); + } + ); test('fails when isolate rbac is set to false', async () => { (context.securitySolution.getEndpointAuthz as jest.Mock).mockReturnValue(() => ({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/update_rule/route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/update_rule/route.test.ts index 87f42a014c1d2..315ab9e80a5de 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/update_rule/route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/update_rule/route.test.ts @@ -17,6 +17,9 @@ import { getRulesSchemaMock } from '../../../../../../../common/api/detection_en import { DETECTION_ENGINE_RULES_URL } from '../../../../../../../common/constants'; import { updateRuleRoute } from './route'; import { + getCreateEqlRuleSchemaMock, + getCreateEsqlRulesSchemaMock, + getCreateNewTermsRulesSchemaMock, getCreateRulesSchemaMock, getUpdateRulesSchemaMock, } from '../../../../../../../common/api/detection_engine/model/rule_schema/mocks'; @@ -189,19 +192,29 @@ describe('Update rule route', () => { }); const defaultAction = getResponseAction(); - test('is successful', async () => { - const request = requestMock.create({ - method: 'post', - path: DETECTION_ENGINE_RULES_URL, - body: { - ...getCreateRulesSchemaMock(), - response_actions: [defaultAction], - }, - }); + const ruleTypes: Array<[string, () => object]> = [ + ['query', () => getCreateRulesSchemaMock()], + ['esql', getCreateEsqlRulesSchemaMock], + ['eql', getCreateEqlRuleSchemaMock], + ['new_terms', getCreateNewTermsRulesSchemaMock], + ]; - const response = await server.inject(request, requestContextMock.convertContext(context)); - expect(response.status).toEqual(200); - }); + test.each(ruleTypes)( + 'is successful for %s rule', + async (ruleType: string, schemaMock: (ruleId: string) => object) => { + const request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: { + ...schemaMock(`rule-${ruleType}`), + response_actions: [defaultAction], + }, + }); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + expect(response.status).toEqual(200); + } + ); test('fails when isolate rbac is set to false', async () => { (context.securitySolution.getEndpointAuthz as jest.Mock).mockReturnValue(() => ({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_rule_response_to_alerting_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_rule_response_to_alerting_rule.ts index 8a2609b712c53..2348c11027c65 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_rule_response_to_alerting_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/convert_rule_response_to_alerting_rule.ts @@ -119,6 +119,9 @@ const typeSpecificSnakeToCamel = (params: TypeSpecificCreateProps): TypeSpecific eventCategoryOverride: params.event_category_override, tiebreakerField: params.tiebreaker_field, alertSuppression: convertObjectKeysToCamelCase(params.alert_suppression), + responseActions: params.response_actions?.map((rule) => + transformRuleToAlertResponseAction(rule) + ), }; } case 'esql': { @@ -127,6 +130,9 @@ const typeSpecificSnakeToCamel = (params: TypeSpecificCreateProps): TypeSpecific language: params.language, query: params.query, alertSuppression: convertObjectKeysToCamelCase(params.alert_suppression), + responseActions: params.response_actions?.map((rule) => + transformRuleToAlertResponseAction(rule) + ), }; } case 'threat_match': { @@ -173,9 +179,6 @@ const typeSpecificSnakeToCamel = (params: TypeSpecificCreateProps): TypeSpecific filters: params.filters, savedId: params.saved_id, dataViewId: params.data_view_id, - responseActions: params.response_actions?.map((rule) => - transformRuleToAlertResponseAction(rule) - ), alertSuppression: convertObjectKeysToCamelCase(params.alert_suppression), }; } @@ -213,6 +216,9 @@ const typeSpecificSnakeToCamel = (params: TypeSpecificCreateProps): TypeSpecific language: params.language ?? 'kuery', dataViewId: params.data_view_id, alertSuppression: convertObjectKeysToCamelCase(params.alert_suppression), + responseActions: params.response_actions?.map((rule) => + transformRuleToAlertResponseAction(rule) + ), }; } default: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/type_specific_camel_to_snake.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/type_specific_camel_to_snake.ts index 0808d1921e9bf..a4b74e31ba291 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/type_specific_camel_to_snake.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/converters/type_specific_camel_to_snake.ts @@ -6,8 +6,8 @@ */ import type { RequiredOptional } from '@kbn/zod-helpers'; -import type { TypeSpecificResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; import { transformAlertToRuleResponseAction } from '../../../../../../../common/detection_engine/transform_actions'; +import type { TypeSpecificResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; import { assertUnreachable } from '../../../../../../../common/utility_types'; import { convertObjectKeysToSnakeCase } from '../../../../../../utils/object_case_converters'; import type { TypeSpecificRuleParams } from '../../../../rule_schema'; @@ -28,6 +28,7 @@ export const typeSpecificCamelToSnake = ( event_category_override: params.eventCategoryOverride, tiebreaker_field: params.tiebreakerField, alert_suppression: convertObjectKeysToSnakeCase(params.alertSuppression), + response_actions: params.responseActions?.map(transformAlertToRuleResponseAction), }; } case 'esql': { @@ -36,6 +37,7 @@ export const typeSpecificCamelToSnake = ( language: params.language, query: params.query, alert_suppression: convertObjectKeysToSnakeCase(params.alertSuppression), + response_actions: params.responseActions?.map(transformAlertToRuleResponseAction), }; } case 'threat_match': { @@ -118,6 +120,7 @@ export const typeSpecificCamelToSnake = ( language: params.language, data_view_id: params.dataViewId, alert_suppression: convertObjectKeysToSnakeCase(params.alertSuppression), + response_actions: params.responseActions?.map(transformAlertToRuleResponseAction), }; } default: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_defaults.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_defaults.ts index 0263a60ab44ad..388b1ab695269 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_defaults.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_defaults.ts @@ -86,6 +86,7 @@ export const setTypeSpecificDefaults = (props: TypeSpecificCreateProps) => { event_category_override: props.event_category_override, tiebreaker_field: props.tiebreaker_field, alert_suppression: props.alert_suppression, + response_actions: props.response_actions, }; } case 'esql': { @@ -94,6 +95,7 @@ export const setTypeSpecificDefaults = (props: TypeSpecificCreateProps) => { language: props.language, query: props.query, alert_suppression: props.alert_suppression, + response_actions: props.response_actions, }; } case 'threat_match': { @@ -176,6 +178,7 @@ export const setTypeSpecificDefaults = (props: TypeSpecificCreateProps) => { language: props.language ?? 'kuery', data_view_id: props.data_view_id, alert_suppression: props.alert_suppression, + response_actions: props.response_actions, }; } default: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts index a8beef1bf2a0e..d864170746ed3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts @@ -138,6 +138,7 @@ const patchEqlParams = ( rulePatch.event_category_override ?? existingRule.event_category_override, tiebreaker_field: rulePatch.tiebreaker_field ?? existingRule.tiebreaker_field, alert_suppression: rulePatch.alert_suppression ?? existingRule.alert_suppression, + response_actions: rulePatch.response_actions ?? existingRule.response_actions, }; }; @@ -150,6 +151,7 @@ const patchEsqlParams = ( language: rulePatch.language ?? existingRule.language, query: rulePatch.query ?? existingRule.query, alert_suppression: rulePatch.alert_suppression ?? existingRule.alert_suppression, + response_actions: rulePatch.response_actions ?? existingRule.response_actions, }; }; @@ -258,6 +260,7 @@ const patchNewTermsParams = ( new_terms_fields: params.new_terms_fields ?? existingRule.new_terms_fields, history_window_start: params.history_window_start ?? existingRule.history_window_start, alert_suppression: params.alert_suppression ?? existingRule.alert_suppression, + response_actions: params.response_actions ?? existingRule.response_actions, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts index 500db54acd867..1274a2d7e7cad 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts @@ -9,8 +9,13 @@ import type { PartialRule } from '@kbn/alerting-plugin/server'; import type { Rule } from '@kbn/alerting-plugin/common'; import { isEqual, xorWith } from 'lodash'; import { stringifyZodError } from '@kbn/zod-helpers'; +import type { + EqlRule, + EsqlRule, + NewTermsRule, + QueryRule, +} from '../../../../../common/api/detection_engine'; import { - type QueryRule, type ResponseAction, type RuleCreateProps, RuleResponse, @@ -21,9 +26,10 @@ import { RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP, RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ, } from '../../../../../common/endpoint/service/response_actions/constants'; -import { isQueryRule } from '../../../../../common/detection_engine/utils'; +import { shouldShowResponseActions } from '../../../../../common/detection_engine/utils'; import type { SecuritySolutionApiRequestHandlerContext } from '../../../..'; import { CustomHttpRequestError } from '../../../../utils/custom_http_request_error'; +import type { EqlRuleParams, EsqlRuleParams, NewTermsRuleParams } from '../../rule_schema'; import { hasValidRuleType, type RuleAlertType, @@ -64,11 +70,21 @@ export const validateResponseActionsPermissions = async ( ruleUpdate: RuleCreateProps | RuleUpdateProps, existingRule?: RuleAlertType | null ): Promise => { - if (!isQueryRule(ruleUpdate.type)) { + const { experimentalFeatures } = await securitySolution.getConfig(); + + if ( + !shouldShowResponseActions( + ruleUpdate.type, + experimentalFeatures.automatedResponseActionsForMoreRulesEnabled + ) + ) { return; } - if (!isQueryRulePayload(ruleUpdate) || (existingRule && !isQueryRuleObject(existingRule))) { + if ( + !rulePayloadContainsResponseActions(ruleUpdate) || + (existingRule && !ruleObjectContainsResponseActions(existingRule)) + ) { return; } @@ -108,10 +124,14 @@ export const validateResponseActionsPermissions = async ( }); }; -function isQueryRulePayload(rule: RuleCreateProps | RuleUpdateProps): rule is QueryRule { +function rulePayloadContainsResponseActions( + rule: RuleCreateProps | RuleUpdateProps +): rule is QueryRule | EsqlRule | EqlRule | NewTermsRule { return 'response_actions' in rule; } -function isQueryRuleObject(rule?: RuleAlertType): rule is Rule { +function ruleObjectContainsResponseActions( + rule?: RuleAlertType +): rule is Rule { return rule != null && 'params' in rule && 'responseActions' in rule?.params; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/endpoint_response_action.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/endpoint_response_action.ts index a310cb33497e8..040433789ecd7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/endpoint_response_action.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/endpoint_response_action.ts @@ -6,7 +6,6 @@ */ import { each } from 'lodash'; -import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; import { stringify } from '../../../endpoint/utils/stringify'; import type { RuleResponseEndpointAction, @@ -29,8 +28,8 @@ export const endpointResponseAction = async ( 'ruleExecution', 'automatedResponseActions' ); - const ruleId = alerts[0][ALERT_RULE_UUID]; - const ruleName = alerts[0][ALERT_RULE_NAME]; + const ruleId = alerts[0].kibana.alert?.rule.uuid; + const ruleName = alerts[0].kibana.alert?.rule.name; const logMsgPrefix = `Rule [${ruleName}][${ruleId}]:`; const { comment, command } = responseAction.params; const errors: string[] = []; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.test.ts index 4dccc9ad0aae7..d98dc0782b796 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.test.ts @@ -96,8 +96,13 @@ describe('ScheduleNotificationResponseActions', () => { }, }, ]; - await scheduleNotificationResponseActions({ signals, responseActions }); + const response = await scheduleNotificationResponseActions({ + signals, + signalsCount: signals.length, + responseActions, + }); + expect(response).not.toBeUndefined(); expect(osqueryActionMock.create).toHaveBeenCalledWith({ ...defaultQueryResultParams, query: simpleQuery, @@ -123,8 +128,13 @@ describe('ScheduleNotificationResponseActions', () => { }, }, ]; - await scheduleNotificationResponseActions({ signals, responseActions }); + const response = await scheduleNotificationResponseActions({ + signals, + signalsCount: signals.length, + responseActions, + }); + expect(response).not.toBeUndefined(); expect(osqueryActionMock.create).toHaveBeenCalledWith({ ...defaultPackResultParams, queries: [{ ...defaultQueries, id: 'query-1', query: simpleQuery }], @@ -149,8 +159,12 @@ describe('ScheduleNotificationResponseActions', () => { }, }, ]; - await scheduleNotificationResponseActions({ signals, responseActions }); - + const response = await scheduleNotificationResponseActions({ + signals, + signalsCount: signals.length, + responseActions, + }); + expect(response).not.toBeUndefined(); expect(endpointActionMock.getInternalResponseActionsClient).toHaveBeenCalledTimes(1); expect(endpointActionMock.getInternalResponseActionsClient).toHaveBeenCalledWith({ agentType: 'endpoint', @@ -188,11 +202,14 @@ describe('ScheduleNotificationResponseActions', () => { }, }, ]; - await scheduleNotificationResponseActions({ + const response = await scheduleNotificationResponseActions({ signals, + signalsCount: signals.length, responseActions, }); + expect(response).not.toBeUndefined(); + expect(mockedResponseActionsClient.killProcess).toHaveBeenCalledWith( { alert_ids: ['alert-id-1'], @@ -223,12 +240,42 @@ describe('ScheduleNotificationResponseActions', () => { }, }, ]; - await scheduleNotificationResponseActions({ + const response = await scheduleNotificationResponseActions({ signals, + signalsCount: signals.length, responseActions, }); + expect(response).not.toBeUndefined(); expect(mockedResponseActionsClient.isolate).toHaveBeenCalledTimes(signals.length - 1); }); + it('should not call any action service if no response actions are provided', async () => { + const response = await scheduleNotificationResponseActions({ + signals: getSignals(), + signalsCount: 2, + responseActions: [], + }); + expect(response).toBeUndefined(); + }); + it('should not call any action service if signalsCount is 0', async () => { + const signals = getSignals(); + const responseActions: RuleResponseAction[] = [ + { + actionTypeId: ResponseActionTypesEnum['.endpoint'], + params: { + command: 'isolate', + comment: 'test process comment', + }, + }, + ]; + + const response = await scheduleNotificationResponseActions({ + signals, + signalsCount: 0, + responseActions, + }); + + expect(response).toBeUndefined(); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.ts index 2fcf09d6cfbb4..b4f4689fed0ff 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.ts @@ -5,13 +5,14 @@ * 2.0. */ +import { expandDottedObject } from '../../../../common/utils/expand_dotted'; import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import type { SetupPlugins } from '../../../plugin_contract'; import { ResponseActionTypesEnum } from '../../../../common/api/detection_engine/model/rule_response_actions'; import { osqueryResponseAction } from './osquery_response_action'; import { endpointResponseAction } from './endpoint_response_action'; import type { ScheduleNotificationActions } from '../rule_types/types'; -import type { AlertWithAgent, Alert } from './types'; +import type { Alert, AlertWithAgent } from './types'; interface ScheduleNotificationResponseActionsService { endpointAppContextService: EndpointAppContextService; @@ -23,10 +24,15 @@ export const getScheduleNotificationResponseActionsService = osqueryCreateActionService, endpointAppContextService, }: ScheduleNotificationResponseActionsService) => - async ({ signals, responseActions }: ScheduleNotificationActions) => { - const alerts = (signals as Alert[]).filter((alert) => alert.agent?.id) as AlertWithAgent[]; + async ({ signals, signalsCount, responseActions }: ScheduleNotificationActions) => { + if (!signalsCount || !responseActions?.length) { + return; + } + // expandDottedObject is needed eg in ESQL rule because it's alerts come without nested agent, host etc data but everything is dotted + const nestedAlerts = signals.map((signal) => expandDottedObject(signal as object)) as Alert[]; + const alerts = nestedAlerts.filter((alert) => alert.agent?.id) as AlertWithAgent[]; - await Promise.all( + return Promise.all( responseActions.map(async (responseAction) => { if ( responseAction.actionTypeId === ResponseActionTypesEnum['.osquery'] && diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/types.ts index e7317acfd7ca1..a72e813dcb6a7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/types.ts @@ -19,6 +19,14 @@ export type Alert = ParsedTechnicalFields & { process?: { pid: string; }; + kibana: { + alert?: { + rule: { + uuid: string; + name: string; + }; + }; + }; }; export interface AlertAgent { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts index 632649f733473..e651ffeebaf49 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts @@ -162,6 +162,7 @@ export const EqlSpecificRuleParams = z.object({ timestampField: TimestampField.optional(), tiebreakerField: TiebreakerField.optional(), alertSuppression: AlertSuppressionCamel.optional(), + responseActions: z.array(RuleResponseAction).optional(), }); export type EqlRuleParams = BaseRuleParams & EqlSpecificRuleParams; @@ -173,6 +174,7 @@ export const EsqlSpecificRuleParams = z.object({ language: z.literal('esql'), query: RuleQuery, alertSuppression: AlertSuppressionCamel.optional(), + responseActions: z.array(RuleResponseAction).optional(), }); export type EsqlRuleParams = BaseRuleParams & EsqlSpecificRuleParams; @@ -280,6 +282,7 @@ export const NewTermsSpecificRuleParams = z.object({ language: KqlQueryLanguage, dataViewId: DataViewId.optional(), alertSuppression: AlertSuppressionCamel.optional(), + responseActions: z.array(RuleResponseAction).optional(), }); export type NewTermsRuleParams = BaseRuleParams & NewTermsSpecificRuleParams; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts index 81971feeecfc1..ca16b38404e48 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts @@ -11,16 +11,22 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; import { SERVER_APP_ID } from '../../../../../common/constants'; import { EqlRuleParams } from '../../rule_schema'; import { eqlExecutor } from './eql'; -import type { CreateRuleOptions, SecurityAlertType, SignalSourceHit } from '../types'; +import type { + CreateRuleOptions, + SecurityAlertType, + SignalSourceHit, + CreateRuleAdditionalOptions, +} from '../types'; import { validateIndexPatterns } from '../utils'; import type { BuildReasonMessage } from '../utils/reason_formatters'; import { wrapSuppressedAlerts } from '../utils/wrap_suppressed_alerts'; import { getIsAlertSuppressionActive } from '../utils/get_is_alert_suppression_active'; export const createEqlAlertType = ( - createOptions: CreateRuleOptions + createOptions: CreateRuleOptions & CreateRuleAdditionalOptions ): SecurityAlertType => { - const { experimentalFeatures, version, licensing } = createOptions; + const { experimentalFeatures, version, licensing, scheduleNotificationResponseActionsService } = + createOptions; return { id: EQL_RULE_TYPE_ID, name: 'Event Correlation Rule', @@ -125,6 +131,7 @@ export const createEqlAlertType = ( alertWithSuppression, isAlertSuppressionActive: isNonSeqAlertSuppressionActive, experimentalFeatures, + scheduleNotificationResponseActionsService, }); return { ...result, state }; }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.test.ts index c16d61d3b0ea5..9ef9faeb9de3a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.test.ts @@ -37,6 +37,7 @@ describe('eql_executor', () => { maxSignals: params.maxSignals, }; const mockExperimentalFeatures = {} as ExperimentalFeatures; + const mockScheduleNotificationResponseActionsService = jest.fn(); beforeEach(() => { jest.clearAllMocks(); @@ -72,6 +73,8 @@ describe('eql_executor', () => { alertWithSuppression: jest.fn(), isAlertSuppressionActive: false, experimentalFeatures: mockExperimentalFeatures, + scheduleNotificationResponseActionsService: + mockScheduleNotificationResponseActionsService, }); expect(result.warningMessages).toEqual([ `The following exceptions won't be applied to rule execution: ${ @@ -121,6 +124,8 @@ describe('eql_executor', () => { alertWithSuppression: jest.fn(), isAlertSuppressionActive: true, experimentalFeatures: mockExperimentalFeatures, + scheduleNotificationResponseActionsService: + mockScheduleNotificationResponseActionsService, }); expect(result.warningMessages).toContain( @@ -154,10 +159,40 @@ describe('eql_executor', () => { alertWithSuppression: jest.fn(), isAlertSuppressionActive: true, experimentalFeatures: mockExperimentalFeatures, + scheduleNotificationResponseActionsService: mockScheduleNotificationResponseActionsService, }); expect(result.userError).toEqual(true); }); + it('should handle scheduleNotificationResponseActionsService call', async () => { + const result = await eqlExecutor({ + inputIndex: DEFAULT_INDEX_PATTERN, + runtimeMappings: {}, + completeRule: eqlCompleteRule, + tuple, + ruleExecutionLogger, + services: alertServices, + version, + bulkCreate: jest.fn(), + wrapHits: jest.fn(), + wrapSequences: jest.fn(), + primaryTimestamp: '@timestamp', + exceptionFilter: undefined, + unprocessedExceptions: [], + wrapSuppressedHits: jest.fn(), + alertTimestampOverride: undefined, + alertWithSuppression: jest.fn(), + isAlertSuppressionActive: false, + experimentalFeatures: mockExperimentalFeatures, + scheduleNotificationResponseActionsService: mockScheduleNotificationResponseActionsService, + }); + expect(mockScheduleNotificationResponseActionsService).toBeCalledWith({ + signals: result.createdSignals, + signalsCount: result.createdSignalsCount, + responseActions: eqlCompleteRule.ruleParams.responseActions, + }); + }); + it('should pass frozen tier filters in eql search request', async () => { getDataTierFilterMock.mockResolvedValue([ { @@ -189,6 +224,7 @@ describe('eql_executor', () => { alertWithSuppression: jest.fn(), isAlertSuppressionActive: true, experimentalFeatures: mockExperimentalFeatures, + scheduleNotificationResponseActionsService: mockScheduleNotificationResponseActionsService, }); const searchArgs = diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.ts index a3a1ba545c0ea..3379d0a0c6867 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/eql.ts @@ -26,6 +26,7 @@ import type { SearchAfterAndBulkCreateReturnType, SignalSource, WrapSuppressedHits, + CreateRuleAdditionalOptions, } from '../types'; import { addToSearchAfterReturn, @@ -66,6 +67,7 @@ interface EqlExecutorParams { alertWithSuppression: SuppressedAlertService; isAlertSuppressionActive: boolean; experimentalFeatures: ExperimentalFeatures; + scheduleNotificationResponseActionsService: CreateRuleAdditionalOptions['scheduleNotificationResponseActionsService']; } export const eqlExecutor = async ({ @@ -88,6 +90,7 @@ export const eqlExecutor = async ({ alertWithSuppression, isAlertSuppressionActive, experimentalFeatures, + scheduleNotificationResponseActionsService, }: EqlExecutorParams): Promise => { const ruleParams = completeRule.ruleParams; @@ -188,6 +191,14 @@ export const eqlExecutor = async ({ result.warningMessages.push(maxSignalsWarning); } + if (scheduleNotificationResponseActionsService) { + scheduleNotificationResponseActionsService({ + signals: result.createdSignals, + signalsCount: result.createdSignalsCount, + responseActions: completeRule.ruleParams.responseActions, + }); + } + return result; } catch (error) { if ( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/esql/create_esql_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/esql/create_esql_alert_type.ts index 10c82ad8fed7c..31afe8d2a191f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/esql/create_esql_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/esql/create_esql_alert_type.ts @@ -11,12 +11,13 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; import { SERVER_APP_ID } from '../../../../../common/constants'; import { EsqlRuleParams } from '../../rule_schema'; import { esqlExecutor } from './esql'; -import type { CreateRuleOptions, SecurityAlertType } from '../types'; +import type { CreateRuleOptions, SecurityAlertType, CreateRuleAdditionalOptions } from '../types'; export const createEsqlAlertType = ( - createOptions: CreateRuleOptions + createOptions: CreateRuleOptions & CreateRuleAdditionalOptions ): SecurityAlertType => { - const { version, experimentalFeatures, licensing } = createOptions; + const { version, experimentalFeatures, licensing, scheduleNotificationResponseActionsService } = + createOptions; return { id: ESQL_RULE_TYPE_ID, name: 'ES|QL Rule', @@ -44,6 +45,13 @@ export const createEsqlAlertType = ( isExportable: false, category: DEFAULT_APP_CATEGORIES.security.id, producer: SERVER_APP_ID, - executor: (params) => esqlExecutor({ ...params, experimentalFeatures, version, licensing }), + executor: (params) => + esqlExecutor({ + ...params, + experimentalFeatures, + version, + licensing, + scheduleNotificationResponseActionsService, + }), }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/esql/esql.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/esql/esql.ts index b129a7ef0c5bb..0dd2b0e50d4ba 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/esql/esql.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/esql/esql.ts @@ -28,8 +28,7 @@ import { rowToDocument } from './utils'; import { fetchSourceDocuments } from './fetch_source_documents'; import { buildReasonMessageForEsqlAlert } from '../utils/reason_formatters'; -import type { RunOpts, SignalSource } from '../types'; - +import type { RunOpts, SignalSource, CreateRuleAdditionalOptions } from '../types'; import { addToSearchAfterReturn, createSearchAfterReturnType, @@ -63,6 +62,7 @@ export const esqlExecutor = async ({ spaceId, experimentalFeatures, licensing, + scheduleNotificationResponseActionsService, }: { runOpts: RunOpts; services: RuleExecutorServices; @@ -71,6 +71,7 @@ export const esqlExecutor = async ({ version: string; experimentalFeatures: ExperimentalFeatures; licensing: LicensingPluginSetup; + scheduleNotificationResponseActionsService: CreateRuleAdditionalOptions['scheduleNotificationResponseActionsService']; }) => { const ruleParams = completeRule.ruleParams; /** @@ -225,6 +226,13 @@ export const esqlExecutor = async ({ break; } } + if (scheduleNotificationResponseActionsService) { + scheduleNotificationResponseActionsService({ + signals: result.createdSignals, + signalsCount: result.createdSignalsCount, + responseActions: completeRule.ruleParams.responseActions, + }); + } // no more results will be found if (response.values.length < size) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts index 74c7d9437851e..e33f580388f98 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts @@ -12,7 +12,7 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; import { SERVER_APP_ID } from '../../../../../common/constants'; import { NewTermsRuleParams } from '../../rule_schema'; -import type { CreateRuleOptions, SecurityAlertType } from '../types'; +import type { CreateRuleOptions, SecurityAlertType, CreateRuleAdditionalOptions } from '../types'; import { singleSearchAfter } from '../utils/single_search_after'; import { getFilter } from '../utils/get_filter'; import { wrapNewTermsAlerts } from './wrap_new_terms_alerts'; @@ -46,9 +46,10 @@ import { multiTermsComposite } from './multi_terms_composite'; import type { GenericBulkCreateResponse } from '../utils/bulk_create_with_suppression'; export const createNewTermsAlertType = ( - createOptions: CreateRuleOptions + createOptions: CreateRuleOptions & CreateRuleAdditionalOptions ): SecurityAlertType => { - const { logger, licensing, experimentalFeatures } = createOptions; + const { logger, licensing, experimentalFeatures, scheduleNotificationResponseActionsService } = + createOptions; return { id: NEW_TERMS_RULE_TYPE_ID, name: 'New Terms Rule', @@ -414,6 +415,15 @@ export const createNewTermsAlertType = ( afterKey = searchResultWithAggs.aggregations.new_terms.after_key; } + + if (scheduleNotificationResponseActionsService) { + scheduleNotificationResponseActionsService({ + signals: result.createdSignals, + signalsCount: result.createdSignalsCount, + responseActions: completeRule.ruleParams.responseActions, + }); + } + return { ...result, state }; }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts index 272184dbf1e58..5915447e5a541 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts @@ -22,7 +22,7 @@ import type { UnifiedQueryRuleParams } from '../../rule_schema'; import type { ExperimentalFeatures } from '../../../../../common/experimental_features'; import { buildReasonMessageForQueryAlert } from '../utils/reason_formatters'; import { withSecuritySpan } from '../../../../utils/with_security_span'; -import type { CreateQueryRuleAdditionalOptions, RunOpts } from '../types'; +import type { CreateRuleAdditionalOptions, RunOpts } from '../types'; export const queryExecutor = async ({ runOpts, @@ -42,7 +42,7 @@ export const queryExecutor = async ({ version: string; spaceId: string; bucketHistory?: BucketHistory[]; - scheduleNotificationResponseActionsService?: CreateQueryRuleAdditionalOptions['scheduleNotificationResponseActionsService']; + scheduleNotificationResponseActionsService: CreateRuleAdditionalOptions['scheduleNotificationResponseActionsService']; licensing: LicensingPluginSetup; }) => { const completeRule = runOpts.completeRule; @@ -99,13 +99,10 @@ export const queryExecutor = async ({ state: {}, }; - if ( - completeRule.ruleParams.responseActions?.length && - result.createdSignalsCount && - scheduleNotificationResponseActionsService - ) { + if (scheduleNotificationResponseActionsService) { scheduleNotificationResponseActionsService({ signals: result.createdSignals, + signalsCount: result.createdSignalsCount, responseActions: completeRule.ruleParams.responseActions, }); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts index 4069b7782e0e8..a29beef7bbb20 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts @@ -161,15 +161,15 @@ export interface CreateRuleOptions { export interface ScheduleNotificationActions { signals: unknown[]; - responseActions: RuleResponseAction[]; + signalsCount: number; + responseActions: RuleResponseAction[] | undefined; } -export interface CreateQueryRuleAdditionalOptions { + +export interface CreateRuleAdditionalOptions { scheduleNotificationResponseActionsService?: (params: ScheduleNotificationActions) => void; } -export interface CreateQueryRuleOptions - extends CreateRuleOptions, - CreateQueryRuleAdditionalOptions { +export interface CreateQueryRuleOptions extends CreateRuleOptions, CreateRuleAdditionalOptions { id: typeof QUERY_RULE_TYPE_ID | typeof SAVED_QUERY_RULE_TYPE_ID; name: 'Custom Query Rule' | 'Saved Query Rule'; } diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index c24e70baa5db8..a46863c78c25e 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -78,7 +78,7 @@ import type { IRuleMonitoringService } from './lib/detection_engine/rule_monitor import { createRuleMonitoringService } from './lib/detection_engine/rule_monitoring'; import { EndpointMetadataService } from './endpoint/services/metadata'; import type { - CreateQueryRuleAdditionalOptions, + CreateRuleAdditionalOptions, CreateRuleOptions, } from './lib/detection_engine/rule_types/types'; // eslint-disable-next-line no-restricted-imports @@ -311,7 +311,7 @@ export class Plugin implements ISecuritySolutionPlugin { analytics: core.analytics, }; - const queryRuleAdditionalOptions: CreateQueryRuleAdditionalOptions = { + const ruleAdditionalOptions: CreateRuleAdditionalOptions = { scheduleNotificationResponseActionsService: getScheduleNotificationResponseActionsService({ endpointAppContextService: this.endpointAppContextService, osqueryCreateActionService: plugins.osquery.createActionService, @@ -320,15 +320,19 @@ export class Plugin implements ISecuritySolutionPlugin { const securityRuleTypeWrapper = createSecurityRuleTypeWrapper(securityRuleTypeOptions); - plugins.alerting.registerType(securityRuleTypeWrapper(createEqlAlertType(ruleOptions))); + plugins.alerting.registerType( + securityRuleTypeWrapper(createEqlAlertType({ ...ruleOptions, ...ruleAdditionalOptions })) + ); if (!experimentalFeatures.esqlRulesDisabled) { - plugins.alerting.registerType(securityRuleTypeWrapper(createEsqlAlertType(ruleOptions))); + plugins.alerting.registerType( + securityRuleTypeWrapper(createEsqlAlertType({ ...ruleOptions, ...ruleAdditionalOptions })) + ); } plugins.alerting.registerType( securityRuleTypeWrapper( createQueryAlertType({ ...ruleOptions, - ...queryRuleAdditionalOptions, + ...ruleAdditionalOptions, id: SAVED_QUERY_RULE_TYPE_ID, name: 'Saved Query Rule', }) @@ -342,14 +346,16 @@ export class Plugin implements ISecuritySolutionPlugin { securityRuleTypeWrapper( createQueryAlertType({ ...ruleOptions, - ...queryRuleAdditionalOptions, + ...ruleAdditionalOptions, id: QUERY_RULE_TYPE_ID, name: 'Custom Query Rule', }) ) ); plugins.alerting.registerType(securityRuleTypeWrapper(createThresholdAlertType(ruleOptions))); - plugins.alerting.registerType(securityRuleTypeWrapper(createNewTermsAlertType(ruleOptions))); + plugins.alerting.registerType( + securityRuleTypeWrapper(createNewTermsAlertType({ ...ruleOptions, ...ruleAdditionalOptions })) + ); // TODO We need to get the endpoint routes inside of initRoutes initRoutes( From de51a1a94e73ab4f2e3df06f75f3b25b0209f084 Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 18 Sep 2024 19:01:45 +0200 Subject: [PATCH 11/11] Add debug logging for flaky session tests (#193279) ## Summary Add settings to the ES Test cluster to enable debug logs so that if this test fails in the future, we will have more logs to investigate the issue. __Related:__ https://github.com/elastic/kibana/issues/152260 --- .../tests/session_concurrent_limit/cleanup.ts | 11 +++++++++++ .../tests/session_concurrent_limit/global_limit.ts | 11 +++++++++++ .../tests/session_invalidate/invalidate.ts | 11 +++++++++++ 3 files changed, 33 insertions(+) diff --git a/x-pack/test/security_api_integration/tests/session_concurrent_limit/cleanup.ts b/x-pack/test/security_api_integration/tests/session_concurrent_limit/cleanup.ts index f04d7e74d6068..6aa782ad260df 100644 --- a/x-pack/test/security_api_integration/tests/session_concurrent_limit/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_concurrent_limit/cleanup.ts @@ -25,6 +25,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); + const esSupertest = getService('esSupertest'); const es = getService('es'); const security = getService('security'); const esDeleteAllIndices = getService('esDeleteAllIndices'); @@ -150,6 +151,15 @@ export default function ({ getService }: FtrProviderContext) { }); } + async function addESDebugLoggingSettings() { + const addLogging = { + persistent: { + 'logger.org.elasticsearch.xpack.security.authc': 'debug', + }, + }; + await esSupertest.put('/_cluster/settings').send(addLogging).expect(200); + } + describe('Session Concurrent Limit cleanup', () => { before(async () => { await security.user.create('anonymous_user', { @@ -166,6 +176,7 @@ export default function ({ getService }: FtrProviderContext) { beforeEach(async function () { this.timeout(120000); await es.cluster.health({ index: '.kibana_security_session*', wait_for_status: 'green' }); + await addESDebugLoggingSettings(); await esDeleteAllIndices('.kibana_security_session*'); }); diff --git a/x-pack/test/security_api_integration/tests/session_concurrent_limit/global_limit.ts b/x-pack/test/security_api_integration/tests/session_concurrent_limit/global_limit.ts index b0d65147b054a..df120e033377c 100644 --- a/x-pack/test/security_api_integration/tests/session_concurrent_limit/global_limit.ts +++ b/x-pack/test/security_api_integration/tests/session_concurrent_limit/global_limit.ts @@ -17,6 +17,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); + const esSupertest = getService('esSupertest'); const es = getService('es'); const security = getService('security'); const config = getService('config'); @@ -116,6 +117,15 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); } + async function addESDebugLoggingSettings() { + const addLogging = { + persistent: { + 'logger.org.elasticsearch.xpack.security.authc': 'debug', + }, + }; + await esSupertest.put('/_cluster/settings').send(addLogging).expect(200); + } + describe('Session Global Concurrent Limit', () => { before(async function () { this.timeout(120000); @@ -138,6 +148,7 @@ export default function ({ getService }: FtrProviderContext) { await security.testUser.setRoles(['kibana_admin']); await es.indices.refresh({ index: '.kibana_security_session*' }); await es.cluster.health({ index: '.kibana_security_session*', wait_for_status: 'green' }); + await addESDebugLoggingSettings(); await supertest .post('/api/security/session/_invalidate') .set('kbn-xsrf', 'xxx') diff --git a/x-pack/test/security_api_integration/tests/session_invalidate/invalidate.ts b/x-pack/test/security_api_integration/tests/session_invalidate/invalidate.ts index ab6cfdfb692ad..7c4d344a481d0 100644 --- a/x-pack/test/security_api_integration/tests/session_invalidate/invalidate.ts +++ b/x-pack/test/security_api_integration/tests/session_invalidate/invalidate.ts @@ -17,6 +17,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); + const esSupertest = getService('esSupertest'); const es = getService('es'); const security = getService('security'); const esDeleteAllIndices = getService('esDeleteAllIndices'); @@ -88,9 +89,19 @@ export default function ({ getService }: FtrProviderContext) { return cookie; } + async function addESDebugLoggingSettings() { + const addLogging = { + persistent: { + 'logger.org.elasticsearch.xpack.security.authc': 'debug', + }, + }; + await esSupertest.put('/_cluster/settings').send(addLogging).expect(200); + } + describe('Session Invalidate', () => { beforeEach(async () => { await es.cluster.health({ index: '.kibana_security_session*', wait_for_status: 'green' }); + await addESDebugLoggingSettings(); await esDeleteAllIndices('.kibana_security_session*'); await security.testUser.setRoles(['kibana_admin']); });