;
@@ -874,12 +928,8 @@ export interface OverlayStart {
banners: OverlayBannersStart;
// (undocumented)
openConfirm: OverlayModalStart['openConfirm'];
- // Warning: (ae-forgotten-export) The symbol "OverlayFlyoutStart" needs to be exported by the entry point index.d.ts
- //
// (undocumented)
openFlyout: OverlayFlyoutStart['open'];
- // Warning: (ae-forgotten-export) The symbol "OverlayModalStart" needs to be exported by the entry point index.d.ts
- //
// (undocumented)
openModal: OverlayModalStart['open'];
}
diff --git a/src/core/public/utils/crypto/sha256.ts b/src/core/public/utils/crypto/sha256.ts
index eaa057d604689..13e0d405a706b 100644
--- a/src/core/public/utils/crypto/sha256.ts
+++ b/src/core/public/utils/crypto/sha256.ts
@@ -130,7 +130,7 @@ type BufferEncoding =
| 'binary'
| 'hex';
-/* eslint-disable no-bitwise, no-shadow */
+/* eslint-disable no-bitwise, @typescript-eslint/no-shadow */
export class Sha256 {
private _a: number;
private _b: number;
diff --git a/src/core/server/http/router/validator/validator.ts b/src/core/server/http/router/validator/validator.ts
index babca87495a4e..be7781fdacbe0 100644
--- a/src/core/server/http/router/validator/validator.ts
+++ b/src/core/server/http/router/validator/validator.ts
@@ -143,8 +143,8 @@ export type RouteValidatorFullConfig = RouteValidatorConfig
&
* @internal
*/
export class RouteValidator
{
- public static from
(
- opts: RouteValidator
| RouteValidatorFullConfig
+ public static from<_P = {}, _Q = {}, _B = {}>(
+ opts: RouteValidator<_P, _Q, _B> | RouteValidatorFullConfig<_P, _Q, _B>
) {
if (opts instanceof RouteValidator) {
return opts;
diff --git a/src/fixtures/telemetry_collectors/nested_collector.ts b/src/fixtures/telemetry_collectors/nested_collector.ts
index bde89fe4a7060..ce563b46b0c4e 100644
--- a/src/fixtures/telemetry_collectors/nested_collector.ts
+++ b/src/fixtures/telemetry_collectors/nested_collector.ts
@@ -29,7 +29,7 @@ interface Usage {
}
export class NestedInside {
- collector?: UsageCollector;
+ collector?: UsageCollector;
createMyCollector() {
this.collector = collectorSet.makeUsageCollector({
type: 'my_nested_collector',
diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts
index 3ffac0c12eb22..4f4a593764b1e 100644
--- a/src/plugins/data/common/search/aggs/agg_type.ts
+++ b/src/plugins/data/common/search/aggs/agg_type.ts
@@ -54,7 +54,7 @@ export interface AggTypeConfig<
aggConfigs: IAggConfigs,
aggConfig: TAggConfig,
searchSource: ISearchSource,
- inspectorRequestAdapter: RequestAdapter,
+ inspectorRequestAdapter?: RequestAdapter,
abortSignal?: AbortSignal
) => Promise;
getSerializedFormat?: (agg: TAggConfig) => SerializedFieldFormat;
@@ -189,7 +189,7 @@ export class AggType<
aggConfigs: IAggConfigs,
aggConfig: TAggConfig,
searchSource: ISearchSource,
- inspectorRequestAdapter: RequestAdapter,
+ inspectorRequestAdapter?: RequestAdapter,
abortSignal?: AbortSignal
) => Promise;
/**
diff --git a/src/plugins/data/common/search/aggs/buckets/terms.ts b/src/plugins/data/common/search/aggs/buckets/terms.ts
index 3d543e6c5f574..ac65e7fa813b3 100644
--- a/src/plugins/data/common/search/aggs/buckets/terms.ts
+++ b/src/plugins/data/common/search/aggs/buckets/terms.ts
@@ -19,6 +19,7 @@
import { noop } from 'lodash';
import { i18n } from '@kbn/i18n';
+import type { RequestAdapter } from 'src/plugins/inspector/common';
import { BucketAggType, IBucketAggConfig } from './bucket_agg_type';
import { BUCKET_TYPES } from './bucket_agg_types';
@@ -111,27 +112,32 @@ export const getTermsBucketAgg = () =>
nestedSearchSource.setField('aggs', filterAgg);
- const request = inspectorRequestAdapter.start(
- i18n.translate('data.search.aggs.buckets.terms.otherBucketTitle', {
- defaultMessage: 'Other bucket',
- }),
- {
- description: i18n.translate('data.search.aggs.buckets.terms.otherBucketDescription', {
- defaultMessage:
- 'This request counts the number of documents that fall ' +
- 'outside the criterion of the data buckets.',
+ let request: ReturnType | undefined;
+ if (inspectorRequestAdapter) {
+ request = inspectorRequestAdapter.start(
+ i18n.translate('data.search.aggs.buckets.terms.otherBucketTitle', {
+ defaultMessage: 'Other bucket',
}),
- }
- );
- nestedSearchSource.getSearchRequestBody().then((body) => {
- request.json(body);
- });
- request.stats(getRequestInspectorStats(nestedSearchSource));
+ {
+ description: i18n.translate('data.search.aggs.buckets.terms.otherBucketDescription', {
+ defaultMessage:
+ 'This request counts the number of documents that fall ' +
+ 'outside the criterion of the data buckets.',
+ }),
+ }
+ );
+ nestedSearchSource.getSearchRequestBody().then((body) => {
+ request!.json(body);
+ });
+ request.stats(getRequestInspectorStats(nestedSearchSource));
+ }
const response = await nestedSearchSource.fetch({ abortSignal });
- request
- .stats(getResponseInspectorStats(response, nestedSearchSource))
- .ok({ json: response });
+ if (request) {
+ request
+ .stats(getResponseInspectorStats(response, nestedSearchSource))
+ .ok({ json: response });
+ }
resp = mergeOtherBucketAggResponse(aggConfigs, resp, response, aggConfig, filterAgg());
}
if (aggConfig.params.missingBucket) {
diff --git a/src/plugins/data/common/search/session/index.ts b/src/plugins/data/common/search/session/index.ts
index d8f7b5091eb8f..0feb43f8f1d4b 100644
--- a/src/plugins/data/common/search/session/index.ts
+++ b/src/plugins/data/common/search/session/index.ts
@@ -17,4 +17,5 @@
* under the License.
*/
+export * from './status';
export * from './types';
diff --git a/src/plugins/data/common/search/session/mocks.ts b/src/plugins/data/common/search/session/mocks.ts
index 370faaa640c56..4604e15e4e93b 100644
--- a/src/plugins/data/common/search/session/mocks.ts
+++ b/src/plugins/data/common/search/session/mocks.ts
@@ -27,5 +27,12 @@ export function getSessionServiceMock(): jest.Mocked {
restore: jest.fn(),
getSessionId: jest.fn(),
getSession$: jest.fn(() => new BehaviorSubject(undefined).asObservable()),
+ isStored: jest.fn(),
+ isRestore: jest.fn(),
+ save: jest.fn(),
+ get: jest.fn(),
+ find: jest.fn(),
+ update: jest.fn(),
+ delete: jest.fn(),
};
}
diff --git a/src/plugins/data/common/search/session/status.ts b/src/plugins/data/common/search/session/status.ts
new file mode 100644
index 0000000000000..1f6b6eb3084bb
--- /dev/null
+++ b/src/plugins/data/common/search/session/status.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 BackgroundSessionStatus {
+ IN_PROGRESS = 'in_progress',
+ ERROR = 'error',
+ COMPLETE = 'complete',
+ CANCELLED = 'cancelled',
+ EXPIRED = 'expired',
+}
diff --git a/src/plugins/data/common/search/session/types.ts b/src/plugins/data/common/search/session/types.ts
index 6660b8395547f..d1ab22057695a 100644
--- a/src/plugins/data/common/search/session/types.ts
+++ b/src/plugins/data/common/search/session/types.ts
@@ -18,6 +18,7 @@
*/
import { Observable } from 'rxjs';
+import type { SavedObject, SavedObjectsFindResponse } from 'kibana/server';
export interface ISessionService {
/**
@@ -30,6 +31,17 @@ export interface ISessionService {
* @returns `Observable`
*/
getSession$: () => Observable;
+
+ /**
+ * Whether the active session is already saved (i.e. sent to background)
+ */
+ isStored: () => boolean;
+
+ /**
+ * Whether the active session is restored (i.e. reusing previous search IDs)
+ */
+ isRestore: () => boolean;
+
/**
* Starts a new session
*/
@@ -38,10 +50,58 @@ export interface ISessionService {
/**
* Restores existing session
*/
- restore: (sessionId: string) => void;
+ restore: (sessionId: string) => Promise>;
/**
* Clears the active session.
*/
clear: () => void;
+
+ /**
+ * Saves a session
+ */
+ save: (name: string, url: string) => Promise>;
+
+ /**
+ * Gets a saved session
+ */
+ get: (sessionId: string) => Promise>;
+
+ /**
+ * Gets a list of saved sessions
+ */
+ find: (
+ options: SearchSessionFindOptions
+ ) => Promise>;
+
+ /**
+ * Updates a session
+ */
+ update: (
+ sessionId: string,
+ attributes: Partial
+ ) => Promise;
+
+ /**
+ * Deletes a session
+ */
+ delete: (sessionId: string) => Promise;
+}
+
+export interface BackgroundSessionSavedObjectAttributes {
+ name: string;
+ created: string;
+ expires: string;
+ status: string;
+ initialState: Record;
+ restoreState: Record;
+ idMapping: Record;
+}
+
+export interface SearchSessionFindOptions {
+ page?: number;
+ perPage?: number;
+ sortField?: string;
+ sortOrder?: string;
+ filter?: string;
}
diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts
index 7451edf5e2fa3..695ee34d3b468 100644
--- a/src/plugins/data/common/search/types.ts
+++ b/src/plugins/data/common/search/types.ts
@@ -92,4 +92,15 @@ export interface ISearchOptions {
* A session ID, grouping multiple search requests into a single session.
*/
sessionId?: string;
+
+ /**
+ * Whether the session is already saved (i.e. sent to background)
+ */
+ isStored?: boolean;
+
+ /**
+ * Whether the session is restored (i.e. search requests should re-use the stored search IDs,
+ * rather than starting from scratch)
+ */
+ isRestore?: boolean;
}
diff --git a/src/plugins/data/common/utils/index.ts b/src/plugins/data/common/utils/index.ts
index 8b8686c51b9c1..4b602cb963a8f 100644
--- a/src/plugins/data/common/utils/index.ts
+++ b/src/plugins/data/common/utils/index.ts
@@ -19,3 +19,4 @@
/** @internal */
export { shortenDottedString } from './shorten_dotted_string';
+export { tapFirst } from './tap_first';
diff --git a/src/plugins/data/common/utils/tap_first.test.ts b/src/plugins/data/common/utils/tap_first.test.ts
new file mode 100644
index 0000000000000..033ae59f8c715
--- /dev/null
+++ b/src/plugins/data/common/utils/tap_first.test.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 { of } from 'rxjs';
+import { tapFirst } from './tap_first';
+
+describe('tapFirst', () => {
+ it('should tap the first and only the first', () => {
+ const fn = jest.fn();
+ of(1, 2, 3).pipe(tapFirst(fn)).subscribe();
+ expect(fn).toBeCalledTimes(1);
+ expect(fn).lastCalledWith(1);
+ });
+});
diff --git a/src/plugins/data/common/utils/tap_first.ts b/src/plugins/data/common/utils/tap_first.ts
new file mode 100644
index 0000000000000..2c783a3ef87f0
--- /dev/null
+++ b/src/plugins/data/common/utils/tap_first.ts
@@ -0,0 +1,31 @@
+/*
+ * 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 { pipe } from 'rxjs';
+import { tap } from 'rxjs/operators';
+
+export function tapFirst(next: (x: T) => void) {
+ let isFirst = true;
+ return pipe(
+ tap((x: T) => {
+ if (isFirst) next(x);
+ isFirst = false;
+ })
+ );
+}
diff --git a/src/plugins/data/public/autocomplete/autocomplete_service.ts b/src/plugins/data/public/autocomplete/autocomplete_service.ts
index 2136a405baad6..5e9aede0760fe 100644
--- a/src/plugins/data/public/autocomplete/autocomplete_service.ts
+++ b/src/plugins/data/public/autocomplete/autocomplete_service.ts
@@ -18,6 +18,7 @@
*/
import { CoreSetup, PluginInitializerContext } from 'src/core/public';
+import { TimefilterSetup } from '../query';
import { QuerySuggestionGetFn } from './providers/query_suggestion_provider';
import {
getEmptyValueSuggestions,
@@ -57,9 +58,9 @@ export class AutocompleteService {
private hasQuerySuggestions = (language: string) => this.querySuggestionProviders.has(language);
/** @public **/
- public setup(core: CoreSetup) {
+ public setup(core: CoreSetup, { timefilter }: { timefilter: TimefilterSetup }) {
this.getValueSuggestions = this.autocompleteConfig.valueSuggestions.enabled
- ? setupValueSuggestionProvider(core)
+ ? setupValueSuggestionProvider(core, { timefilter })
: getEmptyValueSuggestions;
return {
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 0ef5b7db958e4..4e1745ffcabb2 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,29 +18,10 @@
*/
import { stubIndexPattern, stubFields } from '../../stubs';
+import { TimefilterSetup } from '../../query';
import { setupValueSuggestionProvider, ValueSuggestionsGetFn } from './value_suggestion_provider';
import { IUiSettingsClient, CoreSetup } from 'kibana/public';
-jest.mock('../../services', () => ({
- getQueryService: () => ({
- timefilter: {
- timefilter: {
- createFilter: () => {
- return {
- time: 'fake',
- };
- },
- getTime: () => {
- return {
- to: 'now',
- from: 'now-15m',
- };
- },
- },
- },
- }),
-}));
-
describe('FieldSuggestions', () => {
let getValueSuggestions: ValueSuggestionsGetFn;
let http: any;
@@ -50,7 +31,23 @@ describe('FieldSuggestions', () => {
const uiSettings = { get: (key: string) => shouldSuggestValues } as IUiSettingsClient;
http = { fetch: jest.fn() };
- getValueSuggestions = setupValueSuggestionProvider({ http, uiSettings } as CoreSetup);
+ getValueSuggestions = setupValueSuggestionProvider({ http, uiSettings } as CoreSetup, {
+ timefilter: ({
+ timefilter: {
+ createFilter: () => {
+ return {
+ time: 'fake',
+ };
+ },
+ getTime: () => {
+ return {
+ to: 'now',
+ from: 'now-15m',
+ };
+ },
+ },
+ } as unknown) as TimefilterSetup,
+ });
});
describe('with value suggestions disabled', () => {
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 fe9f939a0261d..ee92fce02dda5 100644
--- a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts
+++ b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts
@@ -21,7 +21,7 @@ import dateMath from '@elastic/datemath';
import { memoize } from 'lodash';
import { CoreSetup } from 'src/core/public';
import { IIndexPattern, IFieldType, UI_SETTINGS, buildQueryFromFilters } from '../../../common';
-import { getQueryService } from '../../services';
+import { TimefilterSetup } from '../../query';
function resolver(title: string, field: IFieldType, query: string, filters: any[]) {
// Only cache results for a minute
@@ -40,8 +40,10 @@ interface ValueSuggestionsGetFnArgs {
signal?: AbortSignal;
}
-const getAutocompleteTimefilter = (indexPattern: IIndexPattern) => {
- const { timefilter } = getQueryService().timefilter;
+const getAutocompleteTimefilter = (
+ { timefilter }: TimefilterSetup,
+ indexPattern: IIndexPattern
+) => {
const timeRange = timefilter.getTime();
// Use a rounded timerange so that memoizing works properly
@@ -54,7 +56,10 @@ const getAutocompleteTimefilter = (indexPattern: IIndexPattern) => {
export const getEmptyValueSuggestions = (() => Promise.resolve([])) as ValueSuggestionsGetFn;
-export const setupValueSuggestionProvider = (core: CoreSetup): ValueSuggestionsGetFn => {
+export const setupValueSuggestionProvider = (
+ core: CoreSetup,
+ { timefilter }: { timefilter: TimefilterSetup }
+): ValueSuggestionsGetFn => {
const requestSuggestions = memoize(
(index: string, field: IFieldType, query: string, filters: any = [], signal?: AbortSignal) =>
core.http.fetch(`/api/kibana/suggestions/values/${index}`, {
@@ -86,7 +91,9 @@ export const setupValueSuggestionProvider = (core: CoreSetup): ValueSuggestionsG
return [];
}
- const timeFilter = useTimeRange ? getAutocompleteTimefilter(indexPattern) : undefined;
+ const timeFilter = useTimeRange
+ ? getAutocompleteTimefilter(timefilter, indexPattern)
+ : undefined;
const filterQuery = timeFilter ? buildQueryFromFilters([timeFilter], indexPattern).filter : [];
const filters = [...(boolFilter ? boolFilter : []), ...filterQuery];
return await requestSuggestions(title, field, query, filters, signal);
diff --git a/src/plugins/data/public/index_patterns/index_pattern.stub.ts b/src/plugins/data/public/index_patterns/index_pattern.stub.ts
index e5c6c008e3e28..804f0d7d89225 100644
--- a/src/plugins/data/public/index_patterns/index_pattern.stub.ts
+++ b/src/plugins/data/public/index_patterns/index_pattern.stub.ts
@@ -20,20 +20,9 @@
import sinon from 'sinon';
import { CoreSetup } from 'src/core/public';
-import { FieldFormat as FieldFormatImpl } from '../../common/field_formats';
import { IFieldType, FieldSpec } from '../../common/index_patterns';
-import { FieldFormatsStart } from '../field_formats';
import { IndexPattern, indexPatterns, KBN_FIELD_TYPES, fieldList } from '../';
import { getFieldFormatsRegistry } from '../test_utils';
-import { setFieldFormats } from '../services';
-
-setFieldFormats(({
- getDefaultInstance: () =>
- ({
- getConverterFor: () => (value: any) => value,
- convert: (value: any) => JSON.stringify(value),
- } as FieldFormatImpl),
-} as unknown) as FieldFormatsStart);
export function getStubIndexPattern(
pattern: string,
diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts
index afa8d935f367b..7e8283476ffc5 100644
--- a/src/plugins/data/public/plugin.ts
+++ b/src/plugins/data/public/plugin.ts
@@ -41,16 +41,14 @@ import {
UiSettingsPublicToCommon,
} from './index_patterns';
import {
- setFieldFormats,
setIndexPatterns,
setNotifications,
setOverlays,
- setQueryService,
setSearchService,
setUiSettings,
} from './services';
import { createSearchBar } from './ui/search_bar/create_search_bar';
-import { esaggs } from './search/expressions';
+import { getEsaggs } from './search/expressions';
import {
SELECT_RANGE_TRIGGER,
VALUE_CLICK_TRIGGER,
@@ -111,8 +109,22 @@ export class DataPublicPlugin
): DataPublicPluginSetup {
const startServices = createStartServicesGetter(core.getStartServices);
- expressions.registerFunction(esaggs);
expressions.registerFunction(indexPatternLoad);
+ expressions.registerFunction(
+ getEsaggs({
+ getStartDependencies: async () => {
+ const [, , self] = await core.getStartServices();
+ const { fieldFormats, indexPatterns, query, search } = self;
+ return {
+ addFilters: query.filterManager.addFilters.bind(query.filterManager),
+ aggs: search.aggs,
+ deserializeFieldFormat: fieldFormats.deserialize.bind(fieldFormats),
+ indexPatterns,
+ searchSource: search.searchSource,
+ };
+ },
+ })
+ );
this.usageCollection = usageCollection;
@@ -145,7 +157,7 @@ export class DataPublicPlugin
});
return {
- autocomplete: this.autocomplete.setup(core),
+ autocomplete: this.autocomplete.setup(core, { timefilter: queryService.timefilter }),
search: searchService,
fieldFormats: this.fieldFormatsService.setup(core),
query: queryService,
@@ -162,7 +174,6 @@ export class DataPublicPlugin
setUiSettings(uiSettings);
const fieldFormats = this.fieldFormatsService.start();
- setFieldFormats(fieldFormats);
const indexPatterns = new IndexPatternsService({
uiSettings: new UiSettingsPublicToCommon(uiSettings),
@@ -186,7 +197,6 @@ export class DataPublicPlugin
savedObjectsClient: savedObjects.client,
uiSettings,
});
- setQueryService(query);
const search = this.searchService.start(core, { fieldFormats, indexPatterns });
setSearchService(search);
diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md
index 0768658e40299..6c4609e5506c2 100644
--- a/src/plugins/data/public/public.api.md
+++ b/src/plugins/data/public/public.api.md
@@ -27,6 +27,7 @@ import { EuiButtonEmptyProps } from '@elastic/eui';
import { EuiComboBoxProps } from '@elastic/eui';
import { EuiConfirmModalProps } from '@elastic/eui';
import { EuiGlobalToastListToast } from '@elastic/eui';
+import { EventEmitter } from 'events';
import { ExclusiveUnion } from '@elastic/eui';
import { ExecutionContext } from 'src/plugins/expressions/common';
import { ExpressionAstFunction } from 'src/plugins/expressions/common';
@@ -66,13 +67,15 @@ import * as React_2 from 'react';
import { RecursiveReadonly } from '@kbn/utility-types';
import { Reporter } from '@kbn/analytics';
import { RequestAdapter } from 'src/plugins/inspector/common';
-import { RequestStatistics } from 'src/plugins/inspector/common';
+import { RequestStatistics as RequestStatistics_2 } from 'src/plugins/inspector/common';
import { Required } from '@kbn/utility-types';
import * as Rx from 'rxjs';
-import { SavedObject } from 'src/core/server';
-import { SavedObject as SavedObject_2 } from 'src/core/public';
+import { SavedObject } from 'kibana/server';
+import { SavedObject as SavedObject_2 } from 'src/core/server';
+import { SavedObject as SavedObject_3 } from 'src/core/public';
import { SavedObjectReference } from 'src/core/types';
import { SavedObjectsClientContract } from 'src/core/public';
+import { SavedObjectsFindResponse } from 'kibana/server';
import { Search } from '@elastic/elasticsearch/api/requestParams';
import { SearchResponse } from 'elasticsearch';
import { SerializedFieldFormat as SerializedFieldFormat_2 } from 'src/plugins/expressions/common';
@@ -1388,7 +1391,7 @@ export class IndexPatternsService {
// Warning: (ae-forgotten-export) The symbol "IndexPatternSavedObjectAttrs" needs to be exported by the entry point index.d.ts
//
// (undocumented)
- getCache: () => Promise[] | null | undefined>;
+ getCache: () => Promise[] | null | undefined>;
getDefault: () => Promise;
getFieldsForIndexPattern: (indexPattern: IndexPattern | IndexPatternSpec, options?: GetFieldsOptions | undefined) => Promise;
// Warning: (ae-forgotten-export) The symbol "GetFieldsOptions" needs to be exported by the entry point index.d.ts
@@ -1400,7 +1403,7 @@ export class IndexPatternsService {
}>>;
getTitles: (refresh?: boolean) => Promise;
refreshFields: (indexPattern: IndexPattern) => Promise;
- savedObjectToSpec: (savedObject: SavedObject) => IndexPatternSpec;
+ savedObjectToSpec: (savedObject: SavedObject_2) => IndexPatternSpec;
setDefault: (id: string, force?: boolean) => Promise;
updateSavedObject(indexPattern: IndexPattern, saveAttempts?: number, ignoreErrors?: boolean): Promise;
}
@@ -1445,6 +1448,8 @@ export type ISearchGeneric = | undefined
// @public (undocumented)
export interface ISessionService {
clear: () => void;
+ delete: (sessionId: string) => Promise;
+ // Warning: (ae-forgotten-export) The symbol "SearchSessionFindOptions" needs to be exported by the entry point index.d.ts
+ find: (options: SearchSessionFindOptions) => Promise>;
+ get: (sessionId: string) => Promise>;
getSession$: () => Observable;
getSessionId: () => string | undefined;
- restore: (sessionId: string) => void;
+ isRestore: () => boolean;
+ isStored: () => boolean;
+ // Warning: (ae-forgotten-export) The symbol "BackgroundSessionSavedObjectAttributes" needs to be exported by the entry point index.d.ts
+ restore: (sessionId: string) => Promise>;
+ save: (name: string, url: string) => Promise>;
start: () => string;
+ update: (sessionId: string, attributes: Partial) => Promise;
}
// Warning: (ae-missing-release-tag) "isFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -2068,7 +2082,7 @@ export class SearchInterceptor {
// @internal
protected pendingCount$: BehaviorSubject;
// @internal (undocumented)
- protected runSearch(request: IKibanaSearchRequest, signal: AbortSignal, strategy?: string): Promise;
+ protected runSearch(request: IKibanaSearchRequest, options?: ISearchOptions): Promise;
search(request: IKibanaSearchRequest, options?: ISearchOptions): Observable;
// @internal (undocumented)
protected setupAbortSignal({ abortSignal, timeout, }: {
diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts
deleted file mode 100644
index 3932484801fa8..0000000000000
--- a/src/plugins/data/public/search/expressions/esaggs.ts
+++ /dev/null
@@ -1,323 +0,0 @@
-/*
- * 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 { get, hasIn } from 'lodash';
-import { i18n } from '@kbn/i18n';
-import { Datatable, DatatableColumn } from 'src/plugins/expressions/public';
-import { PersistedState } from '../../../../../plugins/visualizations/public';
-import { Adapters } from '../../../../../plugins/inspector/public';
-
-import {
- calculateBounds,
- EsaggsExpressionFunctionDefinition,
- Filter,
- getTime,
- IIndexPattern,
- isRangeFilter,
- Query,
- TimeRange,
-} from '../../../common';
-import {
- getRequestInspectorStats,
- getResponseInspectorStats,
- IAggConfigs,
- ISearchSource,
- tabifyAggResponse,
-} from '../../../common/search';
-
-import { FilterManager } from '../../query';
-import {
- getFieldFormats,
- getIndexPatterns,
- getQueryService,
- getSearchService,
-} from '../../services';
-import { buildTabularInspectorData } from './build_tabular_inspector_data';
-
-export interface RequestHandlerParams {
- searchSource: ISearchSource;
- aggs: IAggConfigs;
- timeRange?: TimeRange;
- timeFields?: string[];
- indexPattern?: IIndexPattern;
- query?: Query;
- filters?: Filter[];
- filterManager: FilterManager;
- uiState?: PersistedState;
- partialRows?: boolean;
- inspectorAdapters: Adapters;
- metricsAtAllLevels?: boolean;
- visParams?: any;
- abortSignal?: AbortSignal;
- searchSessionId?: string;
-}
-
-const name = 'esaggs';
-
-const handleCourierRequest = async ({
- searchSource,
- aggs,
- timeRange,
- timeFields,
- indexPattern,
- query,
- filters,
- partialRows,
- metricsAtAllLevels,
- inspectorAdapters,
- filterManager,
- abortSignal,
- searchSessionId,
-}: RequestHandlerParams) => {
- // Create a new search source that inherits the original search source
- // but has the appropriate timeRange applied via a filter.
- // This is a temporary solution until we properly pass down all required
- // information for the request to the request handler (https://github.com/elastic/kibana/issues/16641).
- // Using callParentStartHandlers: true we make sure, that the parent searchSource
- // onSearchRequestStart will be called properly even though we use an inherited
- // search source.
- const timeFilterSearchSource = searchSource.createChild({ callParentStartHandlers: true });
- const requestSearchSource = timeFilterSearchSource.createChild({ callParentStartHandlers: true });
-
- aggs.setTimeRange(timeRange as TimeRange);
-
- // For now we need to mirror the history of the passed search source, since
- // the request inspector wouldn't work otherwise.
- Object.defineProperty(requestSearchSource, 'history', {
- get() {
- return searchSource.history;
- },
- set(history) {
- return (searchSource.history = history);
- },
- });
-
- requestSearchSource.setField('aggs', function () {
- return aggs.toDsl(metricsAtAllLevels);
- });
-
- requestSearchSource.onRequestStart((paramSearchSource, options) => {
- return aggs.onSearchRequestStart(paramSearchSource, options);
- });
-
- // If timeFields have been specified, use the specified ones, otherwise use primary time field of index
- // pattern if it's available.
- const defaultTimeField = indexPattern?.getTimeField?.();
- const defaultTimeFields = defaultTimeField ? [defaultTimeField.name] : [];
- const allTimeFields = timeFields && timeFields.length > 0 ? timeFields : defaultTimeFields;
-
- // If a timeRange has been specified and we had at least one timeField available, create range
- // filters for that those time fields
- if (timeRange && allTimeFields.length > 0) {
- timeFilterSearchSource.setField('filter', () => {
- return allTimeFields
- .map((fieldName) => getTime(indexPattern, timeRange, { fieldName }))
- .filter(isRangeFilter);
- });
- }
-
- requestSearchSource.setField('filter', filters);
- requestSearchSource.setField('query', query);
-
- inspectorAdapters.requests.reset();
- const request = inspectorAdapters.requests.start(
- i18n.translate('data.functions.esaggs.inspector.dataRequest.title', {
- defaultMessage: 'Data',
- }),
- {
- description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', {
- defaultMessage:
- 'This request queries Elasticsearch to fetch the data for the visualization.',
- }),
- searchSessionId,
- }
- );
- request.stats(getRequestInspectorStats(requestSearchSource));
-
- try {
- const response = await requestSearchSource.fetch({
- abortSignal,
- sessionId: searchSessionId,
- });
-
- request.stats(getResponseInspectorStats(response, searchSource)).ok({ json: response });
-
- (searchSource as any).rawResponse = response;
- } catch (e) {
- // Log any error during request to the inspector
- request.error({ json: e });
- throw e;
- } finally {
- // Add the request body no matter if things went fine or not
- requestSearchSource.getSearchRequestBody().then((req: unknown) => {
- request.json(req);
- });
- }
-
- // Note that rawResponse is not deeply cloned here, so downstream applications using courier
- // must take care not to mutate it, or it could have unintended side effects, e.g. displaying
- // response data incorrectly in the inspector.
- let resp = (searchSource as any).rawResponse;
- for (const agg of aggs.aggs) {
- if (hasIn(agg, 'type.postFlightRequest')) {
- resp = await agg.type.postFlightRequest(
- resp,
- aggs,
- agg,
- requestSearchSource,
- inspectorAdapters.requests,
- abortSignal
- );
- }
- }
-
- (searchSource as any).finalResponse = resp;
-
- const parsedTimeRange = timeRange ? calculateBounds(timeRange) : null;
- const tabifyParams = {
- metricsAtAllLevels,
- partialRows,
- timeRange: parsedTimeRange
- ? { from: parsedTimeRange.min, to: parsedTimeRange.max, timeFields: allTimeFields }
- : undefined,
- };
-
- const response = tabifyAggResponse(aggs, (searchSource as any).finalResponse, tabifyParams);
-
- (searchSource as any).tabifiedResponse = response;
-
- inspectorAdapters.data.setTabularLoader(
- () =>
- buildTabularInspectorData((searchSource as any).tabifiedResponse, {
- queryFilter: filterManager,
- deserializeFieldFormat: getFieldFormats().deserialize,
- }),
- { returnsFormattedValues: true }
- );
-
- return response;
-};
-
-export const esaggs = (): EsaggsExpressionFunctionDefinition => ({
- name,
- type: 'datatable',
- inputTypes: ['kibana_context', 'null'],
- help: i18n.translate('data.functions.esaggs.help', {
- defaultMessage: 'Run AggConfig aggregation',
- }),
- args: {
- index: {
- types: ['string'],
- help: '',
- },
- metricsAtAllLevels: {
- types: ['boolean'],
- default: false,
- help: '',
- },
- partialRows: {
- types: ['boolean'],
- default: false,
- help: '',
- },
- includeFormatHints: {
- types: ['boolean'],
- default: false,
- help: '',
- },
- aggConfigs: {
- types: ['string'],
- default: '""',
- help: '',
- },
- timeFields: {
- types: ['string'],
- help: '',
- multi: true,
- },
- },
- async fn(input, args, { inspectorAdapters, abortSignal, getSearchSessionId }) {
- const indexPatterns = getIndexPatterns();
- const { filterManager } = getQueryService();
- const searchService = getSearchService();
-
- const aggConfigsState = JSON.parse(args.aggConfigs);
- const indexPattern = await indexPatterns.get(args.index);
- const aggs = searchService.aggs.createAggConfigs(indexPattern, aggConfigsState);
-
- // we should move searchSource creation inside courier request handler
- const searchSource = await searchService.searchSource.create();
-
- searchSource.setField('index', indexPattern);
- searchSource.setField('size', 0);
-
- const resolvedTimeRange = input?.timeRange && calculateBounds(input.timeRange);
-
- const response = await handleCourierRequest({
- searchSource,
- aggs,
- indexPattern,
- timeRange: get(input, 'timeRange', undefined),
- query: get(input, 'query', undefined) as any,
- filters: get(input, 'filters', undefined),
- timeFields: args.timeFields,
- metricsAtAllLevels: args.metricsAtAllLevels,
- partialRows: args.partialRows,
- inspectorAdapters: inspectorAdapters as Adapters,
- filterManager,
- abortSignal: (abortSignal as unknown) as AbortSignal,
- searchSessionId: getSearchSessionId(),
- });
-
- const table: Datatable = {
- type: 'datatable',
- rows: response.rows,
- columns: response.columns.map((column) => {
- const cleanedColumn: DatatableColumn = {
- id: column.id,
- name: column.name,
- meta: {
- type: column.aggConfig.params.field?.type || 'number',
- field: column.aggConfig.params.field?.name,
- index: indexPattern.title,
- params: column.aggConfig.toSerializedFieldFormat(),
- source: 'esaggs',
- sourceParams: {
- indexPatternId: indexPattern.id,
- appliedTimeRange:
- column.aggConfig.params.field?.name &&
- input?.timeRange &&
- args.timeFields &&
- args.timeFields.includes(column.aggConfig.params.field?.name)
- ? {
- from: resolvedTimeRange?.min?.toISOString(),
- to: resolvedTimeRange?.max?.toISOString(),
- }
- : undefined,
- ...column.aggConfig.serialize(),
- },
- },
- };
- return cleanedColumn;
- }),
- };
-
- return table;
- },
-});
diff --git a/src/plugins/data/public/search/expressions/build_tabular_inspector_data.ts b/src/plugins/data/public/search/expressions/esaggs/build_tabular_inspector_data.ts
similarity index 78%
rename from src/plugins/data/public/search/expressions/build_tabular_inspector_data.ts
rename to src/plugins/data/public/search/expressions/esaggs/build_tabular_inspector_data.ts
index 7eff6f25fd828..79dedf4131764 100644
--- a/src/plugins/data/public/search/expressions/build_tabular_inspector_data.ts
+++ b/src/plugins/data/public/search/expressions/esaggs/build_tabular_inspector_data.ts
@@ -18,35 +18,41 @@
*/
import { set } from '@elastic/safer-lodash-set';
-import { FormattedData } from '../../../../../plugins/inspector/public';
-import { TabbedTable } from '../../../common';
-import { FormatFactory } from '../../../common/field_formats/utils';
-import { createFilter } from './create_filter';
+import {
+ FormattedData,
+ TabularData,
+ TabularDataValue,
+} from '../../../../../../plugins/inspector/common';
+import { Filter, TabbedTable } from '../../../../common';
+import { FormatFactory } from '../../../../common/field_formats/utils';
+import { createFilter } from '../create_filter';
/**
- * @deprecated
+ * Type borrowed from the client-side FilterManager['addFilters'].
*
- * Do not use this function.
- *
- * @todo This function is used only by Courier. Courier will
- * soon be removed, and this function will be deleted, too. If Courier is not removed,
- * move this function inside Courier.
- *
- * ---
+ * We need to use a custom type to make this isomorphic since FilterManager
+ * doesn't exist on the server.
*
+ * @internal
+ */
+export type AddFilters = (filters: Filter[] | Filter, pinFilterStatus?: boolean) => void;
+
+/**
* This function builds tabular data from the response and attaches it to the
* inspector. It will only be called when the data view in the inspector is opened.
+ *
+ * @internal
*/
export async function buildTabularInspectorData(
table: TabbedTable,
{
- queryFilter,
+ addFilters,
deserializeFieldFormat,
}: {
- queryFilter: { addFilters: (filter: any) => void };
+ addFilters?: AddFilters;
deserializeFieldFormat: FormatFactory;
}
-) {
+): Promise {
const aggConfigs = table.columns.map((column) => column.aggConfig);
const rows = table.rows.map((row) => {
return table.columns.reduce>((prev, cur, colIndex) => {
@@ -74,20 +80,22 @@ export async function buildTabularInspectorData(
name: col.name,
field: `col-${colIndex}-${col.aggConfig.id}`,
filter:
+ addFilters &&
isCellContentFilterable &&
- ((value: { raw: unknown }) => {
+ ((value: TabularDataValue) => {
const rowIndex = rows.findIndex(
(row) => row[`col-${colIndex}-${col.aggConfig.id}`].raw === value.raw
);
const filter = createFilter(aggConfigs, table, colIndex, rowIndex, value.raw);
if (filter) {
- queryFilter.addFilters(filter);
+ addFilters(filter);
}
}),
filterOut:
+ addFilters &&
isCellContentFilterable &&
- ((value: { raw: unknown }) => {
+ ((value: TabularDataValue) => {
const rowIndex = rows.findIndex(
(row) => row[`col-${colIndex}-${col.aggConfig.id}`].raw === value.raw
);
@@ -101,7 +109,7 @@ export async function buildTabularInspectorData(
} else {
set(filter, 'meta.negate', notOther && notMissing);
}
- queryFilter.addFilters(filter);
+ addFilters(filter);
}
}),
};
diff --git a/src/plugins/data/public/search/expressions/esaggs/esaggs_fn.ts b/src/plugins/data/public/search/expressions/esaggs/esaggs_fn.ts
new file mode 100644
index 0000000000000..ce3bd9bdaee76
--- /dev/null
+++ b/src/plugins/data/public/search/expressions/esaggs/esaggs_fn.ts
@@ -0,0 +1,155 @@
+/*
+ * 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 { get } from 'lodash';
+import { i18n } from '@kbn/i18n';
+
+import { Datatable, DatatableColumn } from 'src/plugins/expressions/common';
+import { Adapters } from 'src/plugins/inspector/common';
+
+import { calculateBounds, EsaggsExpressionFunctionDefinition } from '../../../../common';
+import { FormatFactory } from '../../../../common/field_formats/utils';
+import { IndexPatternsContract } from '../../../../common/index_patterns/index_patterns';
+import { ISearchStartSearchSource, AggsStart } from '../../../../common/search';
+
+import { AddFilters } from './build_tabular_inspector_data';
+import { handleRequest } from './request_handler';
+
+const name = 'esaggs';
+
+interface StartDependencies {
+ addFilters: AddFilters;
+ aggs: AggsStart;
+ deserializeFieldFormat: FormatFactory;
+ indexPatterns: IndexPatternsContract;
+ searchSource: ISearchStartSearchSource;
+}
+
+export function getEsaggs({
+ getStartDependencies,
+}: {
+ getStartDependencies: () => Promise;
+}) {
+ return (): EsaggsExpressionFunctionDefinition => ({
+ name,
+ type: 'datatable',
+ inputTypes: ['kibana_context', 'null'],
+ help: i18n.translate('data.functions.esaggs.help', {
+ defaultMessage: 'Run AggConfig aggregation',
+ }),
+ args: {
+ index: {
+ types: ['string'],
+ help: '',
+ },
+ metricsAtAllLevels: {
+ types: ['boolean'],
+ default: false,
+ help: '',
+ },
+ partialRows: {
+ types: ['boolean'],
+ default: false,
+ help: '',
+ },
+ includeFormatHints: {
+ types: ['boolean'],
+ default: false,
+ help: '',
+ },
+ aggConfigs: {
+ types: ['string'],
+ default: '""',
+ help: '',
+ },
+ timeFields: {
+ types: ['string'],
+ help: '',
+ multi: true,
+ },
+ },
+ async fn(input, args, { inspectorAdapters, abortSignal, getSearchSessionId }) {
+ const {
+ addFilters,
+ aggs,
+ deserializeFieldFormat,
+ indexPatterns,
+ searchSource,
+ } = await getStartDependencies();
+
+ const aggConfigsState = JSON.parse(args.aggConfigs);
+ const indexPattern = await indexPatterns.get(args.index);
+ const aggConfigs = aggs.createAggConfigs(indexPattern, aggConfigsState);
+
+ const resolvedTimeRange = input?.timeRange && calculateBounds(input.timeRange);
+
+ const response = await handleRequest({
+ abortSignal: (abortSignal as unknown) as AbortSignal,
+ addFilters,
+ aggs: aggConfigs,
+ deserializeFieldFormat,
+ filters: get(input, 'filters', undefined),
+ indexPattern,
+ inspectorAdapters: inspectorAdapters as Adapters,
+ metricsAtAllLevels: args.metricsAtAllLevels,
+ partialRows: args.partialRows,
+ query: get(input, 'query', undefined) as any,
+ searchSessionId: getSearchSessionId(),
+ searchSourceService: searchSource,
+ timeFields: args.timeFields,
+ timeRange: get(input, 'timeRange', undefined),
+ });
+
+ const table: Datatable = {
+ type: 'datatable',
+ rows: response.rows,
+ columns: response.columns.map((column) => {
+ const cleanedColumn: DatatableColumn = {
+ id: column.id,
+ name: column.name,
+ meta: {
+ type: column.aggConfig.params.field?.type || 'number',
+ field: column.aggConfig.params.field?.name,
+ index: indexPattern.title,
+ params: column.aggConfig.toSerializedFieldFormat(),
+ source: name,
+ sourceParams: {
+ indexPatternId: indexPattern.id,
+ appliedTimeRange:
+ column.aggConfig.params.field?.name &&
+ input?.timeRange &&
+ args.timeFields &&
+ args.timeFields.includes(column.aggConfig.params.field?.name)
+ ? {
+ from: resolvedTimeRange?.min?.toISOString(),
+ to: resolvedTimeRange?.max?.toISOString(),
+ }
+ : undefined,
+ ...column.aggConfig.serialize(),
+ },
+ },
+ };
+ return cleanedColumn;
+ }),
+ };
+
+ return table;
+ },
+ });
+}
diff --git a/src/plugins/data/public/search/expressions/esaggs/index.ts b/src/plugins/data/public/search/expressions/esaggs/index.ts
new file mode 100644
index 0000000000000..cbd3fb9cc5e91
--- /dev/null
+++ b/src/plugins/data/public/search/expressions/esaggs/index.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 * from './esaggs_fn';
diff --git a/src/plugins/data/public/search/expressions/esaggs/request_handler.ts b/src/plugins/data/public/search/expressions/esaggs/request_handler.ts
new file mode 100644
index 0000000000000..93b5705b821c0
--- /dev/null
+++ b/src/plugins/data/public/search/expressions/esaggs/request_handler.ts
@@ -0,0 +1,213 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+import { Adapters } from 'src/plugins/inspector/common';
+
+import {
+ calculateBounds,
+ Filter,
+ getTime,
+ IndexPattern,
+ isRangeFilter,
+ Query,
+ TimeRange,
+} from '../../../../common';
+import {
+ getRequestInspectorStats,
+ getResponseInspectorStats,
+ IAggConfigs,
+ ISearchStartSearchSource,
+ tabifyAggResponse,
+} from '../../../../common/search';
+import { FormatFactory } from '../../../../common/field_formats/utils';
+
+import { AddFilters, buildTabularInspectorData } from './build_tabular_inspector_data';
+
+interface RequestHandlerParams {
+ abortSignal?: AbortSignal;
+ addFilters?: AddFilters;
+ aggs: IAggConfigs;
+ deserializeFieldFormat: FormatFactory;
+ filters?: Filter[];
+ indexPattern?: IndexPattern;
+ inspectorAdapters: Adapters;
+ metricsAtAllLevels?: boolean;
+ partialRows?: boolean;
+ query?: Query;
+ searchSessionId?: string;
+ searchSourceService: ISearchStartSearchSource;
+ timeFields?: string[];
+ timeRange?: TimeRange;
+}
+
+export const handleRequest = async ({
+ abortSignal,
+ addFilters,
+ aggs,
+ deserializeFieldFormat,
+ filters,
+ indexPattern,
+ inspectorAdapters,
+ metricsAtAllLevels,
+ partialRows,
+ query,
+ searchSessionId,
+ searchSourceService,
+ timeFields,
+ timeRange,
+}: RequestHandlerParams) => {
+ const searchSource = await searchSourceService.create();
+
+ searchSource.setField('index', indexPattern);
+ searchSource.setField('size', 0);
+
+ // Create a new search source that inherits the original search source
+ // but has the appropriate timeRange applied via a filter.
+ // This is a temporary solution until we properly pass down all required
+ // information for the request to the request handler (https://github.com/elastic/kibana/issues/16641).
+ // Using callParentStartHandlers: true we make sure, that the parent searchSource
+ // onSearchRequestStart will be called properly even though we use an inherited
+ // search source.
+ const timeFilterSearchSource = searchSource.createChild({ callParentStartHandlers: true });
+ const requestSearchSource = timeFilterSearchSource.createChild({ callParentStartHandlers: true });
+
+ aggs.setTimeRange(timeRange as TimeRange);
+
+ // For now we need to mirror the history of the passed search source, since
+ // the request inspector wouldn't work otherwise.
+ Object.defineProperty(requestSearchSource, 'history', {
+ get() {
+ return searchSource.history;
+ },
+ set(history) {
+ return (searchSource.history = history);
+ },
+ });
+
+ requestSearchSource.setField('aggs', function () {
+ return aggs.toDsl(metricsAtAllLevels);
+ });
+
+ requestSearchSource.onRequestStart((paramSearchSource, options) => {
+ return aggs.onSearchRequestStart(paramSearchSource, options);
+ });
+
+ // If timeFields have been specified, use the specified ones, otherwise use primary time field of index
+ // pattern if it's available.
+ const defaultTimeField = indexPattern?.getTimeField?.();
+ const defaultTimeFields = defaultTimeField ? [defaultTimeField.name] : [];
+ const allTimeFields = timeFields && timeFields.length > 0 ? timeFields : defaultTimeFields;
+
+ // If a timeRange has been specified and we had at least one timeField available, create range
+ // filters for that those time fields
+ if (timeRange && allTimeFields.length > 0) {
+ timeFilterSearchSource.setField('filter', () => {
+ return allTimeFields
+ .map((fieldName) => getTime(indexPattern, timeRange, { fieldName }))
+ .filter(isRangeFilter);
+ });
+ }
+
+ requestSearchSource.setField('filter', filters);
+ requestSearchSource.setField('query', query);
+
+ let request;
+ if (inspectorAdapters.requests) {
+ inspectorAdapters.requests.reset();
+ request = inspectorAdapters.requests.start(
+ i18n.translate('data.functions.esaggs.inspector.dataRequest.title', {
+ defaultMessage: 'Data',
+ }),
+ {
+ description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', {
+ defaultMessage:
+ 'This request queries Elasticsearch to fetch the data for the visualization.',
+ }),
+ searchSessionId,
+ }
+ );
+ request.stats(getRequestInspectorStats(requestSearchSource));
+ }
+
+ try {
+ const response = await requestSearchSource.fetch({
+ abortSignal,
+ sessionId: searchSessionId,
+ });
+
+ if (request) {
+ request.stats(getResponseInspectorStats(response, searchSource)).ok({ json: response });
+ }
+
+ (searchSource as any).rawResponse = response;
+ } catch (e) {
+ // Log any error during request to the inspector
+ if (request) {
+ request.error({ json: e });
+ }
+ throw e;
+ } finally {
+ // Add the request body no matter if things went fine or not
+ if (request) {
+ request.json(await requestSearchSource.getSearchRequestBody());
+ }
+ }
+
+ // Note that rawResponse is not deeply cloned here, so downstream applications using courier
+ // must take care not to mutate it, or it could have unintended side effects, e.g. displaying
+ // response data incorrectly in the inspector.
+ let response = (searchSource as any).rawResponse;
+ for (const agg of aggs.aggs) {
+ if (typeof agg.type.postFlightRequest === 'function') {
+ response = await agg.type.postFlightRequest(
+ response,
+ aggs,
+ agg,
+ requestSearchSource,
+ inspectorAdapters.requests,
+ abortSignal
+ );
+ }
+ }
+
+ const parsedTimeRange = timeRange ? calculateBounds(timeRange) : null;
+ const tabifyParams = {
+ metricsAtAllLevels,
+ partialRows,
+ timeRange: parsedTimeRange
+ ? { from: parsedTimeRange.min, to: parsedTimeRange.max, timeFields: allTimeFields }
+ : undefined,
+ };
+
+ const tabifiedResponse = tabifyAggResponse(aggs, response, tabifyParams);
+
+ if (inspectorAdapters.data) {
+ inspectorAdapters.data.setTabularLoader(
+ () =>
+ buildTabularInspectorData(tabifiedResponse, {
+ addFilters,
+ deserializeFieldFormat,
+ }),
+ { returnsFormattedValues: true }
+ );
+ }
+
+ return tabifiedResponse;
+};
diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts
index 78e65802bcf99..3fadb723b27cd 100644
--- a/src/plugins/data/public/search/search_interceptor.ts
+++ b/src/plugins/data/public/search/search_interceptor.ts
@@ -126,18 +126,25 @@ export class SearchInterceptor {
*/
protected runSearch(
request: IKibanaSearchRequest,
- signal: AbortSignal,
- strategy?: string
+ options?: ISearchOptions
): Promise {
const { id, ...searchRequest } = request;
- const path = trimEnd(`/internal/search/${strategy || ES_SEARCH_STRATEGY}/${id || ''}`, '/');
- const body = JSON.stringify(searchRequest);
+ const path = trimEnd(
+ `/internal/search/${options?.strategy ?? ES_SEARCH_STRATEGY}/${id ?? ''}`,
+ '/'
+ );
+ const body = JSON.stringify({
+ sessionId: options?.sessionId,
+ isStored: options?.isStored,
+ isRestore: options?.isRestore,
+ ...searchRequest,
+ });
return this.deps.http.fetch({
method: 'POST',
path,
body,
- signal,
+ signal: options?.abortSignal,
});
}
@@ -235,7 +242,7 @@ export class SearchInterceptor {
abortSignal: options?.abortSignal,
});
this.pendingCount$.next(this.pendingCount$.getValue() + 1);
- return from(this.runSearch(request, combinedSignal, options?.strategy)).pipe(
+ return from(this.runSearch(request, { ...options, abortSignal: combinedSignal })).pipe(
catchError((e: Error) => {
return throwError(this.handleSearchError(e, request, timeoutSignal, options));
}),
diff --git a/src/plugins/data/public/search/session_service.ts b/src/plugins/data/public/search/session_service.ts
index a172738812937..0141cff258a9f 100644
--- a/src/plugins/data/public/search/session_service.ts
+++ b/src/plugins/data/public/search/session_service.ts
@@ -19,9 +19,13 @@
import uuid from 'uuid';
import { BehaviorSubject, Subscription } from 'rxjs';
-import { PluginInitializerContext, StartServicesAccessor } from 'kibana/public';
+import { HttpStart, PluginInitializerContext, StartServicesAccessor } from 'kibana/public';
import { ConfigSchema } from '../../config';
-import { ISessionService } from '../../common/search';
+import {
+ ISessionService,
+ BackgroundSessionSavedObjectAttributes,
+ SearchSessionFindOptions,
+} from '../../common';
export class SessionService implements ISessionService {
private session$ = new BehaviorSubject(undefined);
@@ -30,6 +34,18 @@ export class SessionService implements ISessionService {
}
private appChangeSubscription$?: Subscription;
private curApp?: string;
+ private http!: HttpStart;
+
+ /**
+ * Has the session already been stored (i.e. "sent to background")?
+ */
+ private _isStored: boolean = false;
+
+ /**
+ * Is this session a restored session (have these requests already been made, and we're just
+ * looking to re-use the previous search IDs)?
+ */
+ private _isRestore: boolean = false;
constructor(
initializerContext: PluginInitializerContext,
@@ -39,6 +55,8 @@ export class SessionService implements ISessionService {
Make sure that apps don't leave sessions open.
*/
getStartServices().then(([coreStart]) => {
+ this.http = coreStart.http;
+
this.appChangeSubscription$ = coreStart.application.currentAppId$.subscribe((appName) => {
if (this.sessionId) {
const message = `Application '${this.curApp}' had an open session while navigating`;
@@ -69,16 +87,63 @@ export class SessionService implements ISessionService {
return this.session$.asObservable();
}
+ public isStored() {
+ return this._isStored;
+ }
+
+ public isRestore() {
+ return this._isRestore;
+ }
+
public start() {
+ this._isStored = false;
+ this._isRestore = false;
this.session$.next(uuid.v4());
return this.sessionId!;
}
public restore(sessionId: string) {
+ this._isStored = true;
+ this._isRestore = true;
this.session$.next(sessionId);
+ return this.http.get(`/internal/session/${encodeURIComponent(sessionId)}`);
}
public clear() {
+ this._isStored = false;
+ this._isRestore = false;
this.session$.next(undefined);
}
+
+ public async save(name: string, url: string) {
+ const response = await this.http.post(`/internal/session`, {
+ body: JSON.stringify({
+ name,
+ url,
+ sessionId: this.sessionId,
+ }),
+ });
+ this._isStored = true;
+ return response;
+ }
+
+ public get(sessionId: string) {
+ return this.http.get(`/internal/session/${encodeURIComponent(sessionId)}`);
+ }
+
+ public find(options: SearchSessionFindOptions) {
+ return this.http.post(`/internal/session`, {
+ body: JSON.stringify(options),
+ });
+ }
+
+ public update(sessionId: string, attributes: Partial) {
+ return this.http.put(`/internal/session/${encodeURIComponent(sessionId)}`, {
+ body: JSON.stringify(attributes),
+ });
+ }
+
+ public delete(sessionId: string) {
+ return this.http.delete(`/internal/session/${encodeURIComponent(sessionId)}`);
+ }
}
diff --git a/src/plugins/data/public/services.ts b/src/plugins/data/public/services.ts
index 032bce6d8d2aa..28fb4ff8b53ae 100644
--- a/src/plugins/data/public/services.ts
+++ b/src/plugins/data/public/services.ts
@@ -18,7 +18,6 @@
*/
import { NotificationsStart, CoreStart } from 'src/core/public';
-import { FieldFormatsStart } from './field_formats';
import { createGetterSetter } from '../../kibana_utils/public';
import { IndexPatternsContract } from './index_patterns';
import { DataPublicPluginStart } from './types';
@@ -31,20 +30,12 @@ export const [getUiSettings, setUiSettings] = createGetterSetter(
- 'FieldFormats'
-);
-
export const [getOverlays, setOverlays] = createGetterSetter('Overlays');
export const [getIndexPatterns, setIndexPatterns] = createGetterSetter(
'IndexPatterns'
);
-export const [getQueryService, setQueryService] = createGetterSetter<
- DataPublicPluginStart['query']
->('Query');
-
export const [getSearchService, setSearchService] = createGetterSetter<
DataPublicPluginStart['search']
>('Search');
diff --git a/src/plugins/data/server/saved_objects/background_session.ts b/src/plugins/data/server/saved_objects/background_session.ts
new file mode 100644
index 0000000000000..74b03c4d867e4
--- /dev/null
+++ b/src/plugins/data/server/saved_objects/background_session.ts
@@ -0,0 +1,56 @@
+/*
+ * 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 BACKGROUND_SESSION_TYPE = 'background-session';
+
+export const backgroundSessionMapping: SavedObjectsType = {
+ name: BACKGROUND_SESSION_TYPE,
+ namespaceType: 'single',
+ hidden: true,
+ mappings: {
+ properties: {
+ name: {
+ type: 'keyword',
+ },
+ created: {
+ type: 'date',
+ },
+ expires: {
+ type: 'date',
+ },
+ status: {
+ type: 'keyword',
+ },
+ initialState: {
+ type: 'object',
+ enabled: false,
+ },
+ restoreState: {
+ type: 'object',
+ enabled: false,
+ },
+ idMapping: {
+ type: 'object',
+ enabled: false,
+ },
+ },
+ },
+};
diff --git a/src/plugins/data/server/saved_objects/index.ts b/src/plugins/data/server/saved_objects/index.ts
index 077f9380823d0..7cd4d319e6417 100644
--- a/src/plugins/data/server/saved_objects/index.ts
+++ b/src/plugins/data/server/saved_objects/index.ts
@@ -20,3 +20,4 @@ export { querySavedObjectType } from './query';
export { indexPatternSavedObjectType } from './index_patterns';
export { kqlTelemetry } from './kql_telemetry';
export { searchTelemetry } from './search_telemetry';
+export { BACKGROUND_SESSION_TYPE, backgroundSessionMapping } from './background_session';
diff --git a/src/plugins/data/server/search/mocks.ts b/src/plugins/data/server/search/mocks.ts
index 4914726c85ef8..290e94ee7cf99 100644
--- a/src/plugins/data/server/search/mocks.ts
+++ b/src/plugins/data/server/search/mocks.ts
@@ -17,6 +17,8 @@
* under the License.
*/
+import type { RequestHandlerContext } from 'src/core/server';
+import { coreMock } from '../../../../core/server/mocks';
import { ISearchSetup, ISearchStart } from './types';
import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks';
import { searchSourceMock } from './search_source/mocks';
@@ -40,3 +42,22 @@ export function createSearchStartMock(): jest.Mocked {
searchSource: searchSourceMock.createStartContract(),
};
}
+
+export function createSearchRequestHandlerContext(): jest.Mocked {
+ return {
+ core: coreMock.createRequestHandlerContext(),
+ search: {
+ search: jest.fn(),
+ cancel: jest.fn(),
+ session: {
+ save: jest.fn(),
+ get: jest.fn(),
+ find: jest.fn(),
+ update: jest.fn(),
+ delete: jest.fn(),
+ trackId: jest.fn(),
+ getId: jest.fn(),
+ },
+ },
+ };
+}
diff --git a/src/plugins/data/server/search/routes/search.ts b/src/plugins/data/server/search/routes/search.ts
index a4161fe47b388..ed519164c8e43 100644
--- a/src/plugins/data/server/search/routes/search.ts
+++ b/src/plugins/data/server/search/routes/search.ts
@@ -35,11 +35,18 @@ export function registerSearchRoute(router: IRouter): void {
query: schema.object({}, { unknowns: 'allow' }),
- body: schema.object({}, { unknowns: 'allow' }),
+ body: schema.object(
+ {
+ sessionId: schema.maybe(schema.string()),
+ isStored: schema.maybe(schema.boolean()),
+ isRestore: schema.maybe(schema.boolean()),
+ },
+ { unknowns: 'allow' }
+ ),
},
},
async (context, request, res) => {
- const searchRequest = request.body;
+ const { sessionId, isStored, isRestore, ...searchRequest } = request.body;
const { strategy, id } = request.params;
const abortSignal = getRequestAbortedSignal(request.events.aborted$);
@@ -50,6 +57,9 @@ export function registerSearchRoute(router: IRouter): void {
{
abortSignal,
strategy,
+ sessionId,
+ isStored,
+ isRestore,
}
)
.pipe(first())
diff --git a/src/plugins/data/server/search/routes/session.test.ts b/src/plugins/data/server/search/routes/session.test.ts
new file mode 100644
index 0000000000000..f697f6d5a5c2b
--- /dev/null
+++ b/src/plugins/data/server/search/routes/session.test.ts
@@ -0,0 +1,119 @@
+/*
+ * 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 type { MockedKeys } from '@kbn/utility-types/jest';
+import type { CoreSetup, RequestHandlerContext } from 'kibana/server';
+import type { DataPluginStart } from '../../plugin';
+import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks';
+import { createSearchRequestHandlerContext } from '../mocks';
+import { registerSessionRoutes } from './session';
+
+describe('registerSessionRoutes', () => {
+ let mockCoreSetup: MockedKeys>;
+ let mockContext: jest.Mocked;
+
+ beforeEach(() => {
+ mockCoreSetup = coreMock.createSetup();
+ mockContext = createSearchRequestHandlerContext();
+ registerSessionRoutes(mockCoreSetup.http.createRouter());
+ });
+
+ it('save calls session.save with sessionId and attributes', async () => {
+ const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+ const name = 'my saved background search session';
+ const body = { sessionId, name };
+
+ const mockRequest = httpServerMock.createKibanaRequest({ body });
+ const mockResponse = httpServerMock.createResponseFactory();
+
+ const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value;
+ const [[, saveHandler]] = mockRouter.post.mock.calls;
+
+ saveHandler(mockContext, mockRequest, mockResponse);
+
+ expect(mockContext.search!.session.save).toHaveBeenCalledWith(sessionId, { name });
+ });
+
+ it('get calls session.get with sessionId', async () => {
+ const id = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+ const params = { id };
+
+ const mockRequest = httpServerMock.createKibanaRequest({ params });
+ const mockResponse = httpServerMock.createResponseFactory();
+
+ const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value;
+ const [[, getHandler]] = mockRouter.get.mock.calls;
+
+ getHandler(mockContext, mockRequest, mockResponse);
+
+ expect(mockContext.search!.session.get).toHaveBeenCalledWith(id);
+ });
+
+ it('find calls session.find with options', async () => {
+ const page = 1;
+ const perPage = 5;
+ const sortField = 'my_field';
+ const sortOrder = 'desc';
+ const filter = 'foo: bar';
+ const body = { page, perPage, sortField, sortOrder, filter };
+
+ const mockRequest = httpServerMock.createKibanaRequest({ body });
+ const mockResponse = httpServerMock.createResponseFactory();
+
+ const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value;
+ const [, [, findHandler]] = mockRouter.post.mock.calls;
+
+ findHandler(mockContext, mockRequest, mockResponse);
+
+ expect(mockContext.search!.session.find).toHaveBeenCalledWith(body);
+ });
+
+ it('update calls session.update with id and attributes', async () => {
+ const id = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+ const name = 'my saved background search session';
+ const expires = new Date().toISOString();
+ const params = { id };
+ const body = { name, expires };
+
+ const mockRequest = httpServerMock.createKibanaRequest({ params, body });
+ const mockResponse = httpServerMock.createResponseFactory();
+
+ const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value;
+ const [[, updateHandler]] = mockRouter.put.mock.calls;
+
+ updateHandler(mockContext, mockRequest, mockResponse);
+
+ expect(mockContext.search!.session.update).toHaveBeenCalledWith(id, body);
+ });
+
+ it('delete calls session.delete with id', async () => {
+ const id = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+ const params = { id };
+
+ const mockRequest = httpServerMock.createKibanaRequest({ params });
+ const mockResponse = httpServerMock.createResponseFactory();
+
+ const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value;
+ const [[, deleteHandler]] = mockRouter.delete.mock.calls;
+
+ deleteHandler(mockContext, mockRequest, mockResponse);
+
+ expect(mockContext.search!.session.delete).toHaveBeenCalledWith(id);
+ });
+});
diff --git a/src/plugins/data/server/search/routes/session.ts b/src/plugins/data/server/search/routes/session.ts
new file mode 100644
index 0000000000000..93f07ecfb92ff
--- /dev/null
+++ b/src/plugins/data/server/search/routes/session.ts
@@ -0,0 +1,201 @@
+/*
+ * 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 { IRouter } from 'src/core/server';
+
+export function registerSessionRoutes(router: IRouter): void {
+ router.post(
+ {
+ path: '/internal/session',
+ validate: {
+ body: schema.object({
+ sessionId: schema.string(),
+ name: schema.string(),
+ expires: schema.maybe(schema.string()),
+ initialState: schema.maybe(schema.object({}, { unknowns: 'allow' })),
+ restoreState: schema.maybe(schema.object({}, { unknowns: 'allow' })),
+ }),
+ },
+ },
+ async (context, request, res) => {
+ const { sessionId, name, expires, initialState, restoreState } = request.body;
+
+ try {
+ const response = await context.search!.session.save(sessionId, {
+ name,
+ expires,
+ initialState,
+ restoreState,
+ });
+
+ return res.ok({
+ body: response,
+ });
+ } catch (err) {
+ return res.customError({
+ statusCode: err.statusCode || 500,
+ body: {
+ message: err.message,
+ attributes: {
+ error: err.body?.error || err.message,
+ },
+ },
+ });
+ }
+ }
+ );
+
+ router.get(
+ {
+ path: '/internal/session/{id}',
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ },
+ },
+ async (context, request, res) => {
+ const { id } = request.params;
+ try {
+ const response = await context.search!.session.get(id);
+
+ return res.ok({
+ body: response,
+ });
+ } catch (err) {
+ return res.customError({
+ statusCode: err.statusCode || 500,
+ body: {
+ message: err.message,
+ attributes: {
+ error: err.body?.error || err.message,
+ },
+ },
+ });
+ }
+ }
+ );
+
+ router.post(
+ {
+ path: '/internal/session/_find',
+ validate: {
+ body: schema.object({
+ page: schema.maybe(schema.number()),
+ perPage: schema.maybe(schema.number()),
+ sortField: schema.maybe(schema.string()),
+ sortOrder: schema.maybe(schema.string()),
+ filter: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ async (context, request, res) => {
+ const { page, perPage, sortField, sortOrder, filter } = request.body;
+ try {
+ const response = await context.search!.session.find({
+ page,
+ perPage,
+ sortField,
+ sortOrder,
+ filter,
+ });
+
+ return res.ok({
+ body: response,
+ });
+ } catch (err) {
+ return res.customError({
+ statusCode: err.statusCode || 500,
+ body: {
+ message: err.message,
+ attributes: {
+ error: err.body?.error || err.message,
+ },
+ },
+ });
+ }
+ }
+ );
+
+ router.delete(
+ {
+ path: '/internal/session/{id}',
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ },
+ },
+ async (context, request, res) => {
+ const { id } = request.params;
+ try {
+ await context.search!.session.delete(id);
+
+ return res.ok();
+ } catch (err) {
+ return res.customError({
+ statusCode: err.statusCode || 500,
+ body: {
+ message: err.message,
+ attributes: {
+ error: err.body?.error || err.message,
+ },
+ },
+ });
+ }
+ }
+ );
+
+ router.put(
+ {
+ path: '/internal/session/{id}',
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ body: schema.object({
+ name: schema.maybe(schema.string()),
+ expires: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ async (context, request, res) => {
+ const { id } = request.params;
+ const { name, expires } = request.body;
+ try {
+ const response = await context.search!.session.update(id, { name, expires });
+
+ return res.ok({
+ body: response,
+ });
+ } catch (err) {
+ return res.customError({
+ statusCode: err.statusCode || 500,
+ body: {
+ message: err.message,
+ attributes: {
+ error: err.body?.error || err.message,
+ },
+ },
+ });
+ }
+ }
+ );
+}
diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts
index d8aa588719e3e..b44980164d097 100644
--- a/src/plugins/data/server/search/search_service.ts
+++ b/src/plugins/data/server/search/search_service.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { BehaviorSubject, Observable } from 'rxjs';
+import { BehaviorSubject, from, Observable } from 'rxjs';
import { pick } from 'lodash';
import {
CoreSetup,
@@ -29,7 +29,7 @@ import {
SharedGlobalConfig,
StartServicesAccessor,
} from 'src/core/server';
-import { first } from 'rxjs/operators';
+import { first, switchMap } from 'rxjs/operators';
import { ExpressionsServerSetup } from 'src/plugins/expressions/server';
import {
ISearchSetup,
@@ -49,7 +49,7 @@ 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 { BACKGROUND_SESSION_TYPE, searchTelemetry } from '../saved_objects';
import {
IEsSearchRequest,
IEsSearchResponse,
@@ -70,10 +70,14 @@ import {
} from '../../common/search/aggs/buckets/shard_delay';
import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn';
import { ConfigSchema } from '../../config';
+import { BackgroundSessionService, ISearchSessionClient } from './session';
+import { registerSessionRoutes } from './routes/session';
+import { backgroundSessionMapping } from '../saved_objects';
+import { tapFirst } from '../../common/utils';
declare module 'src/core/server' {
interface RequestHandlerContext {
- search?: ISearchClient;
+ search?: ISearchClient & { session: ISearchSessionClient };
}
}
@@ -102,6 +106,7 @@ export class SearchService implements Plugin {
private readonly searchSourceService = new SearchSourceService();
private defaultSearchStrategyName: string = ES_SEARCH_STRATEGY;
private searchStrategies: StrategyMap = {};
+ private sessionService: BackgroundSessionService = new BackgroundSessionService();
constructor(
private initializerContext: PluginInitializerContext,
@@ -121,12 +126,17 @@ export class SearchService implements Plugin {
};
registerSearchRoute(router);
registerMsearchRoute(router, routeDependencies);
+ registerSessionRoutes(router);
core.http.registerRouteHandlerContext('search', async (context, request) => {
const [coreStart] = await core.getStartServices();
- return this.asScopedProvider(coreStart)(request);
+ const search = this.asScopedProvider(coreStart)(request);
+ const session = this.sessionService.asScopedProvider(coreStart)(request);
+ return { ...search, session };
});
+ core.savedObjects.registerType(backgroundSessionMapping);
+
this.registerSearchStrategy(
ES_SEARCH_STRATEGY,
esSearchStrategyProvider(
@@ -223,6 +233,7 @@ export class SearchService implements Plugin {
public stop() {
this.aggsService.stop();
+ this.sessionService.stop();
}
private registerSearchStrategy = <
@@ -248,7 +259,24 @@ export class SearchService implements Plugin {
options.strategy
);
- return strategy.search(searchRequest, options, deps);
+ // If this is a restored background search session, look up the ID using the provided sessionId
+ const getSearchRequest = async () =>
+ !options.isRestore || searchRequest.id
+ ? searchRequest
+ : {
+ ...searchRequest,
+ id: await this.sessionService.getId(searchRequest, options, deps),
+ };
+
+ return from(getSearchRequest()).pipe(
+ switchMap((request) => strategy.search(request, options, deps)),
+ tapFirst((response) => {
+ if (searchRequest.id || !options.sessionId || !response.id || options.isRestore) return;
+ this.sessionService.trackId(searchRequest, response.id, options, {
+ savedObjectsClient: deps.savedObjectsClient,
+ });
+ })
+ );
};
private cancel = (id: string, options: ISearchOptions, deps: SearchStrategyDependencies) => {
@@ -273,7 +301,9 @@ export class SearchService implements Plugin {
private asScopedProvider = ({ elasticsearch, savedObjects, uiSettings }: CoreStart) => {
return (request: KibanaRequest): ISearchClient => {
- const savedObjectsClient = savedObjects.getScopedClient(request);
+ const savedObjectsClient = savedObjects.getScopedClient(request, {
+ includedHiddenTypes: [BACKGROUND_SESSION_TYPE],
+ });
const deps = {
savedObjectsClient,
esClient: elasticsearch.client.asScoped(request),
diff --git a/src/plugins/data/server/search/session/index.ts b/src/plugins/data/server/search/session/index.ts
new file mode 100644
index 0000000000000..11b5b16a02b56
--- /dev/null
+++ b/src/plugins/data/server/search/session/index.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 { BackgroundSessionService, ISearchSessionClient } from './session_service';
diff --git a/src/plugins/data/server/search/session/session_service.test.ts b/src/plugins/data/server/search/session/session_service.test.ts
new file mode 100644
index 0000000000000..1ceebae967d4c
--- /dev/null
+++ b/src/plugins/data/server/search/session/session_service.test.ts
@@ -0,0 +1,233 @@
+/*
+ * 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 type { SavedObject, SavedObjectsClientContract } from 'kibana/server';
+import { savedObjectsClientMock } from '../../../../../core/server/mocks';
+import { BackgroundSessionStatus } from '../../../common';
+import { BACKGROUND_SESSION_TYPE } from '../../saved_objects';
+import { BackgroundSessionService } from './session_service';
+import { createRequestHash } from './utils';
+
+describe('BackgroundSessionService', () => {
+ let savedObjectsClient: jest.Mocked;
+ let service: BackgroundSessionService;
+
+ const mockSavedObject: SavedObject = {
+ id: 'd7170a35-7e2c-48d6-8dec-9a056721b489',
+ type: BACKGROUND_SESSION_TYPE,
+ attributes: {
+ name: 'my_name',
+ idMapping: {},
+ },
+ references: [],
+ };
+
+ beforeEach(() => {
+ savedObjectsClient = savedObjectsClientMock.create();
+ service = new BackgroundSessionService();
+ });
+
+ it('save throws if `name` is not provided', () => {
+ const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+
+ expect(() => service.save(sessionId, {}, { savedObjectsClient })).rejects.toMatchInlineSnapshot(
+ `[Error: Name is required]`
+ );
+ });
+
+ it('get calls saved objects client', async () => {
+ savedObjectsClient.get.mockResolvedValue(mockSavedObject);
+
+ const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+ const response = await service.get(sessionId, { savedObjectsClient });
+
+ expect(response).toBe(mockSavedObject);
+ expect(savedObjectsClient.get).toHaveBeenCalledWith(BACKGROUND_SESSION_TYPE, sessionId);
+ });
+
+ it('find calls saved objects client', async () => {
+ const mockFindSavedObject = {
+ ...mockSavedObject,
+ score: 1,
+ };
+ const mockResponse = {
+ saved_objects: [mockFindSavedObject],
+ total: 1,
+ per_page: 1,
+ page: 0,
+ };
+ savedObjectsClient.find.mockResolvedValue(mockResponse);
+
+ const options = { page: 0, perPage: 5 };
+ const response = await service.find(options, { savedObjectsClient });
+
+ expect(response).toBe(mockResponse);
+ expect(savedObjectsClient.find).toHaveBeenCalledWith({
+ ...options,
+ type: BACKGROUND_SESSION_TYPE,
+ });
+ });
+
+ it('update calls saved objects client', async () => {
+ const mockUpdateSavedObject = {
+ ...mockSavedObject,
+ attributes: {},
+ };
+ savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject);
+
+ const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+ const attributes = { name: 'new_name' };
+ const response = await service.update(sessionId, attributes, { savedObjectsClient });
+
+ expect(response).toBe(mockUpdateSavedObject);
+ expect(savedObjectsClient.update).toHaveBeenCalledWith(
+ BACKGROUND_SESSION_TYPE,
+ sessionId,
+ attributes
+ );
+ });
+
+ it('delete calls saved objects client', async () => {
+ savedObjectsClient.delete.mockResolvedValue({});
+
+ const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+ const response = await service.delete(sessionId, { savedObjectsClient });
+
+ expect(response).toEqual({});
+ expect(savedObjectsClient.delete).toHaveBeenCalledWith(BACKGROUND_SESSION_TYPE, sessionId);
+ });
+
+ describe('trackId', () => {
+ it('stores hash in memory when `isStored` is `false` for when `save` is called', async () => {
+ const searchRequest = { params: {} };
+ const requestHash = createRequestHash(searchRequest.params);
+ const searchId = 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0';
+ const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+ const isStored = false;
+ const name = 'my saved background search session';
+ const created = new Date().toISOString();
+ const expires = new Date().toISOString();
+
+ await service.trackId(
+ searchRequest,
+ searchId,
+ { sessionId, isStored },
+ { savedObjectsClient }
+ );
+
+ expect(savedObjectsClient.update).not.toHaveBeenCalled();
+
+ await service.save(sessionId, { name, created, expires }, { savedObjectsClient });
+
+ expect(savedObjectsClient.create).toHaveBeenCalledWith(
+ BACKGROUND_SESSION_TYPE,
+ {
+ name,
+ created,
+ expires,
+ initialState: {},
+ restoreState: {},
+ status: BackgroundSessionStatus.IN_PROGRESS,
+ idMapping: { [requestHash]: searchId },
+ },
+ { id: sessionId }
+ );
+ });
+
+ it('updates saved object when `isStored` is `true`', async () => {
+ const searchRequest = { params: {} };
+ const requestHash = createRequestHash(searchRequest.params);
+ const searchId = 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0';
+ const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+ const isStored = true;
+
+ await service.trackId(
+ searchRequest,
+ searchId,
+ { sessionId, isStored },
+ { savedObjectsClient }
+ );
+
+ expect(savedObjectsClient.update).toHaveBeenCalledWith(BACKGROUND_SESSION_TYPE, sessionId, {
+ idMapping: { [requestHash]: searchId },
+ });
+ });
+ });
+
+ describe('getId', () => {
+ it('throws if `sessionId` is not provided', () => {
+ const searchRequest = { params: {} };
+
+ expect(() =>
+ service.getId(searchRequest, {}, { savedObjectsClient })
+ ).rejects.toMatchInlineSnapshot(`[Error: Session ID is required]`);
+ });
+
+ it('throws if there is not a saved object', () => {
+ const searchRequest = { params: {} };
+ const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+
+ expect(() =>
+ service.getId(searchRequest, { sessionId, isStored: false }, { savedObjectsClient })
+ ).rejects.toMatchInlineSnapshot(
+ `[Error: Cannot get search ID from a session that is not stored]`
+ );
+ });
+
+ it('throws if not restoring a saved session', () => {
+ const searchRequest = { params: {} };
+ const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+
+ expect(() =>
+ service.getId(
+ searchRequest,
+ { sessionId, isStored: true, isRestore: false },
+ { savedObjectsClient }
+ )
+ ).rejects.toMatchInlineSnapshot(
+ `[Error: Get search ID is only supported when restoring a session]`
+ );
+ });
+
+ it('returns the search ID from the saved object ID mapping', async () => {
+ const searchRequest = { params: {} };
+ const requestHash = createRequestHash(searchRequest.params);
+ const searchId = 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0';
+ const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
+ const mockSession = {
+ id: 'd7170a35-7e2c-48d6-8dec-9a056721b489',
+ type: BACKGROUND_SESSION_TYPE,
+ attributes: {
+ name: 'my_name',
+ idMapping: { [requestHash]: searchId },
+ },
+ references: [],
+ };
+ savedObjectsClient.get.mockResolvedValue(mockSession);
+
+ const id = await service.getId(
+ searchRequest,
+ { sessionId, isStored: true, isRestore: true },
+ { savedObjectsClient }
+ );
+
+ expect(id).toBe(searchId);
+ });
+ });
+});
diff --git a/src/plugins/data/server/search/session/session_service.ts b/src/plugins/data/server/search/session/session_service.ts
new file mode 100644
index 0000000000000..eca5f428b8555
--- /dev/null
+++ b/src/plugins/data/server/search/session/session_service.ts
@@ -0,0 +1,204 @@
+/*
+ * 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 { CoreStart, KibanaRequest, SavedObjectsClientContract } from 'kibana/server';
+import {
+ BackgroundSessionSavedObjectAttributes,
+ IKibanaSearchRequest,
+ ISearchOptions,
+ SearchSessionFindOptions,
+ BackgroundSessionStatus,
+} from '../../../common';
+import { BACKGROUND_SESSION_TYPE } from '../../saved_objects';
+import { createRequestHash } from './utils';
+
+const DEFAULT_EXPIRATION = 7 * 24 * 60 * 60 * 1000;
+
+export interface BackgroundSessionDependencies {
+ savedObjectsClient: SavedObjectsClientContract;
+}
+
+export type ISearchSessionClient = ReturnType<
+ ReturnType
+>;
+
+export class BackgroundSessionService {
+ /**
+ * Map of sessionId to { [requestHash]: searchId }
+ * @private
+ */
+ private sessionSearchMap = new Map>();
+
+ constructor() {}
+
+ public setup = () => {};
+
+ public start = (core: CoreStart) => {
+ return {
+ asScoped: this.asScopedProvider(core),
+ };
+ };
+
+ public stop = () => {
+ this.sessionSearchMap.clear();
+ };
+
+ // TODO: Generate the `userId` from the realm type/realm name/username
+ public save = async (
+ sessionId: string,
+ {
+ name,
+ created = new Date().toISOString(),
+ expires = new Date(Date.now() + DEFAULT_EXPIRATION).toISOString(),
+ status = BackgroundSessionStatus.IN_PROGRESS,
+ initialState = {},
+ restoreState = {},
+ }: Partial,
+ { savedObjectsClient }: BackgroundSessionDependencies
+ ) => {
+ if (!name) throw new Error('Name is required');
+
+ // Get the mapping of request hash/search ID for this session
+ const searchMap = this.sessionSearchMap.get(sessionId) ?? new Map();
+ const idMapping = Object.fromEntries(searchMap.entries());
+ const attributes = { name, created, expires, status, initialState, restoreState, idMapping };
+ const session = await savedObjectsClient.create(
+ BACKGROUND_SESSION_TYPE,
+ attributes,
+ { id: sessionId }
+ );
+
+ // Clear out the entries for this session ID so they don't get saved next time
+ this.sessionSearchMap.delete(sessionId);
+
+ return session;
+ };
+
+ // TODO: Throw an error if this session doesn't belong to this user
+ public get = (sessionId: string, { savedObjectsClient }: BackgroundSessionDependencies) => {
+ return savedObjectsClient.get(
+ BACKGROUND_SESSION_TYPE,
+ sessionId
+ );
+ };
+
+ // TODO: Throw an error if this session doesn't belong to this user
+ public find = (
+ options: SearchSessionFindOptions,
+ { savedObjectsClient }: BackgroundSessionDependencies
+ ) => {
+ return savedObjectsClient.find({
+ ...options,
+ type: BACKGROUND_SESSION_TYPE,
+ });
+ };
+
+ // TODO: Throw an error if this session doesn't belong to this user
+ public update = (
+ sessionId: string,
+ attributes: Partial,
+ { savedObjectsClient }: BackgroundSessionDependencies
+ ) => {
+ return savedObjectsClient.update(
+ BACKGROUND_SESSION_TYPE,
+ sessionId,
+ attributes
+ );
+ };
+
+ // TODO: Throw an error if this session doesn't belong to this user
+ public delete = (sessionId: string, { savedObjectsClient }: BackgroundSessionDependencies) => {
+ return savedObjectsClient.delete(BACKGROUND_SESSION_TYPE, sessionId);
+ };
+
+ /**
+ * Tracks the given search request/search ID in the saved session (if it exists). Otherwise, just
+ * store it in memory until a saved session exists.
+ * @internal
+ */
+ public trackId = async (
+ searchRequest: IKibanaSearchRequest,
+ searchId: string,
+ { sessionId, isStored }: ISearchOptions,
+ deps: BackgroundSessionDependencies
+ ) => {
+ if (!sessionId || !searchId) return;
+ const requestHash = createRequestHash(searchRequest.params);
+
+ // If there is already a saved object for this session, update it to include this request/ID.
+ // Otherwise, just update the in-memory mapping for this session for when the session is saved.
+ if (isStored) {
+ const attributes = { idMapping: { [requestHash]: searchId } };
+ await this.update(sessionId, attributes, deps);
+ } else {
+ const map = this.sessionSearchMap.get(sessionId) ?? new Map();
+ map.set(requestHash, searchId);
+ this.sessionSearchMap.set(sessionId, map);
+ }
+ };
+
+ /**
+ * Look up an existing search ID that matches the given request in the given session so that the
+ * request can continue rather than restart.
+ * @internal
+ */
+ public getId = async (
+ searchRequest: IKibanaSearchRequest,
+ { sessionId, isStored, isRestore }: ISearchOptions,
+ deps: BackgroundSessionDependencies
+ ) => {
+ if (!sessionId) {
+ throw new Error('Session ID is required');
+ } else if (!isStored) {
+ throw new Error('Cannot get search ID from a session that is not stored');
+ } else if (!isRestore) {
+ throw new Error('Get search ID is only supported when restoring a session');
+ }
+
+ const session = await this.get(sessionId, deps);
+ const requestHash = createRequestHash(searchRequest.params);
+ if (!session.attributes.idMapping.hasOwnProperty(requestHash)) {
+ throw new Error('No search ID in this session matching the given search request');
+ }
+
+ return session.attributes.idMapping[requestHash];
+ };
+
+ public asScopedProvider = ({ savedObjects }: CoreStart) => {
+ return (request: KibanaRequest) => {
+ const savedObjectsClient = savedObjects.getScopedClient(request, {
+ includedHiddenTypes: [BACKGROUND_SESSION_TYPE],
+ });
+ const deps = { savedObjectsClient };
+ return {
+ save: (sessionId: string, attributes: Partial) =>
+ this.save(sessionId, attributes, deps),
+ get: (sessionId: string) => this.get(sessionId, deps),
+ find: (options: SearchSessionFindOptions) => this.find(options, deps),
+ update: (sessionId: string, attributes: Partial) =>
+ this.update(sessionId, attributes, deps),
+ delete: (sessionId: string) => this.delete(sessionId, deps),
+ trackId: (searchRequest: IKibanaSearchRequest, searchId: string, options: ISearchOptions) =>
+ this.trackId(searchRequest, searchId, options, deps),
+ getId: (searchRequest: IKibanaSearchRequest, options: ISearchOptions) =>
+ this.getId(searchRequest, options, deps),
+ };
+ };
+ };
+}
diff --git a/src/plugins/data/server/search/session/utils.test.ts b/src/plugins/data/server/search/session/utils.test.ts
new file mode 100644
index 0000000000000..d190f892a7f84
--- /dev/null
+++ b/src/plugins/data/server/search/session/utils.test.ts
@@ -0,0 +1,37 @@
+/*
+ * 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 { createRequestHash } from './utils';
+
+describe('data/search/session utils', () => {
+ describe('createRequestHash', () => {
+ it('ignores `preference`', () => {
+ const request = {
+ foo: 'bar',
+ };
+
+ const withPreference = {
+ ...request,
+ preference: 1234,
+ };
+
+ expect(createRequestHash(request)).toEqual(createRequestHash(withPreference));
+ });
+ });
+});
diff --git a/src/plugins/data/server/search/session/utils.ts b/src/plugins/data/server/search/session/utils.ts
new file mode 100644
index 0000000000000..c3332f80b6e3f
--- /dev/null
+++ b/src/plugins/data/server/search/session/utils.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 { createHash } from 'crypto';
+
+/**
+ * Generate the hash for this request so that, in the future, this hash can be used to look up
+ * existing search IDs for this request. Ignores the `preference` parameter since it generally won't
+ * match from one request to another identical request.
+ */
+export function createRequestHash(keys: Record) {
+ const { preference, ...params } = keys;
+ return createHash(`sha256`).update(JSON.stringify(params)).digest('hex');
+}
diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md
index b2db4f5c74729..8d1699c4ad5ed 100644
--- a/src/plugins/data/server/server.api.md
+++ b/src/plugins/data/server/server.api.md
@@ -27,15 +27,15 @@ import { ExpressionAstFunction } from 'src/plugins/expressions/common';
import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common';
import { ExpressionsServerSetup } from 'src/plugins/expressions/server';
import { ExpressionValueBoxed } from 'src/plugins/expressions/common';
-import { ISavedObjectsRepository } from 'kibana/server';
+import { ISavedObjectsRepository } from 'src/core/server';
import { IScopedClusterClient } from 'src/core/server';
import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public';
import { ISearchSource } from 'src/plugins/data/public';
import { IUiSettingsClient } from 'src/core/server';
import { KibanaRequest } from 'src/core/server';
-import { LegacyAPICaller } from 'kibana/server';
-import { Logger } from 'kibana/server';
-import { Logger as Logger_2 } from 'src/core/server';
+import { LegacyAPICaller } from 'src/core/server';
+import { Logger } from 'src/core/server';
+import { Logger as Logger_2 } from 'kibana/server';
import { LoggerFactory } from '@kbn/logging';
import { Moment } from 'moment';
import moment from 'moment';
@@ -753,6 +753,8 @@ export class IndexPatternsService implements Plugin_3 {
componentProps?: Record;
readDefaultValueOnForm?: boolean;
onChange?: (value: I) => void;
- children?: (field: FieldHook) => JSX.Element;
+ children?: (field: FieldHook) => JSX.Element | null;
[key: string]: any;
}
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts
index f4f13a698ee30..eb67842bff833 100644
--- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts
+++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts
@@ -118,16 +118,16 @@ export const useField = (
* updating the "value" state.
*/
const formatInputValue = useCallback(
- (inputValue: unknown): T => {
+ (inputValue: unknown): U => {
const isEmptyString = typeof inputValue === 'string' && inputValue.trim() === '';
if (isEmptyString || !formatters) {
- return inputValue as T;
+ return inputValue as U;
}
const formData = __getFormData$().value;
- return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as T;
+ return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as U;
},
[formatters, __getFormData$]
);
diff --git a/src/plugins/inspector/common/adapters/data/data_adapter.ts b/src/plugins/inspector/common/adapters/data/data_adapter.ts
index 34e6c278c693f..a21aa7db39145 100644
--- a/src/plugins/inspector/common/adapters/data/data_adapter.ts
+++ b/src/plugins/inspector/common/adapters/data/data_adapter.ts
@@ -20,7 +20,7 @@
import { EventEmitter } from 'events';
import { TabularCallback, TabularHolder, TabularLoaderOptions } from './types';
-class DataAdapter extends EventEmitter {
+export class DataAdapter extends EventEmitter {
private tabular?: TabularCallback;
private tabularOptions?: TabularLoaderOptions;
@@ -38,5 +38,3 @@ class DataAdapter extends EventEmitter {
return Promise.resolve(this.tabular()).then((data) => ({ data, options }));
}
}
-
-export { DataAdapter };
diff --git a/src/plugins/inspector/common/adapters/data/data_adapters.test.ts b/src/plugins/inspector/common/adapters/data/data_adapters.test.ts
index 287024ca1b59e..7cc52807548f0 100644
--- a/src/plugins/inspector/common/adapters/data/data_adapters.test.ts
+++ b/src/plugins/inspector/common/adapters/data/data_adapters.test.ts
@@ -35,33 +35,37 @@ describe('DataAdapter', () => {
});
it('should call the provided callback and resolve with its value', async () => {
- const spy = jest.fn(() => 'foo');
+ const data = { columns: [], rows: [] };
+ const spy = jest.fn(() => data);
adapter.setTabularLoader(spy);
expect(spy).not.toBeCalled();
const result = await adapter.getTabular();
expect(spy).toBeCalled();
- expect(result.data).toBe('foo');
+ expect(result.data).toBe(data);
});
it('should pass through options specified via setTabularLoader', async () => {
- adapter.setTabularLoader(() => 'foo', { returnsFormattedValues: true });
+ const data = { columns: [], rows: [] };
+ adapter.setTabularLoader(() => data, { returnsFormattedValues: true });
const result = await adapter.getTabular();
expect(result.options).toEqual({ returnsFormattedValues: true });
});
it('should return options set when starting loading data', async () => {
- adapter.setTabularLoader(() => 'foo', { returnsFormattedValues: true });
+ const data = { columns: [], rows: [] };
+ adapter.setTabularLoader(() => data, { returnsFormattedValues: true });
const waitForResult = adapter.getTabular();
- adapter.setTabularLoader(() => 'bar', { returnsFormattedValues: false });
+ adapter.setTabularLoader(() => data, { returnsFormattedValues: false });
const result = await waitForResult;
expect(result.options).toEqual({ returnsFormattedValues: true });
});
});
it('should emit a "tabular" event when a new tabular loader is specified', () => {
+ const data = { columns: [], rows: [] };
const spy = jest.fn();
adapter.once('change', spy);
- adapter.setTabularLoader(() => 42);
+ adapter.setTabularLoader(() => data);
expect(spy).toBeCalled();
});
});
diff --git a/src/plugins/inspector/common/adapters/data/formatted_data.ts b/src/plugins/inspector/common/adapters/data/formatted_data.ts
index c752e8670aca3..08c956f27d011 100644
--- a/src/plugins/inspector/common/adapters/data/formatted_data.ts
+++ b/src/plugins/inspector/common/adapters/data/formatted_data.ts
@@ -17,8 +17,6 @@
* under the License.
*/
-class FormattedData {
+export class FormattedData {
constructor(public readonly raw: any, public readonly formatted: any) {}
}
-
-export { FormattedData };
diff --git a/src/plugins/inspector/common/adapters/data/index.ts b/src/plugins/inspector/common/adapters/data/index.ts
index 920e298ab455f..a8b1abcd8cd7e 100644
--- a/src/plugins/inspector/common/adapters/data/index.ts
+++ b/src/plugins/inspector/common/adapters/data/index.ts
@@ -17,5 +17,6 @@
* under the License.
*/
-export { FormattedData } from './formatted_data';
-export { DataAdapter } from './data_adapter';
+export * from './data_adapter';
+export * from './formatted_data';
+export * from './types';
diff --git a/src/plugins/inspector/common/adapters/data/types.ts b/src/plugins/inspector/common/adapters/data/types.ts
index 1c7b17c143eca..040724f4ae36e 100644
--- a/src/plugins/inspector/common/adapters/data/types.ts
+++ b/src/plugins/inspector/common/adapters/data/types.ts
@@ -17,8 +17,25 @@
* under the License.
*/
-// TODO: add a more specific TabularData type.
-export type TabularData = any;
+export interface TabularDataValue {
+ formatted: string;
+ raw: unknown;
+}
+
+export interface TabularDataColumn {
+ name: string;
+ field: string;
+ filter?: (value: TabularDataValue) => void;
+ filterOut?: (value: TabularDataValue) => void;
+}
+
+export type TabularDataRow = Record;
+
+export interface TabularData {
+ columns: TabularDataColumn[];
+ rows: TabularDataRow[];
+}
+
export type TabularCallback = () => TabularData | Promise;
export interface TabularHolder {
diff --git a/src/plugins/inspector/common/adapters/index.ts b/src/plugins/inspector/common/adapters/index.ts
index 1e7a44a2c60b1..0c6319a2905a8 100644
--- a/src/plugins/inspector/common/adapters/index.ts
+++ b/src/plugins/inspector/common/adapters/index.ts
@@ -17,12 +17,6 @@
* under the License.
*/
-export { Adapters } from './types';
-export { DataAdapter, FormattedData } from './data';
-export {
- RequestAdapter,
- RequestStatistic,
- RequestStatistics,
- RequestStatus,
- RequestResponder,
-} from './request';
+export * from './data';
+export * from './request';
+export * from './types';
diff --git a/src/plugins/inspector/common/adapters/request/request_adapter.ts b/src/plugins/inspector/common/adapters/request/request_adapter.ts
index af10d1b77b16d..5f5728e1cf331 100644
--- a/src/plugins/inspector/common/adapters/request/request_adapter.ts
+++ b/src/plugins/inspector/common/adapters/request/request_adapter.ts
@@ -29,7 +29,7 @@ import { Request, RequestParams, RequestStatus } from './types';
* instead it offers a generic API to log requests of any kind.
* @extends EventEmitter
*/
-class RequestAdapter extends EventEmitter {
+export class RequestAdapter extends EventEmitter {
private requests: Map;
constructor() {
@@ -78,5 +78,3 @@ class RequestAdapter extends EventEmitter {
this.emit('change');
}
}
-
-export { RequestAdapter };
diff --git a/src/plugins/inspector/common/adapters/types.ts b/src/plugins/inspector/common/adapters/types.ts
index 362c69e299c9d..b51c3e56c749f 100644
--- a/src/plugins/inspector/common/adapters/types.ts
+++ b/src/plugins/inspector/common/adapters/types.ts
@@ -17,9 +17,14 @@
* under the License.
*/
+import type { DataAdapter } from './data';
+import type { RequestAdapter } from './request';
+
/**
* The interface that the adapters used to open an inspector have to fullfill.
*/
export interface Adapters {
+ data?: DataAdapter;
+ requests?: RequestAdapter;
[key: string]: any;
}
diff --git a/src/plugins/inspector/common/index.ts b/src/plugins/inspector/common/index.ts
index 06ab36a577d98..c5755b22095dc 100644
--- a/src/plugins/inspector/common/index.ts
+++ b/src/plugins/inspector/common/index.ts
@@ -17,4 +17,17 @@
* under the License.
*/
-export * from './adapters';
+export {
+ Adapters,
+ DataAdapter,
+ FormattedData,
+ RequestAdapter,
+ RequestStatistic,
+ RequestStatistics,
+ RequestStatus,
+ RequestResponder,
+ TabularData,
+ TabularDataColumn,
+ TabularDataRow,
+ TabularDataValue,
+} from './adapters';
diff --git a/src/plugins/inspector/public/test/is_available.test.ts b/src/plugins/inspector/public/test/is_available.test.ts
index 0604129a0734a..c38d9d7a3f825 100644
--- a/src/plugins/inspector/public/test/is_available.test.ts
+++ b/src/plugins/inspector/public/test/is_available.test.ts
@@ -18,8 +18,7 @@
*/
import { inspectorPluginMock } from '../mocks';
-import { DataAdapter } from '../../common/adapters/data/data_adapter';
-import { RequestAdapter } from '../../common/adapters/request/request_adapter';
+import { DataAdapter, RequestAdapter } from '../../common/adapters';
const adapter1 = new DataAdapter();
const adapter2 = new RequestAdapter();
diff --git a/src/plugins/inspector/public/views/data/components/data_view.tsx b/src/plugins/inspector/public/views/data/components/data_view.tsx
index 100fa7787321c..324094d8f93d0 100644
--- a/src/plugins/inspector/public/views/data/components/data_view.tsx
+++ b/src/plugins/inspector/public/views/data/components/data_view.tsx
@@ -35,7 +35,7 @@ import { Adapters } from '../../../../common';
import {
TabularLoaderOptions,
TabularData,
- TabularCallback,
+ TabularHolder,
} from '../../../../common/adapters/data/types';
import { IUiSettingsClient } from '../../../../../../core/public';
import { withKibana, KibanaReactContextValue } from '../../../../../kibana_react/public';
@@ -44,7 +44,7 @@ interface DataViewComponentState {
tabularData: TabularData | null;
tabularOptions: TabularLoaderOptions;
adapters: Adapters;
- tabularPromise: TabularCallback | null;
+ tabularPromise: Promise | null;
}
interface DataViewComponentProps extends InspectorViewProps {
@@ -73,7 +73,7 @@ class DataViewComponent extends Component string;
+
export interface DataViewColumn {
name: string;
field: string;
- sortable: (item: DataViewRow) => string | number;
+ sortable: (item: TabularDataRow) => string | number;
render: DataViewColumnRender;
}
-type DataViewColumnRender = (value: string, _item: DataViewRow) => string;
-
-export interface DataViewRow {
- [fields: string]: {
- formatted: string;
- raw: any;
- };
-}
+export type DataViewRow = TabularDataRow;
diff --git a/src/plugins/inspector/public/views/requests/components/requests_view.tsx b/src/plugins/inspector/public/views/requests/components/requests_view.tsx
index 7762689daf4e6..e1879f7a6b6c8 100644
--- a/src/plugins/inspector/public/views/requests/components/requests_view.tsx
+++ b/src/plugins/inspector/public/views/requests/components/requests_view.tsx
@@ -31,7 +31,7 @@ import { RequestDetails } from './request_details';
interface RequestSelectorState {
requests: Request[];
- request: Request;
+ request: Request | null;
}
export class RequestsViewComponent extends Component {
@@ -43,9 +43,9 @@ export class RequestsViewComponent extends Component {
- const requests = this.props.adapters.requests.getRequests();
+ const requests = this.props.adapters.requests!.getRequests();
const newState = { requests } as RequestSelectorState;
- if (!requests.includes(this.state.request)) {
+ if (!this.state.request || !requests.includes(this.state.request)) {
newState.request = requests.length ? requests[0] : null;
}
this.setState(newState);
@@ -69,7 +69,7 @@ export class RequestsViewComponent extends Component
-
-
+ {this.state.request && (
+ <>
+
+
+ >
+ )}
{this.state.request && this.state.request.description && (
diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts
index c1457c64080a6..6cb104416ef58 100644
--- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts
+++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts
@@ -19,7 +19,7 @@
import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../core/server/mocks';
import {
- CollectorOptions,
+ Collector,
createUsageCollectionSetupMock,
} from '../../../../usage_collection/server/usage_collection.mock';
@@ -40,11 +40,11 @@ describe('telemetry_application_usage', () => {
const logger = loggingSystemMock.createLogger();
- let collector: CollectorOptions;
+ let collector: Collector;
const usageCollectionMock = createUsageCollectionSetupMock();
usageCollectionMock.makeUsageCollector.mockImplementation((config) => {
- collector = config;
+ collector = new Collector(logger, config);
return createUsageCollectionSetupMock().makeUsageCollector(config);
});
diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts
index e8efa9997c459..e31437a744e29 100644
--- a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts
+++ b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts
@@ -18,20 +18,22 @@
*/
import {
- CollectorOptions,
+ Collector,
createUsageCollectionSetupMock,
} from '../../../../usage_collection/server/usage_collection.mock';
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
import { registerCoreUsageCollector } from '.';
-import { coreUsageDataServiceMock } from '../../../../../core/server/mocks';
+import { coreUsageDataServiceMock, loggingSystemMock } from '../../../../../core/server/mocks';
import { CoreUsageData } from 'src/core/server/';
+const logger = loggingSystemMock.createLogger();
+
describe('telemetry_core', () => {
- let collector: CollectorOptions;
+ let collector: Collector;
const usageCollectionMock = createUsageCollectionSetupMock();
usageCollectionMock.makeUsageCollector.mockImplementation((config) => {
- collector = config;
+ collector = new Collector(logger, config);
return createUsageCollectionSetupMock().makeUsageCollector(config);
});
diff --git a/src/plugins/kibana_usage_collection/server/collectors/csp/csp_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/csp/csp_collector.test.ts
index 03184d7385861..2851382f7559a 100644
--- a/src/plugins/kibana_usage_collection/server/collectors/csp/csp_collector.test.ts
+++ b/src/plugins/kibana_usage_collection/server/collectors/csp/csp_collector.test.ts
@@ -19,8 +19,13 @@
import { CspConfig, ICspConfig } from '../../../../../core/server';
import { createCspCollector } from './csp_collector';
-import { httpServiceMock } from '../../../../../core/server/mocks';
-import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
+import { httpServiceMock, loggingSystemMock } from '../../../../../core/server/mocks';
+import {
+ Collector,
+ createCollectorFetchContextMock,
+} from 'src/plugins/usage_collection/server/mocks';
+
+const logger = loggingSystemMock.createLogger();
describe('csp collector', () => {
let httpMock: ReturnType;
@@ -36,7 +41,7 @@ describe('csp collector', () => {
});
test('fetches whether strict mode is enabled', async () => {
- const collector = createCspCollector(httpMock);
+ const collector = new Collector(logger, createCspCollector(httpMock));
expect((await collector.fetch(mockedFetchContext)).strict).toEqual(true);
@@ -45,7 +50,7 @@ describe('csp collector', () => {
});
test('fetches whether the legacy browser warning is enabled', async () => {
- const collector = createCspCollector(httpMock);
+ const collector = new Collector(logger, createCspCollector(httpMock));
expect((await collector.fetch(mockedFetchContext)).warnLegacyBrowsers).toEqual(true);
@@ -54,7 +59,7 @@ describe('csp collector', () => {
});
test('fetches whether the csp rules have been changed or not', async () => {
- const collector = createCspCollector(httpMock);
+ const collector = new Collector(logger, createCspCollector(httpMock));
expect((await collector.fetch(mockedFetchContext)).rulesChangedFromDefault).toEqual(false);
@@ -63,7 +68,7 @@ describe('csp collector', () => {
});
test('does not include raw csp rules under any property names', async () => {
- const collector = createCspCollector(httpMock);
+ const collector = new Collector(logger, createCspCollector(httpMock));
// It's important that we do not send the value of csp.rules here as it
// can be customized with values that can be identifiable to given
diff --git a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts
index 88ccb2016d420..16a60eca60f47 100644
--- a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts
+++ b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts
@@ -17,20 +17,25 @@
* under the License.
*/
-import { pluginInitializerContextConfigMock } from '../../../../../core/server/mocks';
import {
- CollectorOptions,
+ loggingSystemMock,
+ pluginInitializerContextConfigMock,
+} from '../../../../../core/server/mocks';
+import {
+ Collector,
createUsageCollectionSetupMock,
} from '../../../../usage_collection/server/usage_collection.mock';
import { createCollectorFetchContextMock } from '../../../../usage_collection/server/mocks';
import { registerKibanaUsageCollector } from './';
+const logger = loggingSystemMock.createLogger();
+
describe('telemetry_kibana', () => {
- let collector: CollectorOptions;
+ let collector: Collector;
const usageCollectionMock = createUsageCollectionSetupMock();
usageCollectionMock.makeUsageCollector.mockImplementation((config) => {
- collector = config;
+ collector = new Collector(logger, config);
return createUsageCollectionSetupMock().makeUsageCollector(config);
});
diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/management/index.test.ts
index e671f739ee083..0aafee01cf49d 100644
--- a/src/plugins/kibana_usage_collection/server/collectors/management/index.test.ts
+++ b/src/plugins/kibana_usage_collection/server/collectors/management/index.test.ts
@@ -17,21 +17,23 @@
* under the License.
*/
-import { uiSettingsServiceMock } from '../../../../../core/server/mocks';
+import { loggingSystemMock, uiSettingsServiceMock } from '../../../../../core/server/mocks';
import {
- CollectorOptions,
+ Collector,
createUsageCollectionSetupMock,
createCollectorFetchContextMock,
} from '../../../../usage_collection/server/usage_collection.mock';
import { registerManagementUsageCollector } from './';
+const logger = loggingSystemMock.createLogger();
+
describe('telemetry_application_usage_collector', () => {
- let collector: CollectorOptions;
+ let collector: Collector;
const usageCollectionMock = createUsageCollectionSetupMock();
usageCollectionMock.makeUsageCollector.mockImplementation((config) => {
- collector = config;
+ collector = new Collector(logger, config);
return createUsageCollectionSetupMock().makeUsageCollector(config);
});
diff --git a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts
index 61990730812cc..8db7010e64026 100644
--- a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts
+++ b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts
@@ -19,20 +19,23 @@
import { Subject } from 'rxjs';
import {
- CollectorOptions,
+ Collector,
createUsageCollectionSetupMock,
createCollectorFetchContextMock,
} from '../../../../usage_collection/server/usage_collection.mock';
import { registerOpsStatsCollector } from './';
import { OpsMetrics } from '../../../../../core/server';
+import { loggingSystemMock } from '../../../../../core/server/mocks';
+
+const logger = loggingSystemMock.createLogger();
describe('telemetry_ops_stats', () => {
- let collector: CollectorOptions;
+ let collector: Collector;
const usageCollectionMock = createUsageCollectionSetupMock();
usageCollectionMock.makeStatsCollector.mockImplementation((config) => {
- collector = config;
+ collector = new Collector(logger, config);
return createUsageCollectionSetupMock().makeStatsCollector(config);
});
diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts
index 48e4e0d99d3cd..90e3b7110e1dc 100644
--- a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts
+++ b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts
@@ -17,21 +17,23 @@
* under the License.
*/
-import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks';
+import { loggingSystemMock, savedObjectsRepositoryMock } from '../../../../../core/server/mocks';
import {
- CollectorOptions,
+ Collector,
createUsageCollectionSetupMock,
createCollectorFetchContextMock,
} from '../../../../usage_collection/server/usage_collection.mock';
import { registerUiMetricUsageCollector } from './';
+const logger = loggingSystemMock.createLogger();
+
describe('telemetry_ui_metric', () => {
- let collector: CollectorOptions;
+ let collector: Collector;
const usageCollectionMock = createUsageCollectionSetupMock();
usageCollectionMock.makeUsageCollector.mockImplementation((config) => {
- collector = config;
+ collector = new Collector(logger, config);
return createUsageCollectionSetupMock().makeUsageCollector(config);
});
diff --git a/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.test.tsx b/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.test.tsx
index 81101f3180738..48e5ee3c87e37 100644
--- a/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.test.tsx
+++ b/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.test.tsx
@@ -97,11 +97,11 @@ test('context receives stateContainer', () => {
const { Provider, context } = createStateContainerReactHelpers();
ReactDOM.render(
- /* eslint-disable no-shadow */
+ /* eslint-disable @typescript-eslint/no-shadow */
{(stateContainer) => stateContainer.get().foo}
,
- /* eslint-enable no-shadow */
+ /* eslint-enable @typescript-eslint/no-shadow */
container
);
@@ -116,7 +116,7 @@ describe('hooks', () => {
const stateContainer = createStateContainer({ foo: 'bar' });
const { Provider, useContainer } = createStateContainerReactHelpers();
const Demo: React.FC<{}> = () => {
- // eslint-disable-next-line no-shadow
+ // eslint-disable-next-line @typescript-eslint/no-shadow
const stateContainer = useContainer();
return <>{stateContainer.get().foo}>;
};
diff --git a/src/plugins/kibana_utils/demos/state_sync/url.ts b/src/plugins/kibana_utils/demos/state_sync/url.ts
index e8e63eefe866c..f7a66e79b8170 100644
--- a/src/plugins/kibana_utils/demos/state_sync/url.ts
+++ b/src/plugins/kibana_utils/demos/state_sync/url.ts
@@ -56,9 +56,9 @@ export const result = Promise.resolve()
});
function withDefaultState(
- // eslint-disable-next-line no-shadow
+ // eslint-disable-next-line @typescript-eslint/no-shadow
stateContainer: BaseStateContainer,
- // eslint-disable-next-line no-shadow
+ // eslint-disable-next-line @typescript-eslint/no-shadow
defaultState: State
): INullableBaseStateContainer {
return {
diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts
index 4b2b2bd99911b..f96c243e82f89 100644
--- a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts
@@ -354,7 +354,7 @@ describe('state_sync', () => {
function withDefaultState(
stateContainer: BaseStateContainer,
- // eslint-disable-next-line no-shadow
+ // eslint-disable-next-line @typescript-eslint/no-shadow
defaultState: State
): INullableBaseStateContainer {
return {
diff --git a/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts
index 654c5435650cf..207a467ca5fd0 100644
--- a/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts
+++ b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts
@@ -21,6 +21,7 @@ import { omit } from 'lodash';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import {
ISavedObjectsRepository,
+ KibanaRequest,
LegacyAPICaller,
SavedObjectsClientContract,
} from 'kibana/server';
@@ -89,8 +90,14 @@ export async function getKibana(
usageCollection: UsageCollectionSetup,
callWithInternalUser: LegacyAPICaller,
asInternalUser: ElasticsearchClient,
- soClient: SavedObjectsClientContract | ISavedObjectsRepository
+ soClient: SavedObjectsClientContract | ISavedObjectsRepository,
+ kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter
): Promise {
- const usage = await usageCollection.bulkFetch(callWithInternalUser, asInternalUser, soClient);
+ const usage = await usageCollection.bulkFetch(
+ callWithInternalUser,
+ asInternalUser,
+ soClient,
+ kibanaRequest
+ );
return usageCollection.toObject(usage);
}
diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts
index 9298b36ac3ea6..6231fd29e7d3d 100644
--- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts
+++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts
@@ -24,7 +24,7 @@ import {
usageCollectionPluginMock,
createCollectorFetchContextMock,
} from '../../../usage_collection/server/mocks';
-import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks';
+import { elasticsearchServiceMock, httpServerMock } from '../../../../../src/core/server/mocks';
function mockUsageCollection(kibanaUsage = {}) {
const usageCollection = usageCollectionPluginMock.createSetupContract();
@@ -87,6 +87,7 @@ function mockStatsCollectionConfig(clusterInfo: any, clusterStats: any, kibana:
...createCollectorFetchContextMock(),
esClient: mockGetLocalStats(clusterInfo, clusterStats),
usageCollection: mockUsageCollection(kibana),
+ kibanaRequest: httpServerMock.createKibanaRequest(),
timestamp: Date.now(),
};
}
diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts
index 4aeefb1d81d6a..a3666683a05a1 100644
--- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts
+++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts
@@ -62,16 +62,16 @@ export type TelemetryLocalStats = ReturnType;
/**
* Get statistics for all products joined by Elasticsearch cluster.
- * @param {Array} cluster uuids
- * @param {Object} config contains the new esClient already scoped contains usageCollection, callCluster, esClient, start, end
+ * @param {Array} cluster uuids array of cluster uuid's
+ * @param {Object} config contains the usageCollection, callCluster (deprecated), the esClient and Saved Objects client scoped to the request or the internal repository, and the kibana request
* @param {Object} StatsCollectionContext contains logger and version (string)
*/
export const getLocalStats: StatsGetter<{}, TelemetryLocalStats> = async (
- clustersDetails, // array of cluster uuid's
- config, // contains the new esClient already scoped contains usageCollection, callCluster, esClient, start, end and the saved objects client scoped to the request or the internal repository
- context // StatsCollectionContext contains logger and version (string)
+ clustersDetails,
+ config,
+ context
) => {
- const { callCluster, usageCollection, esClient, soClient } = config;
+ const { callCluster, usageCollection, esClient, soClient, kibanaRequest } = config;
return await Promise.all(
clustersDetails.map(async (clustersDetail) => {
@@ -79,7 +79,7 @@ export const getLocalStats: StatsGetter<{}, TelemetryLocalStats> = async (
getClusterInfo(esClient), // cluster info
getClusterStats(esClient), // cluster stats (not to be confused with cluster _state_)
getNodesUsage(esClient), // nodes_usage info
- getKibana(usageCollection, callCluster, esClient, soClient),
+ getKibana(usageCollection, callCluster, esClient, soClient, kibanaRequest),
getDataTelemetry(esClient),
]);
return handleLocalStats(
diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts
index e1e1379097adf..c9e2f22fa19aa 100644
--- a/src/plugins/telemetry_collection_manager/server/plugin.ts
+++ b/src/plugins/telemetry_collection_manager/server/plugin.ts
@@ -157,7 +157,10 @@ export class TelemetryCollectionManagerPlugin
const soClient = config.unencrypted
? collectionSoService.getScopedClient(config.request)
: collectionSoService.createInternalRepository();
- return { callCluster, timestamp, usageCollection, esClient, soClient };
+ // Provide the kibanaRequest so opted-in plugins can scope their custom clients only if the request is not encrypted
+ const kibanaRequest = config.unencrypted ? request : void 0;
+
+ return { callCluster, timestamp, usageCollection, esClient, soClient, kibanaRequest };
}
private async getOptInStats(optInStatus: boolean, config: StatsGetterConfig) {
@@ -286,9 +289,9 @@ export class TelemetryCollectionManagerPlugin
return stats.map((stat) => {
const license = licenses[stat.cluster_uuid];
return {
+ collectionSource: collection.title,
...(license ? { license } : {}),
...stat,
- collectionSource: collection.title,
};
});
}
diff --git a/src/plugins/telemetry_collection_manager/server/types.ts b/src/plugins/telemetry_collection_manager/server/types.ts
index 0100fdbbb3970..7d25b8c8261c4 100644
--- a/src/plugins/telemetry_collection_manager/server/types.ts
+++ b/src/plugins/telemetry_collection_manager/server/types.ts
@@ -79,6 +79,7 @@ export interface StatsCollectionConfig {
timestamp: number;
esClient: ElasticsearchClient;
soClient: SavedObjectsClientContract | ISavedObjectsRepository;
+ kibanaRequest: KibanaRequest | undefined; // intentionally `| undefined` to enforce providing the parameter
}
export interface BasicStatsPayload {
diff --git a/src/plugins/ui_actions/public/public.api.md b/src/plugins/ui_actions/public/public.api.md
index 3e40c94e116fb..3a14f49169e09 100644
--- a/src/plugins/ui_actions/public/public.api.md
+++ b/src/plugins/ui_actions/public/public.api.md
@@ -8,6 +8,7 @@ import { CoreSetup } from 'src/core/public';
import { CoreStart } from 'src/core/public';
import { EnvironmentMode } from '@kbn/config';
import { EuiContextMenuPanelDescriptor } from '@elastic/eui';
+import { EventEmitter } from 'events';
import { Observable } from 'rxjs';
import { PackageInfo } from '@kbn/config';
import { Plugin } from 'src/core/public';
diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md
index 2ac3de510f8ae..5e6ed901c7647 100644
--- a/src/plugins/usage_collection/README.md
+++ b/src/plugins/usage_collection/README.md
@@ -31,7 +31,7 @@ Then you need to make the Telemetry service aware of the collector by registerin
```ts
// server/plugin.ts
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
- import { CoreSetup, CoreStart } from 'kibana/server';
+ import { CoreSetup, CoreStart } from 'src/core/server';
class Plugin {
public setup(core: CoreSetup, plugins: { usageCollection?: UsageCollectionSetup }) {
@@ -46,7 +46,7 @@ Then you need to make the Telemetry service aware of the collector by registerin
```ts
// server/collectors/register.ts
import { UsageCollectionSetup, CollectorFetchContext } from 'src/plugins/usage_collection/server';
- import { APICluster } from 'kibana/server';
+ import { APICluster } from 'src/core/server';
interface Usage {
my_objects: {
@@ -95,8 +95,8 @@ Some background:
- `isReady` (added in v7.2.0 and v6.8.4) is a way for a usage collector to announce that some async process must finish first before it can return data in the `fetch` method (e.g. a client needs to ne initialized, or the task manager needs to run a task first). If any collector reports that it is not ready when we call its `fetch` method, we reset a flag to try again and, after a set amount of time, collect data from those collectors that are ready and skip any that are not. This means that if a collector returns `true` for `isReady` and it actually isn't ready to return data, there won't be telemetry data from that collector in that telemetry report (usually once per day). You should consider what it means if your collector doesn't return data in the first few documents when Kibana starts or, if we should wait for any other reason (e.g. the task manager needs to run your task first). If you need to tell telemetry collection to wait, you should implement this function with custom logic. If your `fetch` method can run without the need of any previous dependencies, then you can return true for `isReady` as shown in the example below.
-- The `fetch` method needs to support multiple contexts in which it is called. For example, when stats are pulled from a Kibana Metricbeat module, the Beat calls Kibana's stats API to invoke usage collection.
-In this case, the `fetch` method is called as a result of an HTTP API request and `callCluster` wraps `callWithRequest` or `esClient` wraps `asCurrentUser`, where the request headers are expected to have read privilege on the entire `.kibana' index. The `fetch` method also exposes the saved objects client that will have the correct scope when the collectors' `fetch` method is called.
+- The `fetch` method needs to support multiple contexts in which it is called. For example, when a user requests the example of what we collect in the **Kibana>Advanced Settings>Usage data** section, the clients provided in the context of the function (`CollectorFetchContext`) are scoped to that user's privileges. The reason is to avoid exposing via telemetry any data that user should not have access to (i.e.: if the user does not have access to certain indices, they shouldn't be allowed to see the number of documents that exists in it). In this case, the `fetch` method receives the clients `callCluster`, `esClient` and `soClient` scoped to the user who performed the HTTP API request. Alternatively, when requesting the usage data to be reported to the Remote Telemetry Service, the clients are scoped to the internal Kibana user (`kibana_system`). Please, mind it might have lower-level access than the default super-admin `elastic` test user.
+In some scenarios, your collector might need to maintain its own client. An example of that is the `monitoring` plugin, that maintains a connection to the Remote Monitoring Cluster to push its monitoring data. If that's the case, your plugin can opt-in to receive the additional `kibanaRequest` parameter by adding `extendFetchContext.kibanaRequest: true` to the collector's config: it will be appended to the context of the `fetch` method only if the request needs to be scoped to a user other than Kibana Internal, so beware that your collector will need to work for both scenarios (especially for the scenario when `kibanaRequest` is missing).
Note: there will be many cases where you won't need to use the `callCluster`, `esClient` or `soClient` function that gets passed in to your `fetch` method at all. Your feature might have an accumulating value in server memory, or read something from the OS.
@@ -105,7 +105,7 @@ In the case of using a custom SavedObjects client, it is up to the plugin to ini
```ts
// server/plugin.ts
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
-import { CoreSetup, CoreStart } from 'kibana/server';
+import { CoreSetup, CoreStart } from 'src/core/server';
class Plugin {
private savedObjectsRepository?: ISavedObjectsRepository;
diff --git a/src/plugins/usage_collection/server/collector/collector.ts b/src/plugins/usage_collection/server/collector/collector.ts
index c04b087d4adf5..797fdaa06a620 100644
--- a/src/plugins/usage_collection/server/collector/collector.ts
+++ b/src/plugins/usage_collection/server/collector/collector.ts
@@ -23,7 +23,8 @@ import {
ElasticsearchClient,
ISavedObjectsRepository,
SavedObjectsClientContract,
-} from 'kibana/server';
+ KibanaRequest,
+} from 'src/core/server';
export type CollectorFormatForBulkUpload = (result: T) => { type: string; payload: U };
@@ -46,26 +47,71 @@ export type MakeSchemaFrom = {
: RecursiveMakeSchemaFrom[Key]>;
};
-export interface CollectorFetchContext {
+/**
+ * The context for the `fetch` method: It includes the most commonly used clients in the collectors (ES and SO clients).
+ * Both are scoped based on the request and the context:
+ * - When users are requesting a sample of data, it is scoped to their role to avoid exposing data they shouldn't read
+ * - When building the telemetry data payload to report to the remote cluster, the requests are scoped to the `kibana` internal user
+ *
+ * @remark Bear in mind when testing your collector that your user has the same privileges as the Kibana Internal user to ensure the expected data is sent to the remote cluster.
+ */
+export type CollectorFetchContext = {
/**
- * @depricated Scoped Legacy Elasticsearch client: use esClient instead
+ * @deprecated Scoped Legacy Elasticsearch client: use esClient instead
*/
callCluster: LegacyAPICaller;
/**
- * Request-scoped Elasticsearch client:
- * - When users are requesting a sample of data, it is scoped to their role to avoid exposing data they should't read
- * - When building the telemetry data payload to report to the remote cluster, the requests are scoped to the `kibana` internal user
+ * Request-scoped Elasticsearch client
+ * @remark Bear in mind when testing your collector that your user has the same privileges as the Kibana Internal user to ensure the expected data is sent to the remote cluster (more info: {@link CollectorFetchContext})
*/
esClient: ElasticsearchClient;
/**
- * Request-scoped Saved Objects client:
- * - When users are requesting a sample of data, it is scoped to their role to avoid exposing data they should't read
- * - When building the telemetry data payload to report to the remote cluster, the requests are scoped to the `kibana` internal user
+ * Request-scoped Saved Objects client
+ * @remark Bear in mind when testing your collector that your user has the same privileges as the Kibana Internal user to ensure the expected data is sent to the remote cluster (more info: {@link CollectorFetchContext})
*/
soClient: SavedObjectsClientContract | ISavedObjectsRepository;
+} & (WithKibanaRequest extends true
+ ? {
+ /**
+ * The KibanaRequest that can be used to scope the requests:
+ * It is provided only when your custom clients need to be scoped. If not available, you should use the Internal Client.
+ * More information about when scoping is needed: {@link CollectorFetchContext}
+ * @remark You should only use this if you implement your collector to deal with both scenarios: when provided and, especially, when not provided. When telemetry payload is sent to the remote service the `kibanaRequest` will not be provided.
+ */
+ kibanaRequest?: KibanaRequest;
+ }
+ : {});
+
+export type CollectorFetchMethod<
+ WithKibanaRequest extends boolean | undefined,
+ TReturn,
+ ExtraOptions extends object = {}
+> = (
+ this: Collector & ExtraOptions, // Specify the context of `this` for this.log and others to become available
+ context: CollectorFetchContext
+) => Promise | TReturn;
+
+export interface ICollectorOptionsFetchExtendedContext {
+ /**
+ * Set to `true` if your `fetch` method requires the `KibanaRequest` object to be added in its context {@link CollectorFetchContextWithRequest}.
+ * @remark You should fully understand acknowledge that by using the `KibanaRequest` in your collector, you need to ensure it should specially work without it because it won't be provided when building the telemetry payload actually sent to the remote telemetry service.
+ */
+ kibanaRequest?: WithKibanaRequest;
}
-export interface CollectorOptions {
+export type CollectorOptionsFetchExtendedContext<
+ WithKibanaRequest extends boolean
+> = ICollectorOptionsFetchExtendedContext &
+ (WithKibanaRequest extends true // If enforced to true via Types, the config must be expected
+ ? Required, 'kibanaRequest'>>
+ : {});
+
+export type CollectorOptions<
+ TFetchReturn = unknown,
+ UFormatBulkUploadPayload = TFetchReturn, // TODO: Once we remove bulk_uploader's dependency on usageCollection, we'll be able to remove this type
+ WithKibanaRequest extends boolean = boolean,
+ ExtraOptions extends object = {}
+> = {
/**
* Unique string identifier for the collector
*/
@@ -78,23 +124,42 @@ export interface CollectorOptions {
/**
* Schema definition of the output of the `fetch` method.
*/
- schema?: MakeSchemaFrom;
- fetch: (collectorFetchContext: CollectorFetchContext) => Promise | T;
- /*
+ schema?: MakeSchemaFrom;
+ /**
+ * The method that will collect and return the data in the final format.
+ * @param collectorFetchContext {@link CollectorFetchContext}
+ */
+ fetch: CollectorFetchMethod;
+ /**
* A hook for allowing the fetched data payload to be organized into a typed
* data model for internal bulk upload. See defaultFormatterForBulkUpload for
* a generic example.
* @deprecated Used only by the Legacy Monitoring collection (to be removed in 8.0)
*/
- formatForBulkUpload?: CollectorFormatForBulkUpload;
-}
+ formatForBulkUpload?: CollectorFormatForBulkUpload;
+} & ExtraOptions &
+ (WithKibanaRequest extends true // If enforced to true via Types, the config must be enforced
+ ? {
+ extendFetchContext: CollectorOptionsFetchExtendedContext;
+ }
+ : {
+ extendFetchContext?: CollectorOptionsFetchExtendedContext;
+ });
-export class Collector {
- public readonly type: CollectorOptions['type'];
- public readonly init?: CollectorOptions['init'];
- public readonly fetch: CollectorOptions['fetch'];
- private readonly _formatForBulkUpload?: CollectorFormatForBulkUpload;
- public readonly isReady: CollectorOptions['isReady'];
+export class Collector<
+ TFetchReturn,
+ UFormatBulkUploadPayload = TFetchReturn,
+ ExtraOptions extends object = {}
+> {
+ public readonly extendFetchContext: CollectorOptionsFetchExtendedContext;
+ public readonly type: CollectorOptions['type'];
+ public readonly init?: CollectorOptions['init'];
+ public readonly fetch: CollectorFetchMethod;
+ public readonly isReady: CollectorOptions['isReady'];
+ private readonly _formatForBulkUpload?: CollectorFormatForBulkUpload<
+ TFetchReturn,
+ UFormatBulkUploadPayload
+ >;
/*
* @param {Object} logger - logger object
* @param {String} options.type - property name as the key for the data
@@ -105,8 +170,16 @@ export class Collector {
* @param {Function} options.rest - optional other properties
*/
constructor(
- protected readonly log: Logger,
- { type, init, fetch, formatForBulkUpload, isReady, ...options }: CollectorOptions
+ public readonly log: Logger,
+ {
+ type,
+ init,
+ fetch,
+ formatForBulkUpload,
+ isReady,
+ extendFetchContext = {},
+ ...options
+ }: CollectorOptions
) {
if (type === undefined) {
throw new Error('Collector must be instantiated with a options.type string property');
@@ -126,10 +199,11 @@ export class Collector {
this.init = init;
this.fetch = fetch;
this.isReady = typeof isReady === 'function' ? isReady : () => true;
+ this.extendFetchContext = extendFetchContext;
this._formatForBulkUpload = formatForBulkUpload;
}
- public formatForBulkUpload(result: T) {
+ public formatForBulkUpload(result: TFetchReturn) {
if (this._formatForBulkUpload) {
return this._formatForBulkUpload(result);
} else {
@@ -137,10 +211,10 @@ export class Collector {
}
}
- protected defaultFormatterForBulkUpload(result: T) {
+ protected defaultFormatterForBulkUpload(result: TFetchReturn) {
return {
type: this.type,
- payload: (result as unknown) as U,
+ payload: (result as unknown) as UFormatBulkUploadPayload,
};
}
}
diff --git a/src/plugins/usage_collection/server/collector/collector_set.test.ts b/src/plugins/usage_collection/server/collector/collector_set.test.ts
index 359a2d214f991..fc17ce131430c 100644
--- a/src/plugins/usage_collection/server/collector/collector_set.test.ts
+++ b/src/plugins/usage_collection/server/collector/collector_set.test.ts
@@ -47,6 +47,7 @@ describe('CollectorSet', () => {
const mockCallCluster = jest.fn().mockResolvedValue({ passTest: 1000 });
const mockEsClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
const mockSoClient = savedObjectsRepositoryMock.create();
+ const req = void 0; // No need to instantiate any KibanaRequest in these tests
it('should throw an error if non-Collector type of object is registered', () => {
const collectors = new CollectorSet({ logger });
@@ -93,7 +94,7 @@ describe('CollectorSet', () => {
})
);
- const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient);
+ const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient, req);
expect(loggerSpies.debug).toHaveBeenCalledTimes(1);
expect(loggerSpies.debug).toHaveBeenCalledWith(
'Fetching data from MY_TEST_COLLECTOR collector'
@@ -118,7 +119,7 @@ describe('CollectorSet', () => {
let result;
try {
- result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient);
+ result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient, req);
} catch (err) {
// Do nothing
}
@@ -136,7 +137,7 @@ describe('CollectorSet', () => {
})
);
- const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient);
+ const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient, req);
expect(result).toStrictEqual([
{
type: 'MY_TEST_COLLECTOR',
@@ -154,7 +155,7 @@ describe('CollectorSet', () => {
} as any)
);
- const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient);
+ const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient, req);
expect(result).toStrictEqual([
{
type: 'MY_TEST_COLLECTOR',
@@ -177,7 +178,7 @@ describe('CollectorSet', () => {
})
);
- const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient);
+ const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient, req);
expect(result).toStrictEqual([
{
type: 'MY_TEST_COLLECTOR',
@@ -274,4 +275,272 @@ describe('CollectorSet', () => {
expect(collectors.isUsageCollector(void 0)).toEqual(false);
});
});
+
+ describe('makeStatsCollector', () => {
+ const collectorSet = new CollectorSet({ logger });
+ test('TS should hide kibanaRequest when not opted-in', () => {
+ collectorSet.makeStatsCollector({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ // @ts-expect-error
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ });
+ });
+
+ test('TS should hide kibanaRequest when not opted-in (explicit false)', () => {
+ collectorSet.makeStatsCollector({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ // @ts-expect-error
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ extendFetchContext: {
+ kibanaRequest: false,
+ },
+ });
+ });
+
+ test('TS should allow using kibanaRequest when opted-in (explicit true)', () => {
+ collectorSet.makeStatsCollector({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ extendFetchContext: {
+ kibanaRequest: true,
+ },
+ });
+ });
+
+ test('fetch can use the logger (TS allows it)', () => {
+ const collector = collectorSet.makeStatsCollector({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch() {
+ this.log.info("I can use the Collector's class logger!");
+ return { test: 1 };
+ },
+ });
+ expect(
+ collector.fetch(
+ // @ts-expect-error: the test implementation is not using it
+ {}
+ )
+ ).toStrictEqual({ test: 1 });
+ });
+ });
+
+ describe('makeUsageCollector', () => {
+ const collectorSet = new CollectorSet({ logger });
+ describe('TS validations', () => {
+ describe('when types are inferred', () => {
+ test('TS should hide kibanaRequest when not opted-in', () => {
+ collectorSet.makeUsageCollector({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ // @ts-expect-error
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ });
+ });
+
+ test('TS should hide kibanaRequest when not opted-in (explicit false)', () => {
+ collectorSet.makeUsageCollector({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ // @ts-expect-error
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ extendFetchContext: {
+ kibanaRequest: false,
+ },
+ });
+ });
+
+ test('TS should allow using kibanaRequest when opted-in (explicit true)', () => {
+ collectorSet.makeUsageCollector({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ extendFetchContext: {
+ kibanaRequest: true,
+ },
+ });
+ });
+ });
+
+ describe('when types are explicit', () => {
+ test('TS should hide `kibanaRequest` from ctx when undefined or false', () => {
+ collectorSet.makeUsageCollector<{ test: number }>({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ // @ts-expect-error
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ });
+ collectorSet.makeUsageCollector<{ test: number }, unknown, false>({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ // @ts-expect-error
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ extendFetchContext: {
+ kibanaRequest: false,
+ },
+ });
+ collectorSet.makeUsageCollector<{ test: number }, unknown, false>({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ // @ts-expect-error
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ });
+ });
+ test('TS should not allow `true` when types declare false', () => {
+ // false is the default when at least 1 type is specified
+ collectorSet.makeUsageCollector<{ test: number }>({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ // @ts-expect-error
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ extendFetchContext: {
+ // @ts-expect-error
+ kibanaRequest: true,
+ },
+ });
+ collectorSet.makeUsageCollector<{ test: number }, unknown, false>({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ // @ts-expect-error
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ extendFetchContext: {
+ // @ts-expect-error
+ kibanaRequest: true,
+ },
+ });
+ });
+
+ test('TS should allow `true` when types explicitly declare `true` and do not allow `false` or undefined', () => {
+ // false is the default when at least 1 type is specified
+ collectorSet.makeUsageCollector<{ test: number }, unknown, true>({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ extendFetchContext: {
+ kibanaRequest: true,
+ },
+ });
+ collectorSet.makeUsageCollector<{ test: number }, unknown, true>({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ extendFetchContext: {
+ // @ts-expect-error
+ kibanaRequest: false,
+ },
+ });
+ collectorSet.makeUsageCollector<{ test: number }, unknown, true>({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ extendFetchContext: {
+ // @ts-expect-error
+ kibanaRequest: undefined,
+ },
+ });
+ collectorSet.makeUsageCollector<{ test: number }, unknown, true>({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ // @ts-expect-error
+ extendFetchContext: {},
+ });
+ collectorSet.makeUsageCollector<{ test: number }, unknown, true>(
+ // @ts-expect-error
+ {
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch: (ctx) => {
+ const { kibanaRequest } = ctx;
+ return { test: kibanaRequest ? 1 : 0 };
+ },
+ }
+ );
+ });
+ });
+ });
+
+ test('fetch can use the logger (TS allows it)', () => {
+ const collector = collectorSet.makeUsageCollector({
+ type: 'MY_TEST_COLLECTOR',
+ isReady: () => true,
+ schema: { test: { type: 'long' } },
+ fetch() {
+ this.log.info("I can use the Collector's class logger!");
+ return { test: 1 };
+ },
+ });
+ expect(
+ collector.fetch(
+ // @ts-expect-error: the test implementation is not using it
+ {}
+ )
+ ).toStrictEqual({ test: 1 });
+ });
+ });
});
diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts
index c52830cda6513..fe4f3536ffed6 100644
--- a/src/plugins/usage_collection/server/collector/collector_set.ts
+++ b/src/plugins/usage_collection/server/collector/collector_set.ts
@@ -24,50 +24,79 @@ import {
ElasticsearchClient,
ISavedObjectsRepository,
SavedObjectsClientContract,
-} from 'kibana/server';
+ KibanaRequest,
+} from 'src/core/server';
import { Collector, CollectorOptions } from './collector';
import { UsageCollector, UsageCollectorOptions } from './usage_collector';
+type AnyCollector = Collector;
+type AnyUsageCollector = UsageCollector;
+
interface CollectorSetConfig {
logger: Logger;
maximumWaitTimeForAllCollectorsInS?: number;
- collectors?: Array>;
+ collectors?: AnyCollector[];
}
export class CollectorSet {
private _waitingForAllCollectorsTimestamp?: number;
private readonly logger: Logger;
private readonly maximumWaitTimeForAllCollectorsInS: number;
- private readonly collectors: Map>;
+ private readonly collectors: Map;
constructor({ logger, maximumWaitTimeForAllCollectorsInS, collectors = [] }: CollectorSetConfig) {
this.logger = logger;
this.collectors = new Map(collectors.map((collector) => [collector.type, collector]));
this.maximumWaitTimeForAllCollectorsInS = maximumWaitTimeForAllCollectorsInS || 60;
}
+ /**
+ * Instantiates a stats collector with the definition provided in the options
+ * @param options Definition of the collector {@link CollectorOptions}
+ */
public makeStatsCollector = <
- T,
- U,
- O extends CollectorOptions = CollectorOptions // Used to allow extra properties (the Collector constructor extends the class with the additional options provided)
+ TFetchReturn,
+ TFormatForBulkUpload,
+ WithKibanaRequest extends boolean,
+ ExtraOptions extends object = {}
>(
- options: O
+ options: CollectorOptions
) => {
- return new Collector(this.logger, options);
+ return new Collector(this.logger, options);
};
+
+ /**
+ * Instantiates an usage collector with the definition provided in the options
+ * @param options Definition of the collector {@link CollectorOptions}
+ */
public makeUsageCollector = <
- T,
- U = T,
- O extends UsageCollectorOptions = UsageCollectorOptions
+ TFetchReturn,
+ TFormatForBulkUpload = { usage: { [key: string]: TFetchReturn } },
+ // TODO: Right now, users will need to explicitly claim `true` for TS to allow `kibanaRequest` usage.
+ // If we improve `telemetry-check-tools` so plugins do not need to specify TFetchReturn,
+ // we'll be able to remove the type defaults and TS will successfully infer the config value as provided in JS.
+ WithKibanaRequest extends boolean = false,
+ ExtraOptions extends object = {}
>(
- options: O
+ options: UsageCollectorOptions<
+ TFetchReturn,
+ TFormatForBulkUpload,
+ WithKibanaRequest,
+ ExtraOptions
+ >
) => {
- return new UsageCollector(this.logger, options);
+ return new UsageCollector(
+ this.logger,
+ options
+ );
};
- /*
- * @param collector {Collector} collector object
+ /**
+ * Registers a collector to be used when collecting all the usage and stats data
+ * @param collector Collector to be added to the set (previously created via `makeUsageCollector` or `makeStatsCollector`)
*/
- public registerCollector = (collector: Collector) => {
+ public registerCollector = (
+ collector: Collector
+ ) => {
// check instanceof
if (!(collector instanceof Collector)) {
throw new Error('CollectorSet can only have Collector instances registered');
@@ -89,7 +118,7 @@ export class CollectorSet {
return [...this.collectors.values()].find((c) => c.type === type);
};
- public isUsageCollector = (x: UsageCollector | any): x is UsageCollector => {
+ public isUsageCollector = (x: AnyUsageCollector | any): x is AnyUsageCollector => {
return x instanceof UsageCollector;
};
@@ -144,15 +173,22 @@ export class CollectorSet {
callCluster: LegacyAPICaller,
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract | ISavedObjectsRepository,
- collectors: Map> = this.collectors
+ kibanaRequest: KibanaRequest | undefined, // intentionally `| undefined` to enforce providing the parameter
+ collectors: Map = this.collectors
) => {
const responses = await Promise.all(
[...collectors.values()].map(async (collector) => {
this.logger.debug(`Fetching data from ${collector.type} collector`);
try {
+ const context = {
+ callCluster,
+ esClient,
+ soClient,
+ ...(collector.extendFetchContext.kibanaRequest && { kibanaRequest }),
+ };
return {
type: collector.type,
- result: await collector.fetch({ callCluster, esClient, soClient }),
+ result: await collector.fetch(context),
};
} catch (err) {
this.logger.warn(err);
@@ -169,7 +205,7 @@ export class CollectorSet {
/*
* @return {new CollectorSet}
*/
- public getFilteredCollectorSet = (filter: (col: Collector) => boolean) => {
+ public getFilteredCollectorSet = (filter: (col: AnyCollector) => boolean) => {
const filtered = [...this.collectors.values()].filter(filter);
return this.makeCollectorSetFromArray(filtered);
};
@@ -177,13 +213,15 @@ export class CollectorSet {
public bulkFetchUsage = async (
callCluster: LegacyAPICaller,
esClient: ElasticsearchClient,
- savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository
+ savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository,
+ kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter
) => {
const usageCollectors = this.getFilteredCollectorSet((c) => c instanceof UsageCollector);
return await this.bulkFetch(
callCluster,
esClient,
savedObjectsClient,
+ kibanaRequest,
usageCollectors.collectors
);
};
@@ -239,7 +277,7 @@ export class CollectorSet {
return [...this.collectors.values()].some(someFn);
};
- private makeCollectorSetFromArray = (collectors: Collector[]) => {
+ private makeCollectorSetFromArray = (collectors: AnyCollector[]) => {
return new CollectorSet({
logger: this.logger,
maximumWaitTimeForAllCollectorsInS: this.maximumWaitTimeForAllCollectorsInS,
diff --git a/src/plugins/usage_collection/server/collector/usage_collector.ts b/src/plugins/usage_collection/server/collector/usage_collector.ts
index 5bfc36537e0b0..a042ea113d5cc 100644
--- a/src/plugins/usage_collection/server/collector/usage_collector.ts
+++ b/src/plugins/usage_collection/server/collector/usage_collector.ts
@@ -22,25 +22,39 @@ import { KIBANA_STATS_TYPE } from '../../common/constants';
import { Collector, CollectorOptions } from './collector';
// Enforce the `schema` property for UsageCollectors
-export type UsageCollectorOptions = CollectorOptions &
- Required, 'schema'>>;
+export type UsageCollectorOptions<
+ TFetchReturn = unknown,
+ UFormatBulkUploadPayload = { usage: { [key: string]: TFetchReturn } },
+ WithKibanaRequest extends boolean = false,
+ ExtraOptions extends object = {}
+> = CollectorOptions &
+ Required, 'schema'>>;
-export class UsageCollector extends Collector<
- T,
- U
-> {
- constructor(protected readonly log: Logger, collectorOptions: UsageCollectorOptions) {
+export class UsageCollector<
+ TFetchReturn,
+ UFormatBulkUploadPayload = { usage: { [key: string]: TFetchReturn } },
+ ExtraOptions extends object = {}
+> extends Collector {
+ constructor(
+ public readonly log: Logger,
+ collectorOptions: UsageCollectorOptions<
+ TFetchReturn,
+ UFormatBulkUploadPayload,
+ any,
+ ExtraOptions
+ >
+ ) {
super(log, collectorOptions);
}
- protected defaultFormatterForBulkUpload(result: T) {
+ protected defaultFormatterForBulkUpload(result: TFetchReturn) {
return {
type: KIBANA_STATS_TYPE,
payload: ({
usage: {
[this.type]: result,
},
- } as unknown) as U,
+ } as unknown) as UFormatBulkUploadPayload,
};
}
}
diff --git a/src/plugins/usage_collection/server/config.ts b/src/plugins/usage_collection/server/config.ts
index 76379d9385cff..09b0e05025e63 100644
--- a/src/plugins/usage_collection/server/config.ts
+++ b/src/plugins/usage_collection/server/config.ts
@@ -18,7 +18,7 @@
*/
import { schema, TypeOf } from '@kbn/config-schema';
-import { PluginConfigDescriptor } from 'kibana/server';
+import { PluginConfigDescriptor } from 'src/core/server';
import { DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S } from '../common/constants';
export const configSchema = schema.object({
diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts
index f7a08fdb5e9dd..48ea9afa13976 100644
--- a/src/plugins/usage_collection/server/index.ts
+++ b/src/plugins/usage_collection/server/index.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { PluginInitializerContext } from 'kibana/server';
+import { PluginInitializerContext } from 'src/core/server';
import { UsageCollectionPlugin } from './plugin';
export {
diff --git a/src/plugins/usage_collection/server/mocks.ts b/src/plugins/usage_collection/server/mocks.ts
index d08db1eaec0e1..3d89380f629dc 100644
--- a/src/plugins/usage_collection/server/mocks.ts
+++ b/src/plugins/usage_collection/server/mocks.ts
@@ -20,7 +20,7 @@
import { loggingSystemMock } from '../../../core/server/mocks';
import { UsageCollectionSetup } from './plugin';
import { CollectorSet } from './collector';
-export { createCollectorFetchContextMock } from './usage_collection.mock';
+export { Collector, createCollectorFetchContextMock } from './usage_collection.mock';
const createSetupContract = () => {
return {
diff --git a/src/plugins/usage_collection/server/plugin.ts b/src/plugins/usage_collection/server/plugin.ts
index 74e70d5ea9d35..9a8876446d01e 100644
--- a/src/plugins/usage_collection/server/plugin.ts
+++ b/src/plugins/usage_collection/server/plugin.ts
@@ -25,7 +25,7 @@ import {
CoreStart,
ISavedObjectsRepository,
Plugin,
-} from 'kibana/server';
+} from 'src/core/server';
import { ConfigType } from './config';
import { CollectorSet } from './collector';
import { setupRoutes } from './routes';
diff --git a/src/plugins/usage_collection/server/report/store_report.ts b/src/plugins/usage_collection/server/report/store_report.ts
index c40622831eeee..d9aac23fd1ff0 100644
--- a/src/plugins/usage_collection/server/report/store_report.ts
+++ b/src/plugins/usage_collection/server/report/store_report.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { ISavedObjectsRepository, SavedObject } from 'kibana/server';
+import { ISavedObjectsRepository, SavedObject } from 'src/core/server';
import { ReportSchemaType } from './schema';
export async function storeReport(
diff --git a/src/plugins/usage_collection/server/routes/index.ts b/src/plugins/usage_collection/server/routes/index.ts
index b367ddc184be7..15d408ff3723b 100644
--- a/src/plugins/usage_collection/server/routes/index.ts
+++ b/src/plugins/usage_collection/server/routes/index.ts
@@ -22,7 +22,7 @@ import {
ISavedObjectsRepository,
MetricsServiceSetup,
ServiceStatus,
-} from 'kibana/server';
+} from 'src/core/server';
import { Observable } from 'rxjs';
import { CollectorSet } from '../collector';
import { registerUiMetricRoute } from './report_metrics';
diff --git a/src/plugins/usage_collection/server/routes/report_metrics.ts b/src/plugins/usage_collection/server/routes/report_metrics.ts
index a72222968eabf..590c3340697b8 100644
--- a/src/plugins/usage_collection/server/routes/report_metrics.ts
+++ b/src/plugins/usage_collection/server/routes/report_metrics.ts
@@ -18,7 +18,7 @@
*/
import { schema } from '@kbn/config-schema';
-import { IRouter, ISavedObjectsRepository } from 'kibana/server';
+import { IRouter, ISavedObjectsRepository } from 'src/core/server';
import { storeReport, reportSchema } from '../report';
export function registerUiMetricRoute(
diff --git a/src/plugins/usage_collection/server/routes/stats/stats.ts b/src/plugins/usage_collection/server/routes/stats/stats.ts
index d38250067053c..16a1c2c742f04 100644
--- a/src/plugins/usage_collection/server/routes/stats/stats.ts
+++ b/src/plugins/usage_collection/server/routes/stats/stats.ts
@@ -27,6 +27,7 @@ import {
ElasticsearchClient,
IRouter,
ISavedObjectsRepository,
+ KibanaRequest,
LegacyAPICaller,
MetricsServiceSetup,
SavedObjectsClientContract,
@@ -67,9 +68,15 @@ export function registerStatsRoute({
const getUsage = async (
callCluster: LegacyAPICaller,
esClient: ElasticsearchClient,
- savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository
+ savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository,
+ kibanaRequest: KibanaRequest
): Promise => {
- const usage = await collectorSet.bulkFetchUsage(callCluster, esClient, savedObjectsClient);
+ const usage = await collectorSet.bulkFetchUsage(
+ callCluster,
+ esClient,
+ savedObjectsClient,
+ kibanaRequest
+ );
return collectorSet.toObject(usage);
};
@@ -115,7 +122,7 @@ export function registerStatsRoute({
}
const usagePromise = shouldGetUsage
- ? getUsage(callCluster, asCurrentUser, savedObjectsClient)
+ ? getUsage(callCluster, asCurrentUser, savedObjectsClient, req)
: Promise.resolve({});
const [usage, clusterUuid] = await Promise.all([
usagePromise,
diff --git a/src/plugins/usage_collection/server/usage_collection.mock.ts b/src/plugins/usage_collection/server/usage_collection.mock.ts
index c31756c60e32d..05dae8fa85164 100644
--- a/src/plugins/usage_collection/server/usage_collection.mock.ts
+++ b/src/plugins/usage_collection/server/usage_collection.mock.ts
@@ -19,13 +19,17 @@
import {
elasticsearchServiceMock,
+ httpServerMock,
+ loggingSystemMock,
savedObjectsRepositoryMock,
} from '../../../../src/core/server/mocks';
-import { CollectorOptions } from './collector/collector';
+import { CollectorOptions, Collector, UsageCollector } from './collector';
import { UsageCollectionSetup, CollectorFetchContext } from './index';
-export { CollectorOptions };
+export { CollectorOptions, Collector };
+
+const logger = loggingSystemMock.createLogger();
export const createUsageCollectionSetupMock = () => {
const usageCollectionSetupMock: jest.Mocked = {
@@ -37,13 +41,13 @@ export const createUsageCollectionSetupMock = () => {
// @ts-ignore jest.fn doesn't play nice with type guards
isUsageCollector: jest.fn(),
makeCollectorSetFromArray: jest.fn(),
- makeStatsCollector: jest.fn(),
map: jest.fn(),
maximumWaitTimeForAllCollectorsInS: 0,
some: jest.fn(),
toApiFieldNames: jest.fn(),
toObject: jest.fn(),
- makeUsageCollector: jest.fn(),
+ makeStatsCollector: jest.fn().mockImplementation((cfg) => new Collector(logger, cfg)),
+ makeUsageCollector: jest.fn().mockImplementation((cfg) => new UsageCollector(logger, cfg)),
registerCollector: jest.fn(),
};
@@ -51,11 +55,23 @@ export const createUsageCollectionSetupMock = () => {
return usageCollectionSetupMock;
};
-export function createCollectorFetchContextMock(): jest.Mocked {
- const collectorFetchClientsMock: jest.Mocked = {
+export function createCollectorFetchContextMock(): jest.Mocked> {
+ const collectorFetchClientsMock: jest.Mocked> = {
+ callCluster: elasticsearchServiceMock.createLegacyClusterClient().callAsInternalUser,
+ esClient: elasticsearchServiceMock.createClusterClient().asInternalUser,
+ soClient: savedObjectsRepositoryMock.create(),
+ };
+ return collectorFetchClientsMock;
+}
+
+export function createCollectorFetchContextWithKibanaMock(): jest.Mocked<
+ CollectorFetchContext
+> {
+ const collectorFetchClientsMock: jest.Mocked> = {
callCluster: elasticsearchServiceMock.createLegacyClusterClient().callAsInternalUser,
esClient: elasticsearchServiceMock.createClusterClient().asInternalUser,
soClient: savedObjectsRepositoryMock.create(),
+ kibanaRequest: httpServerMock.createKibanaRequest(),
};
return collectorFetchClientsMock;
}
diff --git a/src/plugins/vis_default_editor/public/components/controls/has_extended_bounds.tsx b/src/plugins/vis_default_editor/public/components/controls/has_extended_bounds.tsx
index ae3da8e203a57..a316a087c8bcb 100644
--- a/src/plugins/vis_default_editor/public/components/controls/has_extended_bounds.tsx
+++ b/src/plugins/vis_default_editor/public/components/controls/has_extended_bounds.tsx
@@ -38,7 +38,6 @@ function HasExtendedBoundsParamEditor(props: AggParamEditorProps) {
setValue(value && agg.params.min_doc_count);
}
- /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [agg.params.min_doc_count, setValue, value]);
return (
diff --git a/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx b/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx
index a448b58afe8a4..baf3365a514a6 100644
--- a/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx
+++ b/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx
@@ -33,6 +33,8 @@ import {
SERIES_ID_ATTR,
colors,
Axis,
+ ACTIVE_CURSOR,
+ eventBus,
} from '../helpers/panel_utils';
import { Series, Sheet } from '../helpers/timelion_request_handler';
@@ -338,16 +340,40 @@ function TimelionVisComponent({
});
}, [legendCaption, legendValueNumbers]);
+ const plotHover = useCallback(
+ (pos: Position) => {
+ (plot as CrosshairPlot).setCrosshair(pos);
+ debouncedSetLegendNumbers(pos);
+ },
+ [plot, debouncedSetLegendNumbers]
+ );
+
const plotHoverHandler = useCallback(
(event: JQuery.TriggeredEvent, pos: Position) => {
if (!plot) {
return;
}
- (plot as CrosshairPlot).setCrosshair(pos);
- debouncedSetLegendNumbers(pos);
+ plotHover(pos);
+ eventBus.trigger(ACTIVE_CURSOR, [event, pos]);
},
- [plot, debouncedSetLegendNumbers]
+ [plot, plotHover]
);
+
+ useEffect(() => {
+ const updateCursor = (_: any, event: JQuery.TriggeredEvent, pos: Position) => {
+ if (!plot) {
+ return;
+ }
+ plotHover(pos);
+ };
+
+ eventBus.on(ACTIVE_CURSOR, updateCursor);
+
+ return () => {
+ eventBus.off(ACTIVE_CURSOR, updateCursor);
+ };
+ }, [plot, plotHover]);
+
const mouseLeaveHandler = useCallback(() => {
if (!plot) {
return;
diff --git a/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts b/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts
index 860b4e9f2dbde..ba363cf30a079 100644
--- a/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts
+++ b/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts
@@ -18,6 +18,7 @@
*/
import { cloneDeep, defaults, mergeWith, compact } from 'lodash';
+import $ from 'jquery';
import moment, { Moment } from 'moment-timezone';
import { TimefilterContract } from 'src/plugins/data/public';
@@ -50,6 +51,9 @@ interface TimeRangeBounds {
max: Moment | undefined;
}
+export const ACTIVE_CURSOR = 'ACTIVE_CURSOR_TIMELION';
+export const eventBus = $({});
+
const colors = [
'#01A4A4',
'#C66',
diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js
index c12e518a9dcd3..f936710bf2b81 100644
--- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js
+++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js
@@ -161,6 +161,10 @@ export class TimeseriesVisualization extends Component {
const yAxis = [];
let mainDomainAdded = false;
+ const allSeriesHaveSameFormatters = seriesModel.every(
+ (seriesGroup) => seriesGroup.formatter === seriesModel[0].formatter
+ );
+
this.showToastNotification = null;
seriesModel.forEach((seriesGroup) => {
@@ -211,7 +215,7 @@ export class TimeseriesVisualization extends Component {
});
} else if (!mainDomainAdded) {
TimeseriesVisualization.addYAxis(yAxis, {
- tickFormatter: series.length === 1 ? undefined : (val) => val,
+ tickFormatter: allSeriesHaveSameFormatters ? seriesGroupTickFormatter : (val) => val,
id: yAxisIdGenerator('main'),
groupId: mainAxisGroupId,
position: model.axis_position,
diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts
index 333ed0ff64fdb..1037dc81b2b17 100644
--- a/src/plugins/vis_type_timeseries/server/index.ts
+++ b/src/plugins/vis_type_timeseries/server/index.ts
@@ -43,7 +43,9 @@ export {
AbstractSearchStrategy,
ReqFacade,
} from './lib/search_strategies/strategies/abstract_search_strategy';
-// @ts-ignore
+
+export { VisPayload } from '../common/types';
+
export { DefaultSearchCapabilities } from './lib/search_strategies/default_search_capabilities';
export function plugin(initializerContext: PluginInitializerContext) {
diff --git a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts
index dc49e280a2bb7..8f87318222f2b 100644
--- a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts
+++ b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts
@@ -38,7 +38,7 @@ export async function getFields(
// removes the need to refactor many layers of dependencies on "req", and instead just augments the top
// level object passed from here. The layers should be refactored fully at some point, but for now
// this works and we are still using the New Platform services for these vis data portions.
- const reqFacade: ReqFacade = {
+ const reqFacade: ReqFacade<{}> = {
requestContext,
...request,
framework,
diff --git a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts
index 5eef2b53e2431..fcb66d2e12fd1 100644
--- a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts
+++ b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts
@@ -64,7 +64,7 @@ export function getVisData(
// removes the need to refactor many layers of dependencies on "req", and instead just augments the top
// level object passed from here. The layers should be refactored fully at some point, but for now
// this works and we are still using the New Platform services for these vis data portions.
- const reqFacade: ReqFacade = {
+ const reqFacade: ReqFacade = {
requestContext,
...request,
framework,
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts
similarity index 90%
rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.js
rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts
index b9b7759711567..a570e02ada8d1 100644
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.js
+++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts
@@ -17,13 +17,15 @@
* under the License.
*/
import { DefaultSearchCapabilities } from './default_search_capabilities';
+import { ReqFacade } from './strategies/abstract_search_strategy';
+import { VisPayload } from '../../../common/types';
describe('DefaultSearchCapabilities', () => {
- let defaultSearchCapabilities;
- let req;
+ let defaultSearchCapabilities: DefaultSearchCapabilities;
+ let req: ReqFacade;
beforeEach(() => {
- req = {};
+ req = {} as ReqFacade;
defaultSearchCapabilities = new DefaultSearchCapabilities(req);
});
@@ -45,13 +47,13 @@ describe('DefaultSearchCapabilities', () => {
});
test('should return Search Timezone', () => {
- defaultSearchCapabilities.request = {
+ defaultSearchCapabilities.request = ({
payload: {
timerange: {
timezone: 'UTC',
},
},
- };
+ } as unknown) as ReqFacade;
expect(defaultSearchCapabilities.searchTimezone).toEqual('UTC');
});
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts
similarity index 69%
rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.js
rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts
index 02a710fef897f..73b701379aee0 100644
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.js
+++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts
@@ -16,40 +16,43 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { Unit } from '@elastic/datemath';
import {
convertIntervalToUnit,
parseInterval,
getSuitableUnit,
} from '../vis_data/helpers/unit_to_seconds';
import { RESTRICTIONS_KEYS } from '../../../common/ui_restrictions';
+import { ReqFacade } from './strategies/abstract_search_strategy';
+import { VisPayload } from '../../../common/types';
-const getTimezoneFromRequest = (request) => {
+const getTimezoneFromRequest = (request: ReqFacade) => {
return request.payload.timerange.timezone;
};
export class DefaultSearchCapabilities {
- constructor(request, fieldsCapabilities = {}) {
- this.request = request;
- this.fieldsCapabilities = fieldsCapabilities;
- }
+ constructor(
+ public request: ReqFacade,
+ public fieldsCapabilities: Record = {}
+ ) {}
- get defaultTimeInterval() {
+ public get defaultTimeInterval() {
return null;
}
- get whiteListedMetrics() {
+ public get whiteListedMetrics() {
return this.createUiRestriction();
}
- get whiteListedGroupByFields() {
+ public get whiteListedGroupByFields() {
return this.createUiRestriction();
}
- get whiteListedTimerangeModes() {
+ public get whiteListedTimerangeModes() {
return this.createUiRestriction();
}
- get uiRestrictions() {
+ public get uiRestrictions() {
return {
[RESTRICTIONS_KEYS.WHITE_LISTED_METRICS]: this.whiteListedMetrics,
[RESTRICTIONS_KEYS.WHITE_LISTED_GROUP_BY_FIELDS]: this.whiteListedGroupByFields,
@@ -57,36 +60,36 @@ export class DefaultSearchCapabilities {
};
}
- get searchTimezone() {
+ public get searchTimezone() {
return getTimezoneFromRequest(this.request);
}
- createUiRestriction(restrictionsObject) {
+ createUiRestriction(restrictionsObject?: Record) {
return {
'*': !restrictionsObject,
...(restrictionsObject || {}),
};
}
- parseInterval(interval) {
+ parseInterval(interval: string) {
return parseInterval(interval);
}
- getSuitableUnit(intervalInSeconds) {
+ getSuitableUnit(intervalInSeconds: string | number) {
return getSuitableUnit(intervalInSeconds);
}
- convertIntervalToUnit(intervalString, unit) {
+ convertIntervalToUnit(intervalString: string, unit: Unit) {
const parsedInterval = this.parseInterval(intervalString);
- if (parsedInterval.unit !== unit) {
+ if (parsedInterval?.unit !== unit) {
return convertIntervalToUnit(intervalString, unit);
}
return parsedInterval;
}
- getValidTimeInterval(intervalString) {
+ getValidTimeInterval(intervalString: string) {
// Default search capabilities doesn't have any restrictions for the interval string
return intervalString;
}
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts
index 66ea4f017dd90..4c3dcbd17bbd9 100644
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts
+++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts
@@ -27,10 +27,10 @@ import { DefaultSearchCapabilities } from './default_search_capabilities';
class MockSearchStrategy extends AbstractSearchStrategy {
checkForViability() {
- return {
+ return Promise.resolve({
isViable: true,
capabilities: {},
- };
+ });
}
}
@@ -65,7 +65,7 @@ describe('SearchStrategyRegister', () => {
});
test('should add a strategy if it is an instance of AbstractSearchStrategy', () => {
- const anotherSearchStrategy = new MockSearchStrategy('es');
+ const anotherSearchStrategy = new MockSearchStrategy();
const addedStrategies = registry.addStrategy(anotherSearchStrategy);
expect(addedStrategies.length).toEqual(2);
@@ -75,7 +75,7 @@ describe('SearchStrategyRegister', () => {
test('should return a MockSearchStrategy instance', async () => {
const req = {};
const indexPattern = '*';
- const anotherSearchStrategy = new MockSearchStrategy('es');
+ const anotherSearchStrategy = new MockSearchStrategy();
registry.addStrategy(anotherSearchStrategy);
const { searchStrategy, capabilities } = (await registry.getViableStrategy(req, indexPattern))!;
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts
index b1e21edf8b588..71461d319f2b6 100644
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts
+++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts
@@ -46,16 +46,8 @@ export interface ReqFacade extends FakeRequest {
getEsShardTimeout: () => Promise;
}
-export class AbstractSearchStrategy {
- public indexType?: string;
- public additionalParams: any;
-
- constructor(type?: string, additionalParams: any = {}) {
- this.indexType = type;
- this.additionalParams = additionalParams;
- }
-
- async search(req: ReqFacade, bodies: any[], options = {}) {
+export abstract class AbstractSearchStrategy {
+ async search(req: ReqFacade, bodies: any[], indexType?: string) {
const requests: any[] = [];
const { sessionId } = req.payload;
@@ -64,15 +56,13 @@ export class AbstractSearchStrategy {
req.requestContext
.search!.search(
{
+ indexType,
params: {
...body,
- ...this.additionalParams,
},
- indexType: this.indexType,
},
{
sessionId,
- ...options,
}
)
.toPromise()
@@ -81,7 +71,18 @@ export class AbstractSearchStrategy {
return Promise.all(requests);
}
- async getFieldsForWildcard(req: ReqFacade, indexPattern: string, capabilities: any) {
+ checkForViability(
+ req: ReqFacade,
+ indexPattern: string
+ ): Promise<{ isViable: boolean; capabilities: unknown }> {
+ throw new TypeError('Must override method');
+ }
+
+ async getFieldsForWildcard(
+ req: ReqFacade,
+ indexPattern: string,
+ capabilities?: unknown
+ ) {
const { indexPatternsService } = req.pre;
return await indexPatternsService!.getFieldsForWildcard({
@@ -89,11 +90,4 @@ export class AbstractSearchStrategy {
fieldCapsOptions: { allow_no_indices: true },
});
}
-
- checkForViability(
- req: ReqFacade,
- indexPattern: string
- ): { isViable: boolean; capabilities: any } {
- throw new TypeError('Must override method');
- }
}
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts
similarity index 79%
rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js
rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts
index a9994ba3e1f75..d8ea6c9c8a526 100644
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js
+++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts
@@ -17,13 +17,15 @@
* under the License.
*/
import { DefaultSearchStrategy } from './default_search_strategy';
+import { ReqFacade } from './abstract_search_strategy';
+import { VisPayload } from '../../../../common/types';
describe('DefaultSearchStrategy', () => {
- let defaultSearchStrategy;
- let req;
+ let defaultSearchStrategy: DefaultSearchStrategy;
+ let req: ReqFacade;
beforeEach(() => {
- req = {};
+ req = {} as ReqFacade;
defaultSearchStrategy = new DefaultSearchStrategy();
});
@@ -34,8 +36,8 @@ describe('DefaultSearchStrategy', () => {
expect(defaultSearchStrategy.getFieldsForWildcard).toBeDefined();
});
- test('should check a strategy for viability', () => {
- const value = defaultSearchStrategy.checkForViability(req);
+ test('should check a strategy for viability', async () => {
+ const value = await defaultSearchStrategy.checkForViability(req);
expect(value.isViable).toBe(true);
expect(value.capabilities).toEqual({
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts
similarity index 82%
rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js
rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts
index 8e57c117637bf..e1f519456d373 100644
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js
+++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts
@@ -17,16 +17,17 @@
* under the License.
*/
-import { AbstractSearchStrategy } from './abstract_search_strategy';
+import { AbstractSearchStrategy, ReqFacade } from './abstract_search_strategy';
import { DefaultSearchCapabilities } from '../default_search_capabilities';
+import { VisPayload } from '../../../../common/types';
export class DefaultSearchStrategy extends AbstractSearchStrategy {
name = 'default';
- checkForViability(req) {
- return {
+ checkForViability(req: ReqFacade) {
+ return Promise.resolve({
isViable: true,
capabilities: new DefaultSearchCapabilities(req),
- };
+ });
}
}
diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js
index 53f0b84b8ec3b..c021ba3cebc66 100644
--- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js
+++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js
@@ -42,14 +42,18 @@ const calculateBucketData = (timeInterval, capabilities) => {
}
// Check decimal
- if (parsedInterval.value % 1 !== 0) {
+ if (parsedInterval && parsedInterval.value % 1 !== 0) {
if (parsedInterval.unit !== 'ms') {
- const { value, unit } = convertIntervalToUnit(
+ const converted = convertIntervalToUnit(
intervalString,
ASCENDING_UNIT_ORDER[ASCENDING_UNIT_ORDER.indexOf(parsedInterval.unit) - 1]
);
- intervalString = value + unit;
+ if (converted) {
+ intervalString = converted.value + converted.unit;
+ }
+
+ intervalString = undefined;
} else {
intervalString = '1ms';
}
diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.ts
similarity index 86%
rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.js
rename to src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.ts
index 5b533178949f1..278e557209a21 100644
--- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.js
+++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.test.ts
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { Unit } from '@elastic/datemath';
import {
getUnitValue,
@@ -51,22 +52,13 @@ describe('unit_to_seconds', () => {
}));
test('should not parse "gm" interval (negative)', () =>
- expect(parseInterval('gm')).toEqual({
- value: undefined,
- unit: undefined,
- }));
+ expect(parseInterval('gm')).toBeUndefined());
test('should not parse "-1d" interval (negative)', () =>
- expect(parseInterval('-1d')).toEqual({
- value: undefined,
- unit: undefined,
- }));
+ expect(parseInterval('-1d')).toBeUndefined());
test('should not parse "M" interval (negative)', () =>
- expect(parseInterval('M')).toEqual({
- value: undefined,
- unit: undefined,
- }));
+ expect(parseInterval('M')).toBeUndefined());
});
describe('convertIntervalToUnit()', () => {
@@ -95,16 +87,10 @@ describe('unit_to_seconds', () => {
}));
test('should not convert "30m" interval to "0" unit (positive)', () =>
- expect(convertIntervalToUnit('30m', 'o')).toEqual({
- value: undefined,
- unit: undefined,
- }));
+ expect(convertIntervalToUnit('30m', 'o' as Unit)).toBeUndefined());
test('should not convert "m" interval to "s" unit (positive)', () =>
- expect(convertIntervalToUnit('m', 's')).toEqual({
- value: undefined,
- unit: undefined,
- }));
+ expect(convertIntervalToUnit('m', 's')).toBeUndefined());
});
describe('getSuitableUnit()', () => {
@@ -155,8 +141,5 @@ describe('unit_to_seconds', () => {
expect(getSuitableUnit(stringValue)).toBeUndefined();
});
-
- test('should return "undefined" in case of no input value(negative)', () =>
- expect(getSuitableUnit()).toBeUndefined());
});
});
diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.ts
similarity index 60%
rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.js
rename to src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.ts
index be8f1741627ba..8950e05c85d4f 100644
--- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.js
+++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/unit_to_seconds.ts
@@ -16,12 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { INTERVAL_STRING_RE } from '../../../../common/interval_regexp';
import { sortBy, isNumber } from 'lodash';
+import { Unit } from '@elastic/datemath';
+
+/** @ts-ignore */
+import { INTERVAL_STRING_RE } from '../../../../common/interval_regexp';
export const ASCENDING_UNIT_ORDER = ['ms', 's', 'm', 'h', 'd', 'w', 'M', 'y'];
-const units = {
+const units: Record = {
ms: 0.001,
s: 1,
m: 60,
@@ -32,49 +35,53 @@ const units = {
y: 86400 * 7 * 4 * 12, // Leap year?
};
-const sortedUnits = sortBy(Object.keys(units), (key) => units[key]);
+const sortedUnits = sortBy(Object.keys(units), (key: Unit) => units[key]);
-export const parseInterval = (intervalString) => {
- let value;
- let unit;
+interface ParsedInterval {
+ value: number;
+ unit: Unit;
+}
+export const parseInterval = (intervalString: string): ParsedInterval | undefined => {
if (intervalString) {
const matches = intervalString.match(INTERVAL_STRING_RE);
if (matches) {
- value = Number(matches[1]);
- unit = matches[2];
+ return {
+ value: Number(matches[1]),
+ unit: matches[2] as Unit,
+ };
}
}
-
- return { value, unit };
};
-export const convertIntervalToUnit = (intervalString, newUnit) => {
+export const convertIntervalToUnit = (
+ intervalString: string,
+ newUnit: Unit
+): ParsedInterval | undefined => {
const parsedInterval = parseInterval(intervalString);
- let value;
- let unit;
- if (parsedInterval.value && units[newUnit]) {
- value = Number(
- ((parsedInterval.value * units[parsedInterval.unit]) / units[newUnit]).toFixed(2)
- );
- unit = newUnit;
+ if (parsedInterval && units[newUnit]) {
+ return {
+ value: Number(
+ ((parsedInterval.value * units[parsedInterval.unit!]) / units[newUnit]).toFixed(2)
+ ),
+ unit: newUnit,
+ };
}
-
- return { value, unit };
};
-export const getSuitableUnit = (intervalInSeconds) =>
+export const getSuitableUnit = (intervalInSeconds: string | number) =>
sortedUnits.find((key, index, array) => {
- const nextUnit = array[index + 1];
+ const nextUnit = array[index + 1] as Unit;
const isValidInput = isNumber(intervalInSeconds) && intervalInSeconds > 0;
const isLastItem = index + 1 === array.length;
return (
isValidInput &&
- ((intervalInSeconds >= units[key] && intervalInSeconds < units[nextUnit]) || isLastItem)
+ ((intervalInSeconds >= units[key as Unit] && intervalInSeconds < units[nextUnit]) ||
+ isLastItem)
);
- });
+ }) as Unit;
-export const getUnitValue = (unit) => units[unit];
+export const getUnitValue = (unit: Unit) => units[unit];
diff --git a/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts b/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts
index e092fc8acfd71..fbef55df39719 100644
--- a/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts
+++ b/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts
@@ -59,9 +59,9 @@ describe('registerVegaUsageCollector', () => {
it('makeUsageCollector config.fetch calls getStats', async () => {
const mockCollectorSet = createUsageCollectionSetupMock();
registerVegaUsageCollector(mockCollectorSet, mockConfig, mockDeps);
- const usageCollectorConfig = mockCollectorSet.makeUsageCollector.mock.calls[0][0];
+ const usageCollector = mockCollectorSet.makeUsageCollector.mock.results[0].value;
const mockedCollectorFetchContext = createCollectorFetchContextMock();
- const fetchResult = await usageCollectorConfig.fetch(mockedCollectorFetchContext);
+ const fetchResult = await usageCollector.fetch(mockedCollectorFetchContext);
expect(mockGetStats).toBeCalledTimes(1);
expect(mockGetStats).toBeCalledWith(
mockedCollectorFetchContext.callCluster,
diff --git a/src/plugins/visualizations/public/vis.ts b/src/plugins/visualizations/public/vis.ts
index cae9058071b6c..75c889af3d5c9 100644
--- a/src/plugins/visualizations/public/vis.ts
+++ b/src/plugins/visualizations/public/vis.ts
@@ -97,13 +97,13 @@ export class Vis {
public readonly uiState: PersistedState;
constructor(visType: string, visState: SerializedVis = {} as any) {
- this.type = this.getType(visType);
+ this.type = this.getType(visType);
this.params = this.getParams(visState.params);
this.uiState = new PersistedState(visState.uiState);
this.id = visState.id;
}
- private getType(visType: string) {
+ private getType(visType: string) {
const type = getTypes().get(visType);
if (!type) {
const errorMessage = i18n.translate('visualizations.visualizationTypeInvalidMessage', {
diff --git a/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts b/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts
index 7789e3de13e5a..380a86e15aa51 100644
--- a/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts
+++ b/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts
@@ -58,9 +58,9 @@ describe('registerVisualizationsCollector', () => {
it('makeUsageCollector config.fetch calls getStats', async () => {
const mockCollectorSet = createUsageCollectionSetupMock();
registerVisualizationsCollector(mockCollectorSet, mockConfig);
- const usageCollectorConfig = mockCollectorSet.makeUsageCollector.mock.calls[0][0];
+ const usageCollector = mockCollectorSet.makeUsageCollector.mock.results[0].value;
const mockCollectorFetchContext = createCollectorFetchContextMock();
- const fetchResult = await usageCollectorConfig.fetch(mockCollectorFetchContext);
+ const fetchResult = await usageCollector.fetch(mockCollectorFetchContext);
expect(mockGetStats).toBeCalledTimes(1);
expect(mockGetStats).toBeCalledWith(mockCollectorFetchContext.callCluster, mockIndex);
expect(fetchResult).toBe(mockStats);
diff --git a/test/common/config.js b/test/common/config.js
index 9d6d531ae4b37..6c7d64e3e0bc0 100644
--- a/test/common/config.js
+++ b/test/common/config.js
@@ -61,6 +61,10 @@ export default function () {
`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'newsfeed')}`,
`--newsfeed.service.urlRoot=${servers.kibana.protocol}://${servers.kibana.hostname}:${servers.kibana.port}`,
`--newsfeed.service.pathTemplate=/api/_newsfeed-FTS-external-service-simulators/kibana/v{VERSION}.json`,
+ // code coverage reporting plugin
+ ...(!!process.env.CODE_COVERAGE
+ ? [`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'coverage')}`]
+ : []),
],
},
services,
diff --git a/test/common/fixtures/plugins/coverage/kibana.json b/test/common/fixtures/plugins/coverage/kibana.json
new file mode 100644
index 0000000000000..d80432534d746
--- /dev/null
+++ b/test/common/fixtures/plugins/coverage/kibana.json
@@ -0,0 +1,6 @@
+{
+ "id": "coverage-fixtures",
+ "version": "kibana",
+ "server": false,
+ "ui": true
+}
\ No newline at end of file
diff --git a/test/common/fixtures/plugins/coverage/public/index.ts b/test/common/fixtures/plugins/coverage/public/index.ts
new file mode 100644
index 0000000000000..ed164c2d6bb94
--- /dev/null
+++ b/test/common/fixtures/plugins/coverage/public/index.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 { CodeCoverageReportingPlugin } from './plugin';
+
+export function plugin() {
+ return new CodeCoverageReportingPlugin();
+}
diff --git a/test/common/fixtures/plugins/coverage/public/plugin.ts b/test/common/fixtures/plugins/coverage/public/plugin.ts
new file mode 100644
index 0000000000000..e4da6b257de9a
--- /dev/null
+++ b/test/common/fixtures/plugins/coverage/public/plugin.ts
@@ -0,0 +1,43 @@
+/*
+ * 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 { Plugin } from 'kibana/server';
+
+declare global {
+ interface Window {
+ __coverage__: any;
+ flushCoverageToLog: any;
+ }
+}
+
+export class CodeCoverageReportingPlugin implements Plugin {
+ constructor() {}
+
+ public start() {}
+
+ public setup() {
+ window.flushCoverageToLog = function () {
+ if (window.__coverage__) {
+ // eslint-disable-next-line no-console
+ console.log('coveragejson:' + btoa(JSON.stringify(window.__coverage__)));
+ }
+ };
+ window.addEventListener('beforeunload', window.flushCoverageToLog);
+ }
+}
diff --git a/test/functional/services/listing_table.ts b/test/functional/services/listing_table.ts
index 53b45697136ed..c9f2b8369783c 100644
--- a/test/functional/services/listing_table.ts
+++ b/test/functional/services/listing_table.ts
@@ -62,6 +62,20 @@ export function ListingTableProvider({ getService, getPageObjects }: FtrProvider
return visualizationNames;
}
+ public async waitUntilTableIsLoaded() {
+ return retry.try(async () => {
+ const isLoaded = await find.existsByDisplayedByCssSelector(
+ '[data-test-subj="itemsInMemTable"]:not(.euiBasicTable-loading)'
+ );
+
+ if (isLoaded) {
+ return true;
+ } else {
+ throw new Error('Waiting');
+ }
+ });
+ }
+
/**
* Navigates through all pages on Landing page and returns array of items names
*/
diff --git a/test/scripts/jenkins_build_plugins.sh b/test/scripts/jenkins_build_plugins.sh
index 59df02d401167..cfb829ea2db54 100755
--- a/test/scripts/jenkins_build_plugins.sh
+++ b/test/scripts/jenkins_build_plugins.sh
@@ -7,5 +7,6 @@ node scripts/build_kibana_platform_plugins \
--oss \
--scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \
--scan-dir "$KIBANA_DIR/test/interpreter_functional/plugins" \
+ --scan-dir "$KIBANA_DIR/test/common/fixtures/plugins" \
--workers 6 \
--verbose
diff --git a/test/scripts/jenkins_xpack_build_plugins.sh b/test/scripts/jenkins_xpack_build_plugins.sh
index 289e64f66c89b..37b6398598788 100755
--- a/test/scripts/jenkins_xpack_build_plugins.sh
+++ b/test/scripts/jenkins_xpack_build_plugins.sh
@@ -5,6 +5,7 @@ source src/dev/ci_setup/setup_env.sh
echo " -> building kibana platform plugins"
node scripts/build_kibana_platform_plugins \
--scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \
+ --scan-dir "$KIBANA_DIR/test/common/fixtures/plugins" \
--scan-dir "$XPACK_DIR/test/plugin_functional/plugins" \
--scan-dir "$XPACK_DIR/test/functional_with_es_ssl/fixtures/plugins" \
--scan-dir "$XPACK_DIR/test/alerting_api_integration/plugins" \
diff --git a/test/tsconfig.json b/test/tsconfig.json
index 2949a764d4b1a..df26441b0806f 100644
--- a/test/tsconfig.json
+++ b/test/tsconfig.json
@@ -1,10 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
- "tsBuildInfoFile": "../build/tsbuildinfo/test",
+ "incremental": false,
"types": ["node", "mocha", "flot"]
},
- "include": ["**/*", "../typings/elastic__node_crypto.d.ts", "typings/**/*"],
+ "include": ["**/*", "../typings/elastic__node_crypto.d.ts", "typings/**/*", "../packages/kbn-test/types/ftr_globals/**/*"],
"exclude": ["plugin_functional/plugins/**/*", "interpreter_functional/plugins/**/*"],
"references": [
{ "path": "../src/core/tsconfig.json" },
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 0aad8d6b9c124..111c9dbc949de 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -53,7 +53,6 @@
"types": [
"node",
"jest",
- "react",
"flot",
"jest-styled-components",
"@testing-library/jest-dom"
diff --git a/tsconfig.json b/tsconfig.json
index 00b33bd0b4451..88ae3e1e826b3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
- "tsBuildInfoFile": "./build/tsbuildinfo/kibana"
+ "incremental": false
},
"include": ["kibana.d.ts", "src/**/*", "typings/**/*", "test_utils/**/*"],
"exclude": [
diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy
index eea3ff18f3453..0051293704717 100644
--- a/vars/kibanaPipeline.groovy
+++ b/vars/kibanaPipeline.groovy
@@ -390,12 +390,7 @@ def scriptTaskDocker(description, script) {
def buildDocker() {
sh(
- script: """
- cp /usr/local/bin/runbld .ci/
- cp /usr/local/bin/bash_standard_lib.sh .ci/
- cd .ci
- docker build -t kibana-ci -f ./Dockerfile .
- """,
+ script: "./.ci/build_docker.sh",
label: 'Build CI Docker image'
)
}
diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json
index 2be68b797ba5f..6937862d20536 100644
--- a/x-pack/.i18nrc.json
+++ b/x-pack/.i18nrc.json
@@ -38,10 +38,11 @@
"xpack.maps": ["plugins/maps"],
"xpack.ml": ["plugins/ml"],
"xpack.monitoring": ["plugins/monitoring"],
- "xpack.remoteClusters": "plugins/remote_clusters",
"xpack.painlessLab": "plugins/painless_lab",
+ "xpack.remoteClusters": "plugins/remote_clusters",
"xpack.reporting": ["plugins/reporting"],
"xpack.rollupJobs": ["plugins/rollup"],
+ "xpack.runtimeFields": "plugins/runtime_fields",
"xpack.searchProfiler": "plugins/searchprofiler",
"xpack.security": "plugins/security",
"xpack.server": "legacy/server",
diff --git a/x-pack/plugins/apm/common/runtime_types/merge/index.test.ts b/x-pack/plugins/apm/common/runtime_types/merge/index.test.ts
new file mode 100644
index 0000000000000..0e0cb4a349c83
--- /dev/null
+++ b/x-pack/plugins/apm/common/runtime_types/merge/index.test.ts
@@ -0,0 +1,71 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import * as t from 'io-ts';
+import { isLeft } from 'fp-ts/lib/Either';
+import { merge } from './';
+import { jsonRt } from '../json_rt';
+
+describe('merge', () => {
+ it('fails on one or more errors', () => {
+ const type = merge([t.type({ foo: t.string }), t.type({ bar: t.number })]);
+
+ const result = type.decode({ foo: '' });
+
+ expect(isLeft(result)).toBe(true);
+ });
+
+ it('merges left to right', () => {
+ const typeBoolean = merge([
+ t.type({ foo: t.string }),
+ t.type({ foo: jsonRt.pipe(t.boolean) }),
+ ]);
+
+ const resultBoolean = typeBoolean.decode({
+ foo: 'true',
+ });
+
+ // @ts-expect-error
+ expect(resultBoolean.right).toEqual({
+ foo: true,
+ });
+
+ const typeString = merge([
+ t.type({ foo: jsonRt.pipe(t.boolean) }),
+ t.type({ foo: t.string }),
+ ]);
+
+ const resultString = typeString.decode({
+ foo: 'true',
+ });
+
+ // @ts-expect-error
+ expect(resultString.right).toEqual({
+ foo: 'true',
+ });
+ });
+
+ it('deeply merges values', () => {
+ const type = merge([
+ t.type({ foo: t.type({ baz: t.string }) }),
+ t.type({ foo: t.type({ bar: t.string }) }),
+ ]);
+
+ const result = type.decode({
+ foo: {
+ bar: '',
+ baz: '',
+ },
+ });
+
+ // @ts-expect-error
+ expect(result.right).toEqual({
+ foo: {
+ bar: '',
+ baz: '',
+ },
+ });
+ });
+});
diff --git a/x-pack/plugins/apm/common/runtime_types/merge/index.ts b/x-pack/plugins/apm/common/runtime_types/merge/index.ts
new file mode 100644
index 0000000000000..76a1092436dce
--- /dev/null
+++ b/x-pack/plugins/apm/common/runtime_types/merge/index.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import * as t from 'io-ts';
+import { merge as lodashMerge } from 'lodash';
+import { isLeft } from 'fp-ts/lib/Either';
+import { ValuesType } from 'utility-types';
+
+export type MergeType<
+ T extends t.Any[],
+ U extends ValuesType = ValuesType
+> = t.Type & {
+ _tag: 'MergeType';
+ types: T;
+};
+
+// this is similar to t.intersection, but does a deep merge
+// instead of a shallow merge
+
+export function merge(
+ types: [A, B]
+): MergeType<[A, B]>;
+
+export function merge(types: t.Any[]) {
+ const mergeType = new t.Type(
+ 'merge',
+ (u): u is unknown => {
+ return types.every((type) => type.is(u));
+ },
+ (input, context) => {
+ const errors: t.Errors = [];
+
+ const successes: unknown[] = [];
+
+ const results = types.map((type, index) =>
+ type.validate(
+ input,
+ context.concat({
+ key: String(index),
+ type,
+ actual: input,
+ })
+ )
+ );
+
+ results.forEach((result) => {
+ if (isLeft(result)) {
+ errors.push(...result.left);
+ } else {
+ successes.push(result.right);
+ }
+ });
+
+ const mergedValues = lodashMerge({}, ...successes);
+
+ return errors.length > 0 ? t.failures(errors) : t.success(mergedValues);
+ },
+ (a) => types.reduce((val, type) => type.encode(val), a)
+ );
+
+ return {
+ ...mergeType,
+ _tag: 'MergeType',
+ types,
+ };
+}
diff --git a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts
new file mode 100644
index 0000000000000..ac2f7d8e1679a
--- /dev/null
+++ b/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts
@@ -0,0 +1,106 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import * as t from 'io-ts';
+import { isRight, isLeft } from 'fp-ts/lib/Either';
+import { strictKeysRt } from './';
+import { jsonRt } from '../json_rt';
+
+describe('strictKeysRt', () => {
+ it('correctly and deeply validates object keys', () => {
+ const checks: Array<{ type: t.Type; passes: any[]; fails: any[] }> = [
+ {
+ type: t.intersection([
+ t.type({ foo: t.string }),
+ t.partial({ bar: t.string }),
+ ]),
+ passes: [{ foo: '' }, { foo: '', bar: '' }],
+ fails: [
+ { foo: '', unknownKey: '' },
+ { foo: '', bar: '', unknownKey: '' },
+ ],
+ },
+ {
+ type: t.type({
+ path: t.union([
+ t.type({ serviceName: t.string }),
+ t.type({ transactionType: t.string }),
+ ]),
+ }),
+ passes: [
+ { path: { serviceName: '' } },
+ { path: { transactionType: '' } },
+ ],
+ fails: [
+ { path: { serviceName: '', unknownKey: '' } },
+ { path: { transactionType: '', unknownKey: '' } },
+ { path: { serviceName: '', transactionType: '' } },
+ { path: { serviceName: '' }, unknownKey: '' },
+ ],
+ },
+ {
+ type: t.intersection([
+ t.type({ query: t.type({ bar: t.string }) }),
+ t.partial({ query: t.partial({ _debug: t.boolean }) }),
+ ]),
+ passes: [{ query: { bar: '', _debug: true } }],
+ fails: [{ query: { _debug: true } }],
+ },
+ ];
+
+ checks.forEach((check) => {
+ const { type, passes, fails } = check;
+
+ const strictType = strictKeysRt(type);
+
+ passes.forEach((value) => {
+ const result = strictType.decode(value);
+
+ if (!isRight(result)) {
+ throw new Error(
+ `Expected ${JSON.stringify(
+ value
+ )} to be allowed, but validation failed with ${
+ result.left[0].message
+ }`
+ );
+ }
+ });
+
+ fails.forEach((value) => {
+ const result = strictType.decode(value);
+
+ if (!isLeft(result)) {
+ throw new Error(
+ `Expected ${JSON.stringify(
+ value
+ )} to be disallowed, but validation succeeded`
+ );
+ }
+ });
+ });
+ });
+
+ it('does not support piped types', () => {
+ const typeA = t.type({
+ query: t.type({ filterNames: jsonRt.pipe(t.array(t.string)) }),
+ } as Record);
+
+ const typeB = t.partial({
+ query: t.partial({ _debug: jsonRt.pipe(t.boolean) }),
+ });
+
+ const value = {
+ query: {
+ _debug: 'true',
+ filterNames: JSON.stringify(['host', 'agentName']),
+ },
+ };
+
+ const pipedType = strictKeysRt(typeA.pipe(typeB));
+
+ expect(isLeft(pipedType.decode(value))).toBe(true);
+ });
+});
diff --git a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts
new file mode 100644
index 0000000000000..9ca37b4a0a26a
--- /dev/null
+++ b/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts
@@ -0,0 +1,195 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import * as t from 'io-ts';
+import { either, isRight } from 'fp-ts/lib/Either';
+import { mapValues, difference, isPlainObject, forEach } from 'lodash';
+import { MergeType, merge } from '../merge';
+
+/*
+ Type that tracks validated keys, and fails when the input value
+ has keys that have not been validated.
+*/
+
+type ParsableType =
+ | t.IntersectionType
+ | t.UnionType
+ | t.PartialType
+ | t.ExactType
+ | t.InterfaceType
+ | MergeType;
+
+function getKeysInObject>(
+ object: T,
+ prefix: string = ''
+): string[] {
+ const keys: string[] = [];
+ forEach(object, (value, key) => {
+ const ownPrefix = prefix ? `${prefix}.${key}` : key;
+ keys.push(ownPrefix);
+ if (isPlainObject(object[key])) {
+ keys.push(
+ ...getKeysInObject(object[key] as Record, ownPrefix)
+ );
+ }
+ });
+ return keys;
+}
+
+function addToContextWhenValidated<
+ T extends t.InterfaceType | t.PartialType
+>(type: T, prefix: string): T {
+ const validate = (input: unknown, context: t.Context) => {
+ const result = type.validate(input, context);
+ const keysType = context[0].type as StrictKeysType;
+ if (!('trackedKeys' in keysType)) {
+ throw new Error('Expected a top-level StrictKeysType');
+ }
+ if (isRight(result)) {
+ keysType.trackedKeys.push(
+ ...Object.keys(type.props).map((propKey) => `${prefix}${propKey}`)
+ );
+ }
+ return result;
+ };
+
+ if (type._tag === 'InterfaceType') {
+ return new t.InterfaceType(
+ type.name,
+ type.is,
+ validate,
+ type.encode,
+ type.props
+ ) as T;
+ }
+
+ return new t.PartialType(
+ type.name,
+ type.is,
+ validate,
+ type.encode,
+ type.props
+ ) as T;
+}
+
+function trackKeysOfValidatedTypes(
+ type: ParsableType | t.Any,
+ prefix: string = ''
+): t.Any {
+ if (!('_tag' in type)) {
+ return type;
+ }
+ const taggedType = type as ParsableType;
+
+ switch (taggedType._tag) {
+ case 'IntersectionType': {
+ const collectionType = type as t.IntersectionType;
+ return t.intersection(
+ collectionType.types.map((rt) =>
+ trackKeysOfValidatedTypes(rt, prefix)
+ ) as [t.Any, t.Any]
+ );
+ }
+
+ case 'UnionType': {
+ const collectionType = type as t.UnionType;
+ return t.union(
+ collectionType.types.map((rt) =>
+ trackKeysOfValidatedTypes(rt, prefix)
+ ) as [t.Any, t.Any]
+ );
+ }
+
+ case 'MergeType': {
+ const collectionType = type as MergeType;
+ return merge(
+ collectionType.types.map((rt) =>
+ trackKeysOfValidatedTypes(rt, prefix)
+ ) as [t.Any, t.Any]
+ );
+ }
+
+ case 'PartialType': {
+ const propsType = type as t.PartialType;
+
+ return addToContextWhenValidated(
+ t.partial(
+ mapValues(propsType.props, (val, key) =>
+ trackKeysOfValidatedTypes(val, `${prefix}${key}.`)
+ )
+ ),
+ prefix
+ );
+ }
+
+ case 'InterfaceType': {
+ const propsType = type as t.InterfaceType;
+
+ return addToContextWhenValidated(
+ t.type(
+ mapValues(propsType.props, (val, key) =>
+ trackKeysOfValidatedTypes(val, `${prefix}${key}.`)
+ )
+ ),
+ prefix
+ );
+ }
+
+ case 'ExactType': {
+ const exactType = type as t.ExactType