From bcc4ef4c5cd0885e1442b4a0935f3b1b38552c27 Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Wed, 15 Jul 2020 15:25:21 +0300 Subject: [PATCH] [Search] Add telemetry for data plugin search service (#70677) (#71826) * [search] Refactor the way search strategies are registered/retrieved on the server * Fix types and tests and update docs * Fix failing test * Fix build of example plugin * Fix functional test * Make server strategies sync * Move strategy name into options * docs * Remove FE strategies * TypeScript of hell delete search explorer * Fix search interceptor OSS tests * typos * test cleanup * Update search interceptor tests and abort utils * [Search] Add telemetry for data plugin search service * Add tracking of average query time * Add tests and rename to collectors * Fix TS * Fixed interceptor jest tests * Add to kibana json * docs * Properly use observables rather than only during setup * Update or create * Swallow version conflict errors Co-authored-by: Liza K Co-authored-by: Elastic Machine Co-authored-by: Lukas Olson Co-authored-by: Elastic Machine --- ...plugin-plugins-data-public.plugin.setup.md | 4 +- ...ugins-data-public.searchinterceptordeps.md | 1 + ...ic.searchinterceptordeps.usagecollector.md | 11 ++ ...plugin-plugins-data-server.isearchsetup.md | 3 +- ...-plugins-data-server.isearchsetup.usage.md | 13 +++ src/plugins/data/kibana.json | 1 + src/plugins/data/public/plugin.ts | 3 +- src/plugins/data/public/public.api.md | 14 ++- .../collectors/create_usage_collector.test.ts | 107 ++++++++++++++++++ .../collectors/create_usage_collector.ts | 92 +++++++++++++++ .../data/public/search/collectors/index.ts | 21 ++++ .../data/public/search/collectors/types.ts | 36 ++++++ .../data/public/search/search_interceptor.ts | 14 ++- .../data/public/search/search_service.ts | 14 ++- src/plugins/data/public/search/types.ts | 21 +++- src/plugins/data/public/types.ts | 2 + src/plugins/data/server/plugin.ts | 2 +- .../data/server/saved_objects/index.ts | 3 +- .../{kql_telementry.ts => kql_telemetry.ts} | 0 .../server/saved_objects/search_telemetry.ts | 29 +++++ .../data/server/search/collectors/fetch.ts | 45 ++++++++ .../data/server/search/collectors/register.ts | 49 ++++++++ .../data/server/search/collectors/routes.ts | 50 ++++++++ .../data/server/search/collectors/usage.ts | 77 +++++++++++++ .../data/server/search/search_service.test.ts | 2 +- .../data/server/search/search_service.ts | 20 +++- src/plugins/data/server/search/types.ts | 6 + src/plugins/data/server/server.api.md | 2 + x-pack/plugins/data_enhanced/public/plugin.ts | 1 + .../public/search/search_interceptor.test.ts | 32 ++++++ .../public/search/search_interceptor.ts | 10 +- 31 files changed, 668 insertions(+), 17 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.usagecollector.md create mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.usage.md create mode 100644 src/plugins/data/public/search/collectors/create_usage_collector.test.ts create mode 100644 src/plugins/data/public/search/collectors/create_usage_collector.ts create mode 100644 src/plugins/data/public/search/collectors/index.ts create mode 100644 src/plugins/data/public/search/collectors/types.ts rename src/plugins/data/server/saved_objects/{kql_telementry.ts => kql_telemetry.ts} (100%) create mode 100644 src/plugins/data/server/saved_objects/search_telemetry.ts create mode 100644 src/plugins/data/server/search/collectors/fetch.ts create mode 100644 src/plugins/data/server/search/collectors/register.ts create mode 100644 src/plugins/data/server/search/collectors/routes.ts create mode 100644 src/plugins/data/server/search/collectors/usage.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md index 51bc46bbdccc83..7bae595e75ad08 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md @@ -7,7 +7,7 @@ Signature: ```typescript -setup(core: CoreSetup, { expressions, uiActions }: DataSetupDependencies): DataPublicPluginSetup; +setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; ``` ## Parameters @@ -15,7 +15,7 @@ setup(core: CoreSetup, { expressions, uiActions }: DataSetupDependencies): DataP | Parameter | Type | Description | | --- | --- | --- | | core | CoreSetup | | -| { expressions, uiActions } | DataSetupDependencies | | +| { expressions, uiActions, usageCollection } | DataSetupDependencies | | Returns: diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md index abd57f3a9568bf..1291af5359887d 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md @@ -18,4 +18,5 @@ export interface SearchInterceptorDeps | [http](./kibana-plugin-plugins-data-public.searchinterceptordeps.http.md) | CoreStart['http'] | | | [toasts](./kibana-plugin-plugins-data-public.searchinterceptordeps.toasts.md) | ToastsStart | | | [uiSettings](./kibana-plugin-plugins-data-public.searchinterceptordeps.uisettings.md) | CoreStart['uiSettings'] | | +| [usageCollector](./kibana-plugin-plugins-data-public.searchinterceptordeps.usagecollector.md) | SearchUsageCollector | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.usagecollector.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.usagecollector.md new file mode 100644 index 00000000000000..21afce19276769 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.usagecollector.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptorDeps](./kibana-plugin-plugins-data-public.searchinterceptordeps.md) > [usageCollector](./kibana-plugin-plugins-data-public.searchinterceptordeps.usagecollector.md) + +## SearchInterceptorDeps.usageCollector property + +Signature: + +```typescript +usageCollector?: SearchUsageCollector; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.md index ca8ad8fdc06eac..3afba80064f084 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.md @@ -14,5 +14,6 @@ export interface ISearchSetup | Property | Type | Description | | --- | --- | --- | -| [registerSearchStrategy](./kibana-plugin-plugins-data-server.isearchsetup.registersearchstrategy.md) | (name: string, strategy: ISearchStrategy) => void | Extension point exposed for other plugins to register their own search strategies. | +| [registerSearchStrategy](./kibana-plugin-plugins-data-server.isearchsetup.registersearchstrategy.md) | TRegisterSearchStrategy | Extension point exposed for other plugins to register their own search strategies. | +| [usage](./kibana-plugin-plugins-data-server.isearchsetup.usage.md) | SearchUsage | Used internally for telemetry | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.usage.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.usage.md new file mode 100644 index 00000000000000..85abd9d9dba980 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.usage.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchSetup](./kibana-plugin-plugins-data-server.isearchsetup.md) > [usage](./kibana-plugin-plugins-data-server.isearchsetup.usage.md) + +## ISearchSetup.usage property + +Used internally for telemetry + +Signature: + +```typescript +usage: SearchUsage; +``` diff --git a/src/plugins/data/kibana.json b/src/plugins/data/kibana.json index 2ffd0688b134ee..b4f20ec6225e2c 100644 --- a/src/plugins/data/kibana.json +++ b/src/plugins/data/kibana.json @@ -10,6 +10,7 @@ "optionalPlugins": ["usageCollection"], "extraPublicDirs": ["common", "common/utils/abort_utils"], "requiredBundles": [ + "usageCollection", "kibanaUtils", "kibanaReact", "kibanaLegacy", diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 92f2399aa0ae40..e3f3bb7ba85cc3 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -112,7 +112,7 @@ export class DataPublicPlugin implements Plugin { + let mockCoreSetup: MockedKeys; + let mockUsageCollectionSetup: Setup; + let usageCollector: SearchUsageCollector; + + beforeEach(() => { + mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup as any).getStartServices.mockResolvedValue([ + { + application: { + currentAppId$: from(['foo/bar']), + }, + } as jest.Mocked, + {} as any, + {} as any, + ]); + mockUsageCollectionSetup = usageCollectionPluginMock.createSetupContract(); + usageCollector = createUsageCollector(mockCoreSetup, mockUsageCollectionSetup); + }); + + test('tracks query timeouts', async () => { + await usageCollector.trackQueryTimedOut(); + expect(mockUsageCollectionSetup.reportUiStats).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiStats.mock.calls[0][0]).toBe('foo/bar'); + expect(mockUsageCollectionSetup.reportUiStats.mock.calls[0][1]).toBe(METRIC_TYPE.LOADED); + expect(mockUsageCollectionSetup.reportUiStats.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.QUERY_TIMED_OUT + ); + }); + + test('tracks query cancellation', async () => { + await usageCollector.trackQueriesCancelled(); + expect(mockUsageCollectionSetup.reportUiStats).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiStats.mock.calls[0][1]).toBe(METRIC_TYPE.LOADED); + expect(mockUsageCollectionSetup.reportUiStats.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.QUERIES_CANCELLED + ); + }); + + test('tracks long popups', async () => { + await usageCollector.trackLongQueryPopupShown(); + expect(mockUsageCollectionSetup.reportUiStats).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiStats.mock.calls[0][1]).toBe(METRIC_TYPE.LOADED); + expect(mockUsageCollectionSetup.reportUiStats.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.LONG_QUERY_POPUP_SHOWN + ); + }); + + test('tracks long popups dismissed', async () => { + await usageCollector.trackLongQueryDialogDismissed(); + expect(mockUsageCollectionSetup.reportUiStats).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiStats.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiStats.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.LONG_QUERY_DIALOG_DISMISSED + ); + }); + + test('tracks run query beyond timeout', async () => { + await usageCollector.trackLongQueryRunBeyondTimeout(); + expect(mockUsageCollectionSetup.reportUiStats).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiStats.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiStats.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.LONG_QUERY_RUN_BEYOND_TIMEOUT + ); + }); + + test('tracks response errors', async () => { + const duration = 10; + await usageCollector.trackError(duration); + expect(mockCoreSetup.http.post).toBeCalled(); + expect(mockCoreSetup.http.post.mock.calls[0][0]).toBe('/api/search/usage'); + }); + + test('tracks response duration', async () => { + const duration = 5; + await usageCollector.trackSuccess(duration); + expect(mockCoreSetup.http.post).toBeCalled(); + expect(mockCoreSetup.http.post.mock.calls[0][0]).toBe('/api/search/usage'); + }); +}); diff --git a/src/plugins/data/public/search/collectors/create_usage_collector.ts b/src/plugins/data/public/search/collectors/create_usage_collector.ts new file mode 100644 index 00000000000000..cb1b2b65c17c84 --- /dev/null +++ b/src/plugins/data/public/search/collectors/create_usage_collector.ts @@ -0,0 +1,92 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { first } from 'rxjs/operators'; +import { CoreSetup } from '../../../../../core/public'; +import { METRIC_TYPE, UsageCollectionSetup } from '../../../../usage_collection/public'; +import { SEARCH_EVENT_TYPE, SearchUsageCollector } from './types'; + +export const createUsageCollector = ( + core: CoreSetup, + usageCollection?: UsageCollectionSetup +): SearchUsageCollector => { + const getCurrentApp = async () => { + const [{ application }] = await core.getStartServices(); + return application.currentAppId$.pipe(first()).toPromise(); + }; + + return { + trackQueryTimedOut: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiStats( + currentApp!, + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.QUERY_TIMED_OUT + ); + }, + trackQueriesCancelled: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiStats( + currentApp!, + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.QUERIES_CANCELLED + ); + }, + trackLongQueryPopupShown: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiStats( + currentApp!, + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.LONG_QUERY_POPUP_SHOWN + ); + }, + trackLongQueryDialogDismissed: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiStats( + currentApp!, + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.LONG_QUERY_DIALOG_DISMISSED + ); + }, + trackLongQueryRunBeyondTimeout: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiStats( + currentApp!, + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.LONG_QUERY_RUN_BEYOND_TIMEOUT + ); + }, + trackError: async (duration: number) => { + return core.http.post('/api/search/usage', { + body: JSON.stringify({ + eventType: 'error', + duration, + }), + }); + }, + trackSuccess: async (duration: number) => { + return core.http.post('/api/search/usage', { + body: JSON.stringify({ + eventType: 'success', + duration, + }), + }); + }, + }; +}; diff --git a/src/plugins/data/public/search/collectors/index.ts b/src/plugins/data/public/search/collectors/index.ts new file mode 100644 index 00000000000000..afe127c00b5dd5 --- /dev/null +++ b/src/plugins/data/public/search/collectors/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { createUsageCollector } from './create_usage_collector'; +export { SEARCH_EVENT_TYPE, SearchUsageCollector } from './types'; diff --git a/src/plugins/data/public/search/collectors/types.ts b/src/plugins/data/public/search/collectors/types.ts new file mode 100644 index 00000000000000..bb85532fd3ab59 --- /dev/null +++ b/src/plugins/data/public/search/collectors/types.ts @@ -0,0 +1,36 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export enum SEARCH_EVENT_TYPE { + QUERY_TIMED_OUT = 'queryTimedOut', + QUERIES_CANCELLED = 'queriesCancelled', + LONG_QUERY_POPUP_SHOWN = 'longQueryPopupShown', + LONG_QUERY_DIALOG_DISMISSED = 'longQueryDialogDismissed', + LONG_QUERY_RUN_BEYOND_TIMEOUT = 'longQueryRunBeyondTimeout', +} + +export interface SearchUsageCollector { + trackQueryTimedOut: () => Promise; + trackQueriesCancelled: () => Promise; + trackLongQueryPopupShown: () => Promise; + trackLongQueryDialogDismissed: () => Promise; + trackLongQueryRunBeyondTimeout: () => Promise; + trackError: (duration: number) => Promise; + trackSuccess: (duration: number) => Promise; +} diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index 8edbfd94deb383..84e24114a9e6c4 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -18,12 +18,13 @@ */ import { BehaviorSubject, throwError, timer, Subscription, defer, from, Observable } from 'rxjs'; -import { finalize, filter } from 'rxjs/operators'; +import { finalize, filter, tap } from 'rxjs/operators'; import { ApplicationStart, Toast, ToastsStart, CoreStart } from 'kibana/public'; import { getCombinedSignal, AbortError } from '../../common/utils'; import { IEsSearchRequest, IEsSearchResponse } from '../../common/search'; import { ISearchOptions } from './types'; import { getLongQueryNotification } from './long_query_notification'; +import { SearchUsageCollector } from './collectors'; const LONG_QUERY_NOTIFICATION_DELAY = 10000; @@ -32,6 +33,7 @@ export interface SearchInterceptorDeps { application: ApplicationStart; http: CoreStart['http']; uiSettings: CoreStart['uiSettings']; + usageCollector?: SearchUsageCollector; } export class SearchInterceptor { @@ -121,6 +123,13 @@ export class SearchInterceptor { this.pendingCount$.next(++this.pendingCount); return this.runSearch(request, combinedSignal).pipe( + tap({ + next: (e) => { + if (this.deps.usageCollector) { + this.deps.usageCollector.trackSuccess(e.rawResponse.took); + } + }, + }), finalize(() => { this.pendingCount$.next(--this.pendingCount); cleanup(); @@ -185,6 +194,9 @@ export class SearchInterceptor { if (this.longRunningToast) { this.deps.toasts.remove(this.longRunningToast); delete this.longRunningToast; + if (this.deps.usageCollector) { + this.deps.usageCollector.trackLongQueryDialogDismissed(); + } } }; } diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index a27eba21714bbe..064e16014cb70a 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -37,9 +37,12 @@ import { getCalculateAutoTimeExpression, } from './aggs'; import { ISearchGeneric } from './types'; +import { SearchUsageCollector, createUsageCollector } from './collectors'; +import { UsageCollectionSetup } from '../../../usage_collection/public'; interface SearchServiceSetupDependencies { expressions: ExpressionsSetup; + usageCollection?: UsageCollectionSetup; getInternalStartServices: GetInternalStartServicesFn; packageInfo: PackageInfo; } @@ -52,6 +55,7 @@ export class SearchService implements Plugin { private esClient?: LegacyApiCaller; private readonly aggTypesRegistry = new AggTypesRegistry(); private searchInterceptor!: SearchInterceptor; + private usageCollector?: SearchUsageCollector; /** * getForceNow uses window.location, so we must have a separate implementation @@ -62,8 +66,14 @@ export class SearchService implements Plugin { public setup( core: CoreSetup, - { expressions, packageInfo, getInternalStartServices }: SearchServiceSetupDependencies + { + expressions, + usageCollection, + packageInfo, + getInternalStartServices, + }: SearchServiceSetupDependencies ): ISearchSetup { + this.usageCollector = createUsageCollector(core, usageCollection); this.esClient = getEsClient(core.injectedMetadata, core.http, packageInfo); const aggTypesSetup = this.aggTypesRegistry.setup(); @@ -102,6 +112,7 @@ export class SearchService implements Plugin { application: core.application, http: core.http, uiSettings: core.uiSettings, + usageCollector: this.usageCollector!, }, core.injectedMetadata.getInjectedVar('esRequestTimeout') as number ); @@ -134,6 +145,7 @@ export class SearchService implements Plugin { types: aggTypesStart, }, search, + usageCollector: this.usageCollector!, searchSource: { create: createSearchSource(dependencies.indexPatterns, searchSourceDependencies), createEmpty: () => { diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 5c4bb42a5948d0..ec74275f35c041 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -18,17 +18,22 @@ */ import { Observable } from 'rxjs'; +import { PackageInfo } from 'kibana/server'; import { SearchAggsSetup, SearchAggsStart } from './aggs'; import { LegacyApiCaller } from './legacy/es_client'; import { SearchInterceptor } from './search_interceptor'; import { ISearchSource, SearchSourceFields } from './search_source'; - +import { SearchUsageCollector } from './collectors'; import { IKibanaSearchRequest, IKibanaSearchResponse, IEsSearchRequest, IEsSearchResponse, } from '../../common/search'; +import { IndexPatternsContract } from '../../common/index_patterns/index_patterns'; +import { ExpressionsSetup } from '../../../expressions/public'; +import { UsageCollectionSetup } from '../../../usage_collection/public'; +import { GetInternalStartServicesFn } from '../types'; export interface ISearchOptions { signal?: AbortSignal; @@ -69,5 +74,19 @@ export interface ISearchStart { create: (fields?: SearchSourceFields) => Promise; createEmpty: () => ISearchSource; }; + usageCollector?: SearchUsageCollector; __LEGACY: ISearchStartLegacy; } + +export { SEARCH_EVENT_TYPE } from './collectors'; + +export interface SearchServiceSetupDependencies { + expressions: ExpressionsSetup; + usageCollection?: UsageCollectionSetup; + getInternalStartServices: GetInternalStartServicesFn; + packageInfo: PackageInfo; +} + +export interface SearchServiceStartDependencies { + indexPatterns: IndexPatternsContract; +} diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index aaef403979de6a..6d671272514244 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -30,10 +30,12 @@ import { QuerySetup, QueryStart } from './query'; import { IndexPatternSelectProps } from './ui/index_pattern_select'; import { IndexPatternsContract } from './index_patterns'; import { StatefulSearchBarProps } from './ui/search_bar/create_search_bar'; +import { UsageCollectionSetup } from '../../usage_collection/public'; export interface DataSetupDependencies { expressions: ExpressionsSetup; uiActions: UiActionsSetup; + usageCollection?: UsageCollectionSetup; } export interface DataStartDependencies { diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index bcf1f4f8ab60bb..8fa32f9bd564f9 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -82,7 +82,7 @@ export class DataServerPlugin implements Plugin) { + return async (callCluster: LegacyAPICaller): Promise => { + const config = await config$.pipe(first()).toPromise(); + + const response = await callCluster('search', { + index: config.kibana.index, + body: { + query: { term: { type: { value: 'search-telemetry' } } }, + }, + ignore: [404], + }); + + return response.hits.hits.length + ? (response.hits.hits[0]._source as Usage) + : { + successCount: 0, + errorCount: 0, + averageDuration: null, + }; + }; +} diff --git a/src/plugins/data/server/search/collectors/register.ts b/src/plugins/data/server/search/collectors/register.ts new file mode 100644 index 00000000000000..ab0ea93edd49e2 --- /dev/null +++ b/src/plugins/data/server/search/collectors/register.ts @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializerContext } from 'kibana/server'; +import { UsageCollectionSetup } from '../../../../usage_collection/server'; +import { fetchProvider } from './fetch'; + +export interface Usage { + successCount: number; + errorCount: number; + averageDuration: number | null; +} + +export async function registerUsageCollector( + usageCollection: UsageCollectionSetup, + context: PluginInitializerContext +) { + try { + const collector = usageCollection.makeUsageCollector({ + type: 'search', + isReady: () => true, + fetch: fetchProvider(context.config.legacy.globalConfig$), + schema: { + successCount: { type: 'number' }, + errorCount: { type: 'number' }, + averageDuration: { type: 'long' }, + }, + }); + usageCollection.registerCollector(collector); + } catch (err) { + return; // kibana plugin is not enabled (test environment) + } +} diff --git a/src/plugins/data/server/search/collectors/routes.ts b/src/plugins/data/server/search/collectors/routes.ts new file mode 100644 index 00000000000000..38fb517e3c3f66 --- /dev/null +++ b/src/plugins/data/server/search/collectors/routes.ts @@ -0,0 +1,50 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { CoreSetup } from '../../../../../core/server'; +import { DataPluginStart } from '../../plugin'; +import { SearchUsage } from './usage'; + +export function registerSearchUsageRoute( + core: CoreSetup, + usage: SearchUsage +): void { + const router = core.http.createRouter(); + + router.post( + { + path: '/api/search/usage', + validate: { + body: schema.object({ + eventType: schema.string(), + duration: schema.number(), + }), + }, + }, + async (context, request, res) => { + const { eventType, duration } = request.body; + + if (eventType === 'success') usage.trackSuccess(duration); + if (eventType === 'error') usage.trackError(duration); + + return res.ok(); + } + ); +} diff --git a/src/plugins/data/server/search/collectors/usage.ts b/src/plugins/data/server/search/collectors/usage.ts new file mode 100644 index 00000000000000..c43c572c2edbb9 --- /dev/null +++ b/src/plugins/data/server/search/collectors/usage.ts @@ -0,0 +1,77 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CoreSetup } from 'kibana/server'; +import { DataPluginStart } from '../../plugin'; +import { Usage } from './register'; + +const SAVED_OBJECT_ID = 'search-telemetry'; + +export interface SearchUsage { + trackError(duration: number): Promise; + trackSuccess(duration: number): Promise; +} + +export function usageProvider(core: CoreSetup): SearchUsage { + const getTracker = (eventType: keyof Usage) => { + return async (duration: number) => { + const repository = await core + .getStartServices() + .then(([coreStart]) => coreStart.savedObjects.createInternalRepository()); + + let attributes: Usage; + let doesSavedObjectExist: boolean = true; + + try { + const response = await repository.get(SAVED_OBJECT_ID, SAVED_OBJECT_ID); + attributes = response.attributes; + } catch (e) { + doesSavedObjectExist = false; + attributes = { + successCount: 0, + errorCount: 0, + averageDuration: 0, + }; + } + + attributes[eventType]++; + + const averageDuration = + (duration + (attributes.averageDuration ?? 0)) / + ((attributes.errorCount ?? 0) + (attributes.successCount ?? 0)); + + const newAttributes = { ...attributes, averageDuration }; + + try { + if (doesSavedObjectExist) { + await repository.update(SAVED_OBJECT_ID, SAVED_OBJECT_ID, newAttributes); + } else { + await repository.create(SAVED_OBJECT_ID, newAttributes, { id: SAVED_OBJECT_ID }); + } + } catch (e) { + // Version conflict error, swallow + } + }; + }; + + return { + trackError: getTracker('errorCount'), + trackSuccess: getTracker('successCount'), + }; +} diff --git a/src/plugins/data/server/search/search_service.test.ts b/src/plugins/data/server/search/search_service.test.ts index 25143fa09e6bff..8c2ed96503003e 100644 --- a/src/plugins/data/server/search/search_service.test.ts +++ b/src/plugins/data/server/search/search_service.test.ts @@ -34,7 +34,7 @@ describe('Search service', () => { describe('setup()', () => { it('exposes proper contract', async () => { - const setup = plugin.setup(mockCoreSetup); + const setup = plugin.setup(mockCoreSetup, {}); expect(setup).toHaveProperty('registerSearchStrategy'); }); }); diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 20f9a7488893f7..5686023e9a667a 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -27,6 +27,11 @@ import { ISearchSetup, ISearchStart, ISearchStrategy } from './types'; import { registerSearchRoute } from './routes'; import { ES_SEARCH_STRATEGY, esSearchStrategyProvider } from './es_search'; import { DataPluginStart } from '../plugin'; +import { UsageCollectionSetup } from '../../../usage_collection/server'; +import { registerUsageCollector } from './collectors/register'; +import { usageProvider } from './collectors/usage'; +import { searchTelemetry } from '../saved_objects'; +import { registerSearchUsageRoute } from './collectors/routes'; import { IEsSearchRequest } from '../../common'; interface StrategyMap { @@ -38,15 +43,26 @@ export class SearchService implements Plugin { constructor(private initializerContext: PluginInitializerContext) {} - public setup(core: CoreSetup): ISearchSetup { + public setup( + core: CoreSetup, + { usageCollection }: { usageCollection?: UsageCollectionSetup } + ): ISearchSetup { this.registerSearchStrategy( ES_SEARCH_STRATEGY, esSearchStrategyProvider(this.initializerContext.config.legacy.globalConfig$) ); + core.savedObjects.registerType(searchTelemetry); + if (usageCollection) { + registerUsageCollector(usageCollection, this.initializerContext); + } + + const usage = usageProvider(core); + registerSearchRoute(core); + registerSearchUsageRoute(core, usage); - return { registerSearchStrategy: this.registerSearchStrategy }; + return { registerSearchStrategy: this.registerSearchStrategy, usage }; } private search(context: RequestHandlerContext, searchRequest: IEsSearchRequest, options: any) { diff --git a/src/plugins/data/server/search/types.ts b/src/plugins/data/server/search/types.ts index 12f1a1a508bd23..25dc890e0257db 100644 --- a/src/plugins/data/server/search/types.ts +++ b/src/plugins/data/server/search/types.ts @@ -19,6 +19,7 @@ import { RequestHandlerContext } from '../../../../core/server'; import { IKibanaSearchResponse, IKibanaSearchRequest } from '../../common/search'; +import { SearchUsage } from './collectors/usage'; import { IEsSearchRequest, IEsSearchResponse } from './es_search'; export interface ISearchOptions { @@ -35,6 +36,11 @@ export interface ISearchSetup { * strategies. */ registerSearchStrategy: (name: string, strategy: ISearchStrategy) => void; + + /** + * Used internally for telemetry + */ + usage: SearchUsage; } export interface ISearchStart { diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index c873986c42e5e0..17f6b6a8b8967d 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -523,6 +523,8 @@ export interface ISearchOptions { // @public (undocumented) export interface ISearchSetup { registerSearchStrategy: (name: string, strategy: ISearchStrategy) => void; + // Warning: (ae-forgotten-export) The symbol "SearchUsage" needs to be exported by the entry point index.d.ts + usage: SearchUsage; } // Warning: (ae-missing-release-tag) "ISearchStart" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 231f1d434b8922..bdf3f6a0acf90c 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -41,6 +41,7 @@ export class DataEnhancedPlugin application: core.application, http: core.http, uiSettings: core.uiSettings, + usageCollector: plugins.data.search.usageCollector, }, core.injectedMetadata.getInjectedVar('esRequestTimeout') as number ); diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 9f018f5b718c73..9bd1ffddeaca8b 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -36,12 +36,25 @@ function mockFetchImplementation(responses: any[]) { } describe('EnhancedSearchInterceptor', () => { + let mockUsageCollector: any; + beforeEach(() => { mockCoreStart = coreMock.createStart(); next.mockClear(); error.mockClear(); complete.mockClear(); + jest.clearAllTimers(); + + mockUsageCollector = { + trackQueryTimedOut: jest.fn(), + trackQueriesCancelled: jest.fn(), + trackLongQueryPopupShown: jest.fn(), + trackLongQueryDialogDismissed: jest.fn(), + trackLongQueryRunBeyondTimeout: jest.fn(), + trackError: jest.fn(), + trackSuccess: jest.fn(), + }; searchInterceptor = new EnhancedSearchInterceptor( { @@ -49,6 +62,7 @@ describe('EnhancedSearchInterceptor', () => { application: mockCoreStart.application, http: mockCoreStart.http, uiSettings: mockCoreStart.uiSettings, + usageCollector: mockUsageCollector, }, 1000 ); @@ -63,6 +77,9 @@ describe('EnhancedSearchInterceptor', () => { is_partial: false, is_running: false, id: 1, + rawResponse: { + took: 1, + }, }, }, ]; @@ -87,6 +104,9 @@ describe('EnhancedSearchInterceptor', () => { is_partial: false, is_running: true, id: 1, + rawResponse: { + took: 1, + }, }, }, { @@ -95,6 +115,9 @@ describe('EnhancedSearchInterceptor', () => { is_partial: false, is_running: false, id: 1, + rawResponse: { + took: 1, + }, }, }, ]; @@ -350,6 +373,7 @@ describe('EnhancedSearchInterceptor', () => { ([{ signal }]) => signal?.aborted ); expect(areAllRequestsAborted).toBe(true); + expect(mockUsageCollector.trackQueriesCancelled).toBeCalledTimes(1); }); }); @@ -361,6 +385,9 @@ describe('EnhancedSearchInterceptor', () => { is_partial: true, is_running: true, id: 1, + rawResponse: { + took: 1, + }, }, }, { @@ -369,6 +396,9 @@ describe('EnhancedSearchInterceptor', () => { is_partial: false, is_running: false, id: 1, + rawResponse: { + took: 1, + }, }, }, ]; @@ -427,6 +457,8 @@ describe('EnhancedSearchInterceptor', () => { expect(next.mock.calls[0][0]).toStrictEqual(timedResponses[0].value); expect(next.mock.calls[1][0]).toStrictEqual(timedResponses[1].value); expect(error).not.toHaveBeenCalled(); + expect(mockUsageCollector.trackLongQueryRunBeyondTimeout).toBeCalledTimes(1); + expect(mockUsageCollector.trackSuccess).toBeCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts index c0e2a6bd113eb4..d1ed410065248b 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -35,6 +35,7 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { this.hideToast(); this.abortController.abort(); this.abortController = new AbortController(); + if (this.deps.usageCollector) this.deps.usageCollector.trackQueriesCancelled(); }; /** @@ -43,6 +44,7 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { public runBeyondTimeout = () => { this.hideToast(); this.timeoutSubscriptions.unsubscribe(); + if (this.deps.usageCollector) this.deps.usageCollector.trackLongQueryRunBeyondTimeout(); }; protected showToast = () => { @@ -59,6 +61,7 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { toastLifeTimeMs: 1000000, } ); + if (this.deps.usageCollector) this.deps.usageCollector.trackLongQueryPopupShown(); }; public search( @@ -85,7 +88,12 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { } // If the response indicates it is complete, stop polling and complete the observable - if (!response.is_running) return EMPTY; + if (!response.is_running) { + if (this.deps.usageCollector && response.rawResponse) { + this.deps.usageCollector.trackSuccess(response.rawResponse.took); + } + return EMPTY; + } id = response.id; // Delay by the given poll interval