diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/esql_response_error.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/esql_response_error.test.ts new file mode 100644 index 0000000000000..238d5b1358cc2 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/esql_response_error.test.ts @@ -0,0 +1,141 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + type EsqlResponseErrorCause, + EsqlResponseError, + extractEsqlEmbeddedError, + extractEsqlResponseErrorCause, + formatErrorCause, +} from './esql_response_error'; + +describe('formatErrorCause', () => { + it('returns message from error type and reason', () => { + expect( + formatErrorCause({ + type: 'remote_transport_exception', + reason: 'ccs query failed', + }) + ).toBe('remote_transport_exception: ccs query failed'); + }); + + it('returns message from root_cause when type and reason are missing', () => { + expect( + formatErrorCause({ + root_cause: [{ type: 'index_not_found_exception', reason: 'no such index [metrics-*]' }], + }) + ).toBe('index_not_found_exception: no such index [metrics-*]'); + }); + + it('returns generic message for empty error object', () => { + expect(formatErrorCause({})).toBe('Elasticsearch returned an error'); + }); +}); + +describe('extractEsqlResponseErrorCause', () => { + it('extracts error cause from response error object', () => { + expect( + extractEsqlResponseErrorCause({ + error: { type: 'remote_transport_exception', reason: 'ccs query failed' }, + }) + ).toEqual({ + type: 'remote_transport_exception', + reason: 'ccs query failed', + }); + }); + + it('returns undefined when response has no error object', () => { + expect(extractEsqlResponseErrorCause({ columns: [], values: [] })).toBeUndefined(); + }); + + it('returns undefined when error is null', () => { + expect(extractEsqlResponseErrorCause({ error: null })).toBeUndefined(); + }); + + it('returns undefined when error is not an object', () => { + expect(extractEsqlResponseErrorCause({ error: 'not-an-object' })).toBeUndefined(); + }); +}); + +describe('extractEsqlEmbeddedError', () => { + it('returns cause and top-level status when present', () => { + expect( + extractEsqlEmbeddedError({ + error: { type: 'remote_transport_exception', reason: 'ccs failed' }, + status: 400, + }) + ).toEqual({ + cause: { type: 'remote_transport_exception', reason: 'ccs failed' }, + status: 400, + }); + }); + + it('omits status when absent or not a finite number', () => { + expect( + extractEsqlEmbeddedError({ + error: { type: 'x', reason: 'y' }, + }) + ).toEqual({ cause: { type: 'x', reason: 'y' } }); + + expect( + extractEsqlEmbeddedError({ + error: { type: 'x', reason: 'y' }, + status: '400', + } as object) + ).toEqual({ cause: { type: 'x', reason: 'y' } }); + + expect( + extractEsqlEmbeddedError({ + error: { type: 'x', reason: 'y' }, + status: Number.NaN, + }) + ).toEqual({ cause: { type: 'x', reason: 'y' } }); + }); +}); + +describe('EsqlResponseError', () => { + it('extends Error with name, message, and copied fields', () => { + const err = new EsqlResponseError({ + type: 'illegal_argument_exception', + reason: 'bad request', + }); + + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(EsqlResponseError); + expect(err.name).toBe('EsqlResponseError'); + expect(err.message).toBe('illegal_argument_exception: bad request'); + expect(err.type).toBe('illegal_argument_exception'); + expect(err.reason).toBe('bad request'); + expect(err.rootCause).toBeUndefined(); + }); + + it('copies root_cause to rootCause', () => { + const rootCause = [{ type: 'shard_failure', reason: 'failed on node-1' }]; + const err = new EsqlResponseError({ root_cause: rootCause }); + + expect(err.rootCause).toEqual(rootCause); + }); + + it('normalizes null reason to undefined (Elasticsearch types allow null)', () => { + const cause = { type: 'x', reason: null } as EsqlResponseErrorCause; + const err = new EsqlResponseError(cause); + + expect(err.reason).toBeUndefined(); + expect(err.message).toBe('x'); + }); + + it('stores optional Elasticsearch payload status', () => { + const err = new EsqlResponseError( + { type: 'remote_transport_exception', reason: 'ccs failed' }, + { status: 400 } + ); + + expect(err.status).toBe(400); + }); +}); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/esql_response_error.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/esql_response_error.ts new file mode 100644 index 0000000000000..c5c4e1340273f --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/esql_response_error.ts @@ -0,0 +1,72 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +// TODO https://github.com/elastic/kibana/issues/260667 +import type { estypes } from '@elastic/elasticsearch'; + +export type EsqlResponseErrorCause = Partial; + +export const formatErrorCause = (errorCause: EsqlResponseErrorCause): string => { + const head = [errorCause.type, errorCause.reason] + .filter((value): value is string => Boolean(value?.trim())) + .join(': '); + if (head) { + return head; + } + + const rootCause = errorCause.root_cause?.[0]; + const fromRootCause = [rootCause?.type, rootCause?.reason] + .filter((value): value is string => Boolean(value?.trim())) + .join(': '); + return fromRootCause || 'Elasticsearch returned an error'; +}; + +export interface EsqlEmbeddedError { + readonly cause: EsqlResponseErrorCause; + readonly status?: number; +} + +/** + * When Elasticsearch returns a body like `{ error: { type, reason }, status: 400 }`, + * returns the error cause and optional status from the payload. + */ +export const extractEsqlEmbeddedError = (response: object): EsqlEmbeddedError | undefined => { + if (!('error' in response) || response.error == null || typeof response.error !== 'object') { + return undefined; + } + + const body = response as { status?: unknown }; + const status = + typeof body.status === 'number' && Number.isFinite(body.status) ? body.status : undefined; + + return { + cause: response.error as EsqlResponseErrorCause, + ...(status !== undefined ? { status } : {}), + }; +}; + +export const extractEsqlResponseErrorCause = ( + response: object +): EsqlResponseErrorCause | undefined => extractEsqlEmbeddedError(response)?.cause; + +export class EsqlResponseError extends Error { + public readonly type?: string; + public readonly reason?: string; + public readonly rootCause?: EsqlResponseErrorCause[]; + public readonly status?: number; + + constructor(errorCause: EsqlResponseErrorCause, options?: { status?: number }) { + super(formatErrorCause(errorCause)); + this.name = 'EsqlResponseError'; + this.type = errorCause.type; + this.reason = errorCause.reason ?? undefined; + this.rootCause = errorCause.root_cause; + this.status = options?.status; + } +} diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/execute_esql_query.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/execute_esql_query.test.ts index 1d3e552c8b4b2..38a8af3810989 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/execute_esql_query.test.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/execute_esql_query.test.ts @@ -17,7 +17,8 @@ import { MetricsExecutionContextAction, MetricsExecutionContextName, } from './execution_context_enums'; -import { executeEsqlQuery } from './execute_esql_query'; +import { EsqlResponseError } from './esql_response_error'; +import { executeEsqlQuery, fetchEsqlResponseOrThrow } from './execute_esql_query'; import { getMetricsExecutionContext } from './execution_context'; jest.mock('@kbn/esql-utils', () => ({ @@ -177,4 +178,79 @@ describe('executeEsqlQuery', () => { }) ); }); + + it('throws EsqlResponseError when response contains an Elasticsearch error object', async () => { + mockGetESQLResults.mockResolvedValueOnce({ + response: { + error: { + type: 'remote_transport_exception', + reason: 'ccs query failed', + }, + }, + params: { query: '' }, + } as unknown as Awaited>); + + await expect( + executeEsqlQuery({ + esqlQuery: 'TS metrics-* | METRICS_INFO', + search: mockSearch, + dataView: dataViewWithAtTimefieldMock, + uiSettings: mockUiSettings, + }) + ).rejects.toThrow(EsqlResponseError); + }); + + it('sets status on EsqlResponseError when response includes top-level status', async () => { + mockGetESQLResults.mockResolvedValueOnce({ + response: { + error: { + type: 'remote_transport_exception', + reason: 'ccs query failed', + }, + status: 400, + }, + params: { query: '' }, + } as unknown as Awaited>); + + await expect( + executeEsqlQuery({ + esqlQuery: 'TS metrics-* | METRICS_INFO', + search: mockSearch, + dataView: dataViewWithAtTimefieldMock, + uiSettings: mockUiSettings, + }) + ).rejects.toMatchObject({ status: 400 }); + }); +}); + +describe('fetchEsqlResponseOrThrow', () => { + it('throws EsqlResponseError for error responses', async () => { + mockGetESQLResults.mockResolvedValueOnce({ + response: { + error: { + type: 'illegal_argument_exception', + reason: 'bad request', + }, + }, + params: { query: '' }, + } as unknown as Awaited>); + + await expect( + fetchEsqlResponseOrThrow({} as Parameters[0]) + ).rejects.toThrow(EsqlResponseError); + }); + + it('passes through payload status on EsqlResponseError', async () => { + mockGetESQLResults.mockResolvedValueOnce({ + response: { + error: { type: 'illegal_argument_exception', reason: 'bad request' }, + status: 400, + }, + params: { query: '' }, + } as unknown as Awaited>); + + await expect( + fetchEsqlResponseOrThrow({} as Parameters[0]) + ).rejects.toMatchObject({ status: 400 }); + }); }); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/execute_esql_query.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/execute_esql_query.ts index 98be3b0dc93f9..61111af4600fa 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/execute_esql_query.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/execute_esql_query.ts @@ -19,6 +19,7 @@ import { MetricsExecutionContextAction, MetricsExecutionContextName, } from './execution_context_enums'; +import { EsqlResponseError, extractEsqlEmbeddedError } from './esql_response_error'; import { esqlResultToPlainObjects } from './esql_result_to_plain_objects'; import { getMetricsExecutionContext } from './execution_context'; @@ -33,8 +34,21 @@ export interface ExecuteEsqlParams { uiSettings: IUiSettingsClient; } +export const fetchEsqlResponseOrThrow = async ( + params: Parameters[0] +): Promise>['response']> => { + const { response } = await getESQLResults(params); + const embedded = extractEsqlEmbeddedError(response as object); + if (embedded) { + throw new EsqlResponseError(embedded.cause, { status: embedded.status }); + } + + return response; +}; + /** * Executes an ES|QL query using the data plugin's search service. + * Rejects when Elasticsearch returns a response body that contains an `error` object. */ export async function executeEsqlQuery>({ esqlQuery, @@ -57,7 +71,7 @@ export async function executeEsqlQuery(response); - - return plainObjects; + return esqlResultToPlainObjects(response); }