diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fba77bf614d5d..c78de33572f14 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -265,6 +265,7 @@ x-pack/examples/files_example @elastic/kibana-app-services /x-pack/plugins/licensing/ @elastic/kibana-core /x-pack/plugins/global_search/ @elastic/kibana-core /x-pack/plugins/cloud/ @elastic/kibana-core +/x-pack/plugins/cloud_integrations/ @elastic/kibana-core /x-pack/plugins/saved_objects_tagging/ @elastic/kibana-core /x-pack/test/saved_objects_field_count/ @elastic/kibana-core /x-pack/test/saved_object_tagging/ @elastic/kibana-core diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 9fd9bf84f09d5..e4524b7fa7828 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -424,6 +424,10 @@ The plugin exposes the static DefaultEditorController class to consume. |The cloud plugin adds Cloud-specific features to Kibana. +|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx[cloudExperiments] +|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. + + |{kib-repo}blob/{branch}/x-pack/plugins/cloud_security_posture/README.md[cloudSecurityPosture] |Cloud Posture automates the identification and remediation of risks across cloud infrastructures diff --git a/package.json b/package.json index f9f2f03e62deb..b6412ba7779db 100644 --- a/package.json +++ b/package.json @@ -503,6 +503,8 @@ "jsonwebtoken": "^8.3.0", "jsts": "^1.6.2", "kea": "^2.4.2", + "launchdarkly-js-client-sdk": "^2.22.1", + "launchdarkly-node-server-sdk": "^6.4.2", "load-json-file": "^6.2.0", "lodash": "^4.17.21", "lru-cache": "^4.1.5", @@ -591,6 +593,7 @@ "redux-saga": "^1.1.3", "redux-thunk": "^2.4.1", "redux-thunks": "^1.0.0", + "remark-gfm": "1.0.0", "remark-parse": "^8.0.3", "remark-stringify": "^8.0.3", "require-in-the-middle": "^5.2.0", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 02687b4a1e624..0c6b68f79cebd 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -10,6 +10,7 @@ pageLoadAssetSize: cases: 144442 charts: 55000 cloud: 21076 + cloudExperiments: 59358 cloudSecurityPosture: 19109 console: 46091 controls: 40000 diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index 5f34bef2df51a..d88ba3a327941 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -89,6 +89,7 @@ export const PROJECTS = [ 'src/plugins/chart_expressions/*/tsconfig.json', 'src/plugins/vis_types/*/tsconfig.json', 'x-pack/plugins/*/tsconfig.json', + 'x-pack/plugins/cloud_integrations/*/tsconfig.json', 'examples/*/tsconfig.json', 'x-pack/examples/*/tsconfig.json', 'test/analytics/fixtures/plugins/*/tsconfig.json', diff --git a/src/plugins/data/common/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts index a17ef12484b65..a980004bd1ceb 100644 --- a/src/plugins/data/common/search/search_source/mocks.ts +++ b/src/plugins/data/common/search/search_source/mocks.ts @@ -49,20 +49,26 @@ export const searchSourceCommonMock: jest.Mocked = { extract: jest.fn(), }; -export const createSearchSourceMock = (fields?: SearchSourceFields, response?: any) => +export const createSearchSourceMock = ( + fields?: SearchSourceFields, + response?: any, + search?: jest.Mock +) => new SearchSource(fields, { aggs: { createAggConfigs: jest.fn(), } as unknown as SearchSourceDependencies['aggs'], getConfig: uiSettingsServiceMock.createStartContract().get, - search: jest.fn().mockReturnValue( - of( - response ?? { - rawResponse: { hits: { hits: [], total: 0 } }, - isPartial: false, - isRunning: false, - } - ) - ), + search: + search || + jest.fn().mockReturnValue( + of( + response ?? { + rawResponse: { hits: { hits: [], total: 0 } }, + isPartial: false, + isRunning: false, + } + ) + ), onResponse: jest.fn().mockImplementation((req, res) => res), }); diff --git a/src/plugins/discover/public/__mocks__/data_views.ts b/src/plugins/discover/public/__mocks__/data_views.ts index cb85f317ccd5b..7832a4c0f4e39 100644 --- a/src/plugins/discover/public/__mocks__/data_views.ts +++ b/src/plugins/discover/public/__mocks__/data_views.ts @@ -26,6 +26,7 @@ export const dataViewsMock = { getIdsWithTitle: jest.fn(() => { return Promise.resolve([dataViewMock, dataViewComplexMock, dataViewWithTimefieldMock]); }), + createFilter: jest.fn(), create: jest.fn(), clearInstanceCache: jest.fn(), } as unknown as jest.Mocked; diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index 953422acc9291..252aead2f76ed 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -123,4 +123,5 @@ export const discoverServiceMock = { expressions: expressionsPlugin, savedObjectsTagging: {}, dataViews: dataViewsMock, + timefilter: { createFilter: jest.fn() }, } as unknown as DiscoverServices; diff --git a/src/plugins/discover/public/application/discover_router.tsx b/src/plugins/discover/public/application/discover_router.tsx index 9cac8a4f1f50a..1fe847d17beb7 100644 --- a/src/plugins/discover/public/application/discover_router.tsx +++ b/src/plugins/discover/public/application/discover_router.tsx @@ -17,8 +17,14 @@ import { DiscoverMainRoute } from './main'; import { NotFoundRoute } from './not_found'; import { DiscoverServices } from '../build_services'; import { ViewAlertRoute } from './view_alert'; +import { HistoryLocationState } from '../locator'; -export const discoverRouter = (services: DiscoverServices, history: History, isDev: boolean) => ( +export const discoverRouter = ( + services: DiscoverServices, + history: History, + isDev: boolean, + historyLocationState?: HistoryLocationState +) => ( @@ -39,10 +45,10 @@ export const discoverRouter = (services: DiscoverServices, history: History, isD - + - + diff --git a/src/plugins/discover/public/application/index.tsx b/src/plugins/discover/public/application/index.tsx index 5ae2ed76923b5..2542a639be71f 100644 --- a/src/plugins/discover/public/application/index.tsx +++ b/src/plugins/discover/public/application/index.tsx @@ -9,8 +9,14 @@ import { i18n } from '@kbn/i18n'; import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public'; import { discoverRouter } from './discover_router'; import { DiscoverServices } from '../build_services'; +import { HistoryLocationState } from '../locator'; -export const renderApp = (element: HTMLElement, services: DiscoverServices, isDev: boolean) => { +export const renderApp = ( + element: HTMLElement, + services: DiscoverServices, + isDev: boolean, + historyLocationState?: HistoryLocationState +) => { const { history: getHistory, capabilities, chrome, data, core } = services; const history = getHistory(); @@ -26,7 +32,7 @@ export const renderApp = (element: HTMLElement, services: DiscoverServices, isDe }); } const unmount = toMountPoint( - wrapWithTheme(discoverRouter(services, history, isDev), core.theme.theme$) + wrapWithTheme(discoverRouter(services, history, isDev, historyLocationState), core.theme.theme$) )(element); return () => { diff --git a/src/plugins/discover/public/application/main/discover_main_route.tsx b/src/plugins/discover/public/application/main/discover_main_route.tsx index d5f693d7f5fb0..4d3dccaa68001 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.tsx @@ -29,6 +29,7 @@ import { DiscoverError } from '../../components/common/error_alert'; import { useDiscoverServices } from '../../hooks/use_discover_services'; import { getUrlTracker } from '../../kibana_services'; import { restoreStateFromSavedSearch } from '../../services/saved_searches/restore_from_saved_search'; +import { HistoryLocationState } from '../../locator'; const DiscoverMainAppMemoized = memo(DiscoverMainApp); @@ -38,6 +39,7 @@ interface DiscoverLandingParams { interface Props { isDev: boolean; + historyLocationState?: HistoryLocationState; } export function DiscoverMainRoute(props: Props) { @@ -93,7 +95,12 @@ export function DiscoverMainRoute(props: Props) { const { appStateContainer } = getState({ history, savedSearch: nextSavedSearch, services }); const { index } = appStateContainer.getState(); - const ip = await loadDataView(data.dataViews, config, index); + const ip = await loadDataView( + data.dataViews, + config, + index, + props.historyLocationState?.dataViewSpec + ); const ipList = ip.list; const dataViewData = resolveDataView(ip, nextSavedSearch.searchSource, toastNotifications); @@ -105,7 +112,15 @@ export function DiscoverMainRoute(props: Props) { setError(e); } }, - [config, data.dataViews, history, isDev, toastNotifications, services] + [ + config, + data.dataViews, + history, + isDev, + props.historyLocationState?.dataViewSpec, + toastNotifications, + services, + ] ); const loadSavedSearch = useCallback(async () => { diff --git a/src/plugins/discover/public/application/main/utils/resolve_data_view.ts b/src/plugins/discover/public/application/main/utils/resolve_data_view.ts index a31d700f37a9f..7baede8101851 100644 --- a/src/plugins/discover/public/application/main/utils/resolve_data_view.ts +++ b/src/plugins/discover/public/application/main/utils/resolve_data_view.ts @@ -7,7 +7,12 @@ */ import { i18n } from '@kbn/i18n'; -import type { DataView, DataViewListItem, DataViewsContract } from '@kbn/data-views-plugin/public'; +import type { + DataView, + DataViewListItem, + DataViewsContract, + DataViewSpec, +} from '@kbn/data-views-plugin/public'; import type { ISearchSource } from '@kbn/data-plugin/public'; import type { IUiSettingsClient, ToastsStart } from '@kbn/core/public'; interface DataViewData { @@ -75,12 +80,33 @@ export function getDataViewId( export async function loadDataView( dataViews: DataViewsContract, config: IUiSettingsClient, - id?: string + id?: string, + dataViewSpec?: DataViewSpec ): Promise { const dataViewList = await dataViews.getIdsWithTitle(); + let fetchId: string | undefined = id; + /** + * Handle redirect with data view spec provided via history location state + */ + if (dataViewSpec) { + const isPersisted = dataViewList.find(({ id: currentId }) => currentId === dataViewSpec.id); + if (!isPersisted) { + const createdAdHocDataView = await dataViews.create(dataViewSpec); + return { + list: dataViewList || [], + loaded: createdAdHocDataView, + stateVal: createdAdHocDataView.id, + stateValFound: true, + }; + } + // reassign fetchId in case of persisted data view spec provided + fetchId = dataViewSpec.id!; + } + + // try to fetch adhoc data view first try { - const fetchedDataView = id ? await dataViews.get(id) : undefined; + const fetchedDataView = fetchId ? await dataViews.get(fetchId) : undefined; if (fetchedDataView && !fetchedDataView.isPersisted()) { return { list: dataViewList || [], @@ -95,12 +121,13 @@ export async function loadDataView( // eslint-disable-next-line no-empty } catch (e) {} - const actualId = getDataViewId(id, dataViewList, config.get('defaultIndex')); + // fetch persisted data view + const actualId = getDataViewId(fetchId, dataViewList, config.get('defaultIndex')); return { list: dataViewList || [], loaded: await dataViews.get(actualId), - stateVal: id, - stateValFound: !!id && actualId === id, + stateVal: fetchId, + stateValFound: !!fetchId && actualId === fetchId, }; } diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts new file mode 100644 index 0000000000000..28a4bb185ea76 --- /dev/null +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ReactElement } from 'react'; +import { FilterManager } from '@kbn/data-plugin/public'; +import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_manager/filter_manager.mock'; +import { getSavedSearchUrl, SearchInput } from '..'; +import { DiscoverServices } from '../build_services'; +import { dataViewMock } from '../__mocks__/data_view'; +import { discoverServiceMock } from '../__mocks__/services'; +import { SavedSearchEmbeddable, SearchEmbeddableConfig } from './saved_search_embeddable'; +import { render } from 'react-dom'; +import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; +import { throwError } from 'rxjs'; +import { ReactWrapper } from 'enzyme'; +import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component'; + +let discoverComponent: ReactWrapper; + +jest.mock('react-dom', () => { + const { mount } = jest.requireActual('enzyme'); + return { + ...jest.requireActual('react-dom'), + render: jest.fn((component: ReactElement) => { + discoverComponent = mount(component); + }), + }; +}); + +const waitOneTick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('saved search embeddable', () => { + let mountpoint: HTMLDivElement; + let filterManagerMock: jest.Mocked; + let servicesMock: jest.Mocked; + let executeTriggerActions: jest.Mock; + + const createEmbeddable = (searchMock?: jest.Mock) => { + const savedSearchMock = { + id: 'mock-id', + sort: [['message', 'asc']] as Array<[string, string]>, + searchSource: createSearchSourceMock({ index: dataViewMock }, undefined, searchMock), + }; + + const url = getSavedSearchUrl(savedSearchMock.id); + const editUrl = `/app/discover${url}`; + const indexPatterns = [dataViewMock]; + const savedSearchEmbeddableConfig: SearchEmbeddableConfig = { + savedSearch: savedSearchMock, + editUrl, + editPath: url, + editable: true, + indexPatterns, + filterManager: filterManagerMock, + services: servicesMock, + }; + const searchInput: SearchInput = { + id: 'mock-embeddable-id', + timeRange: { from: 'now-15m', to: 'now' }, + columns: ['message', 'extension'], + rowHeight: 30, + rowsPerPage: 50, + }; + + executeTriggerActions = jest.fn(); + + const embeddable = new SavedSearchEmbeddable( + savedSearchEmbeddableConfig, + searchInput, + executeTriggerActions + ); + + // this helps to trigger reload + // eslint-disable-next-line dot-notation + embeddable['inputSubject'].next = jest.fn( + (input) => (input.lastReloadRequestTime = Date.now()) + ); + + return { embeddable }; + }; + + beforeEach(() => { + mountpoint = document.createElement('div'); + filterManagerMock = createFilterManagerMock(); + servicesMock = discoverServiceMock as unknown as jest.Mocked; + }); + + afterEach(() => { + mountpoint.remove(); + }); + + it('should render saved search embeddable two times initially', async () => { + const { embeddable } = createEmbeddable(); + embeddable.updateOutput = jest.fn(); + + embeddable.render(mountpoint); + expect(render).toHaveBeenCalledTimes(1); + + // wait for data fetching + await waitOneTick(); + expect(render).toHaveBeenCalledTimes(2); + }); + + it('should update input correctly', async () => { + const { embeddable } = createEmbeddable(); + embeddable.updateOutput = jest.fn(); + + embeddable.render(mountpoint); + await waitOneTick(); + + const searchProps = discoverComponent.find(SavedSearchEmbeddableComponent).prop('searchProps'); + + searchProps.onAddColumn!('bytes'); + await waitOneTick(); + expect(searchProps.columns).toEqual(['message', 'extension', 'bytes']); + + searchProps.onRemoveColumn!('bytes'); + await waitOneTick(); + expect(searchProps.columns).toEqual(['message', 'extension']); + + searchProps.onSetColumns!(['message', 'bytes', 'extension'], false); + await waitOneTick(); + expect(searchProps.columns).toEqual(['message', 'bytes', 'extension']); + + searchProps.onMoveColumn!('bytes', 2); + await waitOneTick(); + expect(searchProps.columns).toEqual(['message', 'extension', 'bytes']); + + expect(searchProps.rowHeightState).toEqual(30); + searchProps.onUpdateRowHeight!(40); + await waitOneTick(); + expect(searchProps.rowHeightState).toEqual(40); + + expect(searchProps.rowsPerPageState).toEqual(50); + searchProps.onUpdateRowsPerPage!(100); + await waitOneTick(); + expect(searchProps.rowsPerPageState).toEqual(100); + + searchProps.onFilter!({ name: 'customer_id', type: 'string', scripted: false }, [17], '+'); + await waitOneTick(); + expect(executeTriggerActions).toHaveBeenCalled(); + }); + + it('should emit error output in case of fetch error', async () => { + const search = jest.fn().mockReturnValue(throwError(new Error('Fetch error'))); + const { embeddable } = createEmbeddable(search); + embeddable.updateOutput = jest.fn(); + + embeddable.render(mountpoint); + // wait for data fetching + await waitOneTick(); + + expect((embeddable.updateOutput as jest.Mock).mock.calls[1][0].error.message).toBe( + 'Fetch error' + ); + }); +}); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index 00cbd0a2ffcb0..ffdead82d1dae 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -15,7 +15,7 @@ import { FilterStateStore, } from '@kbn/es-query'; import React from 'react'; -import ReactDOM from 'react-dom'; +import ReactDOM, { unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; import { isEqual } from 'lodash'; import { I18nProvider } from '@kbn/i18n-react'; @@ -76,7 +76,7 @@ export type SearchProps = Partial & onUpdateRowsPerPage?: (rowsPerPage?: number) => void; }; -interface SearchEmbeddableConfig { +export interface SearchEmbeddableConfig { savedSearch: SavedSearch; editUrl: string; editPath: string; @@ -153,9 +153,9 @@ export class SavedSearchEmbeddable this.searchProps && (titleChanged || this.isFetchRequired(this.searchProps) || - this.isInputChangedAndRerenderRequired(this.searchProps)) + this.isRerenderRequired(this.searchProps)) ) { - this.pushContainerStateParamsToProps(this.searchProps); + this.reload(); } }); } @@ -387,7 +387,7 @@ export class SavedSearchEmbeddable searchSource.setParent(this.filtersSearchSource); - this.pushContainerStateParamsToProps(props); + this.load(props); props.isLoading = true; @@ -420,11 +420,14 @@ export class SavedSearchEmbeddable ); } - private isInputChangedAndRerenderRequired(searchProps?: SearchProps) { + private isRerenderRequired(searchProps?: SearchProps) { if (!searchProps) { return false; } - return this.input.rowsPerPage !== searchProps.rowsPerPageState; + return ( + this.input.rowsPerPage !== searchProps.rowsPerPageState || + (this.input.columns && !isEqual(this.input.columns, searchProps.columns)) + ); } private async pushContainerStateParamsToProps( @@ -466,10 +469,6 @@ export class SavedSearchEmbeddable } else if (this.searchProps && this.node) { this.searchProps = searchProps; } - - if (this.node) { - this.renderReactComponent(this.node, this.searchProps!); - } } /** @@ -480,9 +479,7 @@ export class SavedSearchEmbeddable if (!this.searchProps) { throw new Error('Search props not defined'); } - if (this.node) { - ReactDOM.unmountComponentAtNode(this.node); - } + this.node = domNode; this.renderReactComponent(this.node, this.searchProps!); @@ -545,9 +542,17 @@ export class SavedSearchEmbeddable }); } + private async load(searchProps: SearchProps, forceFetch = false) { + await this.pushContainerStateParamsToProps(searchProps, { forceFetch }); + + if (this.node) { + this.render(this.node); + } + } + public reload() { if (this.searchProps) { - this.pushContainerStateParamsToProps(this.searchProps, { forceFetch: true }); + this.load(this.searchProps, true); } } @@ -584,6 +589,9 @@ export class SavedSearchEmbeddable if (this.searchProps) { delete this.searchProps; } + if (this.node) { + unmountComponentAtNode(this.node); + } this.subscription?.unsubscribe(); if (this.abortController) this.abortController.abort(); diff --git a/src/plugins/discover/public/embeddable/search_embeddable_factory.test.ts b/src/plugins/discover/public/embeddable/search_embeddable_factory.test.ts new file mode 100644 index 0000000000000..fa621879df591 --- /dev/null +++ b/src/plugins/discover/public/embeddable/search_embeddable_factory.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { discoverServiceMock } from '../__mocks__/services'; +import { SearchEmbeddableFactory, type StartServices } from './search_embeddable_factory'; +import { getSavedSearch } from '@kbn/saved-search-plugin/public'; +import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; +import { dataViewMock } from '../__mocks__/data_view'; +import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public'; + +jest.mock('@kbn/saved-search-plugin/public', () => { + return { + ...jest.requireActual('@kbn/saved-search-plugin/public'), + getSavedSearch: jest.fn(), + }; +}); + +jest.mock('@kbn/embeddable-plugin/public', () => { + return { + ...jest.requireActual('@kbn/embeddable-plugin/public'), + ErrorEmbeddable: jest.fn(), + }; +}); + +const input = { + id: 'mock-embeddable-id', + timeRange: { from: 'now-15m', to: 'now' }, + columns: ['message', 'extension'], + rowHeight: 30, + rowsPerPage: 50, +}; + +const getSavedSearchMock = getSavedSearch as unknown as jest.Mock; +const ErrorEmbeddableMock = ErrorEmbeddable as unknown as jest.Mock; + +describe('SearchEmbeddableFactory', () => { + it('should create factory correctly', async () => { + const savedSearchMock = { + id: 'mock-id', + sort: [['message', 'asc']] as Array<[string, string]>, + searchSource: createSearchSourceMock({ index: dataViewMock }, undefined), + }; + getSavedSearchMock.mockResolvedValue(savedSearchMock); + + const factory = new SearchEmbeddableFactory( + () => Promise.resolve({ executeTriggerActions: jest.fn() } as unknown as StartServices), + () => Promise.resolve(discoverServiceMock) + ); + const embeddable = await factory.createFromSavedObject('saved-object-id', input); + + expect(getSavedSearchMock.mock.calls[0][0]).toEqual('saved-object-id'); + expect(embeddable).toBeDefined(); + }); + + it('should throw an error when saved search could not be found', async () => { + getSavedSearchMock.mockRejectedValue('Could not find saved search'); + + const factory = new SearchEmbeddableFactory( + () => Promise.resolve({ executeTriggerActions: jest.fn() } as unknown as StartServices), + () => Promise.resolve(discoverServiceMock) + ); + + await factory.createFromSavedObject('saved-object-id', input); + + expect(ErrorEmbeddableMock.mock.calls[0][0]).toEqual('Could not find saved search'); + }); +}); diff --git a/src/plugins/discover/public/embeddable/search_embeddable_factory.ts b/src/plugins/discover/public/embeddable/search_embeddable_factory.ts index 185b0daf8055b..bdf0371aceaa8 100644 --- a/src/plugins/discover/public/embeddable/search_embeddable_factory.ts +++ b/src/plugins/discover/public/embeddable/search_embeddable_factory.ts @@ -26,7 +26,7 @@ import { SEARCH_EMBEDDABLE_TYPE } from './constants'; import { SavedSearchEmbeddable } from './saved_search_embeddable'; import { DiscoverServices } from '../build_services'; -interface StartServices { +export interface StartServices { executeTriggerActions: UiActionsStart['executeTriggerActions']; isEditable: () => boolean; } diff --git a/src/plugins/discover/public/locator.test.ts b/src/plugins/discover/public/locator.test.ts index 274e339c91343..221c7a4958fa6 100644 --- a/src/plugins/discover/public/locator.test.ts +++ b/src/plugins/discover/public/locator.test.ts @@ -20,9 +20,7 @@ interface SetupParams { } const setup = async ({ useHash = false }: SetupParams = {}) => { - const locator = new DiscoverAppLocatorDefinition({ - useHash, - }); + const locator = new DiscoverAppLocatorDefinition({ useHash }); return { locator, @@ -230,6 +228,18 @@ describe('Discover url generator', () => { expect(path).toEqual(legacyParamsPath); }); + test('should create data view when dataViewSpec is used', async () => { + const dataViewSpecMock = { + id: 'mock-id', + title: 'mock-title', + timeFieldName: 'mock-time-field-name', + }; + const { locator } = await setup(); + const { state } = await locator.getLocation({ dataViewSpec: dataViewSpecMock }); + + expect(state.dataViewSpec).toEqual(dataViewSpecMock); + }); + describe('useHash property', () => { describe('when default useHash is set to false', () => { test('when using default, sets data view ID in the generated URL', async () => { diff --git a/src/plugins/discover/public/locator.ts b/src/plugins/discover/public/locator.ts index 0fa2195cc246f..eb8d1039f27c5 100644 --- a/src/plugins/discover/public/locator.ts +++ b/src/plugins/discover/public/locator.ts @@ -11,6 +11,7 @@ import type { Filter, TimeRange, Query, AggregateQuery } from '@kbn/es-query'; import type { GlobalQueryStateFromUrl, RefreshInterval } from '@kbn/data-plugin/public'; import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; +import { DataViewSpec } from '@kbn/data-views-plugin/public'; import type { VIEW_MODE } from './components/view_mode_toggle'; export const DISCOVER_APP_LOCATOR = 'DISCOVER_APP_LOCATOR'; @@ -30,6 +31,7 @@ export interface DiscoverAppLocatorParams extends SerializableRecord { * @deprecated */ indexPatternId?: string; + dataViewSpec?: DataViewSpec; /** * Optionally set the time range in the time picker. @@ -97,6 +99,10 @@ export interface DiscoverAppLocatorDependencies { useHash: boolean; } +export interface HistoryLocationState { + dataViewSpec?: DataViewSpec; +} + export class DiscoverAppLocatorDefinition implements LocatorDefinition { public readonly id = DISCOVER_APP_LOCATOR; @@ -108,6 +114,7 @@ export class DiscoverAppLocatorDefinition implements LocatorDefinition('_g', queryState, { useHash }, path); path = setStateToKbnUrl('_a', appState, { useHash }, path); @@ -161,7 +173,7 @@ export class DiscoverAppLocatorDefinition implements LocatorDefinition { @@ -304,7 +308,7 @@ export class DiscoverPlugin // FIXME: Temporarily hide overflow-y in Discover app when Field Stats table is shown // due to EUI bug https://github.com/elastic/eui/pull/5152 params.element.classList.add('dscAppWrapper'); - const unmount = renderApp(params.element, services, isDev); + const unmount = renderApp(params.element, services, isDev, historyLocationState); return () => { unlistenParentHistory(); unmount(); diff --git a/test/functional/apps/discover/embeddable/_saved_search_embeddable.ts b/test/functional/apps/discover/embeddable/_saved_search_embeddable.ts index bd47c072e7735..8b9a31e2093c0 100644 --- a/test/functional/apps/discover/embeddable/_saved_search_embeddable.ts +++ b/test/functional/apps/discover/embeddable/_saved_search_embeddable.ts @@ -78,8 +78,23 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.checkCurrentRowsPerPageToBe(10); }); - it('should render duplicate saved search embeddables', async () => { + it('should control columns correctly', async () => { await PageObjects.dashboard.switchToEditMode(); + + const cell = await dataGrid.getCellElement(0, 2); + expect(await cell.getVisibleText()).to.be('Sep 22, 2015 @ 23:50:13.253'); + await dataGrid.clickMoveColumnLeft('agent'); + + const cellAfter = await dataGrid.getCellElement(0, 2); + expect(await cellAfter.getVisibleText()).to.be( + 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)' + ); + + await dataGrid.clickRemoveColumn('agent'); + expect(await cell.getVisibleText()).to.be('Sep 22, 2015 @ 23:50:13.253'); + }); + + it('should render duplicate saved search embeddables', async () => { await addSearchEmbeddableToDashboard(); const [firstGridCell, secondGridCell] = await dataGrid.getAllCellElements(); const firstGridCellContent = await firstGridCell.getVisibleText(); diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index 68b2553478df7..c3107188d7c56 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -254,30 +254,36 @@ export class DataGridService extends FtrService { }); } - public async clickDocSortAsc(field?: string, sortText = 'Sort Old-New') { + private async clickColumnMenuField(field?: string) { if (field) { await this.openColMenuByField(field); } else { await this.find.clickByCssSelector('.euiDataGridHeaderCell__button'); } + } + + public async clickDocSortAsc(field?: string, sortText = 'Sort Old-New') { + await this.clickColumnMenuField(field); await this.find.clickByButtonText(sortText); } public async clickDocSortDesc(field?: string, sortText = 'Sort New-Old') { - if (field) { - await this.openColMenuByField(field); - } else { - await this.find.clickByCssSelector('.euiDataGridHeaderCell__button'); - } + await this.clickColumnMenuField(field); await this.find.clickByButtonText(sortText); } + public async clickMoveColumnRight(field?: string) { + await this.clickColumnMenuField(field); + await this.find.clickByButtonText('Move right'); + } + + public async clickMoveColumnLeft(field?: string) { + await this.clickColumnMenuField(field); + await this.find.clickByButtonText('Move left'); + } + public async clickRemoveColumn(field?: string) { - if (field) { - await this.openColMenuByField(field); - } else { - await this.find.clickByCssSelector('.euiDataGridHeaderCell__button'); - } + await this.clickColumnMenuField(field); await this.find.clickByButtonText('Remove column'); } diff --git a/tsconfig.base.json b/tsconfig.base.json index 9f04a4d6a1e4e..b62beb6650448 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -313,6 +313,8 @@ "@kbn/canvas-plugin/*": ["x-pack/plugins/canvas/*"], "@kbn/cases-plugin": ["x-pack/plugins/cases"], "@kbn/cases-plugin/*": ["x-pack/plugins/cases/*"], + "@kbn/cloud-experiments-plugin": ["x-pack/plugins/cloud_integrations/cloud_experiments"], + "@kbn/cloud-experiments-plugin/*": ["x-pack/plugins/cloud_integrations/cloud_experiments/*"], "@kbn/cloud-security-posture-plugin": ["x-pack/plugins/cloud_security_posture"], "@kbn/cloud-security-posture-plugin/*": ["x-pack/plugins/cloud_security_posture/*"], "@kbn/cloud-plugin": ["x-pack/plugins/cloud"], diff --git a/x-pack/plugins/cloud/kibana.json b/x-pack/plugins/cloud/kibana.json index 8e5fc4e0f11e1..51df5d20d81b9 100644 --- a/x-pack/plugins/cloud/kibana.json +++ b/x-pack/plugins/cloud/kibana.json @@ -7,7 +7,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "cloud"], - "optionalPlugins": ["usageCollection", "home", "security"], + "optionalPlugins": ["cloudExperiments", "usageCollection", "home", "security"], "server": true, "ui": true } diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index 2215fbd64fe98..599dee5e707b7 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -12,6 +12,21 @@ import { coreMock } from '@kbn/core/public/mocks'; import { homePluginMock } from '@kbn/home-plugin/public/mocks'; import { securityMock } from '@kbn/security-plugin/public/mocks'; import { CloudPlugin, type CloudConfigType } from './plugin'; +import { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common'; +import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks'; + +const baseConfig = { + base_url: 'https://cloud.elastic.co', + deployment_url: '/abc123', + profile_url: '/user/settings/', + organization_url: '/account/', + full_story: { + enabled: false, + }, + chat: { + enabled: false, + }, +}; describe('Cloud Plugin', () => { describe('#setup', () => { @@ -22,17 +37,8 @@ describe('Cloud Plugin', () => { const setupPlugin = async ({ config = {} }: { config?: Partial }) => { const initContext = coreMock.createPluginInitializerContext({ + ...baseConfig, id: 'cloudId', - base_url: 'https://cloud.elastic.co', - deployment_url: '/abc123', - profile_url: '/profile/alice', - organization_url: '/org/myOrg', - full_story: { - enabled: false, - }, - chat: { - enabled: false, - }, ...config, }); @@ -92,16 +98,7 @@ describe('Cloud Plugin', () => { currentUserProps?: Record | Error; }) => { const initContext = coreMock.createPluginInitializerContext({ - base_url: 'https://cloud.elastic.co', - deployment_url: '/abc123', - profile_url: '/profile/alice', - organization_url: '/org/myOrg', - full_story: { - enabled: false, - }, - chat: { - enabled: false, - }, + ...baseConfig, ...config, }); @@ -249,17 +246,8 @@ describe('Cloud Plugin', () => { failHttp?: boolean; }) => { const initContext = coreMock.createPluginInitializerContext({ + ...baseConfig, id: isCloudEnabled ? 'cloud-id' : null, - base_url: 'https://cloud.elastic.co', - deployment_url: '/abc123', - profile_url: '/profile/alice', - organization_url: '/org/myOrg', - full_story: { - enabled: false, - }, - chat: { - enabled: false, - }, ...config, }); @@ -322,18 +310,9 @@ describe('Cloud Plugin', () => { describe('interface', () => { const setupPlugin = () => { const initContext = coreMock.createPluginInitializerContext({ + ...baseConfig, id: 'cloudId', cname: 'cloud.elastic.co', - base_url: 'https://cloud.elastic.co', - deployment_url: '/abc123', - profile_url: '/user/settings/', - organization_url: '/account/', - chat: { - enabled: false, - }, - full_story: { - enabled: false, - }, }); const plugin = new CloudPlugin(initContext); @@ -383,6 +362,50 @@ describe('Cloud Plugin', () => { expect(setup.cname).toBe('cloud.elastic.co'); }); }); + + describe('Set up cloudExperiments', () => { + describe('when cloud ID is not provided in the config', () => { + let cloudExperiments: jest.Mocked; + beforeEach(() => { + const plugin = new CloudPlugin(coreMock.createPluginInitializerContext(baseConfig)); + cloudExperiments = cloudExperimentsMock.createSetupMock(); + plugin.setup(coreMock.createSetup(), { cloudExperiments }); + }); + + test('does not call cloudExperiments.identifyUser', async () => { + expect(cloudExperiments.identifyUser).not.toHaveBeenCalled(); + }); + }); + + describe('when cloud ID is provided in the config', () => { + let cloudExperiments: jest.Mocked; + beforeEach(() => { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext({ ...baseConfig, id: 'cloud test' }) + ); + cloudExperiments = cloudExperimentsMock.createSetupMock(); + plugin.setup(coreMock.createSetup(), { cloudExperiments }); + }); + + test('calls cloudExperiments.identifyUser', async () => { + expect(cloudExperiments.identifyUser).toHaveBeenCalledTimes(1); + }); + + test('the cloud ID is hashed when calling cloudExperiments.identifyUser', async () => { + expect(cloudExperiments.identifyUser.mock.calls[0][0]).toEqual( + '1acb4a1cc1c3d672a8d826055d897c2623ceb1d4fb07e46d97986751a36b06cf' + ); + }); + + test('specifies the Kibana version when calling cloudExperiments.identifyUser', async () => { + expect(cloudExperiments.identifyUser.mock.calls[0][1]).toEqual( + expect.objectContaining({ + kibanaVersion: 'version', + }) + ); + }); + }); + }); }); describe('#start', () => { diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index 195e359436e28..c27668feb09bd 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -22,6 +22,7 @@ import { BehaviorSubject, catchError, from, map, of } from 'rxjs'; import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import { Sha256 } from '@kbn/crypto-browser'; +import type { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common'; import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; import { @@ -58,6 +59,7 @@ export interface CloudConfigType { interface CloudSetupDependencies { home?: HomePublicPluginSetup; security?: Pick; + cloudExperiments?: CloudExperimentsPluginSetup; } interface CloudStartDependencies { @@ -93,15 +95,15 @@ interface SetupChatDeps extends Pick { export class CloudPlugin implements Plugin { private readonly config: CloudConfigType; - private isCloudEnabled: boolean; + private readonly isCloudEnabled: boolean; private chatConfig$ = new BehaviorSubject({ enabled: false }); constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); - this.isCloudEnabled = false; + this.isCloudEnabled = getIsCloudEnabled(this.config.id); } - public setup(core: CoreSetup, { home, security }: CloudSetupDependencies) { + public setup(core: CoreSetup, { cloudExperiments, home, security }: CloudSetupDependencies) { this.setupTelemetryContext(core.analytics, security, this.config.id); this.setupFullStory({ analytics: core.analytics, basePath: core.http.basePath }).catch((e) => @@ -118,7 +120,12 @@ export class CloudPlugin implements Plugin { base_url: baseUrl, } = this.config; - this.isCloudEnabled = getIsCloudEnabled(id); + if (this.isCloudEnabled && id) { + // We use the Hashed Cloud Deployment ID as the userId in the Cloud Experiments + cloudExperiments?.identifyUser(sha256(id), { + kibanaVersion: this.initializerContext.env.packageInfo.version, + }); + } this.setupChat({ http: core.http, security }).catch((e) => // eslint-disable-next-line no-console diff --git a/x-pack/plugins/cloud/server/plugin.test.ts b/x-pack/plugins/cloud/server/plugin.test.ts index ccb0b8545fcf6..05109a4c54816 100644 --- a/x-pack/plugins/cloud/server/plugin.test.ts +++ b/x-pack/plugins/cloud/server/plugin.test.ts @@ -10,6 +10,8 @@ import { CloudPlugin } from './plugin'; import { config } from './config'; import { securityMock } from '@kbn/security-plugin/server/mocks'; import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks'; +import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks'; +import { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common'; describe('Cloud Plugin', () => { describe('#setup', () => { @@ -66,5 +68,51 @@ describe('Cloud Plugin', () => { expect(securityDependencyMock.setIsElasticCloudDeployment).toHaveBeenCalledTimes(1); }); }); + + describe('Set up cloudExperiments', () => { + describe('when cloud ID is not provided in the config', () => { + let cloudExperiments: jest.Mocked; + beforeEach(() => { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext(config.schema.validate({})) + ); + cloudExperiments = cloudExperimentsMock.createSetupMock(); + plugin.setup(coreMock.createSetup(), { cloudExperiments }); + }); + + test('does not call cloudExperiments.identifyUser', async () => { + expect(cloudExperiments.identifyUser).not.toHaveBeenCalled(); + }); + }); + + describe('when cloud ID is provided in the config', () => { + let cloudExperiments: jest.Mocked; + beforeEach(() => { + const plugin = new CloudPlugin( + coreMock.createPluginInitializerContext(config.schema.validate({ id: 'cloud test' })) + ); + cloudExperiments = cloudExperimentsMock.createSetupMock(); + plugin.setup(coreMock.createSetup(), { cloudExperiments }); + }); + + test('calls cloudExperiments.identifyUser', async () => { + expect(cloudExperiments.identifyUser).toHaveBeenCalledTimes(1); + }); + + test('the cloud ID is hashed when calling cloudExperiments.identifyUser', async () => { + expect(cloudExperiments.identifyUser.mock.calls[0][0]).toEqual( + '1acb4a1cc1c3d672a8d826055d897c2623ceb1d4fb07e46d97986751a36b06cf' + ); + }); + + test('specifies the Kibana version when calling cloudExperiments.identifyUser', async () => { + expect(cloudExperiments.identifyUser.mock.calls[0][1]).toEqual( + expect.objectContaining({ + kibanaVersion: 'version', + }) + ); + }); + }); + }); }); }); diff --git a/x-pack/plugins/cloud/server/plugin.ts b/x-pack/plugins/cloud/server/plugin.ts index 8d5c38477d0cb..d38a57a4d3bab 100644 --- a/x-pack/plugins/cloud/server/plugin.ts +++ b/x-pack/plugins/cloud/server/plugin.ts @@ -8,6 +8,8 @@ import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import { CoreSetup, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server'; import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; +import type { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common'; +import { createSHA256Hash } from '@kbn/crypto'; import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context'; import { CloudConfigType } from './config'; import { registerCloudUsageCollector } from './collectors'; @@ -20,6 +22,7 @@ import { readInstanceSizeMb } from './env'; interface PluginsSetup { usageCollection?: UsageCollectionSetup; security?: SecurityPluginSetup; + cloudExperiments?: CloudExperimentsPluginSetup; } export interface CloudSetup { @@ -44,7 +47,10 @@ export class CloudPlugin implements Plugin { this.isDev = this.context.env.mode.dev; } - public setup(core: CoreSetup, { usageCollection, security }: PluginsSetup): CloudSetup { + public setup( + core: CoreSetup, + { cloudExperiments, usageCollection, security }: PluginsSetup + ): CloudSetup { this.logger.debug('Setting up Cloud plugin'); const isCloudEnabled = getIsCloudEnabled(this.config.id); registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id); @@ -54,6 +60,13 @@ export class CloudPlugin implements Plugin { security?.setIsElasticCloudDeployment(); } + if (isCloudEnabled && this.config.id) { + // We use the Cloud ID as the userId in the Cloud Experiments + cloudExperiments?.identifyUser(createSHA256Hash(this.config.id), { + kibanaVersion: this.context.env.packageInfo.version, + }); + } + if (this.config.full_story.enabled) { registerFullstoryRoute({ httpResources: core.http.resources, diff --git a/x-pack/plugins/cloud/tsconfig.json b/x-pack/plugins/cloud/tsconfig.json index e743b46ac17eb..d8c8a5c8eca44 100644 --- a/x-pack/plugins/cloud/tsconfig.json +++ b/x-pack/plugins/cloud/tsconfig.json @@ -17,6 +17,7 @@ { "path": "../../../src/core/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../cloud_integrations/cloud_experiments/tsconfig.json" }, { "path": "../security/tsconfig.json" }, ] } diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx b/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx new file mode 100755 index 0000000000000..96f0a3ed4ca50 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx @@ -0,0 +1,179 @@ +--- +id: kibCloudExperimentsPlugin +slug: /kibana-dev-docs/key-concepts/cloud-experiments-plugin +title: Cloud Experiments service +description: 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. +date: 2022-09-07 +tags: ['kibana', 'dev', 'contributor', 'api docs', 'cloud', 'a/b testing', 'experiments'] +--- + +# Kibana Cloud Experiments Service + +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. + +The `cloudExperiments` plugin is disabled by default and only enabled on Elastic Cloud deployments. + +## Public API + +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): + +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 +{ + "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); + } + + 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 a hash of the 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 new file mode 100644 index 0000000000000..8ff277b4abe59 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.test.ts @@ -0,0 +1,25 @@ +/* + * 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 new file mode 100644 index 0000000000000..0cbfeb509e8b3 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts @@ -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. + */ + +/** + * 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', +} + +/** + * 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 new file mode 100755 index 0000000000000..1078d980d4b82 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + CloudExperimentsMetric, + CloudExperimentsMetricNames, + CloudExperimentsPluginStart, + CloudExperimentsPluginSetup, + CloudExperimentsFeatureFlagNames, +} from './types'; diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/mocks.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/mocks.ts new file mode 100644 index 0000000000000..ebfa14a074dec --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/mocks.ts @@ -0,0 +1,26 @@ +/* + * 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 { CloudExperimentsPluginSetup, CloudExperimentsPluginStart } from './types'; + +function createStartMock(): jest.Mocked { + return { + getVariation: jest.fn(), + reportMetric: jest.fn(), + }; +} + +function createSetupMock(): jest.Mocked { + return { + identifyUser: jest.fn(), + }; +} + +export const cloudExperimentsMock = { + createSetupMock, + 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 new file mode 100755 index 0000000000000..225f39a11f0c1 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/types.ts @@ -0,0 +1,92 @@ +/* + * 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 contract of the setup lifecycle method. + * + * @public + */ +export interface CloudExperimentsPluginSetup { + /** + * Identifies the user in the A/B testing service. + * For now, we only rely on the user ID. In the future, we may request further details for more targeted experiments. + * @param userId The unique identifier of the user in the experiment. + * @param userMetadata Additional attributes to the user. Take care to ensure these values do not contain PII. + * + * @deprecated This API will become internal as soon as we reduce the dependency graph of the `cloud` plugin, + * and this plugin depends on it to fetch the data. + */ + identifyUser: ( + userId: string, + userMetadata?: Record> + ) => void; +} + +/** + * 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 + */ +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 + */ + 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 + */ + 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/jest.config.js b/x-pack/plugins/cloud_integrations/cloud_experiments/jest.config.js new file mode 100644 index 0000000000000..a15dd88e1cb41 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/jest.config.js @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../', + roots: ['/x-pack/plugins/cloud_integrations/cloud_experiments'], + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/plugins/cloud_integrations/cloud_experiments', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/cloud_integrations/cloud_experiments/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.json b/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.json new file mode 100755 index 0000000000000..412b16810c8bd --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.json @@ -0,0 +1,15 @@ +{ + "id": "cloudExperiments", + "version": "1.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Kibana Core", + "githubTeam": "@elastic/kibana-core" + }, + "description": "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.", + "server": true, + "ui": true, + "configPath": ["xpack", "cloud_integrations", "experiments"], + "requiredPlugins": [], + "optionalPlugins": ["usageCollection"] +} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/index.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/index.ts new file mode 100755 index 0000000000000..b6704f09e5773 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/public/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PluginInitializerContext } from '@kbn/core/public'; +import { CloudExperimentsPlugin } from './plugin'; + +export function plugin(core: PluginInitializerContext) { + return new CloudExperimentsPlugin(core); +} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.test.mock.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.test.mock.ts new file mode 100644 index 0000000000000..d2bfb5b54213d --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.test.mock.ts @@ -0,0 +1,24 @@ +/* + * 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 { + waitForInitialization: jest.fn(), + variation: jest.fn(), + track: jest.fn(), + identify: 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-js-client-sdk', () => ({ + initialize: () => ldClientMock, +})); 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 new file mode 100644 index 0000000000000..63dd98991d6c6 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.test.ts @@ -0,0 +1,274 @@ +/* + * 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 } from './plugin.test.mock'; +import { CloudExperimentsPlugin } from './plugin'; +import { FEATURE_FLAG_NAMES } from '../common/constants'; + +describe('Cloud Experiments public plugin', () => { + jest.spyOn(console, 'debug').mockImplementation(); // silence console.debug logs + + beforeEach(() => { + jest.resetAllMocks(); + }); + + 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 + 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', () => { + const initializerContext = coreMock.createPluginInitializerContext(); + // @ts-expect-error it's defined as readonly but the mock is not. + initializerContext.env.mode.dev = false; + 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' }); + }); + }); + + describe('setup', () => { + let plugin: CloudExperimentsPlugin; + + beforeEach(() => { + const initializerContext = coreMock.createPluginInitializerContext({ + launch_darkly: { client_id: '1234' }, + flag_overrides: { my_flag: '1234' }, + }); + plugin = new CloudExperimentsPlugin(initializerContext); + }); + + test('returns the contract', () => { + const setupContract = plugin.setup(coreMock.createSetup()); + expect(setupContract).toStrictEqual( + expect.objectContaining({ + identifyUser: expect.any(Function), + }) + ); + }); + + 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' }, + }); + const customPlugin = new CloudExperimentsPlugin(initializerContext); + const setupContract = customPlugin.setup(coreMock.createSetup()); + expect(customPlugin).toHaveProperty('launchDarklyClient', undefined); + setupContract.identifyUser('user-id', {}); + expect(customPlugin).toHaveProperty('launchDarklyClient', undefined); + }); + + test('it initializes the LaunchDarkly client', () => { + const setupContract = plugin.setup(coreMock.createSetup()); + expect(plugin).toHaveProperty('launchDarklyClient', undefined); + setupContract.identifyUser('user-id', {}); + expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock); + expect(ldClientMock.identify).not.toHaveBeenCalled(); + }); + + test('it calls identify if the client already exists', () => { + const setupContract = plugin.setup(coreMock.createSetup()); + expect(plugin).toHaveProperty('launchDarklyClient', undefined); + setupContract.identifyUser('user-id', {}); + expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock); + expect(ldClientMock.identify).not.toHaveBeenCalled(); + ldClientMock.identify.mockResolvedValue({}); // ensure it's a promise + setupContract.identifyUser('user-id', {}); + expect(ldClientMock.identify).toHaveBeenCalledTimes(1); + }); + + test('it handles identify rejections', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const setupContract = plugin.setup(coreMock.createSetup()); + expect(plugin).toHaveProperty('launchDarklyClient', undefined); + setupContract.identifyUser('user-id', {}); + expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock); + expect(ldClientMock.identify).not.toHaveBeenCalled(); + const error = new Error('Something went terribly wrong'); + ldClientMock.identify.mockRejectedValue(error); + setupContract.identifyUser('user-id', {}); + expect(ldClientMock.identify).toHaveBeenCalledTimes(1); + await new Promise((resolve) => process.nextTick(resolve)); + expect(consoleWarnSpy).toHaveBeenCalledWith(error); + }); + }); + }); + + describe('start', () => { + let plugin: CloudExperimentsPlugin; + + 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); + }); + + test('returns the contract', () => { + plugin.setup(coreMock.createSetup()); + const startContract = plugin.start(coreMock.createStart()); + expect(startContract).toStrictEqual( + expect.objectContaining({ + getVariation: expect.any(Function), + reportMetric: expect.any(Function), + }) + ); + }); + + describe('getVariation', () => { + describe('with the user identified', () => { + beforeEach(() => { + const setupContract = plugin.setup(coreMock.createSetup()); + setupContract.identifyUser('user-id', {}); + }); + + test('uses the flag overrides to respond early', async () => { + const startContract = plugin.start(coreMock.createStart()); + await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual( + '1234' + ); + }); + + test('calls the client', async () => { + const startContract = plugin.start(coreMock.createStart()); + ldClientMock.variation.mockReturnValue('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(ldClientMock.variation).toHaveBeenCalledWith( + undefined, // it couldn't find it in FEATURE_FLAG_NAMES + 123 + ); + }); + }); + + describe('with the user not identified', () => { + beforeEach(() => { + plugin.setup(coreMock.createSetup()); + }); + + test('uses the flag overrides to respond early', async () => { + const startContract = plugin.start(coreMock.createStart()); + 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()); + await expect( + startContract.getVariation( + // @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES + 'some-random-flag', + 123 + ) + ).resolves.toStrictEqual(123); + expect(ldClientMock.variation).not.toHaveBeenCalled(); + }); + }); + }); + + describe('reportMetric', () => { + describe('with the user identified', () => { + beforeEach(() => { + const setupContract = plugin.setup(coreMock.createSetup()); + setupContract.identifyUser('user-id', {}); + }); + + test('calls the track API', () => { + const startContract = plugin.start(coreMock.createStart()); + startContract.reportMetric({ + // @ts-expect-error We only allow existing flags in METRIC_NAMES + name: 'my-flag', + meta: {}, + value: 1, + }); + expect(ldClientMock.track).toHaveBeenCalledWith( + undefined, // it couldn't find it in METRIC_NAMES + {}, + 1 + ); + }); + }); + + describe('with the user not identified', () => { + beforeEach(() => { + plugin.setup(coreMock.createSetup()); + }); + + test('calls the track API', () => { + const startContract = plugin.start(coreMock.createStart()); + startContract.reportMetric({ + // @ts-expect-error We only allow existing flags in METRIC_NAMES + name: 'my-flag', + meta: {}, + value: 1, + }); + expect(ldClientMock.track).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('stop', () => { + let plugin: CloudExperimentsPlugin; + + beforeEach(() => { + const initializerContext = coreMock.createPluginInitializerContext({ + launch_darkly: { client_id: '1234' }, + flag_overrides: { my_flag: '1234' }, + }); + plugin = new CloudExperimentsPlugin(initializerContext); + const setupContract = plugin.setup(coreMock.createSetup()); + setupContract.identifyUser('user-id', {}); + plugin.start(coreMock.createStart()); + }); + + test('flushes the events on stop', () => { + ldClientMock.flush.mockResolvedValue(); + expect(() => plugin.stop()).not.toThrow(); + expect(ldClientMock.flush).toHaveBeenCalledTimes(1); + }); + + test('handles errors when flushing events', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const error = new Error('Something went terribly wrong'); + ldClientMock.flush.mockRejectedValue(error); + expect(() => plugin.stop()).not.toThrow(); + expect(ldClientMock.flush).toHaveBeenCalledTimes(1); + await new Promise((resolve) => process.nextTick(resolve)); + expect(consoleWarnSpy).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts new file mode 100755 index 0000000000000..6206aadc24c31 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts @@ -0,0 +1,127 @@ +/* + * 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import LaunchDarkly, { type LDClient } from 'launchdarkly-js-client-sdk'; +import { get, has } from 'lodash'; +import type { + CloudExperimentsFeatureFlagNames, + CloudExperimentsMetric, + CloudExperimentsPluginSetup, + CloudExperimentsPluginStart, +} from '../common'; +import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants'; + +/** + * Browser-side implementation of the Cloud Experiments plugin + */ +export class CloudExperimentsPlugin + implements Plugin +{ + private launchDarklyClient?: LDClient; + private readonly clientId?: string; + private readonly kibanaVersion: string; + private readonly flagOverrides?: Record; + private readonly isDev: boolean; + + /** Constructor of the plugin **/ + constructor(initializerContext: PluginInitializerContext) { + this.isDev = initializerContext.env.mode.dev; + this.kibanaVersion = initializerContext.env.packageInfo.version; + const config = initializerContext.config.get<{ + launch_darkly?: { client_id: string }; + flag_overrides?: Record; + }>(); + if (config.flag_overrides) { + this.flagOverrides = config.flag_overrides; + } + const ldConfig = config.launch_darkly; + if (!ldConfig && !initializerContext.env.mode.dev) { + // If the plugin is enabled, and it's in prod mode, launch_darkly must exist + // (config-schema should enforce it, but just in case). + throw new Error( + 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' + ); + } + if (ldConfig) { + this.clientId = ldConfig.client_id; + } + } + + /** + * Returns the contract {@link CloudExperimentsPluginSetup} + * @param core {@link CoreSetup} + */ + public setup(core: CoreSetup): CloudExperimentsPluginSetup { + return { + identifyUser: (userId, userMetadata) => { + if (!this.clientId) return; // Only applies in dev mode. + + if (!this.launchDarklyClient) { + // If the client has not been initialized, create it with the user data.. + this.launchDarklyClient = LaunchDarkly.initialize( + this.clientId, + { key: userId, custom: userMetadata }, + { application: { id: 'kibana-browser', version: this.kibanaVersion } } + ); + } else { + // Otherwise, call the `identify` method. + this.launchDarklyClient + .identify({ key: userId, custom: userMetadata }) + // eslint-disable-next-line no-console + .catch((err) => console.warn(err)); + } + }, + }; + } + + /** + * Returns the contract {@link CloudExperimentsPluginStart} + * @param core {@link CoreStart} + */ + public start(core: CoreStart): CloudExperimentsPluginStart { + return { + getVariation: this.getVariation, + reportMetric: this.reportMetric, + }; + } + + /** + * Cleans up and flush the sending queues. + */ + public stop() { + this.launchDarklyClient + ?.flush() + // eslint-disable-next-line no-console + .catch((err) => console.warn(err)); + } + + 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; // Skip any action if no LD User is defined + await this.launchDarklyClient.waitForInitialization(); + return this.launchDarklyClient.variation(configKey, defaultValue); + }; + + private reportMetric = ({ name, meta, value }: CloudExperimentsMetric): void => { + const metricName = METRIC_NAMES[name]; + this.launchDarklyClient?.track(metricName, meta, value); + if (this.isDev) { + // eslint-disable-next-line no-console + console.debug(`Reported experimentation metric ${metricName}`, { + experimentationMetric: { name, meta, value }, + }); + } + }; +} 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 new file mode 100644 index 0000000000000..2e762ece1d8fe --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.test.ts @@ -0,0 +1,133 @@ +/* + * 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 { config } from './config'; + +describe('cloudExperiments config', () => { + describe.each([true, false])('when disabled (dev: %p)', (dev) => { + const ctx = { dev }; + test('should default to `enabled:false` and the rest empty', () => { + expect(config.schema.validate({}, ctx)).toStrictEqual({ enabled: false }); + }); + + test('it should allow any additional config', () => { + const cfg = { + enabled: false, + launch_darkly: { + sdk_key: 'sdk-1234', + client_id: '1234', + client_log_level: 'none', + }, + flag_overrides: { + 'my-plugin.my-feature-flag': 1234, + }, + }; + expect(config.schema.validate(cfg, ctx)).toStrictEqual(cfg); + }); + + 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); + }); + + test('it should allow any additional config (missing launch_darkly)', () => { + const cfg = { + enabled: false, + flag_overrides: { + 'my-plugin.my-feature-flag': 1234, + }, + }; + expect(config.schema.validate(cfg, ctx)).toStrictEqual(cfg); + }); + }); + + describe('when enabled', () => { + 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({ + enabled: true, + flag_overrides: { my_flag: 1 }, + }); + }); + + test('in dev mode, it allows `launch_darkly` and `flag_overrides` to be empty', () => { + expect(config.schema.validate({ enabled: true }, ctx)).toStrictEqual({ enabled: true }); + }); + }); + + describe('in prod (non-dev mode)', () => { + const ctx = { dev: false }; + test('it enforces `launch_darkly` config if not in dev-mode', () => { + expect(() => + config.schema.validate({ enabled: true }, ctx) + ).toThrowErrorMatchingInlineSnapshot( + `"[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', + }, + }); + }); + + 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, + }, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.ts new file mode 100644 index 0000000000000..79b49bcb77509 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.ts @@ -0,0 +1,52 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from '@kbn/core/server'; + +const launchDarklySchema = schema.object({ + sdk_key: schema.string({ minLength: 1 }), + client_id: schema.string({ minLength: 1 }), + client_log_level: schema.oneOf( + [ + schema.literal('none'), + schema.literal('error'), + schema.literal('warn'), + schema.literal('info'), + schema.literal('debug'), + ], + { defaultValue: 'none' } + ), +}); + +const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), + launch_darkly: schema.conditional( + schema.siblingRef('enabled'), + true, + schema.conditional( + schema.contextRef('dev'), + schema.literal(true), // this is still optional when running on dev because devs might use the `flag_overrides` + schema.maybe(launchDarklySchema), + launchDarklySchema + ), + schema.maybe(launchDarklySchema) + ), + flag_overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())), +}); + +export type CloudExperimentsConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + launch_darkly: { + client_id: true, + }, + flag_overrides: true, + }, + schema: configSchema, +}; diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/index.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/index.ts new file mode 100755 index 0000000000000..6222c8108c0f5 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { PluginInitializerContext } from '@kbn/core/server'; +import { CloudExperimentsPlugin } from './plugin'; + +export { config } from './config'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new CloudExperimentsPlugin(initializerContext); +} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.test.mock.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.test.mock.ts new file mode 100644 index 0000000000000..b76e629458e00 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.test.mock.ts @@ -0,0 +1,26 @@ +/* + * 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(), + identify: 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/plugin.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.test.ts new file mode 100644 index 0000000000000..1369f80779d7f --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.test.ts @@ -0,0 +1,250 @@ +/* + * 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/server/mocks'; +import { ldClientMock } from './plugin.test.mock'; +import { CloudExperimentsPlugin } from './plugin'; +import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks'; +import { FEATURE_FLAG_NAMES } from '../common/constants'; + +describe('Cloud Experiments server plugin', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('constructor', () => { + test('successfully creates a new 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', () => { + const initializerContext = coreMock.createPluginInitializerContext(); + initializerContext.env.mode.dev = false; + expect(() => new CloudExperimentsPlugin(initializerContext)).toThrowError( + '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' }, + }); + ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock); + const plugin = new CloudExperimentsPlugin(initializerContext); + expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock); + }); + + 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', () => { + let plugin: CloudExperimentsPlugin; + + beforeEach(() => { + const initializerContext = coreMock.createPluginInitializerContext({ + launch_darkly: { sdk_key: 'sdk-1234' }, + flag_overrides: { my_flag: '1234' }, + }); + ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock); + plugin = new CloudExperimentsPlugin(initializerContext); + }); + + test('returns the contract', () => { + const setupContract = plugin.setup(coreMock.createSetup(), {}); + expect(setupContract).toStrictEqual( + expect.objectContaining({ + identifyUser: expect.any(Function), + }) + ); + }); + + test('registers the usage collector when available', () => { + const usageCollection = usageCollectionPluginMock.createSetupContract(); + plugin.setup(coreMock.createSetup(), { usageCollection }); + expect(usageCollection.makeUsageCollector).toHaveBeenCalledTimes(1); + expect(usageCollection.registerCollector).toHaveBeenCalledTimes(1); + }); + + describe('identifyUser', () => { + test('sets launchDarklyUser and calls identify', () => { + expect(plugin).toHaveProperty('launchDarklyUser', undefined); + const setupContract = plugin.setup(coreMock.createSetup(), {}); + setupContract.identifyUser('user-id', {}); + const ldUser = { key: 'user-id', custom: {} }; + expect(plugin).toHaveProperty('launchDarklyUser', ldUser); + expect(ldClientMock.identify).toHaveBeenCalledWith(ldUser); + }); + }); + }); + + describe('start', () => { + let plugin: CloudExperimentsPlugin; + + const firstKnownFlag = Object.keys(FEATURE_FLAG_NAMES)[0] as keyof typeof FEATURE_FLAG_NAMES; + + beforeEach(() => { + const initializerContext = coreMock.createPluginInitializerContext({ + launch_darkly: { sdk_key: 'sdk-1234' }, + flag_overrides: { [firstKnownFlag]: '1234' }, + }); + ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock); + plugin = new CloudExperimentsPlugin(initializerContext); + }); + + test('returns the contract', () => { + plugin.setup(coreMock.createSetup(), {}); + const startContract = plugin.start(coreMock.createStart()); + expect(startContract).toStrictEqual( + expect.objectContaining({ + getVariation: expect.any(Function), + reportMetric: expect.any(Function), + }) + ); + }); + + describe('getVariation', () => { + describe('with the user identified', () => { + beforeEach(() => { + const setupContract = plugin.setup(coreMock.createSetup(), {}); + setupContract.identifyUser('user-id', {}); + }); + + test('uses the flag overrides to respond early', async () => { + const startContract = plugin.start(coreMock.createStart()); + await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual( + '1234' + ); + }); + + test('calls the client', async () => { + const startContract = plugin.start(coreMock.createStart()); + ldClientMock.variation.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(ldClientMock.variation).toHaveBeenCalledWith( + undefined, // it couldn't find it in FEATURE_FLAG_NAMES + { key: 'user-id', custom: {} }, + 123 + ); + }); + }); + + describe('with the user not identified', () => { + beforeEach(() => { + plugin.setup(coreMock.createSetup(), {}); + }); + + test('uses the flag overrides to respond early', async () => { + const startContract = plugin.start(coreMock.createStart()); + 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()); + await expect( + startContract.getVariation( + // @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES + 'some-random-flag', + 123 + ) + ).resolves.toStrictEqual(123); + expect(ldClientMock.variation).not.toHaveBeenCalled(); + }); + }); + }); + + describe('reportMetric', () => { + describe('with the user identified', () => { + beforeEach(() => { + const setupContract = plugin.setup(coreMock.createSetup(), {}); + setupContract.identifyUser('user-id', {}); + }); + + test('calls the track API', () => { + const startContract = plugin.start(coreMock.createStart()); + startContract.reportMetric({ + // @ts-expect-error We only allow existing flags in METRIC_NAMES + name: 'my-flag', + meta: {}, + value: 1, + }); + expect(ldClientMock.track).toHaveBeenCalledWith( + undefined, // it couldn't find it in METRIC_NAMES + { key: 'user-id', custom: {} }, + {}, + 1 + ); + }); + }); + + describe('with the user not identified', () => { + beforeEach(() => { + plugin.setup(coreMock.createSetup(), {}); + }); + + test('calls the track API', () => { + const startContract = plugin.start(coreMock.createStart()); + startContract.reportMetric({ + // @ts-expect-error We only allow existing flags in METRIC_NAMES + name: 'my-flag', + meta: {}, + value: 1, + }); + expect(ldClientMock.track).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('stop', () => { + let plugin: CloudExperimentsPlugin; + + beforeEach(() => { + const initializerContext = coreMock.createPluginInitializerContext({ + launch_darkly: { sdk_key: 'sdk-1234' }, + flag_overrides: { my_flag: '1234' }, + }); + ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock); + plugin = new CloudExperimentsPlugin(initializerContext); + plugin.setup(coreMock.createSetup(), {}); + plugin.start(coreMock.createStart()); + }); + + test('flushes the events', () => { + ldClientMock.flush.mockResolvedValue(); + expect(() => plugin.stop()).not.toThrow(); + expect(ldClientMock.flush).toHaveBeenCalledTimes(1); + }); + + test('handles errors when flushing events', () => { + ldClientMock.flush.mockRejectedValue(new Error('Something went terribly wrong')); + expect(() => plugin.stop()).not.toThrow(); + expect(ldClientMock.flush).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts new file mode 100755 index 0000000000000..2ffc0d0a464f5 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts @@ -0,0 +1,131 @@ +/* + * 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 { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '@kbn/core/server'; +import { get, has } from 'lodash'; +import LaunchDarkly, { type LDClient, type LDUser } from 'launchdarkly-node-server-sdk'; +import type { LogMeta } from '@kbn/logging'; +import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import { registerUsageCollector } from './usage'; +import type { CloudExperimentsConfigType } from './config'; +import type { + CloudExperimentsFeatureFlagNames, + CloudExperimentsMetric, + CloudExperimentsPluginSetup, + CloudExperimentsPluginStart, +} from '../common'; +import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants'; + +interface CloudExperimentsPluginSetupDeps { + usageCollection?: UsageCollectionSetup; +} + +export class CloudExperimentsPlugin + implements + Plugin< + CloudExperimentsPluginSetup, + CloudExperimentsPluginStart, + CloudExperimentsPluginSetupDeps + > +{ + private readonly logger: Logger; + private readonly launchDarklyClient?: LDClient; + private readonly flagOverrides?: Record; + private launchDarklyUser: LDUser | undefined; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + const config = initializerContext.config.get(); + 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 + // (config-schema should enforce it, but just in case). + throw new Error( + 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' + ); + } + if (ldConfig) { + this.launchDarklyClient = LaunchDarkly.init(ldConfig.sdk_key, { + application: { id: `kibana-server`, version: initializerContext.env.packageInfo.version }, + logger: LaunchDarkly.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 setup( + core: CoreSetup, + deps: CloudExperimentsPluginSetupDeps + ): CloudExperimentsPluginSetup { + if (deps.usageCollection) { + registerUsageCollector(deps.usageCollection, () => ({ + launchDarklyClient: this.launchDarklyClient, + launchDarklyUser: this.launchDarklyUser, + })); + } + + return { + identifyUser: (userId, userMetadata) => { + this.launchDarklyUser = { key: userId, custom: userMetadata }; + this.launchDarklyClient?.identify(this.launchDarklyUser!); + }, + }; + } + + public start(core: CoreStart) { + return { + getVariation: this.getVariation, + reportMetric: this.reportMetric, + }; + } + + public stop() { + this.launchDarklyClient?.flush().catch((err) => this.logger.error(err)); + } + + 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.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); + }; + + private reportMetric = ({ name, meta, value }: CloudExperimentsMetric): void => { + const metricName = METRIC_NAMES[name]; + if (!this.launchDarklyUser) return; // Skip any action if no LD User is defined + this.launchDarklyClient?.track(metricName, this.launchDarklyUser, meta, value); + this.logger.debug<{ experimentationMetric: CloudExperimentsMetric } & LogMeta>( + `Reported experimentation metric ${metricName}`, + { + experimentationMetric: { name, meta, value }, + } + ); + }; +} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/index.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/index.ts new file mode 100644 index 0000000000000..59f577ba20b70 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { registerUsageCollector } from './register_usage_collector'; 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 new file mode 100644 index 0000000000000..176bbad4f6702 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { + usageCollectionPluginMock, + createCollectorFetchContextMock, +} from '@kbn/usage-collection-plugin/server/mocks'; +import type { Collector } from '@kbn/usage-collection-plugin/server/mocks'; +import { + registerUsageCollector, + type LaunchDarklyEntitiesGetter, + type Usage, +} from './register_usage_collector'; +import { createLaunchDarklyClientMock } from '../plugin.test.mock'; + +describe('cloudExperiments usage collector', () => { + let collector: Collector; + const getLaunchDarklyEntitiesMock: jest.MockedFunction = jest + .fn() + .mockImplementation(() => ({})); + + beforeEach(() => { + const usageCollectionSetupMock = usageCollectionPluginMock.createSetupContract(); + registerUsageCollector(usageCollectionSetupMock, getLaunchDarklyEntitiesMock); + collector = usageCollectionSetupMock.registerCollector.mock + .calls[0][0] as unknown as Collector; + }); + + test('isReady should always be true', () => { + expect(collector.isReady()).toStrictEqual(true); + }); + + test('should return initialized false and empty values when the user and the client are not initialized', async () => { + await expect(collector.fetch(createCollectorFetchContextMock())).resolves.toStrictEqual({ + flagNames: [], + flags: {}, + initialized: false, + }); + }); + + test('should return initialized false and empty values when the user is not initialized', async () => { + getLaunchDarklyEntitiesMock.mockReturnValueOnce({ + launchDarklyClient: createLaunchDarklyClientMock(), + }); + await expect(collector.fetch(createCollectorFetchContextMock())).resolves.toStrictEqual({ + flagNames: [], + flags: {}, + initialized: false, + }); + }); + + test('should return initialized false and empty values when the client is not initialized', async () => { + getLaunchDarklyEntitiesMock.mockReturnValueOnce({ launchDarklyUser: { key: 'test' } }); + await expect(collector.fetch(createCollectorFetchContextMock())).resolves.toStrictEqual({ + flagNames: [], + flags: {}, + initialized: false, + }); + }); + + test('should return all the flags returned by the client', async () => { + const launchDarklyClient = createLaunchDarklyClientMock(); + getLaunchDarklyEntitiesMock.mockReturnValueOnce({ + launchDarklyClient, + launchDarklyUser: { key: 'test' }, + }); + + launchDarklyClient.allFlagsState.mockResolvedValueOnce({ + valid: true, + getFlagValue: jest.fn(), + getFlagReason: jest.fn(), + toJSON: jest.fn(), + allValues: jest.fn().mockReturnValueOnce({ + 'my-plugin.my-feature-flag': true, + 'my-plugin.my-other-feature-flag': 22, + }), + }); + + await expect(collector.fetch(createCollectorFetchContextMock())).resolves.toStrictEqual({ + flagNames: ['my-plugin.my-feature-flag', 'my-plugin.my-other-feature-flag'], + flags: { + 'my-plugin.my-feature-flag': true, + 'my-plugin.my-other-feature-flag': 22, + }, + initialized: true, + }); + }); +}); 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 new file mode 100644 index 0000000000000..c9390915fd8c2 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.ts @@ -0,0 +1,70 @@ +/* + * 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, LDUser } from 'launchdarkly-node-server-sdk'; +import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; + +export interface Usage { + initialized: boolean; + flags: Record; + flagNames: string[]; +} + +export type LaunchDarklyEntitiesGetter = () => { + launchDarklyUser?: LDUser; + launchDarklyClient?: LDClient; +}; + +export function registerUsageCollector( + usageCollection: UsageCollectionSetup, + getLaunchDarklyEntities: LaunchDarklyEntitiesGetter +) { + usageCollection.registerCollector( + usageCollection.makeUsageCollector({ + type: 'cloudExperiments', + isReady: () => true, + schema: { + initialized: { + type: 'boolean', + _meta: { + description: + 'Whether the A/B testing client is correctly initialized (identify has been called)', + }, + }, + // We'll likely map "flags" as `flattened`, so "flagNames" helps out to discover the key names + flags: { + DYNAMIC_KEY: { + type: 'keyword', + _meta: { description: 'Flags received by the client' }, + }, + }, + flagNames: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'Names of the flags received by the client', + }, + }, + }, + }, + fetch: async () => { + const { launchDarklyUser, launchDarklyClient } = getLaunchDarklyEntities(); + if (!launchDarklyUser || !launchDarklyClient) + 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 launchDarklyClient.allFlagsState(launchDarklyUser); + 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 new file mode 100644 index 0000000000000..de8c2e7bb26a7 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + }, + "include": [ + ".storybook/**/*", + "common/**/*", + "public/**/*", + "server/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../../src/core/tsconfig.json" }, + { "path": "../../../../src/plugins/usage_collection/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index 623427df5c485..df7c53e0bb320 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -37,6 +37,7 @@ export const RULE_FAILED = `failed`; // activated via a simple code change in a single location. export const INTERNAL_FEATURE_FLAGS = { showManageRulesMock: false, + showFindingFlyoutEvidence: false, showFindingsGroupBy: true, } as const; diff --git a/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts b/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts index 43856c4e5b1ba..6d5dcd454a1c0 100644 --- a/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts +++ b/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Truthy } from 'lodash'; import { CSP_RULE_SAVED_OBJECT_TYPE } from '../constants'; /** @@ -15,6 +16,8 @@ import { CSP_RULE_SAVED_OBJECT_TYPE } from '../constants'; export const isNonNullable = (v: T): v is NonNullable => v !== null && v !== undefined; +export const truthy = (value: T): value is Truthy => !!value; + export const extractErrorMessage = (e: unknown, defaultMessage = 'Unknown Error'): string => { if (e instanceof Error) return e.message; if (typeof e === 'string') return e; diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/custom_assets_extension.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/custom_assets_extension.tsx index 584a859baec04..d281cced59cab 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/custom_assets_extension.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/custom_assets_extension.tsx @@ -37,7 +37,7 @@ export const CspCustomAssetsExtension = () => { name: cloudPosturePages.rules.name, url: application.getUrlForApp(SECURITY_APP_NAME, { path: cloudPosturePages.benchmarks.path }), description: i18n.translate('xpack.csp.createPackagePolicy.customAssetsTab.rulesViewLabel', { - defaultMessage: 'Manage CSP Rules ', + defaultMessage: 'View CSP Rules ', }), }, ]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx index 5fdd845359cfb..7797a9a55ef8c 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx @@ -15,7 +15,6 @@ import { import React from 'react'; import { Link, useHistory, generatePath } from 'react-router-dom'; import { pagePathGetters } from '@kbn/fleet-plugin/public'; -import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { TimestampTableCell } from '../../components/timestamp_table_cell'; import type { Benchmark } from '../../../common/types'; @@ -74,18 +73,11 @@ const BENCHMARKS_TABLE_COLUMNS: Array> = [ 'data-test-subj': TEST_SUBJ.BENCHMARKS_TABLE_COLUMNS.INTEGRATION, }, { - field: 'rules', + field: 'rules.enabled', name: i18n.translate('xpack.csp.benchmarks.benchmarksTable.activeRulesColumnTitle', { defaultMessage: 'Active Rules', }), truncateText: true, - render: ({ enabled, all }: Benchmark['rules']) => ( - - ), 'data-test-subj': TEST_SUBJ.BENCHMARKS_TABLE_COLUMNS.ACTIVE_RULES, }, { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/overview_tab.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/overview_tab.tsx index 5b5969a3424a2..5dba83b9019b8 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/overview_tab.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/overview_tab.tsx @@ -18,8 +18,12 @@ import React, { useMemo } from 'react'; import moment from 'moment'; import type { EuiDescriptionListProps, EuiAccordionProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { truthy } from '../../../../common/utils/helpers'; import { CSP_MOMENT_FORMAT } from '../../../common/constants'; -import { LATEST_FINDINGS_INDEX_DEFAULT_NS } from '../../../../common/constants'; +import { + INTERNAL_FEATURE_FLAGS, + LATEST_FINDINGS_INDEX_DEFAULT_NS, +} from '../../../../common/constants'; import { useLatestFindingsDataView } from '../../../common/api/use_latest_findings_data_view'; import { useKibana } from '../../../common/hooks/use_kibana'; import { CspFinding } from '../../../../common/schemas/csp_finding'; @@ -53,6 +57,12 @@ const getDetailsList = (data: CspFinding, discoverIndexLink: string | undefined) }), description: moment(data['@timestamp']).format(CSP_MOMENT_FORMAT), }, + { + title: i18n.translate('xpack.csp.findings.findingsFlyout.overviewTab.resourceIdTitle', { + defaultMessage: 'Resource ID', + }), + description: data.resource.id, + }, { title: i18n.translate('xpack.csp.findings.findingsFlyout.overviewTab.resourceNameTitle', { defaultMessage: 'Resource Name', @@ -135,7 +145,7 @@ const getEvidenceList = ({ result }: CspFinding) => }), description: {JSON.stringify(result.evidence, null, 2)}, }, - ].filter(Boolean) as EuiDescriptionListProps['listItems']; + ].filter(truthy); export const OverviewTab = ({ data }: { data: CspFinding }) => { const { @@ -152,33 +162,34 @@ export const OverviewTab = ({ data }: { data: CspFinding }) => { ); const accordions: Accordion[] = useMemo( - () => [ - { - initialIsOpen: true, - title: i18n.translate('xpack.csp.findings.findingsFlyout.overviewTab.detailsTitle', { - defaultMessage: 'Details', - }), - id: 'detailsAccordion', - listItems: getDetailsList(data, discoverIndexLink), - }, - { - initialIsOpen: true, - title: i18n.translate('xpack.csp.findings.findingsFlyout.overviewTab.remediationTitle', { - defaultMessage: 'Remediation', - }), - id: 'remediationAccordion', - listItems: getRemediationList(data.rule), - }, - { - initialIsOpen: false, - title: i18n.translate( - 'xpack.csp.findings.findingsFlyout.overviewTab.evidenceSourcesTitle', - { defaultMessage: 'Evidence' } - ), - id: 'evidenceAccordion', - listItems: getEvidenceList(data), - }, - ], + () => + [ + { + initialIsOpen: true, + title: i18n.translate('xpack.csp.findings.findingsFlyout.overviewTab.detailsTitle', { + defaultMessage: 'Details', + }), + id: 'detailsAccordion', + listItems: getDetailsList(data, discoverIndexLink), + }, + { + initialIsOpen: true, + title: i18n.translate('xpack.csp.findings.findingsFlyout.overviewTab.remediationTitle', { + defaultMessage: 'Remediation', + }), + id: 'remediationAccordion', + listItems: getRemediationList(data.rule), + }, + INTERNAL_FEATURE_FLAGS.showFindingFlyoutEvidence && { + initialIsOpen: false, + title: i18n.translate( + 'xpack.csp.findings.findingsFlyout.overviewTab.evidenceSourcesTitle', + { defaultMessage: 'Evidence' } + ), + id: 'evidenceAccordion', + listItems: getEvidenceList(data), + }, + ].filter(truthy), [data, discoverIndexLink] ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx index 47c5128f919dc..040b383707a97 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx @@ -8,6 +8,7 @@ import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiBottomBar, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import type { Evaluation } from '../../../../common/types'; import { CloudPosturePageTitle } from '../../../components/cloud_posture_page_title'; import type { FindingsBaseProps } from '../types'; import { FindingsTable } from './latest_findings_table'; @@ -80,6 +81,19 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { [findingsGroupByNone.data?.total, urlQuery.pageIndex, urlQuery.pageSize] ); + const handleDistributionClick = (evaluation: Evaluation) => { + setUrlQuery({ + pageIndex: 0, + filters: getFilters({ + filters: urlQuery.filters, + dataView, + field: 'result.evaluation', + value: evaluation, + negate: false, + }), + }); + }; + return (
{ {findingsGroupByNone.isSuccess && !!findingsGroupByNone.data.page.length && ( { const error = findingsGroupByResource.error || baseEsQuery.error; + const handleDistributionClick = (evaluation: Evaluation) => { + setUrlQuery({ + pageIndex: 0, + filters: getFilters({ + filters: urlQuery.filters, + dataView, + field: 'result.evaluation', + value: evaluation, + negate: false, + }), + }); + }; + return (
{ {findingsGroupByResource.isSuccess && !!findingsGroupByResource.data.page.length && ( { const total = chance.integer() + count + 1; const normalized = count / total; - const [resourceName, resourceSubtype, ...cisSections] = chance.unique(chance.word, 4); + const [resourceName, resourceSubtype, ruleBenchmarkName, ...cisSections] = chance.unique( + chance.word, + 5 + ); return { cluster_id: chance.guid(), @@ -29,6 +32,7 @@ const getFakeFindingsByResource = (): FindingsByResourcePage => { 'resource.name': resourceName, 'resource.sub_type': resourceSubtype, 'rule.section': cisSections, + 'rule.benchmark.name': ruleBenchmarkName, failed_findings: { count, normalized, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx index 497fee1e0b002..9be73dbe4809a 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx @@ -62,6 +62,7 @@ const FindingsByResourceTableComponent = ({ findingsByResourceColumns.resource_id, createColumnWithFilters(findingsByResourceColumns['resource.sub_type'], { onAddFilter }), createColumnWithFilters(findingsByResourceColumns['resource.name'], { onAddFilter }), + createColumnWithFilters(findingsByResourceColumns['rule.benchmark.name'], { onAddFilter }), findingsByResourceColumns['rule.section'], createColumnWithFilters(findingsByResourceColumns.cluster_id, { onAddFilter }), findingsByResourceColumns.failed_findings, @@ -139,6 +140,16 @@ const baseColumns: Array> = /> ), }, + { + field: 'rule.benchmark.name', + truncateText: true, + name: ( + + ), + }, { field: 'rule.section', truncateText: true, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_container.tsx index 89344b13a4f8a..aa86a586faf7e 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_container.tsx @@ -10,6 +10,7 @@ import { Link, useParams } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; import { generatePath } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; +import type { Evaluation } from '../../../../../common/types'; import { CspFinding } from '../../../../../common/schemas/csp_finding'; import { CloudPosturePageTitle } from '../../../../components/cloud_posture_page_title'; import * as TEST_SUBJECTS from '../../test_subjects'; @@ -83,6 +84,19 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => { const error = resourceFindings.error || baseEsQuery.error; + const handleDistributionClick = (evaluation: Evaluation) => { + setUrlQuery({ + pageIndex: 0, + filters: getFilters({ + filters: urlQuery.filters, + dataView, + field: 'result.evaluation', + value: evaluation, + negate: false, + }), + }); + }; + return (
{ {resourceFindings.isSuccess && !!resourceFindings.data.page.length && ( ; subtype: estypes.AggregationsMultiBucketAggregateBase; cluster_id: estypes.AggregationsMultiBucketAggregateBase; + benchmarkName: estypes.AggregationsMultiBucketAggregateBase; cis_sections: estypes.AggregationsMultiBucketAggregateBase; } @@ -91,6 +93,9 @@ export const getFindingsByResourceAggQuery = ({ subtype: { terms: { field: 'resource.sub_type', size: 1 }, }, + benchmarkName: { + terms: { field: 'rule.benchmark.name' }, + }, cis_sections: { terms: { field: 'rule.section' }, }, @@ -172,10 +177,12 @@ export const useFindingsByResource = (options: UseFindingsByResourceOptions) => const createFindingsByResource = (resource: FindingsAggBucket): FindingsByResourcePage => { if ( + !Array.isArray(resource.benchmarkName.buckets) || !Array.isArray(resource.cis_sections.buckets) || !Array.isArray(resource.name.buckets) || !Array.isArray(resource.subtype.buckets) || !Array.isArray(resource.cluster_id.buckets) || + !resource.benchmarkName.buckets.length || !resource.cis_sections.buckets.length || !resource.name.buckets.length || !resource.subtype.buckets.length || @@ -189,6 +196,7 @@ const createFindingsByResource = (resource: FindingsAggBucket): FindingsByResour ['resource.sub_type']: resource.subtype.buckets[0]?.key, cluster_id: resource.cluster_id.buckets[0]?.key, ['rule.section']: resource.cis_sections.buckets.map((v) => v.key), + ['rule.benchmark.name']: resource.benchmarkName.buckets[0]?.key, failed_findings: { count: resource.failed_findings.doc_count, normalized: diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx index fd4b0351e9da0..f293b82341a61 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx @@ -18,11 +18,14 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import numeral from '@elastic/numeral'; +import { RULE_FAILED, RULE_PASSED } from '../../../../common/constants'; +import type { Evaluation } from '../../../../common/types'; interface Props { total: number; passed: number; failed: number; + distributionOnClick: (evaluation: Evaluation) => void; pageStart: number; pageEnd: number; type: string; @@ -102,7 +105,11 @@ const CurrentPageOfTotal = ({ ); -const DistributionBar: React.FC> = ({ passed, failed }) => { +const DistributionBar: React.FC> = ({ + passed, + failed, + distributionOnClick, +}) => { const { euiTheme } = useEuiTheme(); return ( @@ -113,14 +120,35 @@ const DistributionBar: React.FC> = ({ passe background: ${euiTheme.colors.subduedText}; `} > - - + { + distributionOnClick(RULE_PASSED); + }} + /> + { + distributionOnClick(RULE_FAILED); + }} + /> ); }; -const DistributionBarPart = ({ value, color }: { value: number; color: string }) => ( -
void; +}) => ( +