diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.md index 5f940bf70a12..44cfb0c65e38 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.md @@ -21,4 +21,5 @@ export interface IDataPluginServices extends Partial | [savedObjects](./kibana-plugin-plugins-data-public.idatapluginservices.savedobjects.md) | CoreStart['savedObjects'] | | | [storage](./kibana-plugin-plugins-data-public.idatapluginservices.storage.md) | IStorageWrapper | | | [uiSettings](./kibana-plugin-plugins-data-public.idatapluginservices.uisettings.md) | CoreStart['uiSettings'] | | +| [usageCollection](./kibana-plugin-plugins-data-public.idatapluginservices.usagecollection.md) | UsageCollectionStart | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.usagecollection.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.usagecollection.md new file mode 100644 index 000000000000..b803dca76203 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.usagecollection.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IDataPluginServices](./kibana-plugin-plugins-data-public.idatapluginservices.md) > [usageCollection](./kibana-plugin-plugins-data-public.idatapluginservices.usagecollection.md) + +## IDataPluginServices.usageCollection property + +Signature: + +```typescript +usageCollection?: UsageCollectionStart; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index 83fbc00860ca..786ac4f9d61a 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "isLoading" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "isLoading" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/src/plugins/data/public/autocomplete/autocomplete_service.ts b/src/plugins/data/public/autocomplete/autocomplete_service.ts index a99943c6cd87..6b288c4507f0 100644 --- a/src/plugins/data/public/autocomplete/autocomplete_service.ts +++ b/src/plugins/data/public/autocomplete/autocomplete_service.ts @@ -16,6 +16,8 @@ import { } from './providers/value_suggestion_provider'; import { ConfigSchema } from '../../config'; +import { UsageCollectionSetup } from '../../../usage_collection/public'; +import { createUsageCollector } from './collectors'; export class AutocompleteService { autocompleteConfig: ConfigSchema['autocomplete']; @@ -47,9 +49,17 @@ export class AutocompleteService { private hasQuerySuggestions = (language: string) => this.querySuggestionProviders.has(language); /** @public **/ - public setup(core: CoreSetup, { timefilter }: { timefilter: TimefilterSetup }) { + public setup( + core: CoreSetup, + { + timefilter, + usageCollection, + }: { timefilter: TimefilterSetup; usageCollection?: UsageCollectionSetup } + ) { + const usageCollector = createUsageCollector(core.getStartServices, usageCollection); + this.getValueSuggestions = this.autocompleteConfig.valueSuggestions.enabled - ? setupValueSuggestionProvider(core, { timefilter }) + ? setupValueSuggestionProvider(core, { timefilter, usageCollector }) : getEmptyValueSuggestions; return { diff --git a/src/plugins/data/public/autocomplete/collectors/create_usage_collector.ts b/src/plugins/data/public/autocomplete/collectors/create_usage_collector.ts new file mode 100644 index 000000000000..fc0cea2fdbc5 --- /dev/null +++ b/src/plugins/data/public/autocomplete/collectors/create_usage_collector.ts @@ -0,0 +1,57 @@ +/* + * 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 { first } from 'rxjs/operators'; +import { StartServicesAccessor } from '../../../../../core/public'; +import { METRIC_TYPE, UsageCollectionSetup } from '../../../../usage_collection/public'; +import { AUTOCOMPLETE_EVENT_TYPE, AutocompleteUsageCollector } from './types'; + +export const createUsageCollector = ( + getStartServices: StartServicesAccessor, + usageCollection?: UsageCollectionSetup +): AutocompleteUsageCollector => { + const getCurrentApp = async () => { + const [{ application }] = await getStartServices(); + return application.currentAppId$.pipe(first()).toPromise(); + }; + + return { + trackCall: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiCounter( + currentApp!, + METRIC_TYPE.LOADED, + AUTOCOMPLETE_EVENT_TYPE.CALL + ); + }, + trackRequest: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiCounter( + currentApp!, + METRIC_TYPE.LOADED, + AUTOCOMPLETE_EVENT_TYPE.REQUEST + ); + }, + trackResult: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiCounter( + currentApp!, + METRIC_TYPE.LOADED, + AUTOCOMPLETE_EVENT_TYPE.RESULT + ); + }, + trackError: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiCounter( + currentApp!, + METRIC_TYPE.LOADED, + AUTOCOMPLETE_EVENT_TYPE.ERROR + ); + }, + }; +}; diff --git a/src/plugins/data/public/autocomplete/collectors/index.ts b/src/plugins/data/public/autocomplete/collectors/index.ts new file mode 100644 index 000000000000..5cfaab19787d --- /dev/null +++ b/src/plugins/data/public/autocomplete/collectors/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { createUsageCollector } from './create_usage_collector'; +export { AUTOCOMPLETE_EVENT_TYPE, AutocompleteUsageCollector } from './types'; diff --git a/src/plugins/data/public/autocomplete/collectors/types.ts b/src/plugins/data/public/autocomplete/collectors/types.ts new file mode 100644 index 000000000000..60eb9103dc44 --- /dev/null +++ b/src/plugins/data/public/autocomplete/collectors/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export enum AUTOCOMPLETE_EVENT_TYPE { + CALL = 'autocomplete:call', + REQUEST = 'autocomplete:req', + RESULT = 'autocomplete:res', + ERROR = 'autocomplete:err', +} + +export interface AutocompleteUsageCollector { + trackCall: () => Promise; + trackRequest: () => Promise; + trackResult: () => Promise; + trackError: () => Promise; +} diff --git a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts index 23fc9f5405ae..a7b1bd2c7839 100644 --- a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts +++ b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts @@ -18,7 +18,7 @@ describe('FieldSuggestions', () => { beforeEach(() => { const uiSettings = { get: (key: string) => shouldSuggestValues } as IUiSettingsClient; - http = { fetch: jest.fn() }; + http = { fetch: jest.fn().mockResolvedValue([]) }; getValueSuggestions = setupValueSuggestionProvider({ http, uiSettings } as CoreSetup, { timefilter: ({ diff --git a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts index d8c6d16174d1..b8af6ad3a99e 100644 --- a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts +++ b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts @@ -11,12 +11,7 @@ import { memoize } from 'lodash'; import { CoreSetup } from 'src/core/public'; import { IIndexPattern, IFieldType, UI_SETTINGS, buildQueryFromFilters } from '../../../common'; import { TimefilterSetup } from '../../query'; - -function resolver(title: string, field: IFieldType, query: string, filters: any[]) { - // Only cache results for a minute - const ttl = Math.floor(Date.now() / 1000 / 60); - return [ttl, query, title, field.name, JSON.stringify(filters)].join('|'); -} +import { AutocompleteUsageCollector } from '../collectors'; export type ValueSuggestionsGetFn = (args: ValueSuggestionsGetFnArgs) => Promise; @@ -47,15 +42,31 @@ export const getEmptyValueSuggestions = (() => Promise.resolve([])) as ValueSugg export const setupValueSuggestionProvider = ( core: CoreSetup, - { timefilter }: { timefilter: TimefilterSetup } + { + timefilter, + usageCollector, + }: { timefilter: TimefilterSetup; usageCollector?: AutocompleteUsageCollector } ): ValueSuggestionsGetFn => { + function resolver(title: string, field: IFieldType, query: string, filters: any[]) { + // Only cache results for a minute + const ttl = Math.floor(Date.now() / 1000 / 60); + return [ttl, query, title, field.name, JSON.stringify(filters)].join('|'); + } + const requestSuggestions = memoize( - (index: string, field: IFieldType, query: string, filters: any = [], signal?: AbortSignal) => - core.http.fetch(`/api/kibana/suggestions/values/${index}`, { - method: 'POST', - body: JSON.stringify({ query, field: field.name, filters }), - signal, - }), + (index: string, field: IFieldType, query: string, filters: any = [], signal?: AbortSignal) => { + usageCollector?.trackRequest(); + return core.http + .fetch(`/api/kibana/suggestions/values/${index}`, { + method: 'POST', + body: JSON.stringify({ query, field: field.name, filters }), + signal, + }) + .then((r) => { + usageCollector?.trackResult(); + return r; + }); + }, resolver ); @@ -85,6 +96,16 @@ export const setupValueSuggestionProvider = ( : undefined; const filterQuery = timeFilter ? buildQueryFromFilters([timeFilter], indexPattern).filter : []; const filters = [...(boolFilter ? boolFilter : []), ...filterQuery]; - return await requestSuggestions(title, field, query, filters, signal); + try { + usageCollector?.trackCall(); + return await requestSuggestions(title, field, query, filters, signal); + } catch (e) { + if (!signal?.aborted) { + usageCollector?.trackError(); + } + // Remove rejected results from memoize cache + requestSuggestions.cache.delete(resolver(title, field, query, filters)); + return []; + } }; }; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 39d3ca57215b..862dd63948a2 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -115,7 +115,10 @@ export class DataPublicPlugin ); return { - autocomplete: this.autocomplete.setup(core, { timefilter: queryService.timefilter }), + autocomplete: this.autocomplete.setup(core, { + timefilter: queryService.timefilter, + usageCollection, + }), search: searchService, fieldFormats: this.fieldFormatsService.setup(core), query: queryService, @@ -195,10 +198,7 @@ export class DataPublicPlugin core, data: dataServices, storage: this.storage, - trackUiMetric: this.usageCollection?.reportUiCounter.bind( - this.usageCollection, - 'data_plugin' - ), + usageCollection: this.usageCollection, }); return { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 0a3e4666da74..745f4a7d29d2 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -105,7 +105,6 @@ import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; import { UiActionsSetup } from 'src/plugins/ui_actions/public'; import { UiActionsStart } from 'src/plugins/ui_actions/public'; -import { UiCounterMetricType } from '@kbn/analytics'; import { Unit } from '@elastic/datemath'; import { UnregisterCallback } from 'history'; import { URL } from 'url'; @@ -1094,6 +1093,10 @@ export interface IDataPluginServices extends Partial { storage: IStorageWrapper; // (undocumented) uiSettings: CoreStart_2['uiSettings']; + // Warning: (ae-forgotten-export) The symbol "UsageCollectionStart" needs to be exported by the entry point index.d.ts + // + // (undocumented) + usageCollection?: UsageCollectionStart; } // Warning: (ae-missing-release-tag) "IEsSearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -2250,8 +2253,8 @@ export const SEARCH_SESSIONS_MANAGEMENT_ID = "search_sessions"; // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const SearchBar: React.ComponentClass, "query" | "isLoading" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +export const SearchBar: React.ComponentClass, "query" | "isLoading" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; }; // Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index acf9a4b084c0..8686823ef056 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -19,7 +19,7 @@ import { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; import { QuerySetup, QueryStart } from './query'; import { IndexPatternsContract } from './index_patterns'; import { IndexPatternSelectProps, StatefulSearchBarProps } from './ui'; -import { UsageCollectionSetup } from '../../usage_collection/public'; +import { UsageCollectionSetup, UsageCollectionStart } from '../../usage_collection/public'; import { Setup as InspectorSetup } from '../../inspector/public'; import { NowProviderPublicContract } from './now_provider'; @@ -120,4 +120,5 @@ export interface IDataPluginServices extends Partial { http: CoreStart['http']; storage: IStorageWrapper; data: DataPublicPluginStart; + usageCollection?: UsageCollectionStart; } diff --git a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx index 5b52a529ae7a..9605eba9c1c2 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx @@ -11,12 +11,12 @@ import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import classNames from 'classnames'; import React, { useState } from 'react'; -import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; +import { METRIC_TYPE } from '@kbn/analytics'; import { FilterEditor } from './filter_editor'; import { FILTER_EDITOR_WIDTH, FilterItem } from './filter_item'; import { FilterOptions } from './filter_options'; import { useKibana } from '../../../../kibana_react/public'; -import { IIndexPattern } from '../..'; +import { IDataPluginServices, IIndexPattern } from '../..'; import { buildEmptyFilter, Filter, @@ -36,17 +36,16 @@ interface Props { indexPatterns: IIndexPattern[]; intl: InjectedIntl; appName: string; - // Track UI Metrics - trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; } function FilterBarUI(props: Props) { const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false); - const kibana = useKibana(); - - const uiSettings = kibana.services.uiSettings; + const kibana = useKibana(); + const { appName, usageCollection, uiSettings } = kibana.services; if (!uiSettings) return null; + const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName); + function onFiltersUpdated(filters: Filter[]) { if (props.onFiltersUpdated) { props.onFiltersUpdated(filters); @@ -119,66 +118,65 @@ function FilterBarUI(props: Props) { } function onAdd(filter: Filter) { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:added`); setIsAddFilterPopoverOpen(false); - if (props.trackUiMetric) { - props.trackUiMetric(METRIC_TYPE.CLICK, `${props.appName}:filter_added`); - } + const filters = [...props.filters, filter]; onFiltersUpdated(filters); } function onRemove(i: number) { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:removed`); const filters = [...props.filters]; filters.splice(i, 1); onFiltersUpdated(filters); } function onUpdate(i: number, filter: Filter) { - if (props.trackUiMetric) { - props.trackUiMetric(METRIC_TYPE.CLICK, `${props.appName}:filter_edited`); - } + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:edited`); const filters = [...props.filters]; filters[i] = filter; onFiltersUpdated(filters); } function onEnableAll() { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:enable_all`); const filters = props.filters.map(enableFilter); onFiltersUpdated(filters); } function onDisableAll() { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:disable_all`); const filters = props.filters.map(disableFilter); onFiltersUpdated(filters); } function onPinAll() { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:pin_all`); const filters = props.filters.map(pinFilter); onFiltersUpdated(filters); } function onUnpinAll() { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:unpin_all`); const filters = props.filters.map(unpinFilter); onFiltersUpdated(filters); } function onToggleAllNegated() { - if (props.trackUiMetric) { - props.trackUiMetric(METRIC_TYPE.CLICK, `${props.appName}:filter_invertInclusion`); - } + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:invert_all`); const filters = props.filters.map(toggleFilterNegated); onFiltersUpdated(filters); } function onToggleAllDisabled() { - if (props.trackUiMetric) { - props.trackUiMetric(METRIC_TYPE.CLICK, `${props.appName}:filter_toggleAllDisabled`); - } + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:toggle_all`); const filters = props.filters.map(toggleFilterDisabled); onFiltersUpdated(filters); } function onRemoveAll() { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:remove_all`); onFiltersUpdated([]); } diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index aa42e11d3185..aa2fc9e63143 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -26,6 +26,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { debounce, compact, isEqual, isFunction } from 'lodash'; import { Toast } from 'src/core/public'; +import { METRIC_TYPE } from '@kbn/analytics'; import { IDataPluginServices, IIndexPattern, Query } from '../..'; import { QuerySuggestion, QuerySuggestionTypes } from '../../autocomplete'; @@ -105,6 +106,10 @@ export default class QueryStringInputUI extends Component { private abortController?: AbortController; private fetchIndexPatternsAbortController?: AbortController; private services = this.props.kibana.services; + private reportUiCounter = this.services.usageCollection?.reportUiCounter.bind( + this.services.usageCollection, + this.services.appName + ); private componentIsUnmounting = false; private queryBarInputDivRefInstance: RefObject = createRef(); @@ -178,12 +183,14 @@ export default class QueryStringInputUI extends Component { selectionEnd, signal: this.abortController.signal, })) || []; - return [...suggestions, ...recentSearchSuggestions]; } catch (e) { // TODO: Waiting on https://github.com/elastic/kibana/issues/51406 for a properly typed error // Ignore aborted requests if (e.message === 'The user aborted a request.') return; + + this.reportUiCounter?.(METRIC_TYPE.LOADED, `query_string:suggestions_error`); + throw e; } }; @@ -302,7 +309,7 @@ export default class QueryStringInputUI extends Component { } if (isSuggestionsVisible && index !== null && this.state.suggestions[index]) { event.preventDefault(); - this.selectSuggestion(this.state.suggestions[index]); + this.selectSuggestion(this.state.suggestions[index], index); } else { this.onSubmit(this.props.query); this.setState({ @@ -335,7 +342,7 @@ export default class QueryStringInputUI extends Component { } }; - private selectSuggestion = (suggestion: QuerySuggestion) => { + private selectSuggestion = (suggestion: QuerySuggestion, listIndex: number) => { if (!this.inputRef) { return; } @@ -352,6 +359,17 @@ export default class QueryStringInputUI extends Component { const value = query.substr(0, selectionStart) + query.substr(selectionEnd); const newQueryString = value.substr(0, start) + text + value.substr(end); + this.reportUiCounter?.( + METRIC_TYPE.LOADED, + `query_string:${type}:suggestions_select_position`, + listIndex + ); + this.reportUiCounter?.( + METRIC_TYPE.LOADED, + `query_string:${type}:suggestions_select_q_length`, + end - start + ); + this.onQueryStringChange(newQueryString); this.setState({ @@ -458,6 +476,7 @@ export default class QueryStringInputUI extends Component { const newQuery = { query: '', language }; this.onChange(newQuery); this.onSubmit(newQuery); + this.reportUiCounter?.(METRIC_TYPE.LOADED, `query_string:language:${language}`); }; private onOutsideClick = () => { @@ -480,11 +499,11 @@ export default class QueryStringInputUI extends Component { } }; - private onClickSuggestion = (suggestion: QuerySuggestion) => { + private onClickSuggestion = (suggestion: QuerySuggestion, index: number) => { if (!this.inputRef) { return; } - this.selectSuggestion(suggestion); + this.selectSuggestion(suggestion, index); this.inputRef.focus(); }; @@ -588,6 +607,7 @@ export default class QueryStringInputUI extends Component { if (this.props.onChangeQueryInputFocus) { this.props.onChangeQueryInputFocus(true); } + requestAnimationFrame(() => { this.handleAutoHeight(); }); diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx index 8f34ec1912cb..4aba59442d20 100644 --- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx @@ -10,7 +10,6 @@ import _ from 'lodash'; import React, { useEffect, useRef } from 'react'; import { CoreStart } from 'src/core/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { UiCounterMetricType } from '@kbn/analytics'; import { KibanaContextProvider } from '../../../../kibana_react/public'; import { QueryStart, SavedQuery } from '../../query'; import { SearchBar, SearchBarOwnProps } from './'; @@ -20,12 +19,13 @@ import { useSavedQuery } from './lib/use_saved_query'; import { DataPublicPluginStart } from '../../types'; import { Filter, Query, TimeRange } from '../../../common'; import { useQueryStringManager } from './lib/use_query_string_manager'; +import { UsageCollectionSetup } from '../../../../usage_collection/public'; interface StatefulSearchBarDeps { core: CoreStart; data: Omit; storage: IStorageWrapper; - trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; + usageCollection?: UsageCollectionSetup; } export type StatefulSearchBarProps = SearchBarOwnProps & { @@ -110,7 +110,7 @@ const overrideDefaultBehaviors = (props: StatefulSearchBarProps) => { return props.useDefaultBehaviors ? {} : props; }; -export function createSearchBar({ core, storage, data, trackUiMetric }: StatefulSearchBarDeps) { +export function createSearchBar({ core, storage, data, usageCollection }: StatefulSearchBarDeps) { // App name should come from the core application service. // Until it's available, we'll ask the user to provide it for the pre-wired component. return (props: StatefulSearchBarProps) => { @@ -161,6 +161,7 @@ export function createSearchBar({ core, storage, data, trackUiMetric }: Stateful appName: props.appName, data, storage, + usageCollection, ...core, }} > @@ -188,7 +189,6 @@ export function createSearchBar({ core, storage, data, trackUiMetric }: Stateful onClearSavedQuery={defaultOnClearSavedQuery(props, clearSavedQuery)} onSavedQueryUpdated={defaultOnSavedQueryUpdated(props, setSavedQuery)} onSaved={defaultOnSavedQueryUpdated(props, setSavedQuery)} - trackUiMetric={trackUiMetric} {...overrideDefaultBehaviors(props)} /> diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index c87fcc3d9504..fe8165a12714 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -13,7 +13,7 @@ import React, { Component } from 'react'; import ResizeObserver from 'resize-observer-polyfill'; import { get, isEqual } from 'lodash'; -import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; +import { METRIC_TYPE } from '@kbn/analytics'; import { withKibana, KibanaReactContextValue } from '../../../../kibana_react/public'; import QueryBarTopRow from '../query_string_input/query_bar_top_row'; @@ -68,8 +68,6 @@ export interface SearchBarOwnProps { onRefresh?: (payload: { dateRange: TimeRange }) => void; indicateNoData?: boolean; - // Track UI Metrics - trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; } export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; @@ -323,9 +321,11 @@ class SearchBarUI extends Component { }, }); } - if (this.props.trackUiMetric) { - this.props.trackUiMetric(METRIC_TYPE.CLICK, `${this.services.appName}:query_submitted`); - } + this.services.usageCollection?.reportUiCounter( + this.services.appName, + METRIC_TYPE.CLICK, + 'query_submitted' + ); } ); }; @@ -428,7 +428,6 @@ class SearchBarUI extends Component { onFiltersUpdated={this.props.onFiltersUpdated} indexPatterns={this.props.indexPatterns!} appName={this.services.appName} - trackUiMetric={this.props.trackUiMetric} /> diff --git a/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap b/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap index 2fa7834872f6..9185e6a77d10 100644 --- a/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap +++ b/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap @@ -22,6 +22,7 @@ exports[`SuggestionsComponent Passing the index should control which suggestion > { it('Should display the suggestion and use the provided ariaId', () => { const component = shallow( { it('Should make the element active if the selected prop is true', () => { const component = shallow( { mount( { const component = shallow( { component.simulate('click'); expect(mockHandler).toHaveBeenCalledTimes(1); - expect(mockHandler).toHaveBeenCalledWith(mockSuggestion); + expect(mockHandler).toHaveBeenCalledWith(mockSuggestion, 0); }); it('Should call onMouseEnter when user mouses over the element', () => { @@ -100,6 +104,7 @@ describe('SuggestionComponent', () => { const component = shallow( void; + onClick: SuggestionOnClick; onMouseEnter: () => void; selected: boolean; + index: number; suggestion: QuerySuggestion; innerRef: (node: HTMLDivElement) => void; ariaId: string; @@ -48,7 +50,7 @@ export function SuggestionComponent(props: Props) { active: props.selected, })} role="option" - onClick={() => props.onClick(props.suggestion)} + onClick={() => props.onClick(props.suggestion, props.index)} onMouseEnter={props.onMouseEnter} ref={props.innerRef} id={props.ariaId} diff --git a/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx b/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx index ebbdc7fc55e3..dce8d5bdcfcd 100644 --- a/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx @@ -115,7 +115,7 @@ describe('SuggestionsComponent', () => { component.find(SuggestionComponent).at(1).simulate('click'); expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenCalledWith(mockSuggestions[1]); + expect(mockCallback).toHaveBeenCalledWith(mockSuggestions[1], 1); }); it('Should call onMouseEnter with the index of the suggestion that was entered', () => { diff --git a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx index fa1f4aa6a8ce..6bc91619fe86 100644 --- a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx @@ -17,11 +17,12 @@ import { SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET, SUGGESTIONS_LIST_REQUIRED_WIDTH, } from './constants'; +import { SuggestionOnClick } from './types'; // @internal export interface SuggestionsComponentProps { index: number | null; - onClick: (suggestion: QuerySuggestion) => void; + onClick: SuggestionOnClick; onMouseEnter: (index: number) => void; show: boolean; suggestions: QuerySuggestion[]; @@ -50,6 +51,7 @@ export default class SuggestionsComponent extends Component (this.childNodes[index] = node)} selected={index === this.props.index} + index={index} suggestion={suggestion} onClick={this.props.onClick} onMouseEnter={() => this.props.onMouseEnter(index)} diff --git a/src/plugins/data/public/ui/typeahead/types.ts b/src/plugins/data/public/ui/typeahead/types.ts new file mode 100644 index 000000000000..d0be717b2bf9 --- /dev/null +++ b/src/plugins/data/public/ui/typeahead/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { QuerySuggestion } from '../../autocomplete'; + +export type SuggestionOnClick = (suggestion: QuerySuggestion, index: number) => void;