[Search] Add telemetry for data plugin search service (#70677)

* [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 <liza.katz@elastic.co>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Lukas Olson 2020-07-15 01:49:34 -07:00 committed by GitHub
parent fc5bc6b6a2
commit 25d143fdf7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 669 additions and 18 deletions

View file

@ -7,7 +7,7 @@
<b>Signature:</b>
```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 | <code>CoreSetup</code> | |
| { expressions, uiActions } | <code>DataSetupDependencies</code> | |
| { expressions, uiActions, usageCollection } | <code>DataSetupDependencies</code> | |
<b>Returns:</b>

View file

@ -18,4 +18,5 @@ export interface SearchInterceptorDeps
| [http](./kibana-plugin-plugins-data-public.searchinterceptordeps.http.md) | <code>CoreStart['http']</code> | |
| [toasts](./kibana-plugin-plugins-data-public.searchinterceptordeps.toasts.md) | <code>ToastsStart</code> | |
| [uiSettings](./kibana-plugin-plugins-data-public.searchinterceptordeps.uisettings.md) | <code>CoreStart['uiSettings']</code> | |
| [usageCollector](./kibana-plugin-plugins-data-public.searchinterceptordeps.usagecollector.md) | <code>SearchUsageCollector</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptorDeps](./kibana-plugin-plugins-data-public.searchinterceptordeps.md) &gt; [usageCollector](./kibana-plugin-plugins-data-public.searchinterceptordeps.usagecollector.md)
## SearchInterceptorDeps.usageCollector property
<b>Signature:</b>
```typescript
usageCollector?: SearchUsageCollector;
```

View file

@ -14,5 +14,6 @@ export interface ISearchSetup
| Property | Type | Description |
| --- | --- | --- |
| [registerSearchStrategy](./kibana-plugin-plugins-data-server.isearchsetup.registersearchstrategy.md) | <code>(name: string, strategy: ISearchStrategy) =&gt; void</code> | Extension point exposed for other plugins to register their own search strategies. |
| [registerSearchStrategy](./kibana-plugin-plugins-data-server.isearchsetup.registersearchstrategy.md) | <code>TRegisterSearchStrategy</code> | Extension point exposed for other plugins to register their own search strategies. |
| [usage](./kibana-plugin-plugins-data-server.isearchsetup.usage.md) | <code>SearchUsage</code> | Used internally for telemetry |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) &gt; [ISearchSetup](./kibana-plugin-plugins-data-server.isearchsetup.md) &gt; [usage](./kibana-plugin-plugins-data-server.isearchsetup.usage.md)
## ISearchSetup.usage property
Used internally for telemetry
<b>Signature:</b>
```typescript
usage: SearchUsage;
```

View file

@ -10,6 +10,7 @@
"optionalPlugins": ["usageCollection"],
"extraPublicDirs": ["common", "common/utils/abort_utils"],
"requiredBundles": [
"usageCollection",
"kibanaUtils",
"kibanaReact",
"kibanaLegacy",

View file

@ -111,7 +111,7 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
public setup(
core: CoreSetup,
{ expressions, uiActions }: DataSetupDependencies
{ expressions, uiActions, usageCollection }: DataSetupDependencies
): DataPublicPluginSetup {
const startServices = createStartServicesGetter(core.getStartServices);
@ -152,6 +152,7 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
autocomplete: this.autocomplete.setup(core),
search: this.searchService.setup(core, {
expressions,
usageCollection,
getInternalStartServices,
packageInfo: this.packageInfo,
}),

View file

@ -118,6 +118,7 @@ import { KibanaConfigType } from 'src/core/server/kibana_config';
import { Location } from 'history';
import { LocationDescriptorObject } from 'history';
import { MaybePromise } from '@kbn/utility-types';
import { METRIC_TYPE } from '@kbn/analytics';
import { MGetParams } from 'elasticsearch';
import { MGetResponse } from 'elasticsearch';
import { Moment } from 'moment';
@ -145,6 +146,7 @@ import { RecursiveReadonly } from '@kbn/utility-types';
import { ReindexParams } from 'elasticsearch';
import { ReindexRethrottleParams } from 'elasticsearch';
import { RenderSearchTemplateParams } from 'elasticsearch';
import { Reporter } from '@kbn/analytics';
import { RequestAdapter } from 'src/plugins/inspector/common';
import { RequestStatistics as RequestStatistics_2 } from 'src/plugins/inspector/common';
import { Required } from '@kbn/utility-types';
@ -1450,7 +1452,7 @@ export class Plugin implements Plugin_2<DataPublicPluginSetup, DataPublicPluginS
// Warning: (ae-forgotten-export) The symbol "DataSetupDependencies" needs to be exported by the entry point index.d.ts
//
// (undocumented)
setup(core: CoreSetup, { expressions, uiActions }: DataSetupDependencies): DataPublicPluginSetup;
setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup;
// Warning: (ae-forgotten-export) The symbol "DataStartDependencies" needs to be exported by the entry point index.d.ts
//
// (undocumented)
@ -1778,6 +1780,10 @@ export interface SearchInterceptorDeps {
toasts: ToastsStart;
// (undocumented)
uiSettings: CoreStart['uiSettings'];
// Warning: (ae-forgotten-export) The symbol "SearchUsageCollector" needs to be exported by the entry point index.d.ts
//
// (undocumented)
usageCollector?: SearchUsageCollector;
}
// Warning: (ae-missing-release-tag) "SearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@ -2003,9 +2009,9 @@ export const UI_SETTINGS: {
// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:41:60 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/types.ts:53:5 - (ae-forgotten-export) The symbol "createFiltersFromRangeSelectAction" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/types.ts:61:5 - (ae-forgotten-export) The symbol "IndexPatternSelectProps" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/types.ts:54:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/types.ts:55:5 - (ae-forgotten-export) The symbol "createFiltersFromRangeSelectAction" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/types.ts:63:5 - (ae-forgotten-export) The symbol "IndexPatternSelectProps" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)

View file

@ -0,0 +1,107 @@
/*
* 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, CoreStart } from '../../../../../core/public';
import { coreMock } from '../../../../../core/public/mocks';
import { usageCollectionPluginMock, Setup } from '../../../../usage_collection/public/mocks';
import { createUsageCollector } from './create_usage_collector';
import { SEARCH_EVENT_TYPE, SearchUsageCollector } from './types';
import { METRIC_TYPE } from '@kbn/analytics';
import { from } from 'rxjs';
describe('Search Usage Collector', () => {
let mockCoreSetup: MockedKeys<CoreSetup>;
let mockUsageCollectionSetup: Setup;
let usageCollector: SearchUsageCollector;
beforeEach(() => {
mockCoreSetup = coreMock.createSetup();
(mockCoreSetup as any).getStartServices.mockResolvedValue([
{
application: {
currentAppId$: from(['foo/bar']),
},
} as jest.Mocked<CoreStart>,
{} 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');
});
});

View file

@ -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,
}),
});
},
};
};

View file

@ -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';

View file

@ -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<void>;
trackQueriesCancelled: () => Promise<void>;
trackLongQueryPopupShown: () => Promise<void>;
trackLongQueryDialogDismissed: () => Promise<void>;
trackLongQueryRunBeyondTimeout: () => Promise<void>;
trackError: (duration: number) => Promise<void>;
trackSuccess: (duration: number) => Promise<void>;
}

View file

@ -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();
}
}
};
}

View file

@ -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<ISearchSetup, ISearchStart> {
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<ISearchSetup, ISearchStart> {
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<ISearchSetup, ISearchStart> {
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<ISearchSetup, ISearchStart> {
types: aggTypesStart,
},
search,
usageCollector: this.usageCollector!,
searchSource: {
create: createSearchSource(dependencies.indexPatterns, searchSourceDependencies),
createEmpty: () => {

View file

@ -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<ISearchSource>;
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;
}

View file

@ -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 {

View file

@ -82,7 +82,7 @@ export class DataServerPlugin implements Plugin<DataPluginSetup, DataPluginStart
core.uiSettings.register(getUiSettings());
return {
search: this.searchService.setup(core),
search: this.searchService.setup(core, { usageCollection }),
fieldFormats: this.fieldFormats.setup(),
};
}

View file

@ -18,4 +18,5 @@
*/
export { querySavedObjectType } from './query';
export { indexPatternSavedObjectType } from './index_patterns';
export { kqlTelemetry } from './kql_telementry';
export { kqlTelemetry } from './kql_telemetry';
export { searchTelemetry } from './search_telemetry';

View file

@ -0,0 +1,29 @@
/*
* 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 { SavedObjectsType } from 'kibana/server';
export const searchTelemetry: SavedObjectsType = {
name: 'search-telemetry',
namespaceType: 'agnostic',
hidden: false,
mappings: {
dynamic: false,
properties: {},
},
};

View file

@ -0,0 +1,45 @@
/*
* 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 { Observable } from 'rxjs';
import { first } from 'rxjs/operators';
import { LegacyAPICaller, SharedGlobalConfig } from '../../../../../core/server';
import { Usage } from './register';
export function fetchProvider(config$: Observable<SharedGlobalConfig>) {
return async (callCluster: LegacyAPICaller): Promise<Usage> => {
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,
};
};
}

View file

@ -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<Usage>({
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)
}
}

View file

@ -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<object, DataPluginStart>,
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();
}
);
}

View file

@ -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<void>;
trackSuccess(duration: number): Promise<void>;
}
export function usageProvider(core: CoreSetup<object, DataPluginStart>): 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<Usage>(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'),
};
}

View file

@ -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');
});
});

View file

@ -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<ISearchSetup, ISearchStart> {
constructor(private initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup<object, DataPluginStart>): ISearchSetup {
public setup(
core: CoreSetup<object, DataPluginStart>,
{ usageCollection }: { usageCollection?: UsageCollectionSetup }
): ISearchSetup {
this.registerSearchStrategy(
ES_SEARCH_STRATEGY,
esSearchStrategyProvider(this.initializerContext.config.legacy.globalConfig$)
);
registerSearchRoute(core);
core.savedObjects.registerType(searchTelemetry);
if (usageCollection) {
registerUsageCollector(usageCollection, this.initializerContext);
}
return { registerSearchStrategy: this.registerSearchStrategy };
const usage = usageProvider(core);
registerSearchRoute(core);
registerSearchUsageRoute(core, usage);
return { registerSearchStrategy: this.registerSearchStrategy, usage };
}
private search(context: RequestHandlerContext, searchRequest: IEsSearchRequest, options: any) {

View file

@ -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 {

View file

@ -532,6 +532,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)

View file

@ -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
);

View file

@ -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);
});
});
});

View file

@ -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