[inspector] show request method, path, and querystring (#169970)

Closes https://github.com/elastic/kibana/issues/45931

PR updates data plugin `search` and `bsearch` endpoints to return
method, path, and querystring request params from elasticsearch-js
client requests. This provides inspector with the exact details used to
fetch data from elasticsearch, ensuring inspector displays request
exactly as used by elasticsearch-js client.

**ESQL** This PR makes it possible to open ESQL searches in console.
<img width="500" alt="Screen Shot 2023-09-16 at 4 19 58 PM"
src="56019fb5-ca88-46cf-a42f-86f5f51edfcc">

### background
If you are thinking to yourself, "haven't I reviewed this before?", you
are right. This functionality has been through several iterations.
1) Original PR https://github.com/elastic/kibana/pull/166565 was
reverted for exposing `headers`.
2) [Fix to only expose method, path, and querystring keys from request
parameters](https://github.com/elastic/kibana/pull/167544) was rejected
because it applied changes to
`kibana_utils/server/report_server_error.ts`, which is used extensively
throughout Kibana.
3) This iteration moves logic into the data plugin to be as narrow as
possible.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2023-11-03 15:46:55 -06:00 committed by GitHub
parent 45d0e32244
commit f28445449e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 627 additions and 43 deletions

View file

@ -126,7 +126,7 @@ export const getEsdslFn = ({
});
try {
const { rawResponse } = await lastValueFrom(
const { rawResponse, requestParams } = await lastValueFrom(
search(
{
params: {
@ -180,7 +180,7 @@ export const getEsdslFn = ({
};
}
request.stats(stats).ok({ json: rawResponse });
request.stats(stats).ok({ json: rawResponse, requestParams });
request.json(dsl);
return {

View file

@ -220,7 +220,7 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => {
return throwError(() => error);
}),
tap({
next({ rawResponse }) {
next({ rawResponse, requestParams }) {
logInspectorRequest()
.stats({
hits: {
@ -234,12 +234,14 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => {
},
})
.json(params)
.ok({ json: rawResponse });
.ok({ json: rawResponse, requestParams });
},
error(error) {
logInspectorRequest().error({
json: 'attributes' in error ? error.attributes : { message: error.message },
});
logInspectorRequest()
.json(params)
.error({
json: 'attributes' in error ? error.attributes : { message: error.message },
});
},
})
);

View file

@ -217,7 +217,7 @@ export const getEssqlFn = ({ getStartDependencies }: EssqlFnArguments) => {
return throwError(() => error);
}),
tap({
next({ rawResponse, took }) {
next({ rawResponse, requestParams, took }) {
logInspectorRequest()
.stats({
hits: {
@ -245,7 +245,7 @@ export const getEssqlFn = ({ getStartDependencies }: EssqlFnArguments) => {
},
})
.json(params)
.ok({ json: rawResponse });
.ok({ json: rawResponse, requestParams });
},
error(error) {
logInspectorRequest().error({

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
import { estypes } from '@elastic/elasticsearch';
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';
@ -39,6 +41,11 @@ export interface ISearchClient {
extend: ISearchExtendGeneric;
}
export type SanitizedConnectionRequestParams = Pick<
ConnectionRequestParams,
'method' | 'path' | 'querystring'
>;
export interface IKibanaSearchResponse<RawResponse = any> {
/**
* Some responses may contain a unique id to identify the request this response came from.
@ -86,6 +93,17 @@ export interface IKibanaSearchResponse<RawResponse = any> {
* 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?: SanitizedConnectionRequestParams;
}
export interface IEsErrorAttributes {
error?: estypes.ErrorCause;
rawResponse?: estypes.SearchResponseBody;
requestParams?: SanitizedConnectionRequestParams;
}
export interface IKibanaSearchRequest<Params = any> {

View file

@ -6,13 +6,8 @@
* Side Public License, v 1.
*/
import { estypes } from '@elastic/elasticsearch';
import { KibanaServerError } from '@kbn/kibana-utils-plugin/common';
interface IEsErrorAttributes {
rawResponse?: estypes.SearchResponseBody;
error?: estypes.ErrorCause;
}
import { IEsErrorAttributes } from '../../../common';
export type IEsError = KibanaServerError<IEsErrorAttributes>;

View file

@ -56,6 +56,7 @@ import {
ISearchOptionsSerializable,
pollSearch,
UI_SETTINGS,
type SanitizedConnectionRequestParams,
} from '../../../common';
import { SearchUsageCollector } from '../collectors';
import {
@ -304,18 +305,38 @@ export class SearchInterceptor {
const cancel = () => id && !isSavedToBackground && sendCancelRequest();
// Async search requires a series of requests
// 1) POST /<index pattern>/_async_search/
// 2..n) GET /_async_search/<async search identifier>
//
// First request contains useful request params for tools like Inspector.
// Preserve and project first request params into responses.
let firstRequestParams: SanitizedConnectionRequestParams;
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 (!isRunningResponse(response)) {
searchTracker?.complete();
}
}),
map((response) => {
return firstRequestParams
? {
...response,
requestParams: firstRequestParams,
}
: response;
}),
catchError((e: Error) => {
searchTracker?.error();
cancel();

View file

@ -6,9 +6,12 @@
* Side Public License, v 1.
*/
import type { ConnectionRequestParams } from '@elastic/transport';
import { errors } from '@elastic/elasticsearch';
import { KibanaResponseFactory } from '@kbn/core/server';
import { KbnError } from '@kbn/kibana-utils-plugin/common';
import type { SanitizedConnectionRequestParams } from '../../common';
import { sanitizeRequestParams } from './sanitize_request_params';
// Why not use just use kibana-utils-plugin KbnServerError and reportServerError?
//
@ -19,9 +22,17 @@ import { KbnError } from '@kbn/kibana-utils-plugin/common';
// non-search usages of KbnServerError and reportServerError with extra information.
export class KbnSearchError extends KbnError {
public errBody?: Record<string, any>;
constructor(message: string, public readonly statusCode: number, errBody?: Record<string, any>) {
public requestParams?: SanitizedConnectionRequestParams;
constructor(
message: string,
public readonly statusCode: number,
errBody?: Record<string, any>,
requestParams?: ConnectionRequestParams
) {
super(message);
this.errBody = errBody;
this.requestParams = requestParams ? sanitizeRequestParams(requestParams) : undefined;
}
}
@ -35,7 +46,8 @@ export function getKbnSearchError(e: Error) {
return new KbnSearchError(
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
);
}
@ -53,6 +65,7 @@ export function reportSearchError(res: KibanaResponseFactory, err: KbnSearchErro
? {
error: err.errBody.error,
rawResponse: err.errBody.response,
...(err.requestParams ? { requestParams: err.requestParams } : {}),
}
: undefined,
},

View file

@ -50,6 +50,7 @@ export function registerBsearchRoute(
? {
error: err.errBody.error,
rawResponse: err.errBody.response,
...(err.requestParams ? { requestParams: err.requestParams } : {}),
}
: undefined,
};

View file

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

View file

@ -0,0 +1,20 @@
/*
* 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 type { SanitizedConnectionRequestParams } from '../../common';
export function sanitizeRequestParams(
requestParams: ConnectionRequestParams
): SanitizedConnectionRequestParams {
return {
method: requestParams.method,
path: requestParams.path,
...(requestParams.querystring ? { querystring: requestParams.querystring } : {}),
};
}

View file

@ -77,7 +77,11 @@ export const eqlSearchStrategyProvider = (
meta: true,
});
return toEqlKibanaSearchResponse(response as TransportResult<EqlSearchResponse>);
return toEqlKibanaSearchResponse(
response as TransportResult<EqlSearchResponse>,
// do not return requestParams on polling calls
id ? undefined : (response as TransportResult<EqlSearchResponse>).meta?.request?.params
);
};
const cancel = async () => {

View file

@ -6,21 +6,25 @@
* Side Public License, v 1.
*/
import type { ConnectionRequestParams } from '@elastic/transport';
import type { TransportResult } from '@elastic/elasticsearch';
import { EqlSearchResponse } from './types';
import { EqlSearchStrategyResponse } from '../../../../common';
import { sanitizeRequestParams } from '../../sanitize_request_params';
/**
* Get the Kibana representation of an EQL search response (see `IKibanaSearchResponse`).
* (EQL does not provide _shard info, so total/loaded cannot be calculated.)
*/
export function toEqlKibanaSearchResponse(
response: TransportResult<EqlSearchResponse>
response: TransportResult<EqlSearchResponse>,
requestParams?: ConnectionRequestParams
): EqlSearchStrategyResponse {
return {
id: response.body.id,
rawResponse: response.body,
isPartial: response.body.is_partial,
isRunning: response.body.is_running,
...(requestParams ? { requestParams: sanitizeRequestParams(requestParams) } : {}),
};
}

View file

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

View file

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

View file

@ -6,8 +6,10 @@
* Side Public License, v 1.
*/
import type { ConnectionRequestParams } from '@elastic/transport';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ISearchOptions } from '../../../../common';
import { sanitizeRequestParams } from '../../sanitize_request_params';
/**
* Get the `total`/`loaded` for this response (see `IKibanaSearchResponse`). Note that `skipped` is
@ -24,11 +26,15 @@ export function getTotalLoaded(response: estypes.SearchResponse<unknown>) {
* Get the Kibana representation of this response (see `IKibanaSearchResponse`).
* @internal
*/
export function toKibanaSearchResponse(rawResponse: estypes.SearchResponse<unknown>) {
export function toKibanaSearchResponse(
rawResponse: estypes.SearchResponse<unknown>,
requestParams?: ConnectionRequestParams
) {
return {
rawResponse,
isPartial: false,
isRunning: false,
...(requestParams ? { requestParams: sanitizeRequestParams(requestParams) } : {}),
...getTotalLoaded(rawResponse),
};
}

View file

@ -35,6 +35,7 @@ import {
shimHitsTotal,
} from '../es_search';
import { SearchConfigSchema } from '../../../../config';
import { sanitizeRequestParams } from '../../sanitize_request_params';
export const enhancedEsSearchStrategyProvider = (
legacyConfig$: Observable<SharedGlobalConfig>,
@ -66,7 +67,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 }
@ -79,7 +80,12 @@ export const enhancedEsSearchStrategyProvider = (
const response = shimHitsTotal(body.response, options);
return toAsyncKibanaSearchResponse({ ...body, response }, headers?.warning);
return toAsyncKibanaSearchResponse(
{ ...body, response },
headers?.warning,
// do not return requestParams on polling calls
id ? undefined : meta?.request?.params
);
};
const cancel = async () => {
@ -134,6 +140,9 @@ export const enhancedEsSearchStrategyProvider = (
const response = esResponse.body as estypes.SearchResponse<any>;
return {
rawResponse: shimHitsTotal(response, options),
...(esResponse.meta?.request?.params
? { requestParams: sanitizeRequestParams(esResponse.meta?.request?.params) }
: {}),
...getTotalLoaded(response),
};
} catch (e) {

View file

@ -6,19 +6,26 @@
* Side Public License, v 1.
*/
import type { ConnectionRequestParams } from '@elastic/transport';
import type { AsyncSearchResponse } from './types';
import { getTotalLoaded } from '../es_search';
import { sanitizeRequestParams } from '../../sanitize_request_params';
/**
* 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),
};
}

View file

@ -10,6 +10,7 @@ import { from } from 'rxjs';
import type { Logger } from '@kbn/core/server';
import { getKbnSearchError, KbnSearchError } from '../../report_search_error';
import type { ISearchStrategy } from '../../types';
import { sanitizeRequestParams } from '../../sanitize_request_params';
const ES_TIMEOUT_IN_MS = 120000;
@ -45,7 +46,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',
@ -64,6 +65,9 @@ export const esqlSearchStrategyProvider = (
rawResponse: body,
isPartial: false,
isRunning: false,
...(meta?.request?.params
? { requestParams: sanitizeRequestParams(meta?.request?.params) }
: {}),
warning: headers?.warning,
};
} catch (e) {

View file

@ -6,8 +6,10 @@
* Side Public License, v 1.
*/
import type { ConnectionRequestParams } from '@elastic/transport';
import { SqlQueryResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { SqlSearchStrategyResponse } from '../../../../common';
import { sanitizeRequestParams } from '../../sanitize_request_params';
/**
* Get the Kibana representation of an async search response
@ -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) } : {}),
};
}

View file

@ -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 { getKbnSearchError } from '../../report_search_error';
@ -49,9 +50,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),
@ -60,7 +62,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),
@ -80,7 +82,13 @@ export const sqlSearchStrategyProvider = (
}
}
return toAsyncKibanaSearchResponse(body, startTime, headers?.warning);
return toAsyncKibanaSearchResponse(
body,
startTime,
headers?.warning,
// do not return requestParams on polling calls
id ? undefined : meta?.request?.params
);
};
const cancel = async () => {

View file

@ -0,0 +1,35 @@
/*
* 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';
describe('moveRequestParamsToTopLevel', () => {
test('should move request meta to top level', () => {
expect(
moveRequestParamsToTopLevel({
json: {
rawResponse: {},
requestParams: {
method: 'POST',
path: '/_query',
},
},
time: 1,
})
).toEqual({
json: {
rawResponse: {},
},
requestParams: {
method: 'POST',
path: '/_query',
},
time: 1,
});
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 { Response } from './types';
interface SearchResponse {
[key: string]: unknown;
requestParams?: ConnectionRequestParams;
}
export function moveRequestParamsToTopLevel(response: Response) {
const requestParams = (response.json as SearchResponse)?.requestParams;
if (!requestParams) {
return response;
}
const json = { ...response.json } as SearchResponse;
delete json.requestParams;
return {
...response,
json,
requestParams,
};
}

View file

@ -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(response);
this.onChange();
}

View file

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

View file

@ -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<InspectorPluginStartDeps>();
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
<EuiFlexItem grow={true} data-test-subj="inspectorRequestCodeViewerContainer">
<CodeEditor
languageId={XJsonLang.ID}
value={json}
value={value}
options={{
readOnly: true,
lineNumbers: 'off',

View file

@ -24,6 +24,7 @@ export class RequestDetailsRequest extends Component<DetailViewProps> {
return (
<RequestCodeViewer
indexPattern={this.props.request.stats?.indexPattern?.value}
requestParams={this.props.request.response?.requestParams}
json={JSON.stringify(json, null, 2)}
/>
);

View file

@ -232,6 +232,327 @@ 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.attributes.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.attributes.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.attributes.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.attributes.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',
});
});
});
});
});
});
}

View file

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

View file

@ -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<string, any> }> {
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<Record<string, any>> {
await (await this.getOpenRequestDetailResponseButton()).click();