diff --git a/src/plugins/data/common/search/expressions/esdsl.ts b/src/plugins/data/common/search/expressions/esdsl.ts index 34a67223b4be5..cc0a84cd4a908 100644 --- a/src/plugins/data/common/search/expressions/esdsl.ts +++ b/src/plugins/data/common/search/expressions/esdsl.ts @@ -126,7 +126,7 @@ export const getEsdslFn = ({ }); try { - const { rawResponse } = await lastValueFrom( + const finalResponse = await lastValueFrom( search( { params: { @@ -141,14 +141,14 @@ export const getEsdslFn = ({ const stats: RequestStatistics = {}; - if (rawResponse?.took) { + if (finalResponse.rawResponse?.took) { stats.queryTime = { label: i18n.translate('data.search.es_search.queryTimeLabel', { defaultMessage: 'Query time', }), value: i18n.translate('data.search.es_search.queryTimeValue', { defaultMessage: '{queryTime}ms', - values: { queryTime: rawResponse.took }, + values: { queryTime: finalResponse.rawResponse.took }, }), description: i18n.translate('data.search.es_search.queryTimeDescription', { defaultMessage: @@ -158,12 +158,12 @@ export const getEsdslFn = ({ }; } - if (rawResponse?.hits) { + if (finalResponse.rawResponse?.hits) { stats.hitsTotal = { label: i18n.translate('data.search.es_search.hitsTotalLabel', { defaultMessage: 'Hits (total)', }), - value: `${rawResponse.hits.total}`, + value: `${finalResponse.rawResponse.hits.total}`, description: i18n.translate('data.search.es_search.hitsTotalDescription', { defaultMessage: 'The number of documents that match the query.', }), @@ -173,19 +173,19 @@ export const getEsdslFn = ({ label: i18n.translate('data.search.es_search.hitsLabel', { defaultMessage: 'Hits', }), - value: `${rawResponse.hits.hits.length}`, + value: `${finalResponse.rawResponse.hits.hits.length}`, description: i18n.translate('data.search.es_search.hitsDescription', { defaultMessage: 'The number of documents returned by the query.', }), }; } - request.stats(stats).ok({ json: rawResponse }); + request.stats(stats).ok({ json: finalResponse }); request.json(dsl); return { type: 'es_raw_response', - body: rawResponse, + body: finalResponse.rawResponse, }; } catch (e) { request.error({ json: e }); diff --git a/src/plugins/data/common/search/expressions/esql.ts b/src/plugins/data/common/search/expressions/esql.ts index 8ef0f49588303..b2d6a0458c63b 100644 --- a/src/plugins/data/common/search/expressions/esql.ts +++ b/src/plugins/data/common/search/expressions/esql.ts @@ -210,24 +210,24 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => { return throwError(() => error); }), tap({ - next({ rawResponse }) { + next(finalResponse) { logInspectorRequest() .stats({ hits: { label: i18n.translate('data.search.es_search.hitsLabel', { defaultMessage: 'Hits', }), - value: `${rawResponse.values.length}`, + value: `${finalResponse.rawResponse.values.length}`, description: i18n.translate('data.search.es_search.hitsDescription', { defaultMessage: 'The number of documents returned by the query.', }), }, }) .json(params) - .ok({ json: rawResponse }); + .ok({ json: finalResponse }); }, error(error) { - logInspectorRequest().error({ json: error }); + logInspectorRequest().json(params).error({ json: error }); }, }) ); diff --git a/src/plugins/data/common/search/expressions/essql.ts b/src/plugins/data/common/search/expressions/essql.ts index a5db4674a7d14..e93ee85441a22 100644 --- a/src/plugins/data/common/search/expressions/essql.ts +++ b/src/plugins/data/common/search/expressions/essql.ts @@ -217,14 +217,14 @@ export const getEssqlFn = ({ getStartDependencies }: EssqlFnArguments) => { return throwError(() => error); }), tap({ - next({ rawResponse, took }) { + next(finalResponse) { logInspectorRequest() .stats({ hits: { label: i18n.translate('data.search.es_search.hitsLabel', { defaultMessage: 'Hits', }), - value: `${rawResponse.rows.length}`, + value: `${finalResponse.rawResponse.rows.length}`, description: i18n.translate('data.search.es_search.hitsDescription', { defaultMessage: 'The number of documents returned by the query.', }), @@ -235,7 +235,7 @@ export const getEssqlFn = ({ getStartDependencies }: EssqlFnArguments) => { }), value: i18n.translate('data.search.es_search.queryTimeValue', { defaultMessage: '{queryTime}ms', - values: { queryTime: took }, + values: { queryTime: finalResponse.took }, }), description: i18n.translate('data.search.es_search.queryTimeDescription', { defaultMessage: @@ -245,10 +245,10 @@ export const getEssqlFn = ({ getStartDependencies }: EssqlFnArguments) => { }, }) .json(params) - .ok({ json: rawResponse }); + .ok({ json: finalResponse }); }, error(error) { - logInspectorRequest().error({ json: error }); + logInspectorRequest().json(params).error({ json: error }); }, }) ); diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index cedfa3ee02274..b2f818acaa0ac 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { ConnectionRequestParams } from '@elastic/transport'; import type { TransportRequestOptions } from '@elastic/elasticsearch'; import type { KibanaExecutionContext } from '@kbn/core/public'; import type { DataView } from '@kbn/data-views-plugin/common'; @@ -86,6 +87,11 @@ export interface IKibanaSearchResponse { * The raw response returned by the internal search method (usually the raw ES response) */ rawResponse: RawResponse; + + /** + * HTTP request parameters from elasticsearch transport client t + */ + requestParams?: ConnectionRequestParams; } export interface IKibanaSearchRequest { diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts index 00ed4226fea3d..414230b7e5add 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts @@ -29,6 +29,7 @@ import { takeUntil, tap, } from 'rxjs/operators'; +import type { ConnectionRequestParams } from '@elastic/transport'; import { PublicMethodsOf } from '@kbn/utility-types'; import type { HttpSetup, IHttpFetchError } from '@kbn/core-http-browser'; import { BfetchRequestError } from '@kbn/bfetch-plugin/public'; @@ -304,18 +305,38 @@ export class SearchInterceptor { const cancel = () => id && !isSavedToBackground && sendCancelRequest(); + // Async search requires a series of requests + // 1) POST //_async_search/ + // 2..n) GET /_async_search/ + // + // First request contains useful request params for tools like Inspector. + // Preserve and project first request params into responses. + let firstRequestParams: ConnectionRequestParams; + return pollSearch(search, cancel, { pollInterval: this.deps.searchConfig.asyncSearch.pollInterval, ...options, abortSignal: searchAbortController.getSignal(), }).pipe( tap((response) => { + if (!firstRequestParams && response.requestParams) { + firstRequestParams = response.requestParams; + } + id = response.id; if (isCompleteResponse(response)) { searchTracker?.complete(); } }), + map((response) => { + return firstRequestParams + ? { + ...response, + requestParams: firstRequestParams, + } + : response; + }), catchError((e: Error) => { searchTracker?.error(); cancel(); diff --git a/src/plugins/data/server/search/routes/bsearch.ts b/src/plugins/data/server/search/routes/bsearch.ts index 581920feef89d..3f2035b913f08 100644 --- a/src/plugins/data/server/search/routes/bsearch.ts +++ b/src/plugins/data/server/search/routes/bsearch.ts @@ -8,8 +8,10 @@ import { firstValueFrom } from 'rxjs'; import { catchError } from 'rxjs/operators'; +import { errors } from '@elastic/elasticsearch'; import { BfetchServerSetup } from '@kbn/bfetch-plugin/server'; import type { ExecutionContextSetup } from '@kbn/core/server'; +import { sanitizeRequestParams } from '@kbn/kibana-utils-plugin/server'; import apm from 'elastic-apm-node'; import { IKibanaSearchRequest, @@ -47,6 +49,12 @@ export function registerBsearchRoute( message: err.message, statusCode: err.statusCode, attributes: err.errBody?.error, + // TODO remove 'instanceof errors.ResponseError' check when + // eql strategy throws KbnServerError (like all of the other strategies) + requestParams: + err instanceof errors.ResponseError + ? sanitizeRequestParams(err.meta?.meta?.request?.params) + : err.requestParams, }; }) ) diff --git a/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts b/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts index d6f5d948c784a..45a7b4d90cd41 100644 --- a/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts @@ -77,7 +77,10 @@ export const eqlSearchStrategyProvider = ( meta: true, }); - return toEqlKibanaSearchResponse(response as TransportResult); + return toEqlKibanaSearchResponse( + response as TransportResult, + (response as TransportResult).meta?.request?.params + ); }; const cancel = async () => { diff --git a/src/plugins/data/server/search/strategies/eql_search/response_utils.ts b/src/plugins/data/server/search/strategies/eql_search/response_utils.ts index f9bdf5bc7de30..f9fb0a728bea8 100644 --- a/src/plugins/data/server/search/strategies/eql_search/response_utils.ts +++ b/src/plugins/data/server/search/strategies/eql_search/response_utils.ts @@ -6,7 +6,9 @@ * Side Public License, v 1. */ +import type { ConnectionRequestParams } from '@elastic/transport'; import type { TransportResult } from '@elastic/elasticsearch'; +import { sanitizeRequestParams } from '@kbn/kibana-utils-plugin/server'; import { EqlSearchResponse } from './types'; import { EqlSearchStrategyResponse } from '../../../../common'; @@ -15,12 +17,14 @@ import { EqlSearchStrategyResponse } from '../../../../common'; * (EQL does not provide _shard info, so total/loaded cannot be calculated.) */ export function toEqlKibanaSearchResponse( - response: TransportResult + response: TransportResult, + requestParams?: ConnectionRequestParams ): EqlSearchStrategyResponse { return { id: response.body.id, rawResponse: response, isPartial: response.body.is_partial, isRunning: response.body.is_running, + ...(requestParams ? { requestParams: sanitizeRequestParams(requestParams) } : {}), }; } diff --git a/src/plugins/data/server/search/strategies/es_search/es_search_strategy.test.ts b/src/plugins/data/server/search/strategies/es_search/es_search_strategy.test.ts index 15a6a4df7eed8..679bb5ae2a699 100644 --- a/src/plugins/data/server/search/strategies/es_search/es_search_strategy.test.ts +++ b/src/plugins/data/server/search/strategies/es_search/es_search_strategy.test.ts @@ -113,7 +113,7 @@ describe('ES search strategy', () => { ) ); const [, searchOptions] = esClient.search.mock.calls[0]; - expect(searchOptions).toEqual({ signal: undefined, maxRetries: 5 }); + expect(searchOptions).toEqual({ signal: undefined, maxRetries: 5, meta: true }); }); it('can be aborted', async () => { @@ -131,7 +131,10 @@ describe('ES search strategy', () => { ...params, track_total_hits: true, }); - expect(esClient.search.mock.calls[0][1]).toEqual({ signal: expect.any(AbortSignal) }); + expect(esClient.search.mock.calls[0][1]).toEqual({ + signal: expect.any(AbortSignal), + meta: true, + }); }); it('throws normalized error if ResponseError is thrown', async () => { diff --git a/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts b/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts index b2aed5804f248..1dc9beb565c79 100644 --- a/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts @@ -50,12 +50,13 @@ export const esSearchStrategyProvider = ( ...(terminateAfter ? { terminate_after: terminateAfter } : {}), ...requestParams, }; - const body = await esClient.asCurrentUser.search(params, { + const { body, meta } = await esClient.asCurrentUser.search(params, { signal: abortSignal, ...transport, + meta: true, }); const response = shimHitsTotal(body, options); - return toKibanaSearchResponse(response); + return toKibanaSearchResponse(response, meta?.request?.params); } catch (e) { throw getKbnServerError(e); } diff --git a/src/plugins/data/server/search/strategies/es_search/response_utils.ts b/src/plugins/data/server/search/strategies/es_search/response_utils.ts index 4773b6df3bbaf..e3dccb8ef1372 100644 --- a/src/plugins/data/server/search/strategies/es_search/response_utils.ts +++ b/src/plugins/data/server/search/strategies/es_search/response_utils.ts @@ -6,7 +6,9 @@ * Side Public License, v 1. */ +import type { ConnectionRequestParams } from '@elastic/transport'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { sanitizeRequestParams } from '@kbn/kibana-utils-plugin/server'; import { ISearchOptions } from '../../../../common'; /** @@ -24,11 +26,15 @@ export function getTotalLoaded(response: estypes.SearchResponse) { * Get the Kibana representation of this response (see `IKibanaSearchResponse`). * @internal */ -export function toKibanaSearchResponse(rawResponse: estypes.SearchResponse) { +export function toKibanaSearchResponse( + rawResponse: estypes.SearchResponse, + requestParams?: ConnectionRequestParams +) { return { rawResponse, isPartial: false, isRunning: false, + ...(requestParams ? { requestParams: sanitizeRequestParams(requestParams) } : {}), ...getTotalLoaded(rawResponse), }; } diff --git a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts index 298933907b8bb..247a4876af3cc 100644 --- a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts @@ -11,7 +11,11 @@ import type { IScopedClusterClient, Logger, SharedGlobalConfig } from '@kbn/core import { catchError, tap } from 'rxjs/operators'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { firstValueFrom, from } from 'rxjs'; -import { getKbnServerError, KbnServerError } from '@kbn/kibana-utils-plugin/server'; +import { + getKbnServerError, + KbnServerError, + sanitizeRequestParams, +} from '@kbn/kibana-utils-plugin/server'; import type { ISearchStrategy, SearchStrategyDependencies } from '../../types'; import type { IAsyncSearchOptions, @@ -65,7 +69,7 @@ export const enhancedEsSearchStrategyProvider = ( ...(await getDefaultAsyncSubmitParams(uiSettingsClient, searchConfig, options)), ...request.params, }; - const { body, headers } = id + const { body, headers, meta } = id ? await client.asyncSearch.get( { ...params, id }, { ...options.transport, signal: options.abortSignal, meta: true } @@ -78,7 +82,11 @@ export const enhancedEsSearchStrategyProvider = ( const response = shimHitsTotal(body.response, options); - return toAsyncKibanaSearchResponse({ ...body, response }, headers?.warning); + return toAsyncKibanaSearchResponse( + { ...body, response }, + headers?.warning, + meta?.request?.params + ); }; const cancel = async () => { @@ -131,8 +139,10 @@ export const enhancedEsSearchStrategyProvider = ( ); const response = esResponse.body as estypes.SearchResponse; + const requestParams = esResponse.meta?.request?.params; return { rawResponse: shimHitsTotal(response, options), + ...(requestParams ? { requestParams: sanitizeRequestParams(requestParams) } : {}), ...getTotalLoaded(response), }; } catch (e) { diff --git a/src/plugins/data/server/search/strategies/ese_search/response_utils.ts b/src/plugins/data/server/search/strategies/ese_search/response_utils.ts index c9390a1b381d5..515bdafe98d84 100644 --- a/src/plugins/data/server/search/strategies/ese_search/response_utils.ts +++ b/src/plugins/data/server/search/strategies/ese_search/response_utils.ts @@ -6,19 +6,26 @@ * Side Public License, v 1. */ +import type { ConnectionRequestParams } from '@elastic/transport'; +import { sanitizeRequestParams } from '@kbn/kibana-utils-plugin/server'; import type { AsyncSearchResponse } from './types'; import { getTotalLoaded } from '../es_search'; /** * Get the Kibana representation of an async search response (see `IKibanaSearchResponse`). */ -export function toAsyncKibanaSearchResponse(response: AsyncSearchResponse, warning?: string) { +export function toAsyncKibanaSearchResponse( + response: AsyncSearchResponse, + warning?: string, + requestParams?: ConnectionRequestParams +) { return { id: response.id, rawResponse: response.response, isPartial: response.is_partial, isRunning: response.is_running, ...(warning ? { warning } : {}), + ...(requestParams ? { requestParams: sanitizeRequestParams(requestParams) } : {}), ...getTotalLoaded(response.response), }; } diff --git a/src/plugins/data/server/search/strategies/esql_search/esql_search_strategy.ts b/src/plugins/data/server/search/strategies/esql_search/esql_search_strategy.ts index 7f3f6f521853d..e40987d10bdf1 100644 --- a/src/plugins/data/server/search/strategies/esql_search/esql_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/esql_search/esql_search_strategy.ts @@ -8,7 +8,11 @@ import { from } from 'rxjs'; import type { Logger } from '@kbn/core/server'; -import { getKbnServerError, KbnServerError } from '@kbn/kibana-utils-plugin/server'; +import { + getKbnServerError, + KbnServerError, + sanitizeRequestParams, +} from '@kbn/kibana-utils-plugin/server'; import type { ISearchStrategy } from '../../types'; export const esqlSearchStrategyProvider = ( @@ -32,7 +36,7 @@ export const esqlSearchStrategyProvider = ( const search = async () => { try { const { terminateAfter, ...requestParams } = request.params ?? {}; - const { headers, body } = await esClient.asCurrentUser.transport.request( + const { headers, body, meta } = await esClient.asCurrentUser.transport.request( { method: 'POST', path: '/_query', @@ -45,10 +49,14 @@ export const esqlSearchStrategyProvider = ( meta: true, } ); + const transportRequestParams = meta?.request?.params; return { rawResponse: body, isPartial: false, isRunning: false, + ...(transportRequestParams + ? { requestParams: sanitizeRequestParams(transportRequestParams) } + : {}), warning: headers?.warning, }; } catch (e) { diff --git a/src/plugins/data/server/search/strategies/sql_search/response_utils.ts b/src/plugins/data/server/search/strategies/sql_search/response_utils.ts index b859df9db4237..e37ce0c57e4cf 100644 --- a/src/plugins/data/server/search/strategies/sql_search/response_utils.ts +++ b/src/plugins/data/server/search/strategies/sql_search/response_utils.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import type { ConnectionRequestParams } from '@elastic/transport'; +import { sanitizeRequestParams } from '@kbn/kibana-utils-plugin/server'; import { SqlQueryResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { SqlSearchStrategyResponse } from '../../../../common'; @@ -15,7 +17,8 @@ import { SqlSearchStrategyResponse } from '../../../../common'; export function toAsyncKibanaSearchResponse( response: SqlQueryResponse, startTime: number, - warning?: string + warning?: string, + requestParams?: ConnectionRequestParams ): SqlSearchStrategyResponse { return { id: response.id, @@ -24,5 +27,6 @@ export function toAsyncKibanaSearchResponse( isRunning: response.is_running, took: Date.now() - startTime, ...(warning ? { warning } : {}), + ...(requestParams ? { requestParams: sanitizeRequestParams(requestParams) } : {}), }; } diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts index c8928a343eec5..b6207787d8fbb 100644 --- a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts @@ -9,6 +9,7 @@ import type { IncomingHttpHeaders } from 'http'; import type { IScopedClusterClient, Logger } from '@kbn/core/server'; import { catchError, tap } from 'rxjs/operators'; +import type { DiagnosticResult } from '@elastic/transport'; import { SqlQueryResponse } from '@elastic/elasticsearch/lib/api/types'; import { getKbnServerError } from '@kbn/kibana-utils-plugin/server'; import type { ISearchStrategy, SearchStrategyDependencies } from '../../types'; @@ -48,9 +49,10 @@ export const sqlSearchStrategyProvider = ( const { keep_cursor: keepCursor, ...params } = request.params ?? {}; let body: SqlQueryResponse; let headers: IncomingHttpHeaders; + let meta: DiagnosticResult['meta']; if (id) { - ({ body, headers } = await client.sql.getAsync( + ({ body, headers, meta } = await client.sql.getAsync( { format: params?.format ?? 'json', ...getDefaultAsyncGetParams(searchConfig, options), @@ -59,7 +61,7 @@ export const sqlSearchStrategyProvider = ( { ...options.transport, signal: options.abortSignal, meta: true } )); } else { - ({ headers, body } = await client.sql.query( + ({ headers, body, meta } = await client.sql.query( { format: params.format ?? 'json', ...getDefaultAsyncSubmitParams(searchConfig, options), @@ -79,7 +81,7 @@ export const sqlSearchStrategyProvider = ( } } - return toAsyncKibanaSearchResponse(body, startTime, headers?.warning); + return toAsyncKibanaSearchResponse(body, startTime, headers?.warning, meta?.request?.params); }; const cancel = async () => { diff --git a/src/plugins/inspector/common/adapters/request/move_request_params_to_top_level.test.ts b/src/plugins/inspector/common/adapters/request/move_request_params_to_top_level.test.ts new file mode 100644 index 0000000000000..37b7a8dd6f283 --- /dev/null +++ b/src/plugins/inspector/common/adapters/request/move_request_params_to_top_level.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { moveRequestParamsToTopLevel } from './move_request_params_to_top_level'; +import { RequestStatus } from './types'; + +describe('moveRequestParamsToTopLevel', () => { + test('should move request meta from error response', () => { + expect( + moveRequestParamsToTopLevel(RequestStatus.ERROR, { + json: { + attributes: {}, + err: { + message: 'simulated error', + requestParams: { + method: 'POST', + path: '/_query', + }, + }, + }, + time: 1, + }) + ).toEqual({ + json: { + attributes: {}, + err: { + message: 'simulated error', + }, + }, + requestParams: { + method: 'POST', + path: '/_query', + }, + time: 1, + }); + }); + + test('should move request meta from ok response', () => { + expect( + moveRequestParamsToTopLevel(RequestStatus.OK, { + json: { + rawResponse: {}, + requestParams: { + method: 'POST', + path: '/_query', + }, + }, + time: 1, + }) + ).toEqual({ + json: { + rawResponse: {}, + }, + requestParams: { + method: 'POST', + path: '/_query', + }, + time: 1, + }); + }); +}); diff --git a/src/plugins/inspector/common/adapters/request/move_request_params_to_top_level.ts b/src/plugins/inspector/common/adapters/request/move_request_params_to_top_level.ts new file mode 100644 index 0000000000000..a00a2d90559c7 --- /dev/null +++ b/src/plugins/inspector/common/adapters/request/move_request_params_to_top_level.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ConnectionRequestParams } from '@elastic/transport'; +import { RequestStatus, Response } from './types'; + +interface ErrorResponse { + [key: string]: unknown; + err?: { + [key: string]: unknown; + requestParams?: ConnectionRequestParams; + }; +} + +interface OkResponse { + [key: string]: unknown; + requestParams?: ConnectionRequestParams; +} + +export function moveRequestParamsToTopLevel(status: RequestStatus, response: Response) { + if (status === RequestStatus.ERROR) { + const requestParams = (response.json as ErrorResponse)?.err?.requestParams; + if (!requestParams) { + return response; + } + + const json = { + ...response.json, + err: { ...(response.json as ErrorResponse).err }, + }; + delete json.err.requestParams; + return { + ...response, + json, + requestParams, + }; + } + + const requestParams = (response.json as OkResponse)?.requestParams; + if (!requestParams) { + return response; + } + + const json = { ...response.json } as OkResponse; + delete json.requestParams; + return { + ...response, + json, + requestParams, + }; +} diff --git a/src/plugins/inspector/common/adapters/request/request_responder.ts b/src/plugins/inspector/common/adapters/request/request_responder.ts index 1d3a999e4834d..cf3a4b6c223da 100644 --- a/src/plugins/inspector/common/adapters/request/request_responder.ts +++ b/src/plugins/inspector/common/adapters/request/request_responder.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import { Request, RequestStatistics, RequestStatus, Response } from './types'; +import { moveRequestParamsToTopLevel } from './move_request_params_to_top_level'; /** * An API to specify information about a specific request that will be logged. @@ -53,7 +54,7 @@ export class RequestResponder { public finish(status: RequestStatus, response: Response): void { this.request.time = response.time ?? Date.now() - this.request.startTime; this.request.status = status; - this.request.response = response; + this.request.response = moveRequestParamsToTopLevel(status, response); this.onChange(); } diff --git a/src/plugins/inspector/common/adapters/request/types.ts b/src/plugins/inspector/common/adapters/request/types.ts index 4e6a8d324559f..d00e1304f74f5 100644 --- a/src/plugins/inspector/common/adapters/request/types.ts +++ b/src/plugins/inspector/common/adapters/request/types.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import type { ConnectionRequestParams } from '@elastic/transport'; + /** * The status a request can have. */ @@ -52,6 +54,8 @@ export interface RequestStatistic { } export interface Response { + // TODO replace object with IKibanaSearchResponse once IKibanaSearchResponse is seperated from data plugin. json?: object; + requestParams?: ConnectionRequestParams; time?: number; } diff --git a/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx index 5ab50ba33a514..58f5dd44f3f11 100644 --- a/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx +++ b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx @@ -12,6 +12,7 @@ /* eslint-disable @elastic/eui/href-or-on-click */ import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import type { ConnectionRequestParams } from '@elastic/transport'; import { i18n } from '@kbn/i18n'; import { XJsonLang } from '@kbn/monaco'; import { compressToEncodedURIComponent } from 'lz-string'; @@ -21,6 +22,7 @@ import { InspectorPluginStartDeps } from '../../../../plugin'; interface RequestCodeViewerProps { indexPattern?: string; + requestParams?: ConnectionRequestParams; json: string; } @@ -39,19 +41,37 @@ const openInSearchProfilerLabel = i18n.translate('inspector.requests.openInSearc /** * @internal */ -export const RequestCodeViewer = ({ indexPattern, json }: RequestCodeViewerProps) => { +export const RequestCodeViewer = ({ + indexPattern, + requestParams, + json, +}: RequestCodeViewerProps) => { const { services } = useKibana(); const navigateToUrl = services.application?.navigateToUrl; - const devToolsDataUri = compressToEncodedURIComponent(`GET ${indexPattern}/_search\n${json}`); + function getValue() { + if (!requestParams) { + return json; + } + + const fullPath = requestParams.querystring + ? `${requestParams.path}?${requestParams.querystring}` + : requestParams.path; + + return `${requestParams.method} ${fullPath}\n${json}`; + } + + const value = getValue(); + + const devToolsDataUri = compressToEncodedURIComponent(value); const consoleHref = services.share.url.locators .get('CONSOLE_APP_LOCATOR') ?.useUrl({ loadFrom: `data:text/plain,${devToolsDataUri}` }); // Check if both the Dev Tools UI and the Console UI are enabled. const canShowDevTools = services.application?.capabilities?.dev_tools.show && consoleHref !== undefined; - const shouldShowDevToolsLink = !!(indexPattern && canShowDevTools); + const shouldShowDevToolsLink = !!(requestParams && canShowDevTools); const handleDevToolsLinkClick = useCallback( () => consoleHref && navigateToUrl && navigateToUrl(consoleHref), [consoleHref, navigateToUrl] @@ -135,7 +155,7 @@ export const RequestCodeViewer = ({ indexPattern, json }: RequestCodeViewerProps { return ( ); diff --git a/src/plugins/kibana_utils/server/index.ts b/src/plugins/kibana_utils/server/index.ts index 2e4f7c7cc52f1..c70b98316f78f 100644 --- a/src/plugins/kibana_utils/server/index.ts +++ b/src/plugins/kibana_utils/server/index.ts @@ -18,3 +18,4 @@ export { } from '../common'; export { KbnServerError, reportServerError, getKbnServerError } from './report_server_error'; +export { sanitizeRequestParams } from './sanitize_request_params'; diff --git a/src/plugins/kibana_utils/server/report_server_error.ts b/src/plugins/kibana_utils/server/report_server_error.ts index 0fcc0c34cc4a9..1ec06408bf018 100644 --- a/src/plugins/kibana_utils/server/report_server_error.ts +++ b/src/plugins/kibana_utils/server/report_server_error.ts @@ -7,14 +7,26 @@ */ import { errors } from '@elastic/elasticsearch'; +import type { ConnectionRequestParams } from '@elastic/transport'; import { KibanaResponseFactory } from '@kbn/core/server'; +import { + sanitizeRequestParams, + type SanitizedConnectionRequestParams, +} from './sanitize_request_params'; import { KbnError } from '../common'; export class KbnServerError extends KbnError { public errBody?: Record; - constructor(message: string, public readonly statusCode: number, errBody?: Record) { + public requestParams?: SanitizedConnectionRequestParams; + constructor( + message: string, + public readonly statusCode: number, + errBody?: Record, + requestParams?: ConnectionRequestParams + ) { super(message); this.errBody = errBody; + this.requestParams = requestParams ? sanitizeRequestParams(requestParams) : undefined; } } @@ -28,7 +40,8 @@ export function getKbnServerError(e: Error) { return new KbnServerError( e.message ?? 'Unknown error', e instanceof errors.ResponseError ? e.statusCode! : 500, - e instanceof errors.ResponseError ? e.body : undefined + e instanceof errors.ResponseError ? e.body : undefined, + e instanceof errors.ResponseError ? e.meta?.meta?.request?.params : undefined ); } @@ -43,6 +56,7 @@ export function reportServerError(res: KibanaResponseFactory, err: KbnServerErro body: { message: err.message, attributes: err.errBody?.error, + ...(err.requestParams ? { requestParams: err.requestParams } : {}), }, }); } diff --git a/src/plugins/kibana_utils/server/sanitize_request_params.test.ts b/src/plugins/kibana_utils/server/sanitize_request_params.test.ts new file mode 100644 index 0000000000000..de3796b20455d --- /dev/null +++ b/src/plugins/kibana_utils/server/sanitize_request_params.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { sanitizeRequestParams } from './sanitize_request_params'; + +describe('sanitizeRequestParams', () => { + test('should remove headers and body', () => { + expect( + sanitizeRequestParams({ + method: 'POST', + path: '/endpoint', + querystring: 'param1=value', + headers: { + Connection: 'Keep-Alive', + }, + body: 'response', + }) + ).toEqual({ + method: 'POST', + path: '/endpoint', + querystring: 'param1=value', + }); + }); + + test('should not include querystring key when its not provided', () => { + expect( + sanitizeRequestParams({ + method: 'POST', + path: '/endpoint', + }) + ).toEqual({ + method: 'POST', + path: '/endpoint', + }); + }); +}); diff --git a/src/plugins/kibana_utils/server/sanitize_request_params.ts b/src/plugins/kibana_utils/server/sanitize_request_params.ts new file mode 100644 index 0000000000000..8587c836f9112 --- /dev/null +++ b/src/plugins/kibana_utils/server/sanitize_request_params.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ConnectionRequestParams } from '@elastic/transport'; + +export type SanitizedConnectionRequestParams = Pick< + ConnectionRequestParams, + 'method' | 'path' | 'querystring' +>; + +export function sanitizeRequestParams(requestParams: ConnectionRequestParams) { + return { + method: requestParams.method, + path: requestParams.path, + ...(requestParams.querystring ? { querystring: requestParams.querystring } : {}), + }; +} diff --git a/test/api_integration/apis/search/bsearch.ts b/test/api_integration/apis/search/bsearch.ts index 9ce10dc38a643..15e2e37acee4f 100644 --- a/test/api_integration/apis/search/bsearch.ts +++ b/test/api_integration/apis/search/bsearch.ts @@ -232,6 +232,357 @@ export default function ({ getService }: FtrProviderContext) { }); }); }); + + describe('request meta', () => { + describe('es', () => { + it(`should return request meta`, async () => { + const resp = await supertest + .post(`/internal/bsearch`) + .set(ELASTIC_HTTP_VERSION_HEADER, BFETCH_ROUTE_VERSION_LATEST) + .send({ + batch: [ + { + request: { + params: { + index: '.kibana', + body: { + query: { + match_all: {}, + }, + }, + }, + }, + options: { + strategy: 'es', + }, + }, + ], + }); + + const jsonBody = parseBfetchResponse(resp); + + expect(resp.status).to.be(200); + expect(jsonBody[0].result.requestParams).to.eql({ + method: 'POST', + path: '/.kibana/_search', + querystring: 'ignore_unavailable=true', + }); + }); + + it(`should return request meta when request fails`, async () => { + const resp = await supertest + .post(`/internal/bsearch`) + .set(ELASTIC_HTTP_VERSION_HEADER, BFETCH_ROUTE_VERSION_LATEST) + .send({ + batch: [ + { + request: { + params: { + index: '.kibana', + body: { + query: { + bool: { + filter: [ + { + error_query: { + indices: [ + { + error_type: 'exception', + message: 'simulated failure', + name: '.kibana', + }, + ], + }, + }, + ], + }, + }, + }, + }, + }, + options: { + strategy: 'es', + }, + }, + ], + }); + + const jsonBody = parseBfetchResponse(resp); + + expect(resp.status).to.be(200); + expect(jsonBody[0].error.requestParams).to.eql({ + method: 'POST', + path: '/.kibana/_search', + querystring: 'ignore_unavailable=true', + }); + }); + }); + + describe('ese', () => { + it(`should return request meta`, async () => { + const resp = await supertest + .post(`/internal/bsearch`) + .set(ELASTIC_HTTP_VERSION_HEADER, BFETCH_ROUTE_VERSION_LATEST) + .send({ + batch: [ + { + request: { + params: { + index: '.kibana', + body: { + query: { + match_all: {}, + }, + }, + }, + }, + options: { + strategy: 'ese', + }, + }, + ], + }); + + const jsonBody = parseBfetchResponse(resp); + + expect(resp.status).to.be(200); + expect(jsonBody[0].result.requestParams).to.eql({ + method: 'POST', + path: '/.kibana/_async_search', + querystring: + 'batched_reduce_size=64&ccs_minimize_roundtrips=true&wait_for_completion_timeout=200ms&keep_on_completion=false&keep_alive=60000ms&ignore_unavailable=true', + }); + }); + + it(`should return request meta when request fails`, async () => { + const resp = await supertest + .post(`/internal/bsearch`) + .set(ELASTIC_HTTP_VERSION_HEADER, BFETCH_ROUTE_VERSION_LATEST) + .send({ + batch: [ + { + request: { + params: { + index: '.kibana', + body: { + bool: { + filter: [ + { + error_query: { + indices: [ + { + error_type: 'exception', + message: 'simulated failure', + name: '.kibana', + }, + ], + }, + }, + ], + }, + }, + }, + }, + options: { + strategy: 'ese', + }, + }, + ], + }); + + const jsonBody = parseBfetchResponse(resp); + + expect(resp.status).to.be(200); + expect(jsonBody[0].error.requestParams).to.eql({ + method: 'POST', + path: '/.kibana/_async_search', + querystring: + 'batched_reduce_size=64&ccs_minimize_roundtrips=true&wait_for_completion_timeout=200ms&keep_on_completion=false&keep_alive=60000ms&ignore_unavailable=true', + }); + }); + }); + + describe('esql', () => { + it(`should return request meta`, async () => { + const resp = await supertest + .post(`/internal/bsearch`) + .set(ELASTIC_HTTP_VERSION_HEADER, BFETCH_ROUTE_VERSION_LATEST) + .send({ + batch: [ + { + request: { + params: { + query: 'from .kibana | limit 1', + }, + }, + options: { + strategy: 'esql', + }, + }, + ], + }); + + const jsonBody = parseBfetchResponse(resp); + + expect(resp.status).to.be(200); + expect(jsonBody[0].result.requestParams).to.eql({ + method: 'POST', + path: '/_query', + }); + }); + + it(`should return request meta when request fails`, async () => { + const resp = await supertest + .post(`/internal/bsearch`) + .set(ELASTIC_HTTP_VERSION_HEADER, BFETCH_ROUTE_VERSION_LATEST) + .send({ + batch: [ + { + request: { + params: { + query: 'fro .kibana | limit 1', + }, + }, + options: { + strategy: 'esql', + }, + }, + ], + }); + + const jsonBody = parseBfetchResponse(resp); + + expect(resp.status).to.be(200); + expect(jsonBody[0].error.requestParams).to.eql({ + method: 'POST', + path: '/_query', + }); + }); + }); + + describe('sql', () => { + it(`should return request meta`, async () => { + const resp = await supertest + .post(`/internal/bsearch`) + .set(ELASTIC_HTTP_VERSION_HEADER, BFETCH_ROUTE_VERSION_LATEST) + .send({ + batch: [ + { + request: { + params: { + query: 'SELECT * FROM ".kibana" LIMIT 1', + }, + }, + options: { + strategy: 'sql', + }, + }, + ], + }); + + const jsonBody = parseBfetchResponse(resp); + + expect(resp.status).to.be(200); + expect(jsonBody[0].result.requestParams).to.eql({ + method: 'POST', + path: '/_sql', + querystring: 'format=json', + }); + }); + + it(`should return request meta when request fails`, async () => { + const resp = await supertest + .post(`/internal/bsearch`) + .set(ELASTIC_HTTP_VERSION_HEADER, BFETCH_ROUTE_VERSION_LATEST) + .send({ + batch: [ + { + request: { + params: { + query: 'SELEC * FROM ".kibana" LIMIT 1', + }, + }, + options: { + strategy: 'sql', + }, + }, + ], + }); + + const jsonBody = parseBfetchResponse(resp); + + expect(resp.status).to.be(200); + expect(jsonBody[0].error.requestParams).to.eql({ + method: 'POST', + path: '/_sql', + querystring: 'format=json', + }); + }); + }); + + describe('eql', () => { + it(`should return request meta`, async () => { + const resp = await supertest + .post(`/internal/bsearch`) + .set(ELASTIC_HTTP_VERSION_HEADER, BFETCH_ROUTE_VERSION_LATEST) + .send({ + batch: [ + { + request: { + params: { + index: '.kibana', + query: 'any where true', + timestamp_field: 'created_at', + }, + }, + options: { + strategy: 'eql', + }, + }, + ], + }); + + const jsonBody = parseBfetchResponse(resp); + + expect(resp.status).to.be(200); + expect(jsonBody[0].result.requestParams).to.eql({ + method: 'POST', + path: '/.kibana/_eql/search', + querystring: 'ignore_unavailable=true', + }); + }); + + it(`should return request meta when request fails`, async () => { + const resp = await supertest + .post(`/internal/bsearch`) + .set(ELASTIC_HTTP_VERSION_HEADER, BFETCH_ROUTE_VERSION_LATEST) + .send({ + batch: [ + { + request: { + params: { + index: '.kibana', + query: 'any where true', + }, + }, + options: { + strategy: 'eql', + }, + }, + ], + }); + + const jsonBody = parseBfetchResponse(resp); + + expect(resp.status).to.be(200); + expect(jsonBody[0].error.requestParams).to.eql({ + method: 'POST', + path: '/.kibana/_eql/search', + querystring: 'ignore_unavailable=true', + }); + }); + }); + }); }); }); } diff --git a/test/functional/apps/visualize/group2/_inspector.ts b/test/functional/apps/visualize/group2/_inspector.ts index 80cfc42ab3cd6..077a37a90c06c 100644 --- a/test/functional/apps/visualize/group2/_inspector.ts +++ b/test/functional/apps/visualize/group2/_inspector.ts @@ -14,7 +14,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const inspector = getService('inspector'); const filterBar = getService('filterBar'); - const monacoEditor = getService('monacoEditor'); const PageObjects = getPageObjects(['visualize', 'visEditor', 'visChart', 'timePicker']); describe('inspector', function describeIndexTests() { @@ -41,11 +40,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await inspector.open(); await inspector.openInspectorRequestsView(); - const requestTab = await inspector.getOpenRequestDetailRequestButton(); - await requestTab.click(); - const requestJSON = JSON.parse(await monacoEditor.getCodeEditorValue(1)); - - expect(requestJSON.aggs['2'].max).property('missing', 10); + const { body } = await inspector.getRequest(1); + expect(body.aggs['2'].max).property('missing', 10); }); after(async () => { diff --git a/test/functional/services/inspector.ts b/test/functional/services/inspector.ts index 6222405aa6dae..7313187047a18 100644 --- a/test/functional/services/inspector.ts +++ b/test/functional/services/inspector.ts @@ -299,6 +299,21 @@ export class InspectorService extends FtrService { return this.testSubjects.find('inspectorRequestDetailResponse'); } + public async getRequest( + codeEditorIndex: number = 0 + ): Promise<{ command: string; body: Record }> { + await (await this.getOpenRequestDetailRequestButton()).click(); + + await this.monacoEditor.waitCodeEditorReady('inspectorRequestCodeViewerContainer'); + const requestString = await this.monacoEditor.getCodeEditorValue(codeEditorIndex); + this.log.debug('Request string from inspector:', requestString); + const openBraceIndex = requestString.indexOf('{'); + return { + command: openBraceIndex >= 0 ? requestString.substring(0, openBraceIndex).trim() : '', + body: openBraceIndex >= 0 ? JSON.parse(requestString.substring(openBraceIndex)) : {}, + }; + } + public async getResponse(): Promise> { await (await this.getOpenRequestDetailResponseButton()).click();