From 80e046fc6fb3bb6e0e1914ecfd91a09ffec5583b Mon Sep 17 00:00:00 2001 From: Jorge Oliveira Date: Fri, 27 Mar 2026 15:13:01 +0000 Subject: [PATCH 1/9] Add simple error handling for ESQL query responses on metrics info call --- .../metrics/utils/execute_esql_query.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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..5ccfd71ced2dd 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 @@ -33,6 +33,24 @@ export interface ExecuteEsqlParams { uiSettings: IUiSettingsClient; } +function getErrorMessageFromEsqlResponse(response: object): string | undefined { + if (!('error' in response) || response.error == null || typeof response.error !== 'object') { + return undefined; + } + const e = response.error as { + type?: string; + reason?: string; + root_cause?: Array<{ type?: string; reason?: string }>; + }; + const head = [e.type, e.reason].filter((x): x is string => Boolean(x?.trim())).join(': '); + if (head) { + return head; + } + const rc = e.root_cause?.[0]; + const fromRoot = [rc?.type, rc?.reason].filter((x): x is string => Boolean(x?.trim())).join(': '); + return fromRoot || 'Elasticsearch returned an error'; +} + /** * Executes an ES|QL query using the data plugin's search service. */ @@ -70,7 +88,10 @@ export async function executeEsqlQuery(response); + const errorMessage = getErrorMessageFromEsqlResponse(response as object); + if (errorMessage) { + throw new Error(errorMessage); + } - return plainObjects; + return esqlResultToPlainObjects(response); } From e341ef8a8e6c1734e9bf632e0ffd5f1b7ccc4938 Mon Sep 17 00:00:00 2001 From: Jorge Oliveira Date: Tue, 31 Mar 2026 10:30:30 +0100 Subject: [PATCH 2/9] Add unit tests for the new `getErrorMessageFromEsqlResponse` function --- .../metrics/utils/execute_esql_query.test.ts | 50 ++++++++++++++++++- .../metrics/utils/execute_esql_query.ts | 2 +- 2 files changed, 50 insertions(+), 2 deletions(-) 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..0dcc13eddd79c 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,7 @@ import { MetricsExecutionContextAction, MetricsExecutionContextName, } from './execution_context_enums'; -import { executeEsqlQuery } from './execute_esql_query'; +import { executeEsqlQuery, getErrorMessageFromEsqlResponse } from './execute_esql_query'; import { getMetricsExecutionContext } from './execution_context'; jest.mock('@kbn/esql-utils', () => ({ @@ -177,4 +177,52 @@ describe('executeEsqlQuery', () => { }) ); }); + + describe('getErrorMessageFromEsqlResponse', () => { + it('returns message from error type and reason', () => { + const result = getErrorMessageFromEsqlResponse({ + error: { type: 'remote_transport_exception', reason: 'ccs query failed' }, + }); + + expect(result).toBe('remote_transport_exception: ccs query failed'); + }); + + it('returns message from root_cause when type and reason are missing', () => { + const result = getErrorMessageFromEsqlResponse({ + error: { + root_cause: [{ type: 'index_not_found_exception', reason: 'no such index [metrics-*]' }], + }, + }); + + expect(result).toBe('index_not_found_exception: no such index [metrics-*]'); + }); + + it('returns generic message for empty error object', () => { + const result = getErrorMessageFromEsqlResponse({ + error: {}, + }); + + expect(result).toBe('Elasticsearch returned an error'); + }); + }); + + it('does not throw when response has no error object (happy path)', async () => { + await expect( + executeEsqlQuery({ + esqlQuery: 'TS metrics-* | METRICS_INFO', + search: mockSearch, + dataView: dataViewWithAtTimefieldMock, + uiSettings: mockUiSettings, + }) + ).resolves.toStrictEqual([ + { + metric_name: 'metric.name', + data_stream: 'metrics-stream-1', + unit: 'ms', + metric_type: 'counter', + field_type: 'gauge', + dimension_fields: 'host', + }, + ]); + }); }); 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 5ccfd71ced2dd..d1e8ba3b7ed8e 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 @@ -33,7 +33,7 @@ export interface ExecuteEsqlParams { uiSettings: IUiSettingsClient; } -function getErrorMessageFromEsqlResponse(response: object): string | undefined { +export function getErrorMessageFromEsqlResponse(response: object): string | undefined { if (!('error' in response) || response.error == null || typeof response.error !== 'object') { return undefined; } From 7fb6e2da221d3a7ed1fe16baabcd1c87ddfd4020 Mon Sep 17 00:00:00 2001 From: Jorge Oliveira Date: Tue, 31 Mar 2026 14:04:32 +0100 Subject: [PATCH 3/9] Add EsqlResponseError class and utility functions for error handling. --- .../metrics/utils/esql_response_error.ts | 40 +++++++ .../metrics/utils/execute_esql_query.test.ts | 110 +++++++++++++----- .../metrics/utils/execute_esql_query.ts | 35 +++--- 3 files changed, 134 insertions(+), 51 deletions(-) create mode 100644 src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/esql_response_error.ts 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..c6ef2c845a346 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/esql_response_error.ts @@ -0,0 +1,40 @@ +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 const extractEsqlResponseErrorCause = (response: object): EsqlResponseErrorCause | undefined => { + if (!('error' in response) || response.error == null || typeof response.error !== 'object') { + return undefined; + } + + return response.error as EsqlResponseErrorCause; +}; + +export class EsqlResponseError extends Error { + public readonly type?: string; + public readonly reason?: string; + public readonly rootCause?: EsqlResponseErrorCause[]; + + constructor(errorCause: EsqlResponseErrorCause) { + super(formatErrorCause(errorCause)); + this.name = 'EsqlResponseError'; + this.type = errorCause.type; + this.reason = errorCause.reason ?? undefined; + this.rootCause = errorCause.root_cause; + } +} 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 0dcc13eddd79c..7d4245c69f5fe 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, getErrorMessageFromEsqlResponse } from './execute_esql_query'; +import { EsqlResponseError, extractEsqlResponseErrorCause, formatErrorCause } from './esql_response_error'; +import { executeEsqlQuery, fetchEsqlResponseOrThrow } from './execute_esql_query'; import { getMetricsExecutionContext } from './execution_context'; jest.mock('@kbn/esql-utils', () => ({ @@ -178,34 +179,6 @@ describe('executeEsqlQuery', () => { ); }); - describe('getErrorMessageFromEsqlResponse', () => { - it('returns message from error type and reason', () => { - const result = getErrorMessageFromEsqlResponse({ - error: { type: 'remote_transport_exception', reason: 'ccs query failed' }, - }); - - expect(result).toBe('remote_transport_exception: ccs query failed'); - }); - - it('returns message from root_cause when type and reason are missing', () => { - const result = getErrorMessageFromEsqlResponse({ - error: { - root_cause: [{ type: 'index_not_found_exception', reason: 'no such index [metrics-*]' }], - }, - }); - - expect(result).toBe('index_not_found_exception: no such index [metrics-*]'); - }); - - it('returns generic message for empty error object', () => { - const result = getErrorMessageFromEsqlResponse({ - error: {}, - }); - - expect(result).toBe('Elasticsearch returned an error'); - }); - }); - it('does not throw when response has no error object (happy path)', async () => { await expect( executeEsqlQuery({ @@ -225,4 +198,83 @@ 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); + }); +}); + +describe('esql response error helpers', () => { + it('extracts error cause from response error object', () => { + const result = extractEsqlResponseErrorCause({ + error: { type: 'remote_transport_exception', reason: 'ccs query failed' }, + }); + + expect(result).toEqual({ + type: 'remote_transport_exception', + reason: 'ccs query failed', + }); + }); + + it('returns undefined when response has no error object', () => { + const result = extractEsqlResponseErrorCause({ columns: [], values: [] }); + expect(result).toBeUndefined(); + }); + + it('formats message from error type and reason', () => { + const result = formatErrorCause({ + type: 'remote_transport_exception', + reason: 'ccs query failed', + }); + + expect(result).toBe('remote_transport_exception: ccs query failed'); + }); + + it('formats message from root_cause when type and reason are missing', () => { + const result = formatErrorCause({ + root_cause: [{ type: 'index_not_found_exception', reason: 'no such index [metrics-*]' }], + }); + + expect(result).toBe('index_not_found_exception: no such index [metrics-*]'); + }); + + it('formats generic message for empty error object', () => { + const result = formatErrorCause({}); + expect(result).toBe('Elasticsearch returned an error'); + }); +}); + +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 + ); + }); }); 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 d1e8ba3b7ed8e..811e8512d0011 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, extractEsqlResponseErrorCause } from './esql_response_error'; import { esqlResultToPlainObjects } from './esql_result_to_plain_objects'; import { getMetricsExecutionContext } from './execution_context'; @@ -33,26 +34,21 @@ export interface ExecuteEsqlParams { uiSettings: IUiSettingsClient; } -export function getErrorMessageFromEsqlResponse(response: object): string | undefined { - if (!('error' in response) || response.error == null || typeof response.error !== 'object') { - return undefined; +export const fetchEsqlResponseOrThrow = async ( + params: Parameters[0] +): Promise>['response']> => { + const { response } = await getESQLResults(params); + const errorCause = extractEsqlResponseErrorCause(response); + if (errorCause) { + throw new EsqlResponseError(errorCause); } - const e = response.error as { - type?: string; - reason?: string; - root_cause?: Array<{ type?: string; reason?: string }>; - }; - const head = [e.type, e.reason].filter((x): x is string => Boolean(x?.trim())).join(': '); - if (head) { - return head; - } - const rc = e.root_cause?.[0]; - const fromRoot = [rc?.type, rc?.reason].filter((x): x is string => Boolean(x?.trim())).join(': '); - return fromRoot || 'Elasticsearch returned an error'; -} + + 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, @@ -75,7 +71,7 @@ export async function executeEsqlQuery(response); } From 4efea20bdbd570a20b3d0bc01ab5775fdf5729cf Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:54:19 +0000 Subject: [PATCH 4/9] Changes from node scripts/eslint_all_files --no-cache --fix --- .../metrics/utils/esql_response_error.ts | 13 ++++++++++++- .../metrics/utils/execute_esql_query.test.ts | 12 ++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) 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 index c6ef2c845a346..bf423474b2fa8 100644 --- 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 @@ -1,3 +1,12 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; export type EsqlResponseErrorCause = Partial; @@ -17,7 +26,9 @@ export const formatErrorCause = (errorCause: EsqlResponseErrorCause): string => return fromRootCause || 'Elasticsearch returned an error'; }; -export const extractEsqlResponseErrorCause = (response: object): EsqlResponseErrorCause | undefined => { +export const extractEsqlResponseErrorCause = ( + response: object +): EsqlResponseErrorCause | undefined => { if (!('error' in response) || response.error == null || typeof response.error !== 'object') { return undefined; } 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 7d4245c69f5fe..440d9d90967bc 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,11 @@ import { MetricsExecutionContextAction, MetricsExecutionContextName, } from './execution_context_enums'; -import { EsqlResponseError, extractEsqlResponseErrorCause, formatErrorCause } from './esql_response_error'; +import { + EsqlResponseError, + extractEsqlResponseErrorCause, + formatErrorCause, +} from './esql_response_error'; import { executeEsqlQuery, fetchEsqlResponseOrThrow } from './execute_esql_query'; import { getMetricsExecutionContext } from './execution_context'; @@ -273,8 +277,8 @@ describe('fetchEsqlResponseOrThrow', () => { params: { query: '' }, } as unknown as Awaited>); - await expect(fetchEsqlResponseOrThrow({} as Parameters[0])).rejects.toThrow( - EsqlResponseError - ); + await expect( + fetchEsqlResponseOrThrow({} as Parameters[0]) + ).rejects.toThrow(EsqlResponseError); }); }); From 4e87b717518cfc6d90a425086483eea360a635fc Mon Sep 17 00:00:00 2001 From: Jorge Oliveira Date: Wed, 1 Apr 2026 10:32:44 +0100 Subject: [PATCH 5/9] Move tests to appropriate place --- .../metrics/utils/esql_response_error.test.ts | 95 +++++++++++++++++++ .../metrics/utils/execute_esql_query.test.ts | 42 +------- 2 files changed, 96 insertions(+), 41 deletions(-) create mode 100644 src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/utils/esql_response_error.test.ts 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..b37150c6beaa0 --- /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,95 @@ +/* + * 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, + 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('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'); + }); +}); 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 7d4245c69f5fe..81e657bfd27a1 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,7 @@ import { MetricsExecutionContextAction, MetricsExecutionContextName, } from './execution_context_enums'; -import { EsqlResponseError, extractEsqlResponseErrorCause, formatErrorCause } from './esql_response_error'; +import { EsqlResponseError } from './esql_response_error'; import { executeEsqlQuery, fetchEsqlResponseOrThrow } from './execute_esql_query'; import { getMetricsExecutionContext } from './execution_context'; @@ -221,46 +221,6 @@ describe('executeEsqlQuery', () => { }); }); -describe('esql response error helpers', () => { - it('extracts error cause from response error object', () => { - const result = extractEsqlResponseErrorCause({ - error: { type: 'remote_transport_exception', reason: 'ccs query failed' }, - }); - - expect(result).toEqual({ - type: 'remote_transport_exception', - reason: 'ccs query failed', - }); - }); - - it('returns undefined when response has no error object', () => { - const result = extractEsqlResponseErrorCause({ columns: [], values: [] }); - expect(result).toBeUndefined(); - }); - - it('formats message from error type and reason', () => { - const result = formatErrorCause({ - type: 'remote_transport_exception', - reason: 'ccs query failed', - }); - - expect(result).toBe('remote_transport_exception: ccs query failed'); - }); - - it('formats message from root_cause when type and reason are missing', () => { - const result = formatErrorCause({ - root_cause: [{ type: 'index_not_found_exception', reason: 'no such index [metrics-*]' }], - }); - - expect(result).toBe('index_not_found_exception: no such index [metrics-*]'); - }); - - it('formats generic message for empty error object', () => { - const result = formatErrorCause({}); - expect(result).toBe('Elasticsearch returned an error'); - }); -}); - describe('fetchEsqlResponseOrThrow', () => { it('throws EsqlResponseError for error responses', async () => { mockGetESQLResults.mockResolvedValueOnce({ From 8c324950ce212e2c60b83abb07a05b9472a74897 Mon Sep 17 00:00:00 2001 From: Jorge Oliveira Date: Wed, 1 Apr 2026 10:34:11 +0100 Subject: [PATCH 6/9] Fix lint --- .../metrics/utils/esql_response_error.ts | 13 ++++++++++++- .../metrics/utils/execute_esql_query.test.ts | 6 +++--- 2 files changed, 15 insertions(+), 4 deletions(-) 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 index c6ef2c845a346..bf423474b2fa8 100644 --- 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 @@ -1,3 +1,12 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; export type EsqlResponseErrorCause = Partial; @@ -17,7 +26,9 @@ export const formatErrorCause = (errorCause: EsqlResponseErrorCause): string => return fromRootCause || 'Elasticsearch returned an error'; }; -export const extractEsqlResponseErrorCause = (response: object): EsqlResponseErrorCause | undefined => { +export const extractEsqlResponseErrorCause = ( + response: object +): EsqlResponseErrorCause | undefined => { if (!('error' in response) || response.error == null || typeof response.error !== 'object') { return undefined; } 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 81e657bfd27a1..ad5a056db0ec8 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 @@ -233,8 +233,8 @@ describe('fetchEsqlResponseOrThrow', () => { params: { query: '' }, } as unknown as Awaited>); - await expect(fetchEsqlResponseOrThrow({} as Parameters[0])).rejects.toThrow( - EsqlResponseError - ); + await expect( + fetchEsqlResponseOrThrow({} as Parameters[0]) + ).rejects.toThrow(EsqlResponseError); }); }); From 3aa02e1dd07b4acabda31a13c849761c10da219e Mon Sep 17 00:00:00 2001 From: Jorge Oliveira Date: Wed, 1 Apr 2026 12:49:01 +0100 Subject: [PATCH 7/9] Add support for status code --- .../metrics/utils/esql_response_error.test.ts | 46 +++++++++++ .../metrics/utils/esql_response_error.ts | 31 ++++++-- .../metrics/utils/execute_esql_query.test.ts | 78 ++++++++++++++++++- .../metrics/utils/execute_esql_query.ts | 8 +- 4 files changed, 153 insertions(+), 10 deletions(-) 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 index b37150c6beaa0..238d5b1358cc2 100644 --- 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 @@ -10,6 +10,7 @@ import { type EsqlResponseErrorCause, EsqlResponseError, + extractEsqlEmbeddedError, extractEsqlResponseErrorCause, formatErrorCause, } from './esql_response_error'; @@ -62,6 +63,42 @@ describe('extractEsqlResponseErrorCause', () => { }); }); +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({ @@ -92,4 +129,13 @@ describe('EsqlResponseError', () => { 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 index bf423474b2fa8..c5c4e1340273f 100644 --- 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 @@ -7,6 +7,7 @@ * 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; @@ -26,26 +27,46 @@ export const formatErrorCause = (errorCause: EsqlResponseErrorCause): string => return fromRootCause || 'Elasticsearch returned an error'; }; -export const extractEsqlResponseErrorCause = ( - response: object -): EsqlResponseErrorCause | undefined => { +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; } - return response.error as EsqlResponseErrorCause; + 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) { + 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 811e8512d0011..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,7 +19,7 @@ import { MetricsExecutionContextAction, MetricsExecutionContextName, } from './execution_context_enums'; -import { EsqlResponseError, extractEsqlResponseErrorCause } from './esql_response_error'; +import { EsqlResponseError, extractEsqlEmbeddedError } from './esql_response_error'; import { esqlResultToPlainObjects } from './esql_result_to_plain_objects'; import { getMetricsExecutionContext } from './execution_context'; @@ -38,9 +38,9 @@ export const fetchEsqlResponseOrThrow = async ( params: Parameters[0] ): Promise>['response']> => { const { response } = await getESQLResults(params); - const errorCause = extractEsqlResponseErrorCause(response); - if (errorCause) { - throw new EsqlResponseError(errorCause); + const embedded = extractEsqlEmbeddedError(response as object); + if (embedded) { + throw new EsqlResponseError(embedded.cause, { status: embedded.status }); } return response; From e21d70a55b9ea99aed28727cb264cff08f615448 Mon Sep 17 00:00:00 2001 From: Jorge Oliveira Date: Wed, 1 Apr 2026 13:49:18 +0100 Subject: [PATCH 8/9] Removed unused utility --- .../metrics/utils/esql_response_error.test.ts | 21 +++++++++---------- .../metrics/utils/esql_response_error.ts | 4 ---- 2 files changed, 10 insertions(+), 15 deletions(-) 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 index 238d5b1358cc2..58b10d4f98739 100644 --- 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 @@ -11,7 +11,6 @@ import { type EsqlResponseErrorCause, EsqlResponseError, extractEsqlEmbeddedError, - extractEsqlResponseErrorCause, formatErrorCause, } from './esql_response_error'; @@ -38,32 +37,32 @@ describe('formatErrorCause', () => { }); }); -describe('extractEsqlResponseErrorCause', () => { - it('extracts error cause from response error object', () => { +describe('extractEsqlEmbeddedError', () => { + it('returns cause when response has error object (no top-level status)', () => { expect( - extractEsqlResponseErrorCause({ + extractEsqlEmbeddedError({ error: { type: 'remote_transport_exception', reason: 'ccs query failed' }, }) ).toEqual({ - type: 'remote_transport_exception', - reason: 'ccs query failed', + cause: { + type: 'remote_transport_exception', + reason: 'ccs query failed', + }, }); }); it('returns undefined when response has no error object', () => { - expect(extractEsqlResponseErrorCause({ columns: [], values: [] })).toBeUndefined(); + expect(extractEsqlEmbeddedError({ columns: [], values: [] })).toBeUndefined(); }); it('returns undefined when error is null', () => { - expect(extractEsqlResponseErrorCause({ error: null })).toBeUndefined(); + expect(extractEsqlEmbeddedError({ error: null })).toBeUndefined(); }); it('returns undefined when error is not an object', () => { - expect(extractEsqlResponseErrorCause({ error: 'not-an-object' })).toBeUndefined(); + expect(extractEsqlEmbeddedError({ error: 'not-an-object' })).toBeUndefined(); }); -}); -describe('extractEsqlEmbeddedError', () => { it('returns cause and top-level status when present', () => { expect( extractEsqlEmbeddedError({ 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 index c5c4e1340273f..9e83826741b31 100644 --- 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 @@ -51,10 +51,6 @@ export const extractEsqlEmbeddedError = (response: object): EsqlEmbeddedError | }; }; -export const extractEsqlResponseErrorCause = ( - response: object -): EsqlResponseErrorCause | undefined => extractEsqlEmbeddedError(response)?.cause; - export class EsqlResponseError extends Error { public readonly type?: string; public readonly reason?: string; From f34dd13670cf5735ba74e2c789824e8d94b880ca Mon Sep 17 00:00:00 2001 From: Jorge Oliveira Date: Wed, 1 Apr 2026 16:10:29 +0100 Subject: [PATCH 9/9] Refactor return and tests --- .../metrics/utils/esql_response_error.test.ts | 9 +++++---- .../observability/metrics/utils/esql_response_error.ts | 5 +---- 2 files changed, 6 insertions(+), 8 deletions(-) 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 index 58b10d4f98739..d0a5f15b8ce63 100644 --- 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 @@ -48,6 +48,7 @@ describe('extractEsqlEmbeddedError', () => { type: 'remote_transport_exception', reason: 'ccs query failed', }, + status: undefined, }); }); @@ -75,26 +76,26 @@ describe('extractEsqlEmbeddedError', () => { }); }); - it('omits status when absent or not a finite number', () => { + it('leaves status undefined when absent or not a finite number', () => { expect( extractEsqlEmbeddedError({ error: { type: 'x', reason: 'y' }, }) - ).toEqual({ cause: { type: 'x', reason: 'y' } }); + ).toEqual({ cause: { type: 'x', reason: 'y' }, status: undefined }); expect( extractEsqlEmbeddedError({ error: { type: 'x', reason: 'y' }, status: '400', } as object) - ).toEqual({ cause: { type: 'x', reason: 'y' } }); + ).toEqual({ cause: { type: 'x', reason: 'y' }, status: undefined }); expect( extractEsqlEmbeddedError({ error: { type: 'x', reason: 'y' }, status: Number.NaN, }) - ).toEqual({ cause: { type: 'x', reason: 'y' } }); + ).toEqual({ cause: { type: 'x', reason: 'y' }, status: undefined }); }); }); 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 index 9e83826741b31..2f2d057c26591 100644 --- 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 @@ -45,10 +45,7 @@ export const extractEsqlEmbeddedError = (response: object): EsqlEmbeddedError | const status = typeof body.status === 'number' && Number.isFinite(body.status) ? body.status : undefined; - return { - cause: response.error as EsqlResponseErrorCause, - ...(status !== undefined ? { status } : {}), - }; + return { cause: response.error as EsqlResponseErrorCause, status }; }; export class EsqlResponseError extends Error {