[search source] return rawResponse on search failure (#168389)

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

#### Problem
`/bsearch` and `/search` APIs only return `error` key from elasticsearch
error response. This is problematic because Inspector needs
`rawResponse` to populate "Clusters and shards"

While working on this issue, I discovered another problem with how error
responses are added to inspector requestResponder. The `Error` instance
is added as `json` key. This is a little awkward since the response tab
just stringifies the contents of `json`, thus stringifing the Error
object instead of just the error body returned from API. This PR address
this problem by setting `json` to either `attributes` or `{ message }`.

#### Solution
PR updates `/bsearch` and `/search` APIs to return `{ attributes: {
error: ErrorCause, rawResponse }}` for failed responses. Solution
avoided changing KbnServerError and reportServerError since these
methods are used extensivly throughout Kibana (see
https://github.com/elastic/kibana/pull/167544#discussion_r1342460941 for
more details). Instead, KbnSearchError and reportSearchError are created
to report search error messages.

#### Test
1) install web logs sample data set
2) open discover
3) add filter
    ```
    {
      "error_query": {
        "indices": [
          {
            "error_type": "exception",
            "message": "local shard failure message 123",
            "name": "kibana_sample_data_logs",
            "shard_ids": [
              0
            ]
          }
        ]
      }
    }
    ```
4) Open inspector. Verify "Clusters and shards" tab is visible and
populated. Verify "Response" tab shows "error" and "rawResponse" keys.
<img width="500" alt="Screenshot 2023-10-09 at 9 29 16 AM"
src="461b0eb0-0502-4d48-a487-68025ef24d35">
<img width="500" alt="Screenshot 2023-10-09 at 9 29 06 AM"
src="9aff41eb-f771-48e3-a66d-1447689c2c6a">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Gloria Hornero <gloria.hornero@elastic.co>
This commit is contained in:
Nathan Reese 2023-10-23 10:59:17 -06:00 committed by GitHub
parent 3587c20835
commit adf3b8b436
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 333 additions and 242 deletions

View file

@ -166,7 +166,7 @@ export const getEqlFn = ({
body: response.rawResponse,
};
} catch (e) {
request.error({ json: e });
request.error({ json: 'attributes' in e ? e.attributes : { message: e.message } });
throw e;
}
},

View file

@ -188,7 +188,7 @@ export const getEsdslFn = ({
body: rawResponse,
};
} catch (e) {
request.error({ json: e });
request.error({ json: 'attributes' in e ? e.attributes : { message: e.message } });
throw e;
}
},

View file

@ -227,7 +227,9 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => {
.ok({ json: rawResponse });
},
error(error) {
logInspectorRequest().error({ json: error });
logInspectorRequest().error({
json: 'attributes' in error ? error.attributes : { message: error.message },
});
},
})
);

View file

@ -248,7 +248,9 @@ export const getEssqlFn = ({ getStartDependencies }: EssqlFnArguments) => {
.ok({ json: rawResponse });
},
error(error) {
logInspectorRequest().error({ json: error });
logInspectorRequest().error({
json: 'attributes' in error ? error.attributes : { message: error.message },
});
},
})
);

View file

@ -458,7 +458,9 @@ export class SearchSource {
const last$ = s$
.pipe(
catchError((e) => {
requestResponder?.error({ json: e });
requestResponder?.error({
json: 'attributes' in e ? e.attributes : { message: e.message },
});
return EMPTY;
}),
last(undefined, null),

View file

@ -167,7 +167,6 @@ export type {
SerializedSearchSourceFields,
// errors
IEsError,
Reason,
WaitUntilNextSessionCompletesOptions,
} from './search';

View file

@ -7,6 +7,7 @@
*/
import { EsError } from './es_error';
import { IEsError } from './types';
describe('EsError', () => {
it('contains the same body as the wrapped error', () => {
@ -19,7 +20,7 @@ describe('EsError', () => {
reason: 'top-level reason',
},
},
} as any;
} as IEsError;
const esError = new EsError(error);
expect(typeof esError.attributes).toEqual('object');
@ -33,20 +34,22 @@ describe('EsError', () => {
'x_content_parse_exception: [x_content_parse_exception] Reason: [1:78] [date_histogram] failed to parse field [calendar_interval]',
statusCode: 400,
attributes: {
root_cause: [
{
type: 'x_content_parse_exception',
reason: '[1:78] [date_histogram] failed to parse field [calendar_interval]',
error: {
root_cause: [
{
type: 'x_content_parse_exception',
reason: '[1:78] [date_histogram] failed to parse field [calendar_interval]',
},
],
type: 'x_content_parse_exception',
reason: '[1:78] [date_histogram] failed to parse field [calendar_interval]',
caused_by: {
type: 'illegal_argument_exception',
reason: 'The supplied interval [2q] could not be parsed as a calendar interval.',
},
],
type: 'x_content_parse_exception',
reason: '[1:78] [date_histogram] failed to parse field [calendar_interval]',
caused_by: {
type: 'illegal_argument_exception',
reason: 'The supplied interval [2q] could not be parsed as a calendar interval.',
},
},
} as any;
} as IEsError;
const esError = new EsError(error);
expect(esError.message).toEqual(
'EsError: The supplied interval [2q] could not be parsed as a calendar interval.'

View file

@ -8,8 +8,8 @@
import React from 'react';
import { EuiCodeBlock, EuiSpacer } from '@elastic/eui';
import { ApplicationStart } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { ApplicationStart } from '@kbn/core/public';
import { KbnError } from '@kbn/kibana-utils-plugin/common';
import { IEsError } from './types';
import { getRootCause } from './utils';
@ -20,7 +20,7 @@ export class EsError extends KbnError {
constructor(protected readonly err: IEsError) {
super(
`EsError: ${
getRootCause(err)?.reason ||
getRootCause(err?.attributes?.error)?.reason ||
i18n.translate('data.esError.unknownRootCause', { defaultMessage: 'unknown' })
}`
);
@ -28,18 +28,20 @@ export class EsError extends KbnError {
}
public getErrorMessage(application: ApplicationStart) {
const rootCause = getRootCause(this.err)?.reason;
const topLevelCause = this.attributes?.reason;
if (!this.attributes?.error) {
return null;
}
const rootCause = getRootCause(this.attributes.error)?.reason;
const topLevelCause = this.attributes.error.reason;
const cause = rootCause ?? topLevelCause;
return (
<>
<EuiSpacer size="s" />
{cause ? (
<EuiCodeBlock data-test-subj="errMessage" isCopyable={true} paddingSize="s">
{cause}
</EuiCodeBlock>
) : null}
<EuiCodeBlock data-test-subj="errMessage" isCopyable={true} paddingSize="s">
{cause}
</EuiCodeBlock>
</>
);
}

View file

@ -23,11 +23,13 @@ describe('PainlessError', () => {
const e = new PainlessError({
statusCode: 400,
message: 'search_phase_execution_exception',
attributes: searchPhaseException.error,
attributes: {
error: searchPhaseException.error,
},
});
const component = mount(e.getErrorMessage(startMock.application));
const failedShards = e.attributes?.failed_shards![0];
const failedShards = searchPhaseException.error.failed_shards![0];
const stackTraceElem = findTestSubject(component, 'painlessStackTrace').getDOMNode();
const stackTrace = failedShards!.reason.script_stack!.splice(-2).join('\n');

View file

@ -31,7 +31,7 @@ export class PainlessError extends EsError {
});
}
const rootCause = getRootCause(this.err);
const rootCause = getRootCause(this.err.attributes?.error);
const scriptFromStackTrace = rootCause?.script_stack
? rootCause?.script_stack?.slice(-2).join('\n')
: undefined;
@ -78,7 +78,7 @@ export class PainlessError extends EsError {
export function isPainlessError(err: Error | IEsError) {
if (!isEsError(err)) return false;
const rootCause = getRootCause(err as IEsError);
const rootCause = getRootCause((err as IEsError).attributes?.error);
if (!rootCause) return false;
const { lang } = rootCause;

View file

@ -5,39 +5,13 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { estypes } from '@elastic/elasticsearch';
import { KibanaServerError } from '@kbn/kibana-utils-plugin/common';
export interface FailedShard {
shard: number;
index: string;
node: string;
reason: Reason;
}
export interface Reason {
type: string;
reason?: string;
script_stack?: string[];
position?: {
offset: number;
start: number;
end: number;
};
lang?: estypes.ScriptLanguage;
script?: string;
caused_by?: {
type: string;
reason: string;
};
}
interface IEsErrorAttributes {
type: string;
reason: string;
root_cause?: Reason[];
failed_shards?: FailedShard[];
caused_by?: IEsErrorAttributes;
rawResponse?: estypes.SearchResponseBody;
error?: estypes.ErrorCause;
}
export type IEsError = KibanaServerError<IEsErrorAttributes>;

View file

@ -5,26 +5,21 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { ErrorCause } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { KibanaServerError } from '@kbn/kibana-utils-plugin/common';
import type { FailedShard, Reason } from './types';
export function getFailedShards(err: KibanaServerError<any>): FailedShard | undefined {
const errorInfo = err.attributes;
const failedShards = errorInfo?.failed_shards || errorInfo?.caused_by?.failed_shards;
return failedShards ? failedShards[0] : undefined;
import { estypes } from '@elastic/elasticsearch';
function getFailedShardCause(error: estypes.ErrorCause): estypes.ErrorCause | undefined {
const failedShards = error.failed_shards || error.caused_by?.failed_shards;
return failedShards ? failedShards[0]?.reason : undefined;
}
function getNestedCause(err: KibanaServerError | ErrorCause): Reason {
const attr = ((err as KibanaServerError).attributes || err) as ErrorCause;
const { type, reason, caused_by: causedBy } = attr;
if (causedBy) {
return getNestedCause(causedBy);
}
return { type, reason };
function getNestedCause(error: estypes.ErrorCause): estypes.ErrorCause {
return error.caused_by ? getNestedCause(error.caused_by) : error;
}
export function getRootCause(err: KibanaServerError) {
// Give shard failures priority, then try to get the error navigating nested objects
return getFailedShards(err)?.reason || getNestedCause(err);
export function getRootCause(error?: estypes.ErrorCause): estypes.ErrorCause | undefined {
return error
? // Give shard failures priority, then try to get the error navigating nested objects
getFailedShardCause(error) || getNestedCause(error)
: undefined;
}

View file

@ -22,6 +22,7 @@ import * as resourceNotFoundException from '../../../common/search/test_data/res
import { BehaviorSubject } from 'rxjs';
import { dataPluginMock } from '../../mocks';
import { UI_SETTINGS } from '../../../common';
import type { IEsError } from '../errors';
jest.mock('./utils', () => {
const originalModule = jest.requireActual('./utils');
@ -151,7 +152,9 @@ describe('SearchInterceptor', () => {
new PainlessError({
statusCode: 400,
message: 'search_phase_execution_exception',
attributes: searchPhaseException.error,
attributes: {
error: searchPhaseException.error,
},
})
);
expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1);
@ -1452,10 +1455,12 @@ describe('SearchInterceptor', () => {
});
test('Should throw Painless error on server error with OSS format', async () => {
const mockResponse: any = {
const mockResponse: IEsError = {
statusCode: 400,
message: 'search_phase_execution_exception',
attributes: searchPhaseException.error,
attributes: {
error: searchPhaseException.error,
},
};
fetchMock.mockRejectedValueOnce(mockResponse);
const mockRequest: IEsSearchRequest = {
@ -1466,10 +1471,12 @@ describe('SearchInterceptor', () => {
});
test('Should throw ES error on ES server error', async () => {
const mockResponse: any = {
const mockResponse: IEsError = {
statusCode: 400,
message: 'resource_not_found_exception',
attributes: resourceNotFoundException.error,
attributes: {
error: resourceNotFoundException.error,
},
};
fetchMock.mockRejectedValueOnce(mockResponse);
const mockRequest: IEsSearchRequest = {

View file

@ -194,18 +194,18 @@ export class SearchInterceptor {
// The timeout error is shown any time a request times out, or once per session, if the request is part of a session.
this.showTimeoutError(err, options?.sessionId);
return err;
} else if (e instanceof AbortError || e instanceof BfetchRequestError) {
}
if (e instanceof AbortError || e instanceof BfetchRequestError) {
// In the case an application initiated abort, throw the existing AbortError, same with BfetchRequestErrors
return e;
} else if (isEsError(e)) {
if (isPainlessError(e)) {
return new PainlessError(e, options?.indexPattern);
} else {
return new EsError(e);
}
} else {
return e instanceof Error ? e : new Error(e.message);
}
if (isEsError(e)) {
return isPainlessError(e) ? new PainlessError(e, options?.indexPattern) : new EsError(e);
}
return e instanceof Error ? e : new Error(e.message);
}
private getSerializableOptions(options?: ISearchOptions) {

View file

@ -0,0 +1,60 @@
/*
* 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 { errors } from '@elastic/elasticsearch';
import { KibanaResponseFactory } from '@kbn/core/server';
import { KbnError } from '@kbn/kibana-utils-plugin/common';
// Why not use just use kibana-utils-plugin KbnServerError and reportServerError?
//
// Search errors need to surface additional information
// such as rawResponse and sanitized requestParams.
// KbnServerError and reportServerError are used widely throughtout Kibana.
// KbnSearchError and reportSearchError exist to avoid polluting
// 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>) {
super(message);
this.errBody = errBody;
}
}
/**
* Formats any error thrown into a standardized `KbnSearchError`.
* @param e `Error` or `ElasticsearchClientError`
* @returns `KbnSearchError`
*/
export function getKbnSearchError(e: Error) {
if (e instanceof KbnSearchError) return e;
return new KbnSearchError(
e.message ?? 'Unknown error',
e instanceof errors.ResponseError ? e.statusCode! : 500,
e instanceof errors.ResponseError ? e.body : undefined
);
}
/**
*
* @param res Formats a `KbnSearchError` into a server error response
* @param err
*/
export function reportSearchError(res: KibanaResponseFactory, err: KbnSearchError) {
return res.customError({
statusCode: err.statusCode ?? 500,
body: {
message: err.message,
attributes: err.errBody
? {
error: err.errBody.error,
rawResponse: err.errBody.response,
}
: undefined,
},
});
}

View file

@ -48,7 +48,12 @@ export function registerBsearchRoute(
throw {
message: err.message,
statusCode: err.statusCode,
attributes: err.errBody?.error,
attributes: err.errBody
? {
error: err.errBody.error,
rawResponse: err.errBody.response,
}
: undefined,
};
})
)

View file

@ -14,13 +14,13 @@ import { registerSearchRoute } from './search';
import { DataPluginStart } from '../../plugin';
import * as searchPhaseException from '../../../common/search/test_data/search_phase_execution_exception.json';
import * as indexNotFoundException from '../../../common/search/test_data/index_not_found_exception.json';
import { KbnServerError } from '@kbn/kibana-utils-plugin/server';
import { KbnSearchError } from '../report_search_error';
describe('Search service', () => {
let mockCoreSetup: MockedKeys<CoreSetup<{}, DataPluginStart>>;
function mockEsError(message: string, statusCode: number, attributes?: Record<string, any>) {
return new KbnServerError(message, statusCode, attributes);
function mockEsError(message: string, statusCode: number, errBody?: Record<string, any>) {
return new KbnSearchError(message, statusCode, errBody);
}
async function runMockSearch(mockContext: any, mockRequest: any, mockResponse: any) {
@ -112,7 +112,10 @@ describe('Search service', () => {
const error: any = mockResponse.customError.mock.calls[0][0];
expect(error.statusCode).toBe(400);
expect(error.body.message).toBe('search_phase_execution_exception');
expect(error.body.attributes).toBe(searchPhaseException.error);
expect(error.body.attributes).toEqual({
error: searchPhaseException.error,
rawResponse: undefined,
});
});
it('handler returns an error response if the search throws an index not found error', async () => {
@ -138,7 +141,10 @@ describe('Search service', () => {
const error: any = mockResponse.customError.mock.calls[0][0];
expect(error.statusCode).toBe(404);
expect(error.body.message).toBe('index_not_found_exception');
expect(error.body.attributes).toBe(indexNotFoundException.error);
expect(error.body.attributes).toEqual({
error: indexNotFoundException.error,
rawResponse: undefined,
});
});
it('handler returns an error response if the search throws a general error', async () => {

View file

@ -9,6 +9,7 @@
import { first } from 'rxjs/operators';
import { schema } from '@kbn/config-schema';
import { reportServerError } from '@kbn/kibana-utils-plugin/server';
import { reportSearchError } from '../report_search_error';
import { getRequestAbortedSignal } from '../../lib';
import type { DataPluginRouter } from '../types';
@ -71,7 +72,7 @@ export function registerSearchRoute(router: DataPluginRouter): void {
return res.ok({ body: response });
} catch (err) {
return reportServerError(res, err);
return reportSearchError(res, err);
}
}
);

View file

@ -14,7 +14,7 @@ import { SearchStrategyDependencies } from '../../types';
import * as indexNotFoundException from '../../../../common/search/test_data/index_not_found_exception.json';
import { errors } from '@elastic/elasticsearch';
import { KbnServerError } from '@kbn/kibana-utils-plugin/server';
import { KbnSearchError } from '../../report_search_error';
import { firstValueFrom } from 'rxjs';
describe('ES search strategy', () => {
@ -150,7 +150,7 @@ describe('ES search strategy', () => {
.toPromise();
} catch (e) {
expect(esClient.search).toBeCalled();
expect(e).toBeInstanceOf(KbnServerError);
expect(e).toBeInstanceOf(KbnSearchError);
expect(e.statusCode).toBe(404);
expect(e.message).toBe(errResponse.message);
expect(e.errBody).toBe(indexNotFoundException);
@ -167,7 +167,7 @@ describe('ES search strategy', () => {
.toPromise();
} catch (e) {
expect(esClient.search).toBeCalled();
expect(e).toBeInstanceOf(KbnServerError);
expect(e).toBeInstanceOf(KbnSearchError);
expect(e.statusCode).toBe(500);
expect(e.message).toBe(errResponse.message);
expect(e.errBody).toBe(undefined);
@ -184,14 +184,14 @@ describe('ES search strategy', () => {
.toPromise();
} catch (e) {
expect(esClient.search).toBeCalled();
expect(e).toBeInstanceOf(KbnServerError);
expect(e).toBeInstanceOf(KbnSearchError);
expect(e.statusCode).toBe(500);
expect(e.message).toBe(errResponse.message);
expect(e.errBody).toBe(undefined);
}
});
it('throws KbnServerError for unknown index type', async () => {
it('throws KbnSearchError for unknown index type', async () => {
const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' };
try {
@ -200,7 +200,7 @@ describe('ES search strategy', () => {
.toPromise();
} catch (e) {
expect(esClient.search).not.toBeCalled();
expect(e).toBeInstanceOf(KbnServerError);
expect(e).toBeInstanceOf(KbnSearchError);
expect(e.message).toBe('Unsupported index pattern type banana');
expect(e.statusCode).toBe(400);
expect(e.errBody).toBe(undefined);

View file

@ -9,7 +9,7 @@
import { firstValueFrom, from, Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import type { Logger, SharedGlobalConfig } from '@kbn/core/server';
import { getKbnServerError, KbnServerError } from '@kbn/kibana-utils-plugin/server';
import { getKbnSearchError, KbnSearchError } from '../../report_search_error';
import type { ISearchStrategy } from '../../types';
import type { SearchUsage } from '../../collectors/search';
import { getDefaultSearchParams, getShardTimeout } from './request_utils';
@ -25,14 +25,14 @@ export const esSearchStrategyProvider = (
* @param request
* @param options
* @param deps
* @throws `KbnServerError`
* @throws `KbnSearchError`
* @returns `Observable<IEsSearchResponse<any>>`
*/
search: (request, { abortSignal, transport, ...options }, { esClient, uiSettingsClient }) => {
// Only default index pattern type is supported here.
// See ese for other type support.
if (request.indexType) {
throw new KbnServerError(`Unsupported index pattern type ${request.indexType}`, 400);
throw new KbnSearchError(`Unsupported index pattern type ${request.indexType}`, 400);
}
const isPit = request.params?.body?.pit != null;
@ -57,7 +57,7 @@ export const esSearchStrategyProvider = (
const response = shimHitsTotal(body, options);
return toKibanaSearchResponse(response);
} catch (e) {
throw getKbnServerError(e);
throw getKbnSearchError(e);
}
};

View file

@ -8,6 +8,7 @@
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { KbnServerError } from '@kbn/kibana-utils-plugin/server';
import { KbnSearchError } from '../../report_search_error';
import { errors } from '@elastic/elasticsearch';
import * as indexNotFoundException from '../../../../common/search/test_data/index_not_found_exception.json';
import * as xContentParseException from '../../../../common/search/test_data/x_content_parse_exception.json';
@ -456,14 +457,14 @@ describe('ES search strategy', () => {
mockLogger
);
let err: KbnServerError | undefined;
let err: KbnSearchError | undefined;
try {
await esSearch.search({ params }, {}, mockDeps).toPromise();
} catch (e) {
err = e;
}
expect(mockSubmitCaller).toBeCalled();
expect(err).toBeInstanceOf(KbnServerError);
expect(err).toBeInstanceOf(KbnSearchError);
expect(err?.statusCode).toBe(404);
expect(err?.message).toBe(errResponse.message);
expect(err?.errBody).toBe(indexNotFoundException);
@ -481,14 +482,14 @@ describe('ES search strategy', () => {
mockLogger
);
let err: KbnServerError | undefined;
let err: KbnSearchError | undefined;
try {
await esSearch.search({ params }, {}, mockDeps).toPromise();
} catch (e) {
err = e;
}
expect(mockSubmitCaller).toBeCalled();
expect(err).toBeInstanceOf(KbnServerError);
expect(err).toBeInstanceOf(KbnSearchError);
expect(err?.statusCode).toBe(500);
expect(err?.message).toBe(errResponse.message);
expect(err?.errBody).toBe(undefined);

View file

@ -11,7 +11,8 @@ 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 } from '@kbn/kibana-utils-plugin/server';
import { getKbnSearchError, KbnSearchError } from '../../report_search_error';
import type { ISearchStrategy, SearchStrategyDependencies } from '../../types';
import type {
IAsyncSearchOptions,
@ -94,7 +95,7 @@ export const enhancedEsSearchStrategyProvider = (
tap((response) => (id = response.id)),
tap(searchUsageObserver(logger, usage)),
catchError((e) => {
throw getKbnServerError(e);
throw getKbnSearchError(e);
})
);
}
@ -136,7 +137,7 @@ export const enhancedEsSearchStrategyProvider = (
...getTotalLoaded(response),
};
} catch (e) {
throw getKbnServerError(e);
throw getKbnSearchError(e);
}
}
@ -146,12 +147,12 @@ export const enhancedEsSearchStrategyProvider = (
* @param options
* @param deps `SearchStrategyDependencies`
* @returns `Observable<IEsSearchResponse<any>>`
* @throws `KbnServerError`
* @throws `KbnSearchError`
*/
search: (request, options: IAsyncSearchOptions, deps) => {
logger.debug(`search ${JSON.stringify(request.params) || request.id}`);
if (request.indexType && request.indexType !== 'rollup') {
throw new KbnServerError('Unknown indexType', 400);
throw new KbnSearchError('Unknown indexType', 400);
}
if (request.indexType === undefined || !deps.rollupsEnabled) {

View file

@ -8,7 +8,7 @@
import { from } from 'rxjs';
import type { Logger } from '@kbn/core/server';
import { getKbnServerError, KbnServerError } from '@kbn/kibana-utils-plugin/server';
import { getKbnSearchError, KbnSearchError } from '../../report_search_error';
import type { ISearchStrategy } from '../../types';
const ES_TIMEOUT_IN_MS = 120000;
@ -21,7 +21,7 @@ export const esqlSearchStrategyProvider = (
* @param request
* @param options
* @param deps
* @throws `KbnServerError`
* @throws `KbnSearchError`
* @returns `Observable<IEsSearchResponse<any>>`
*/
search: (request, { abortSignal, ...options }, { esClient, uiSettingsClient }) => {
@ -39,7 +39,7 @@ export const esqlSearchStrategyProvider = (
// Only default index pattern type is supported here.
// See ese for other type support.
if (request.indexType) {
throw new KbnServerError(`Unsupported index pattern type ${request.indexType}`, 400);
throw new KbnSearchError(`Unsupported index pattern type ${request.indexType}`, 400);
}
const search = async () => {
@ -67,7 +67,7 @@ export const esqlSearchStrategyProvider = (
warning: headers?.warning,
};
} catch (e) {
throw getKbnServerError(e);
throw getKbnSearchError(e);
}
};

View file

@ -7,7 +7,7 @@
*/
import { merge } from 'lodash';
import { KbnServerError } from '@kbn/kibana-utils-plugin/server';
import { KbnSearchError } from '../../report_search_error';
import { errors } from '@elastic/elasticsearch';
import * as indexNotFoundException from '../../../../common/search/test_data/index_not_found_exception.json';
import { SearchStrategyDependencies } from '../../types';
@ -221,14 +221,14 @@ describe('SQL search strategy', () => {
};
const esSearch = await sqlSearchStrategyProvider(mockSearchConfig, mockLogger);
let err: KbnServerError | undefined;
let err: KbnSearchError | undefined;
try {
await esSearch.search({ params }, {}, mockDeps).toPromise();
} catch (e) {
err = e;
}
expect(mockSqlQuery).toBeCalled();
expect(err).toBeInstanceOf(KbnServerError);
expect(err).toBeInstanceOf(KbnSearchError);
expect(err?.statusCode).toBe(404);
expect(err?.message).toBe(errResponse.message);
expect(err?.errBody).toBe(indexNotFoundException);
@ -245,14 +245,14 @@ describe('SQL search strategy', () => {
};
const esSearch = await sqlSearchStrategyProvider(mockSearchConfig, mockLogger);
let err: KbnServerError | undefined;
let err: KbnSearchError | undefined;
try {
await esSearch.search({ params }, {}, mockDeps).toPromise();
} catch (e) {
err = e;
}
expect(mockSqlQuery).toBeCalled();
expect(err).toBeInstanceOf(KbnServerError);
expect(err).toBeInstanceOf(KbnSearchError);
expect(err?.statusCode).toBe(500);
expect(err?.message).toBe(errResponse.message);
expect(err?.errBody).toBe(undefined);

View file

@ -11,6 +11,7 @@ import type { IScopedClusterClient, Logger } from '@kbn/core/server';
import { catchError, tap } from 'rxjs/operators';
import { SqlQueryResponse } from '@elastic/elasticsearch/lib/api/types';
import { getKbnServerError } from '@kbn/kibana-utils-plugin/server';
import { getKbnSearchError } from '../../report_search_error';
import type { ISearchStrategy, SearchStrategyDependencies } from '../../types';
import type {
IAsyncSearchOptions,
@ -94,7 +95,7 @@ export const sqlSearchStrategyProvider = (
}).pipe(
tap((response) => (id = response.id)),
catchError((e) => {
throw getKbnServerError(e);
throw getKbnSearchError(e);
})
);
}
@ -105,7 +106,7 @@ export const sqlSearchStrategyProvider = (
* @param options
* @param deps `SearchStrategyDependencies`
* @returns `Observable<IEsSearchResponse<any>>`
* @throws `KbnServerError`
* @throws `KbnSearchError`
*/
search: (request, options: IAsyncSearchOptions, deps) => {
logger.debug(`sql search: search request=${JSON.stringify(request)}`);

View file

@ -20,7 +20,8 @@ export const verifyErrorResponse = (
}
if (shouldHaveAttrs) {
expect(r).to.have.property('attributes');
expect(r.attributes).to.have.property('root_cause');
expect(r.attributes).to.have.property('error');
expect(r.attributes.error).to.have.property('root_cause');
} else {
expect(r).not.to.have.property('attributes');
}

View file

@ -21,6 +21,45 @@ const runtimeFieldError = {
message: 'status_exception',
statusCode: 400,
attributes: {
error: {
type: 'status_exception',
reason: 'error while executing search',
caused_by: {
type: 'search_phase_execution_exception',
reason: 'all shards failed',
phase: 'query',
grouped: true,
failed_shards: [
{
shard: 0,
index: 'indexpattern_source',
node: 'jtqB1-UhQluyjeXIpQFqAA',
reason: {
type: 'script_exception',
reason: 'runtime error',
script_stack: [
'java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:68)',
'java.base/java.lang.Integer.parseInt(Integer.java:652)',
'java.base/java.lang.Integer.parseInt(Integer.java:770)',
"emit(Integer.parseInt('hello'))",
' ^---- HERE',
],
script: "emit(Integer.parseInt('hello'))",
lang: 'painless',
position: { offset: 12, start: 0, end: 31 },
caused_by: {
type: 'number_format_exception',
reason: 'For input string: "hello"',
},
},
},
],
},
},
},
},
attributes: {
error: {
type: 'status_exception',
reason: 'error while executing search',
caused_by: {
@ -53,38 +92,6 @@ const runtimeFieldError = {
},
},
},
attributes: {
type: 'status_exception',
reason: 'error while executing search',
caused_by: {
type: 'search_phase_execution_exception',
reason: 'all shards failed',
phase: 'query',
grouped: true,
failed_shards: [
{
shard: 0,
index: 'indexpattern_source',
node: 'jtqB1-UhQluyjeXIpQFqAA',
reason: {
type: 'script_exception',
reason: 'runtime error',
script_stack: [
'java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:68)',
'java.base/java.lang.Integer.parseInt(Integer.java:652)',
'java.base/java.lang.Integer.parseInt(Integer.java:770)',
"emit(Integer.parseInt('hello'))",
' ^---- HERE',
],
script: "emit(Integer.parseInt('hello'))",
lang: 'painless',
position: { offset: 12, start: 0, end: 31 },
caused_by: { type: 'number_format_exception', reason: 'For input string: "hello"' },
},
},
],
},
},
},
};
@ -99,6 +106,31 @@ const scriptedFieldError = {
message: 'status_exception',
statusCode: 500,
attributes: {
error: {
type: 'status_exception',
reason: 'error while executing search',
caused_by: {
type: 'search_phase_execution_exception',
reason: 'all shards failed',
phase: 'query',
grouped: true,
failed_shards: [
{
shard: 0,
index: 'indexpattern_source',
node: 'jtqB1-UhQluyjeXIpQFqAA',
reason: {
type: 'aggregation_execution_exception',
reason: 'Unsupported script value [hello], expected a number, date, or boolean',
},
},
],
},
},
},
},
attributes: {
error: {
type: 'status_exception',
reason: 'error while executing search',
caused_by: {
@ -120,27 +152,6 @@ const scriptedFieldError = {
},
},
},
attributes: {
type: 'status_exception',
reason: 'error while executing search',
caused_by: {
type: 'search_phase_execution_exception',
reason: 'all shards failed',
phase: 'query',
grouped: true,
failed_shards: [
{
shard: 0,
index: 'indexpattern_source',
node: 'jtqB1-UhQluyjeXIpQFqAA',
reason: {
type: 'aggregation_execution_exception',
reason: 'Unsupported script value [hello], expected a number, date, or boolean',
},
},
],
},
},
},
};
@ -174,41 +185,7 @@ const tsdbCounterUsedWithWrongOperationError = {
name: 'Error',
original: {
attributes: {
type: 'status_exception',
reason: 'error while executing search',
caused_by: {
type: 'search_phase_execution_exception',
reason: 'all shards failed',
phase: 'query',
grouped: true,
failed_shards: [
{
shard: 0,
index: 'tsdb_index',
reason: {
type: 'illegal_argument_exception',
reason:
'Field [bytes_counter] of type [long][counter] is not supported for aggregation [sum]',
},
},
],
caused_by: {
type: 'illegal_argument_exception',
reason:
'Field [bytes_counter] of type [long][counter] is not supported for aggregation [sum]',
caused_by: {
type: 'illegal_argument_exception',
reason:
'Field [bytes_counter] of type [long][counter] is not supported for aggregation [sum]',
},
},
},
},
err: {
message:
'status_exception\n\tCaused by:\n\t\tsearch_phase_execution_exception: all shards failed',
statusCode: 400,
attributes: {
error: {
type: 'status_exception',
reason: 'error while executing search',
caused_by: {
@ -240,6 +217,44 @@ const tsdbCounterUsedWithWrongOperationError = {
},
},
},
err: {
message:
'status_exception\n\tCaused by:\n\t\tsearch_phase_execution_exception: all shards failed',
statusCode: 400,
attributes: {
error: {
type: 'status_exception',
reason: 'error while executing search',
caused_by: {
type: 'search_phase_execution_exception',
reason: 'all shards failed',
phase: 'query',
grouped: true,
failed_shards: [
{
shard: 0,
index: 'tsdb_index',
reason: {
type: 'illegal_argument_exception',
reason:
'Field [bytes_counter] of type [long][counter] is not supported for aggregation [sum]',
},
},
],
caused_by: {
type: 'illegal_argument_exception',
reason:
'Field [bytes_counter] of type [long][counter] is not supported for aggregation [sum]',
caused_by: {
type: 'illegal_argument_exception',
reason:
'Field [bytes_counter] of type [long][counter] is not supported for aggregation [sum]',
},
},
},
},
},
},
},
};

View file

@ -7,18 +7,16 @@
import { i18n } from '@kbn/i18n';
import { isEqual, uniqWith } from 'lodash';
import { estypes } from '@elastic/elasticsearch';
import { ExpressionRenderError } from '@kbn/expressions-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import { isEsError } from '@kbn/data-plugin/public';
import type { IEsError, Reason } from '@kbn/data-plugin/public';
import React from 'react';
import { EuiLink } from '@elastic/eui';
import { RemovableUserMessage } from '../types';
type ErrorCause = Required<IEsError>['attributes'];
interface RequestError extends Error {
body?: { attributes?: { error: { caused_by: ErrorCause } } };
body?: { attributes?: { error: { caused_by: estypes.ErrorCause } } };
}
interface ReasonDescription {
@ -62,7 +60,7 @@ function getNestedErrorClauseWithContext({
caused_by: causedBy,
lang,
script,
}: Reason): ReasonDescription[] {
}: estypes.ErrorCause): ReasonDescription[] {
if (!causedBy) {
// scripted fields error has changed with no particular hint about painless in it,
// so it tries to lookup in the message for the script word
@ -83,12 +81,12 @@ function getNestedErrorClauseWithContext({
return [{ ...payload, context: { type, reason } }];
}
function getNestedErrorClause(e: ErrorCause | Reason): ReasonDescription[] {
function getNestedErrorClause(e: estypes.ErrorCause): ReasonDescription[] {
const { type, reason = '', caused_by: causedBy } = e;
// Painless scripts errors are nested within the failed_shards property
if ('failed_shards' in e) {
if (e.failed_shards) {
return e.failed_shards.flatMap((shardCause) =>
return (e.failed_shards as estypes.ShardFailure[]).flatMap((shardCause) =>
getNestedErrorClauseWithContext(shardCause.reason)
);
}
@ -101,13 +99,15 @@ function getNestedErrorClause(e: ErrorCause | Reason): ReasonDescription[] {
function getErrorSources(e: Error) {
if (isRequestError(e)) {
return getNestedErrorClause(e.body!.attributes!.error as ErrorCause);
return getNestedErrorClause(e.body!.attributes!.error as estypes.ErrorCause);
}
if (isEsError(e)) {
if (e.attributes?.reason) {
return getNestedErrorClause(e.attributes);
if (e.attributes?.error?.reason) {
return getNestedErrorClause(e.attributes.error);
}
if (e.attributes?.error?.caused_by) {
return getNestedErrorClause(e.attributes.error.caused_by);
}
return getNestedErrorClause(e.attributes?.caused_by as ErrorCause);
}
return [];
}

View file

@ -234,7 +234,7 @@ describe('useAppToasts', () => {
it('prefers the attributes reason if we have it for the message', async () => {
const error: IEsError = {
attributes: { type: 'some type', reason: 'message we want' },
attributes: { error: { type: 'some type', reason: 'message we want' } },
statusCode: 200,
message: 'message we do not want',
};
@ -244,11 +244,11 @@ describe('useAppToasts', () => {
it('works with an EsError, by using the inner error and not outer error if available', async () => {
const error: MaybeESError = {
attributes: { type: 'some type', reason: 'message we want' },
attributes: { error: { type: 'some type', reason: 'message we want' } },
statusCode: 400,
err: {
statusCode: 200,
attributes: { reason: 'attribute message we do not want' },
attributes: { error: { reason: 'attribute message we do not want' } },
},
message: 'main message we do not want',
};
@ -258,11 +258,11 @@ describe('useAppToasts', () => {
it('creates a stack trace of a EsError and not the outer object', async () => {
const error: MaybeESError = {
attributes: { type: 'some type', reason: 'message we do not want' },
attributes: { error: { type: 'some type', reason: 'message we do not want' } },
statusCode: 400,
err: {
statusCode: 200,
attributes: { reason: 'attribute message we do want' },
attributes: { error: { reason: 'attribute message we do want' } },
},
message: 'main message we do not want',
};
@ -270,7 +270,7 @@ describe('useAppToasts', () => {
const parsedStack = JSON.parse(result.stack ?? '');
expect(parsedStack).toEqual({
statusCode: 200,
attributes: { reason: 'attribute message we do want' },
attributes: { error: { reason: 'attribute message we do want' } },
});
});
});

View file

@ -98,8 +98,10 @@ export const esErrorToErrorStack = (error: IEsError & MaybeESError): Error => {
? `(${error.statusCode})`
: '';
const stringifiedError = getStringifiedStack(maybeUnWrapped);
const adaptedError = new Error(`${error.attributes?.reason ?? error.message} ${statusCode}`);
adaptedError.name = error.attributes?.reason ?? error.message;
const adaptedError = new Error(
`${error.attributes?.error?.reason ?? error.message} ${statusCode}`
);
adaptedError.name = error.attributes?.error?.reason ?? error.message;
if (stringifiedError != null) {
adaptedError.stack = stringifiedError;
}

View file

@ -93,8 +93,10 @@ export const esErrorToErrorStack = (error: IEsError & MaybeESError): Error => {
? `(${error.statusCode})`
: '';
const stringifiedError = getStringifiedStack(maybeUnWrapped);
const adaptedError = new Error(`${error.attributes?.reason ?? error.message} ${statusCode}`);
adaptedError.name = error.attributes?.reason ?? error.message;
const adaptedError = new Error(
`${error.attributes?.error?.reason ?? error.message} ${statusCode}`
);
adaptedError.name = error.attributes?.error?.reason ?? error.message;
if (stringifiedError != null) {
adaptedError.stack = stringifiedError;
}

View file

@ -411,7 +411,11 @@ export default function ({ getService }: FtrProviderContext) {
.set('kbn-xsrf', 'foo')
.send()
.expect(400);
verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true);
expect(resp.body.statusCode).to.be(400);
expect(resp.body.message).to.include.string('illegal_argument_exception');
expect(resp.body).to.have.property('attributes');
expect(resp.body.attributes).to.have.property('root_cause');
});
it('should delete an in-progress search', async function () {

View file

@ -19,7 +19,8 @@ export const verifyErrorResponse = (
}
if (shouldHaveAttrs) {
expect(r).to.have.property('attributes');
expect(r.attributes).to.have.property('root_cause');
expect(r.attributes).to.have.property('error');
expect(r.attributes.error).to.have.property('root_cause');
} else {
expect(r).not.to.have.property('attributes');
}

View file

@ -380,7 +380,10 @@ export default function ({ getService }: FtrProviderContext) {
.set('kbn-xsrf', 'foo')
.send()
.expect(400);
verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true);
expect(resp.body.statusCode).to.be(400);
expect(resp.body.message).to.include.string('illegal_argument_exception');
expect(resp.body).to.have.property('attributes');
expect(resp.body.attributes).to.have.property('root_cause');
});
it('should delete an in-progress search', async function () {