diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md index 5f266e7d8bd8c..2247813562dc7 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -28,6 +28,6 @@ export declare class SearchInterceptor | --- | --- | --- | | [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | | | [handleSearchError(e, timeoutSignal, options)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | -| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when cancelPending is called, when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | +| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | | [showError(e)](./kibana-plugin-plugins-data-public.searchinterceptor.showerror.md) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md index 61f8eeb973f4c..a54b43da4add8 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md @@ -4,7 +4,7 @@ ## SearchInterceptor.search() method -Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when `cancelPending` is called, when the request times out, or when the original `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. +Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when the request times out, or when the original `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. Signature: diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 83a248ee2c3de..df799ede08a31 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -389,6 +389,7 @@ export type { ISessionService, SearchSessionInfoProvider, ISessionsClient, + SearchUsageCollector, } from './search'; export { ISearchOptions, isErrorResponse, isCompleteResponse, isPartialResponse } from '../common'; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 782f6f45eadc7..e904bbece7b19 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1669,7 +1669,7 @@ export interface ISearchSetup { aggs: AggsSetup; session: ISessionService; sessionsClient: ISessionsClient; - // Warning: (ae-forgotten-export) The symbol "SearchUsageCollector" needs to be exported by the entry point index.d.ts + // Warning: (ae-incompatible-release-tags) The symbol "usageCollector" is marked as @public, but its signature references "SearchUsageCollector" which is marked as @internal // // (undocumented) usageCollector?: SearchUsageCollector; @@ -2337,6 +2337,8 @@ export interface SearchInterceptorDeps { toasts: ToastsSetup; // (undocumented) uiSettings: CoreSetup_2['uiSettings']; + // Warning: (ae-incompatible-release-tags) The symbol "usageCollector" is marked as @public, but its signature references "SearchUsageCollector" which is marked as @internal + // // (undocumented) usageCollector?: SearchUsageCollector; } @@ -2461,6 +2463,38 @@ export class SearchTimeoutError extends KbnError { mode: TimeoutErrorMode; } +// @internal (undocumented) +export interface SearchUsageCollector { + // (undocumented) + trackQueryTimedOut: () => Promise; + // (undocumented) + trackSessionCancelled: () => Promise; + // (undocumented) + trackSessionDeleted: () => Promise; + // (undocumented) + trackSessionExtended: () => Promise; + // (undocumented) + trackSessionIndicatorSaveDisabled: () => Promise; + // (undocumented) + trackSessionIndicatorTourLoading: () => Promise; + // (undocumented) + trackSessionIndicatorTourRestored: () => Promise; + // (undocumented) + trackSessionIsRestored: () => Promise; + // (undocumented) + trackSessionReloaded: () => Promise; + // (undocumented) + trackSessionSavedResults: () => Promise; + // (undocumented) + trackSessionSentToBackground: () => Promise; + // (undocumented) + trackSessionsListLoaded: () => Promise; + // (undocumented) + trackSessionViewRestored: () => Promise; + // (undocumented) + trackViewSessionsList: () => Promise; +} + // Warning: (ae-missing-release-tag) "SortDirection" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2615,21 +2649,21 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:413:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:425: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:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/search/session/session_service.ts:42:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/collectors/create_usage_collector.test.ts b/src/plugins/data/public/search/collectors/create_usage_collector.test.ts index df9903a4683e1..145bb191fde11 100644 --- a/src/plugins/data/public/search/collectors/create_usage_collector.test.ts +++ b/src/plugins/data/public/search/collectors/create_usage_collector.test.ts @@ -45,12 +45,66 @@ describe('Search Usage Collector', () => { ); }); - test('tracks query cancellation', async () => { - await usageCollector.trackQueriesCancelled(); + test('tracks session sent to background', async () => { + await usageCollector.trackSessionSentToBackground(); expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); - expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.LOADED); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_SENT_TO_BACKGROUND + ); + }); + + test('tracks session saved results', async () => { + await usageCollector.trackSessionSavedResults(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_SAVED_RESULTS + ); + }); + + test('tracks session view restored', async () => { + await usageCollector.trackSessionViewRestored(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_VIEW_RESTORED + ); + }); + + test('tracks session is restored', async () => { + await usageCollector.trackSessionIsRestored(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_IS_RESTORED + ); + }); + + test('tracks session reloaded', async () => { + await usageCollector.trackSessionReloaded(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_RELOADED + ); + }); + + test('tracks session extended', async () => { + await usageCollector.trackSessionExtended(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_EXTENDED + ); + }); + + test('tracks session cancelled', async () => { + await usageCollector.trackSessionCancelled(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( - SEARCH_EVENT_TYPE.QUERIES_CANCELLED + SEARCH_EVENT_TYPE.SESSION_CANCELLED ); }); }); diff --git a/src/plugins/data/public/search/collectors/create_usage_collector.ts b/src/plugins/data/public/search/collectors/create_usage_collector.ts index e9a192a2710c4..3fe135ea29152 100644 --- a/src/plugins/data/public/search/collectors/create_usage_collector.ts +++ b/src/plugins/data/public/search/collectors/create_usage_collector.ts @@ -7,6 +7,7 @@ */ import { first } from 'rxjs/operators'; +import { UiCounterMetricType } from '@kbn/analytics'; import { StartServicesAccessor } from '../../../../../core/public'; import { METRIC_TYPE, UsageCollectionSetup } from '../../../../usage_collection/public'; import { SEARCH_EVENT_TYPE, SearchUsageCollector } from './types'; @@ -20,22 +21,48 @@ export const createUsageCollector = ( return application.currentAppId$.pipe(first()).toPromise(); }; - return { - trackQueryTimedOut: async () => { - const currentApp = await getCurrentApp(); - return usageCollection?.reportUiCounter( - currentApp!, - METRIC_TYPE.LOADED, - SEARCH_EVENT_TYPE.QUERY_TIMED_OUT - ); - }, - trackQueriesCancelled: async () => { + const getCollector = (metricType: UiCounterMetricType, eventType: SEARCH_EVENT_TYPE) => { + return async () => { const currentApp = await getCurrentApp(); - return usageCollection?.reportUiCounter( - currentApp!, - METRIC_TYPE.LOADED, - SEARCH_EVENT_TYPE.QUERIES_CANCELLED - ); - }, + return usageCollection?.reportUiCounter(currentApp!, metricType, eventType); + }; + }; + + return { + trackQueryTimedOut: getCollector(METRIC_TYPE.LOADED, SEARCH_EVENT_TYPE.QUERY_TIMED_OUT), + trackSessionIndicatorTourLoading: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_TOUR_LOADING + ), + trackSessionIndicatorTourRestored: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_TOUR_RESTORED + ), + trackSessionIndicatorSaveDisabled: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_SAVE_DISABLED + ), + trackSessionSentToBackground: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_SENT_TO_BACKGROUND + ), + trackSessionSavedResults: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_SAVED_RESULTS + ), + trackSessionViewRestored: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_VIEW_RESTORED + ), + trackSessionIsRestored: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_IS_RESTORED), + trackSessionReloaded: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_RELOADED), + trackSessionExtended: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_EXTENDED), + trackSessionCancelled: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_CANCELLED), + trackSessionDeleted: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_DELETED), + trackViewSessionsList: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_VIEW_LIST), + trackSessionsListLoaded: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSIONS_LIST_LOADED + ), }; }; diff --git a/src/plugins/data/public/search/collectors/mocks.ts b/src/plugins/data/public/search/collectors/mocks.ts new file mode 100644 index 0000000000000..2a546d6310d7f --- /dev/null +++ b/src/plugins/data/public/search/collectors/mocks.ts @@ -0,0 +1,28 @@ +/* + * 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 { SearchUsageCollector } from './types'; + +export function createSearchUsageCollectorMock(): jest.Mocked { + return { + trackQueryTimedOut: jest.fn(), + trackSessionIndicatorTourLoading: jest.fn(), + trackSessionIndicatorTourRestored: jest.fn(), + trackSessionIndicatorSaveDisabled: jest.fn(), + trackSessionSentToBackground: jest.fn(), + trackSessionSavedResults: jest.fn(), + trackSessionViewRestored: jest.fn(), + trackSessionIsRestored: jest.fn(), + trackSessionReloaded: jest.fn(), + trackSessionExtended: jest.fn(), + trackSessionCancelled: jest.fn(), + trackSessionDeleted: jest.fn(), + trackViewSessionsList: jest.fn(), + trackSessionsListLoaded: jest.fn(), + }; +} diff --git a/src/plugins/data/public/search/collectors/types.ts b/src/plugins/data/public/search/collectors/types.ts index 9668b4dcbefa2..49c240d1ccb16 100644 --- a/src/plugins/data/public/search/collectors/types.ts +++ b/src/plugins/data/public/search/collectors/types.ts @@ -6,12 +6,84 @@ * Side Public License, v 1. */ +/** + * @internal + */ export enum SEARCH_EVENT_TYPE { + /** + * A search reached the timeout configured in UI setting search:timeout + */ QUERY_TIMED_OUT = 'queryTimedOut', - QUERIES_CANCELLED = 'queriesCancelled', + /** + * The session indicator was automatically brought up because of a long running query + */ + SESSION_INDICATOR_TOUR_LOADING = 'sessionIndicatorTourLoading', + /** + * The session indicator was automatically brought up because of a restored session + */ + SESSION_INDICATOR_TOUR_RESTORED = 'sessionIndicatorTourRestored', + /** + * The session indicator was disabled because of a completion timeout + */ + SESSION_INDICATOR_SAVE_DISABLED = 'sessionIndicatorSaveDisabled', + /** + * The user clicked to continue a session in the background (prior to results completing) + */ + SESSION_SENT_TO_BACKGROUND = 'sessionSentToBackground', + /** + * The user clicked to save the session (after results completing) + */ + SESSION_SAVED_RESULTS = 'sessionSavedResults', + /** + * The user clicked to view a completed session + */ + SESSION_VIEW_RESTORED = 'sessionViewRestored', + /** + * The session was successfully restored upon a user navigating + */ + SESSION_IS_RESTORED = 'sessionIsRestored', + /** + * The user clicked to reload an expired/cancelled session + */ + SESSION_RELOADED = 'sessionReloaded', + /** + * The user clicked to extend the expiration of a session + */ + SESSION_EXTENDED = 'sessionExtended', + /** + * The user clicked to cancel a session + */ + SESSION_CANCELLED = 'sessionCancelled', + /** + * The user clicked to delete a session + */ + SESSION_DELETED = 'sessionDeleted', + /** + * The user clicked a link to view the list of sessions + */ + SESSION_VIEW_LIST = 'sessionViewList', + /** + * The user landed on the sessions management page + */ + SESSIONS_LIST_LOADED = 'sessionsListLoaded', } +/** + * @internal + */ export interface SearchUsageCollector { trackQueryTimedOut: () => Promise; - trackQueriesCancelled: () => Promise; + trackSessionIndicatorTourLoading: () => Promise; + trackSessionIndicatorTourRestored: () => Promise; + trackSessionIndicatorSaveDisabled: () => Promise; + trackSessionSentToBackground: () => Promise; + trackSessionSavedResults: () => Promise; + trackSessionViewRestored: () => Promise; + trackSessionIsRestored: () => Promise; + trackSessionReloaded: () => Promise; + trackSessionExtended: () => Promise; + trackSessionCancelled: () => Promise; + trackSessionDeleted: () => Promise; + trackViewSessionsList: () => Promise; + trackSessionsListLoaded: () => Promise; } diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index b1e0bc490823a..fded4c46992c0 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -8,7 +8,13 @@ export * from './expressions'; -export { ISearchSetup, ISearchStart, ISearchStartSearchSource, SearchEnhancements } from './types'; +export { + ISearchSetup, + ISearchStart, + ISearchStartSearchSource, + SearchEnhancements, + SearchUsageCollector, +} from './types'; export { ES_SEARCH_STRATEGY, diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index b16468120d95a..273bbfe9e7b08 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -10,6 +10,7 @@ import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks'; import { searchSourceMock } from './search_source/mocks'; import { ISearchSetup, ISearchStart } from './types'; import { getSessionsClientMock, getSessionServiceMock } from './session/mocks'; +import { createSearchUsageCollectorMock } from './collectors/mocks'; function createSetupContract(): jest.Mocked { return { @@ -17,6 +18,7 @@ function createSetupContract(): jest.Mocked { __enhance: jest.fn(), session: getSessionServiceMock(), sessionsClient: getSessionsClientMock(), + usageCollector: createSearchUsageCollectorMock(), }; } diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index f33740cc45bf9..f46a3d258f948 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -155,13 +155,14 @@ export class SearchInterceptor { const { signal: timeoutSignal } = timeoutController; const timeout$ = timeout ? timer(timeout) : NEVER; const subscription = timeout$.subscribe(() => { + this.deps.usageCollector?.trackQueryTimedOut(); timeoutController.abort(); }); const selfAbortController = new AbortController(); // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: - // 1. The user manually aborts (via `cancelPending`) + // 1. The internal abort controller aborts // 2. The request times out // 3. abort() is called on `selfAbortController`. This is used by session service to abort all pending searches that it tracks // in the current session @@ -221,8 +222,8 @@ export class SearchInterceptor { /** * Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort - * either when `cancelPending` is called, when the request times out, or when the original - * `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. + * either when the request times out, or when the original `AbortSignal` is aborted. Updates + * `pendingCount$` when the request is started/finalized. * * @param request * @options diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 01f5cf3de38bd..391be8e053746 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -15,7 +15,7 @@ import { IndexPatternsContract } from '../../common/index_patterns/index_pattern import { UsageCollectionSetup } from '../../../usage_collection/public'; import { ISessionsClient, ISessionService } from './session'; -export { ISearchStartSearchSource }; +export { ISearchStartSearchSource, SearchUsageCollector }; export interface SearchEnhancements { searchInterceptor: ISearchInterceptor; diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 0a116545e6e36..29f3494433bef 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -8,7 +8,11 @@ import React from 'react'; import moment from 'moment'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; -import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart, + SearchUsageCollector, +} from '../../../../src/plugins/data/public'; import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginStart } from '../../../../src/plugins/share/public'; @@ -40,6 +44,7 @@ export class DataEnhancedPlugin private enhancedSearchInterceptor!: EnhancedSearchInterceptor; private config!: ConfigSchema; private readonly storage = new Storage(window.localStorage); + private usageCollector?: SearchUsageCollector; constructor(private initializerContext: PluginInitializerContext) {} @@ -71,8 +76,10 @@ export class DataEnhancedPlugin this.config = this.initializerContext.config.get(); if (this.config.search.sessions.enabled) { const sessionsConfig = this.config.search.sessions; - registerSearchSessionsMgmt(core, sessionsConfig, { management }); + registerSearchSessionsMgmt(core, sessionsConfig, { data, management }); } + + this.usageCollector = data.search.usageCollector; } public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { @@ -90,6 +97,7 @@ export class DataEnhancedPlugin disableSaveAfterSessionCompletesTimeout: moment .duration(this.config.search.sessions.notTouchedTimeout) .asMilliseconds(), + usageCollector: this.usageCollector, }) ) ), 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 04a777b9b6897..02671974e5053 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 @@ -16,6 +16,7 @@ import { SearchTimeoutError, SearchSessionState, PainlessError, + DataPublicPluginSetup, } from 'src/plugins/data/public'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks'; @@ -51,14 +52,15 @@ function mockFetchImplementation(responses: any[]) { } describe('EnhancedSearchInterceptor', () => { - let mockUsageCollector: any; let sessionService: jest.Mocked; let sessionState$: BehaviorSubject; + let dataPluginMockSetup: DataPublicPluginSetup; beforeEach(() => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); sessionState$ = new BehaviorSubject(SearchSessionState.None); + dataPluginMockSetup = dataPluginMock.createSetupContract(); const dataPluginMockStart = dataPluginMock.createStartContract(); sessionService = { ...(dataPluginMockStart.search.session as jest.Mocked), @@ -80,11 +82,6 @@ describe('EnhancedSearchInterceptor', () => { complete.mockClear(); jest.clearAllTimers(); - mockUsageCollector = { - trackQueryTimedOut: jest.fn(), - trackQueriesCancelled: jest.fn(), - }; - const mockPromise = new Promise((resolve) => { resolve([ { @@ -102,7 +99,7 @@ describe('EnhancedSearchInterceptor', () => { startServices: mockPromise as any, http: mockCoreSetup.http, uiSettings: mockCoreSetup.uiSettings, - usageCollector: mockUsageCollector, + usageCollector: dataPluginMockSetup.search.usageCollector, session: sessionService, }); }); @@ -455,39 +452,6 @@ describe('EnhancedSearchInterceptor', () => { }); }); - describe('cancelPending', () => { - test('should abort all pending requests', async () => { - mockFetchImplementation([ - { - time: 10, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - { - time: 20, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]); - - searchInterceptor.search({}).subscribe({ next, error }); - searchInterceptor.search({}).subscribe({ next, error }); - searchInterceptor.cancelPending(); - - await timeTravel(); - - const areAllRequestsAborted = fetchMock.mock.calls.every(([_, signal]) => signal?.aborted); - expect(areAllRequestsAborted).toBe(true); - expect(mockUsageCollector.trackQueriesCancelled).toBeCalledTimes(1); - }); - }); - describe('session', () => { beforeEach(() => { const responses = [ 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 f211021e45773..0dfec1a35d900 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -46,15 +46,6 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { : TimeoutErrorMode.CONTACT; } - /** - * Abort our `AbortController`, which in turn aborts any intercepted searches. - */ - public cancelPending = () => { - this.abortController.abort(); - this.abortController = new AbortController(); - if (this.deps.usageCollector) this.deps.usageCollector.trackQueriesCancelled(); - }; - public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { const { combinedSignal, timeoutSignal, cleanup, abort } = this.setupAbortSignal({ abortSignal: options.abortSignal, diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx index 177cfbbb4fd7e..2dfca534c20b5 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx @@ -50,6 +50,7 @@ export class SearchSessionsMgmtApp { notifications, urls: share.urlGenerators, application, + usageCollector: pluginsSetup.data.search.usageCollector, }); const documentation = new AsyncSearchIntroDocumentation(docLinks); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx index 1f8f603400c9f..6b94eccc4e707 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx @@ -13,14 +13,17 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { coreMock } from 'src/core/public/mocks'; import { SessionsClient } from 'src/plugins/data/public/search'; -import { SessionsConfigSchema } from '..'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '..'; import { SearchSessionsMgmtAPI } from '../lib/api'; import { AsyncSearchIntroDocumentation } from '../lib/documentation'; import { LocaleWrapper, mockUrls } from '../__mocks__'; import { SearchSessionsMgmtMain } from './main'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { managementPluginMock } from '../../../../../../../src/plugins/management/public/mocks'; let mockCoreSetup: MockedKeys; let mockCoreStart: MockedKeys; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let sessionsClient: SessionsClient; let api: SearchSessionsMgmtAPI; @@ -29,6 +32,10 @@ describe('Background Search Session Management Main', () => { beforeEach(() => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -67,6 +74,7 @@ describe('Background Search Session Management Main', () => { ; let mockCoreStart: CoreStart; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let sessionsClient: SessionsClient; let api: SearchSessionsMgmtAPI; @@ -29,6 +32,10 @@ describe('Background Search Session Management Table', () => { beforeEach(async () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -79,6 +86,7 @@ describe('Background Search Session Management Table', () => { { { { ([]); const [isLoading, setIsLoading] = useState(false); const [debouncedIsLoading, setDebouncedIsLoading] = useState(false); @@ -71,7 +72,8 @@ export function SearchSessionsMgmtTable({ core, api, timezone, config, ...props // initial data load useEffect(() => { doRefresh(); - }, [doRefresh]); + plugins.data.search.usageCollector?.trackSessionsListLoaded(); + }, [doRefresh, plugins]); useInterval(doRefresh, refreshInterval); @@ -110,7 +112,7 @@ export function SearchSessionsMgmtTable({ core, api, timezone, config, ...props rowProps={() => ({ 'data-test-subj': 'searchSessionsRow', })} - columns={getColumns(core, api, config, timezone, onActionComplete)} + columns={getColumns(core, plugins, api, config, timezone, onActionComplete)} items={tableData} pagination={pagination} search={search} diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts index e916eed6bcbc4..0ac8fa798cc92 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { CoreStart, HttpStart, I18nStart, IUiSettingsClient } from 'kibana/public'; import { CoreSetup } from 'kibana/public'; -import type { DataPublicPluginStart } from 'src/plugins/data/public'; +import type { DataPublicPluginSetup, DataPublicPluginStart } from 'src/plugins/data/public'; import type { ManagementSetup } from 'src/plugins/management/public'; import type { SharePluginStart } from 'src/plugins/share/public'; import type { ConfigSchema } from '../../../config'; @@ -18,6 +18,7 @@ import type { AsyncSearchIntroDocumentation } from './lib/documentation'; import { SEARCH_SESSIONS_MANAGEMENT_ID } from '../../../../../../src/plugins/data/public'; export interface IManagementSectionsPluginsSetup { + data: DataPublicPluginSetup; management: ManagementSetup; } diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts index 39da58cb76918..838b51994aa71 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts @@ -11,7 +11,10 @@ import moment from 'moment'; import { from, race, timer } from 'rxjs'; import { mapTo, tap } from 'rxjs/operators'; import type { SharePluginStart } from 'src/plugins/share/public'; -import { ISessionsClient } from '../../../../../../../src/plugins/data/public'; +import { + ISessionsClient, + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; import { SearchSessionStatus } from '../../../../common/search'; import { ACTION } from '../components/actions'; import { PersistedSearchSessionSavedObjectAttributes, UISession } from '../types'; @@ -84,17 +87,18 @@ const mapToUISession = (urls: UrlGeneratorsStart, config: SessionsConfigSchema) }; }; -interface SearcgSessuibManagementDeps { +interface SearchSessionManagementDeps { urls: UrlGeneratorsStart; notifications: NotificationsStart; application: ApplicationStart; + usageCollector?: SearchUsageCollector; } export class SearchSessionsMgmtAPI { constructor( private sessionsClient: ISessionsClient, private config: SessionsConfigSchema, - private deps: SearcgSessuibManagementDeps + private deps: SearchSessionManagementDeps ) {} public async fetchTableData(): Promise { @@ -151,6 +155,7 @@ export class SearchSessionsMgmtAPI { } public reloadSearchSession(reloadUrl: string) { + this.deps.usageCollector?.trackSessionReloaded(); this.deps.application.navigateToUrl(reloadUrl); } @@ -160,6 +165,7 @@ export class SearchSessionsMgmtAPI { // Cancel and expire public async sendCancel(id: string): Promise { + this.deps.usageCollector?.trackSessionDeleted(); try { await this.sessionsClient.delete(id); @@ -179,6 +185,7 @@ export class SearchSessionsMgmtAPI { // Extend public async sendExtend(id: string, expires: string): Promise { + this.deps.usageCollector?.trackSessionExtended(); try { await this.sessionsClient.extend(id, expires); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx index fc0a8849006d3..29f0033aaf012 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx @@ -13,16 +13,19 @@ import moment from 'moment'; import { ReactElement } from 'react'; import { coreMock } from 'src/core/public/mocks'; import { SessionsClient } from 'src/plugins/data/public/search'; -import { SessionsConfigSchema } from '../'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '../'; import { SearchSessionStatus } from '../../../../common/search'; import { OnActionComplete } from '../components'; import { UISession } from '../types'; import { mockUrls } from '../__mocks__'; import { SearchSessionsMgmtAPI } from './api'; import { getColumns } from './get_columns'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { managementPluginMock } from '../../../../../../../src/plugins/management/public/mocks'; let mockCoreSetup: MockedKeys; let mockCoreStart: CoreStart; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let api: SearchSessionsMgmtAPI; let sessionsClient: SessionsClient; @@ -35,6 +38,10 @@ describe('Search Sessions Management table column factory', () => { beforeEach(async () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -72,7 +79,7 @@ describe('Search Sessions Management table column factory', () => { }); test('returns columns', () => { - const columns = getColumns(mockCoreStart, api, mockConfig, tz, handleAction); + const columns = getColumns(mockCoreStart, mockPluginsSetup, api, mockConfig, tz, handleAction); expect(columns).toMatchInlineSnapshot(` Array [ Object { @@ -124,9 +131,14 @@ describe('Search Sessions Management table column factory', () => { describe('name', () => { test('rendering', () => { - const [, nameColumn] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, nameColumn] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; const name = mount(nameColumn.render!(mockSession.name, mockSession) as ReactElement); @@ -137,9 +149,14 @@ describe('Search Sessions Management table column factory', () => { // Status column describe('status', () => { test('render in_progress', () => { - const [, , status] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, , status] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; const statusLine = mount(status.render!(mockSession.status, mockSession) as ReactElement); expect( @@ -148,9 +165,14 @@ describe('Search Sessions Management table column factory', () => { }); test('error handling', () => { - const [, , status] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, , status] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; mockSession.status = 'INVALID' as SearchSessionStatus; const statusLine = mount(status.render!(mockSession.status, mockSession) as ReactElement); @@ -168,6 +190,7 @@ describe('Search Sessions Management table column factory', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, @@ -184,6 +207,7 @@ describe('Search Sessions Management table column factory', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, @@ -198,6 +222,7 @@ describe('Search Sessions Management table column factory', () => { test('error handling', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx index cbd42ec56bb8b..d34998d023178 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx @@ -21,7 +21,7 @@ import { capitalize } from 'lodash'; import React from 'react'; import { FormattedMessage } from 'react-intl'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; -import { SessionsConfigSchema } from '../'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '../'; import { SearchSessionStatus } from '../../../../common/search'; import { TableText } from '../components'; import { OnActionComplete, PopoverActionsMenu } from '../components'; @@ -45,6 +45,7 @@ function isSessionRestorable(status: SearchSessionStatus) { export const getColumns = ( core: CoreStart, + plugins: IManagementSectionsPluginsSetup, api: SearchSessionsMgmtAPI, config: SessionsConfigSchema, timezone: string, @@ -83,6 +84,10 @@ export const getColumns = ( width: '20%', render: (name: UISession['name'], { restoreUrl, reloadUrl, status }) => { const isRestorable = isSessionRestorable(status); + const href = isRestorable ? restoreUrl : reloadUrl; + const trackAction = isRestorable + ? plugins.data.search.usageCollector?.trackSessionViewRestored + : plugins.data.search.usageCollector?.trackSessionReloaded; const notRestorableWarning = isRestorable ? null : ( <> {' '} @@ -99,8 +104,10 @@ export const getColumns = ( ); return ( + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} trackAction?.()} data-test-subj="sessionManagementNameCol" > diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx index aacb86f269727..0aef27310e090 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx @@ -16,18 +16,22 @@ import { ISessionService, RefreshInterval, SearchSessionState, + SearchUsageCollector, TimefilterContract, } from '../../../../../../../src/plugins/data/public'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { TOUR_RESTORE_STEP_KEY, TOUR_TAKING_TOO_LONG_STEP_KEY } from './search_session_tour'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from 'react-intl'; +import { createSearchUsageCollectorMock } from '../../../../../../../src/plugins/data/public/search/collectors/mocks'; const coreStart = coreMock.createStart(); const application = coreStart.application; const dataStart = dataPluginMock.createStartContract(); const sessionService = dataStart.search.session as jest.Mocked; let storage: Storage; +let usageCollector: jest.Mocked; + const refreshInterval$ = new BehaviorSubject({ value: 0, pause: true }); const timeFilter = dataStart.query.timefilter.timefilter as jest.Mocked; timeFilter.getRefreshIntervalUpdate$.mockImplementation(() => refreshInterval$); @@ -41,6 +45,7 @@ function Container({ children }: { children?: ReactNode }) { beforeEach(() => { storage = new Storage(new StubBrowserStorage()); + usageCollector = createSearchUsageCollectorMock(); refreshInterval$.next({ value: 0, pause: true }); sessionService.isSessionStorageReady.mockImplementation(() => true); sessionService.getSearchSessionIndicatorUiConfig.mockImplementation(() => ({ @@ -57,6 +62,7 @@ test("shouldn't show indicator in case no active search session", async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId, container } = render( @@ -84,6 +90,7 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId, container } = render( @@ -113,6 +120,7 @@ test('should show indicator in case there is an active search session', async () timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId } = render( @@ -137,6 +145,7 @@ test('should be disabled in case uiConfig says so ', async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -185,6 +194,7 @@ test('should be disabled during auto-refresh', async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -222,6 +232,7 @@ describe('Completed inactivity', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -253,12 +264,14 @@ describe('Completed inactivity', () => { }); expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled(); + expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(0); act(() => { jest.advanceTimersByTime(2.5 * 60 * 1000); }); expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); + expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(1); }); }); @@ -280,6 +293,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -307,6 +321,9 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeTruthy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(1); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); test("doesn't show tour step if state changed before delay", async () => { @@ -317,6 +334,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -337,6 +355,9 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeFalsy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); }); @@ -348,6 +369,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -360,6 +382,10 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeTruthy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeTruthy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIsRestored).toHaveBeenCalledTimes(1); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(1); }); test("doesn't show tour for irrelevant state", async () => { @@ -370,6 +396,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -383,5 +410,8 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeFalsy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); }); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index 81769e5a25544..7c70a270bd30a 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; -import { debounce, distinctUntilChanged, map, mapTo, switchMap } from 'rxjs/operators'; +import React, { useCallback, useEffect, useState } from 'react'; +import { debounce, distinctUntilChanged, map, mapTo, switchMap, tap } from 'rxjs/operators'; import { merge, of, timer } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; @@ -15,7 +15,8 @@ import { ISessionService, SearchSessionState, TimefilterContract, -} from '../../../../../../../src/plugins/data/public/'; + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; import { ApplicationStart } from '../../../../../../../src/core/public'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; @@ -31,6 +32,7 @@ export interface SearchSessionIndicatorDeps { * after the last search in the session has completed */ disableSaveAfterSessionCompletesTimeout: number; + usageCollector?: SearchUsageCollector; } export const createConnectedSearchSessionIndicator = ({ @@ -39,6 +41,7 @@ export const createConnectedSearchSessionIndicator = ({ timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }: SearchSessionIndicatorDeps): React.FC => { const isAutoRefreshEnabled = () => !timeFilter.getRefreshInterval().pause; const isAutoRefreshEnabled$ = timeFilter @@ -55,7 +58,10 @@ export const createConnectedSearchSessionIndicator = ({ ? merge(of(false), timer(disableSaveAfterSessionCompletesTimeout).pipe(mapTo(true))) : of(false) ), - distinctUntilChanged() + distinctUntilChanged(), + tap((value) => { + if (value) usageCollector?.trackSessionIndicatorSaveDisabled(); + }) ); return () => { @@ -123,7 +129,8 @@ export const createConnectedSearchSessionIndicator = ({ storage, searchSessionIndicator, state, - saveDisabled + saveDisabled, + usageCollector ); const onOpened = useCallback( @@ -138,18 +145,31 @@ export const createConnectedSearchSessionIndicator = ({ const onContinueInBackground = useCallback(() => { if (saveDisabled) return; + usageCollector?.trackSessionSentToBackground(); sessionService.save(); }, [saveDisabled]); const onSaveResults = useCallback(() => { if (saveDisabled) return; + usageCollector?.trackSessionSavedResults(); sessionService.save(); }, [saveDisabled]); const onCancel = useCallback(() => { + usageCollector?.trackSessionCancelled(); sessionService.cancel(); }, []); + const onViewSearchSessions = useCallback(() => { + usageCollector?.trackViewSessionsList(); + }, []); + + useEffect(() => { + if (state === SearchSessionState.Restored) { + usageCollector?.trackSessionIsRestored(); + } + }, [state]); + if (!sessionService.isSessionStorageReady()) return null; return ( @@ -164,6 +184,7 @@ export const createConnectedSearchSessionIndicator = ({ onSaveResults={onSaveResults} onCancel={onCancel} onOpened={onOpened} + onViewSearchSessions={onViewSearchSessions} /> ); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx index 7987278f400ff..1568d54962eca 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx @@ -6,9 +6,13 @@ */ import { useCallback, useEffect } from 'react'; +import { once } from 'lodash'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; import { SearchSessionIndicatorRef } from '../search_session_indicator'; -import { SearchSessionState } from '../../../../../../../src/plugins/data/public'; +import { + SearchSessionState, + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; const TOUR_TAKING_TOO_LONG_TIMEOUT = 10000; export const TOUR_TAKING_TOO_LONG_STEP_KEY = `data.searchSession.tour.takingTooLong`; @@ -18,7 +22,8 @@ export function useSearchSessionTour( storage: IStorageWrapper, searchSessionIndicatorRef: SearchSessionIndicatorRef | null, state: SearchSessionState, - searchSessionsDisabled: boolean + searchSessionsDisabled: boolean, + usageCollector?: SearchUsageCollector ) { const markOpenedDone = useCallback(() => { safeSet(storage, TOUR_TAKING_TOO_LONG_STEP_KEY); @@ -28,6 +33,26 @@ export function useSearchSessionTour( safeSet(storage, TOUR_RESTORE_STEP_KEY); }, [storage]); + // Makes sure `trackSessionIndicatorTourLoading` is called only once per sessionId + // if to call `usageCollector?.trackSessionIndicatorTourLoading()` directly inside the `useEffect` below + // it might happen that we cause excessive logging + // ESLint: React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + const trackSessionIndicatorTourLoading = useCallback( + once(() => usageCollector?.trackSessionIndicatorTourLoading()), + [usageCollector, state] + ); + + // Makes sure `trackSessionIndicatorTourRestored` is called only once per sessionId + // if to call `usageCollector?.trackSessionIndicatorTourRestored()` directly inside the `useEffect` below + // it might happen that we cause excessive logging + // ESLint: React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + const trackSessionIndicatorTourRestored = useCallback( + once(() => usageCollector?.trackSessionIndicatorTourRestored()), + [usageCollector, state] + ); + useEffect(() => { if (searchSessionsDisabled) return; if (!searchSessionIndicatorRef) return; @@ -36,6 +61,7 @@ export function useSearchSessionTour( if (state === SearchSessionState.Loading) { if (!safeHas(storage, TOUR_TAKING_TOO_LONG_STEP_KEY)) { timeoutHandle = window.setTimeout(() => { + trackSessionIndicatorTourLoading(); searchSessionIndicatorRef.openPopover(); }, TOUR_TAKING_TOO_LONG_TIMEOUT); } @@ -43,6 +69,7 @@ export function useSearchSessionTour( if (state === SearchSessionState.Restored) { if (!safeHas(storage, TOUR_RESTORE_STEP_KEY)) { + trackSessionIndicatorTourRestored(); searchSessionIndicatorRef.openPopover(); } } @@ -57,6 +84,9 @@ export function useSearchSessionTour( searchSessionsDisabled, markOpenedDone, markRestoredDone, + usageCollector, + trackSessionIndicatorTourRestored, + trackSessionIndicatorTourLoading, ]); return { diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx index 0d31ce0c98f19..24ffc1359acae 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx @@ -30,6 +30,7 @@ export interface SearchSessionIndicatorProps { onContinueInBackground?: () => void; onCancel?: () => void; viewSearchSessionsLink?: string; + onViewSearchSessions?: () => void; onSaveResults?: () => void; managementDisabled?: boolean; managementDisabledReasonText?: string; @@ -78,13 +79,16 @@ const ContinueInBackgroundButton = ({ const ViewAllSearchSessionsButton = ({ viewSearchSessionsLink = 'management/kibana/search_sessions', + onViewSearchSessions = () => {}, buttonProps = {}, managementDisabled, managementDisabledReasonText, }: ActionButtonProps) => ( + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */}