Skip to content

Commit 253a918

Browse files
[Metrics][Discover] Refactor METRICS_INFO error handling (#270627)
Closes #260667 ## Summary Aligns Metrics in Discover `METRICS_INFO` failures with main Discover search errors by replacing the custom `MetricsInfoError` component with Discover’s shared `ErrorCallout` (via a `ChartSectionSearchError` wrapper). ES|QL error handling is centralized under `src/common/errors/` so Metrics and Traces can reuse the same path, including HTTP 200 responses with an embedded Elasticsearch error body. Discover injects `showErrorDialog` and `esqlReferenceHref` from the metrics profile wrapper—the same pattern as `discover_layout.tsx` after [#261332](#261332). ### Changes - **Error handling** - Moved `esql_response_error` to `src/common/errors/` and improve `formatErrorCause` (all `root_cause` entries, `caused_by` fallback) - Added `normalizeChartSectionSearchError` - Update `execute_esql_query` and `report_chart_section_error` imports to the shared module - **UI** - Added `ChartSectionSearchError` wrapping `@kbn/discover-utils` `ErrorCallout` - Metrics Experience Grid now render `ChartSectionSearchError` on `| METRICS_INFO` failure - Removed `metrics_info_error.tsx` - **Discover host wiring** - `chart_section.tsx` passes `chartSectionSearchError` with `core.notifications.showErrorDialog` and `docLinks.links.query.queryESQL` (same behaviour as main Discover `ErrorCallout`) - Added `ChartSectionSearchErrorHostProps` to `UnifiedMetricsGridProps` - **i18n** - Remove unused `metricsExperience.metricsInfoError.*` keys - Add `metricsExperience.chartSectionError.title` ### Expected Results We're now able to see Discover's error component on a METRICS_INFO call error (Error description is custom for the demonstration) <img width="1584" height="709" alt="image" src="https://github.com/user-attachments/assets/81b7f380-8b07-4d36-b732-de7742c661f7" /> --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent 58be47d commit 253a918

22 files changed

Lines changed: 435 additions & 91 deletions

File tree

src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/utils/esql_response_error.test.ts renamed to src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/errors/esql_response_error.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
EsqlResponseError,
1313
extractEsqlEmbeddedError,
1414
formatErrorCause,
15+
isEsqlResponseError,
1516
} from './esql_response_error';
1617

1718
describe('formatErrorCause', () => {
@@ -32,6 +33,27 @@ describe('formatErrorCause', () => {
3233
).toBe('index_not_found_exception: no such index [metrics-*]');
3334
});
3435

36+
it('joins multiple root_cause entries with newlines', () => {
37+
expect(
38+
formatErrorCause({
39+
root_cause: [
40+
{ type: 'index_not_found_exception', reason: 'no such index [cluster-a:metrics-*]' },
41+
{ type: 'index_not_found_exception', reason: 'no such index [cluster-b:metrics-*]' },
42+
],
43+
})
44+
).toBe(
45+
'index_not_found_exception: no such index [cluster-a:metrics-*]\nindex_not_found_exception: no such index [cluster-b:metrics-*]'
46+
);
47+
});
48+
49+
it('returns message from caused_by when type, reason, and root_cause are missing', () => {
50+
expect(
51+
formatErrorCause({
52+
caused_by: { type: 'illegal_argument_exception', reason: 'invalid query' },
53+
})
54+
).toBe('illegal_argument_exception: invalid query');
55+
});
56+
3557
it('returns generic message for empty error object', () => {
3658
expect(formatErrorCause({})).toBe('Elasticsearch returned an error');
3759
});
@@ -138,4 +160,35 @@ describe('EsqlResponseError', () => {
138160

139161
expect(err.status).toBe(400);
140162
});
163+
164+
it('formats message from multiple root_cause entries when top-level type and reason are absent', () => {
165+
const err = new EsqlResponseError({
166+
root_cause: [
167+
{ type: 'index_not_found_exception', reason: 'no such index [cluster-a:metrics-*]' },
168+
{ type: 'index_not_found_exception', reason: 'no such index [cluster-b:metrics-*]' },
169+
],
170+
});
171+
172+
expect(err.message).toBe(
173+
'index_not_found_exception: no such index [cluster-a:metrics-*]\nindex_not_found_exception: no such index [cluster-b:metrics-*]'
174+
);
175+
});
176+
});
177+
178+
describe('isEsqlResponseError', () => {
179+
it('preserves prototype chain so instanceof works after downlevel emit', () => {
180+
const err = new EsqlResponseError({ type: 'x', reason: 'y' });
181+
182+
expect(Object.getPrototypeOf(err)).toBe(EsqlResponseError.prototype);
183+
expect(isEsqlResponseError(err)).toBe(true);
184+
});
185+
186+
it('returns true for EsqlResponseError instances', () => {
187+
expect(isEsqlResponseError(new EsqlResponseError({ type: 'x', reason: 'y' }))).toBe(true);
188+
});
189+
190+
it('returns false for other errors', () => {
191+
expect(isEsqlResponseError(new Error('network'))).toBe(false);
192+
expect(isEsqlResponseError(undefined)).toBe(false);
193+
});
141194
});

src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/utils/esql_response_error.ts renamed to src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/errors/esql_response_error.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,46 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10-
// TODO https://github.com/elastic/kibana/issues/260667
1110
import type { estypes } from '@elastic/elasticsearch';
1211

1312
export type EsqlResponseErrorCause = Partial<estypes.ErrorCause>;
1413

15-
export const formatErrorCause = (errorCause: EsqlResponseErrorCause): string => {
16-
const head = [errorCause.type, errorCause.reason]
14+
const formatSingleCause = (cause: EsqlResponseErrorCause): string | undefined => {
15+
const formatted = [cause.type, cause.reason]
1716
.filter((value): value is string => Boolean(value?.trim()))
1817
.join(': ');
18+
return formatted || undefined;
19+
};
20+
21+
const formatRootCauses = (rootCauses: EsqlResponseErrorCause[] | undefined): string | undefined => {
22+
if (!rootCauses?.length) {
23+
return undefined;
24+
}
25+
26+
const formatted = rootCauses
27+
.map((cause) => formatSingleCause(cause))
28+
.filter((value): value is string => Boolean(value));
29+
30+
return formatted.length > 0 ? formatted.join('\n') : undefined;
31+
};
32+
33+
/**
34+
* Builds a human-readable message from an Elasticsearch error cause, including
35+
* all `root_cause` entries (e.g. CCS / multi-cluster failures).
36+
*/
37+
export const formatErrorCause = (errorCause: EsqlResponseErrorCause): string => {
38+
const head = formatSingleCause(errorCause);
1939
if (head) {
2040
return head;
2141
}
2242

23-
const rootCause = errorCause.root_cause?.[0];
24-
const fromRootCause = [rootCause?.type, rootCause?.reason]
25-
.filter((value): value is string => Boolean(value?.trim()))
26-
.join(': ');
27-
return fromRootCause || 'Elasticsearch returned an error';
43+
const fromRootCauses = formatRootCauses(errorCause.root_cause);
44+
if (fromRootCauses) {
45+
return fromRootCauses;
46+
}
47+
48+
const fromCausedBy = errorCause.caused_by ? formatSingleCause(errorCause.caused_by) : undefined;
49+
return fromCausedBy || 'Elasticsearch returned an error';
2850
};
2951

3052
export interface EsqlEmbeddedError {
@@ -61,5 +83,12 @@ export class EsqlResponseError extends Error {
6183
this.reason = errorCause.reason ?? undefined;
6284
this.rootCause = errorCause.root_cause;
6385
this.status = options?.status;
86+
87+
// Set the prototype explicitly, see:
88+
// https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
89+
Object.setPrototypeOf(this, EsqlResponseError.prototype);
6490
}
6591
}
92+
93+
export const isEsqlResponseError = (error: unknown): error is EsqlResponseError =>
94+
error instanceof EsqlResponseError;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { EsqlResponseError } from './esql_response_error';
11+
import { normalizeChartSectionSearchError } from './normalize_chart_section_search_error';
12+
13+
describe('normalizeChartSectionSearchError', () => {
14+
it('returns the same Error instance', () => {
15+
const error = new Error('network');
16+
expect(normalizeChartSectionSearchError(error)).toBe(error);
17+
});
18+
19+
it('returns EsqlResponseError instances unchanged', () => {
20+
const error = new EsqlResponseError({ type: 'x', reason: 'y' });
21+
expect(normalizeChartSectionSearchError(error)).toBe(error);
22+
});
23+
24+
it('wraps non-empty strings in Error', () => {
25+
const error = normalizeChartSectionSearchError('fetch failed');
26+
expect(error).toBeInstanceOf(Error);
27+
expect(error.message).toBe('fetch failed');
28+
});
29+
30+
it('wraps other values with String()', () => {
31+
expect(normalizeChartSectionSearchError(42).message).toBe('42');
32+
expect(normalizeChartSectionSearchError(null).message).toBe('null');
33+
});
34+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
/**
11+
* Normalizes fetch-layer failures from chart section ES|QL queries into an `Error`
12+
* suitable for Discover's `ErrorCallout` and related display helpers.
13+
*/
14+
export const normalizeChartSectionSearchError = (error: unknown): Error => {
15+
if (error instanceof Error) {
16+
return error;
17+
}
18+
19+
return new Error(String(error));
20+
};

src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_report_chart_section_error.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import type { Logger } from '@kbn/logging';
1212
import { loggerMock } from '@kbn/logging-mocks';
1313
import { renderHook } from '@testing-library/react';
1414
import React from 'react';
15+
import { EsqlResponseError } from '../../../common/errors/esql_response_error';
1516
import {
1617
ExternalServicesProvider,
1718
type ExternalServices,
1819
} from '../../../context/external_services';
1920
import { ERROR_TYPE } from '../../../utils/error_labels';
20-
import { EsqlResponseError } from '../utils/esql_response_error';
2121
import {
2222
type ReportChartSectionErrorArgs,
2323
useReportChartSectionError,

src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_report_chart_section_error.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useCallback } from 'react';
1313
import { useExternalServices } from '../../../context/external_services';
1414
import { ERROR_TYPE } from '../../../utils/error_labels';
1515
import { toLoggable } from '../../../utils/logger_utils';
16-
import { EsqlResponseError } from '../utils/esql_response_error';
16+
import { EsqlResponseError } from '../../../common/errors/esql_response_error';
1717
import { isSuppressedFetchError } from '../utils/is_suppressed_fetch_error';
1818

1919
/** APM label identifying which chart-section call site produced an error. */
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { EuiProvider } from '@elastic/eui';
11+
import { render, screen } from '@testing-library/react';
12+
import React from 'react';
13+
import { EsqlResponseError } from '../../common/errors/esql_response_error';
14+
import { ExternalServicesProvider, type ExternalServices } from '../../context/external_services';
15+
import { ChartSectionSearchError } from './chart_section_search_error';
16+
17+
const renderChartSectionSearchError = (
18+
ui: React.ReactElement,
19+
externalServices?: ExternalServices
20+
) =>
21+
render(
22+
<EuiProvider highContrastMode={false}>
23+
<ExternalServicesProvider externalServices={externalServices}>{ui}</ExternalServicesProvider>
24+
</EuiProvider>
25+
);
26+
27+
describe('ChartSectionSearchError', () => {
28+
it('renders Discover ErrorCallout with title and error message', () => {
29+
const error = new Error('Network error');
30+
31+
renderChartSectionSearchError(
32+
<ChartSectionSearchError error={error} title="Unable to retrieve search results" />
33+
);
34+
35+
expect(screen.getByTestId('discoverErrorCalloutTitle')).toHaveTextContent(
36+
'Unable to retrieve search results'
37+
);
38+
expect(screen.getByTestId('discoverErrorCalloutMessage')).toHaveTextContent('Network error');
39+
});
40+
41+
it('normalizes non-Error fetch failures before display', () => {
42+
renderChartSectionSearchError(
43+
<ChartSectionSearchError error="fetch failed" title="Unable to retrieve search results" />
44+
);
45+
46+
expect(screen.getByTestId('discoverErrorCalloutMessage')).toHaveTextContent('fetch failed');
47+
});
48+
49+
it('displays EsqlResponseError messages from embedded Elasticsearch errors', () => {
50+
const error = new EsqlResponseError({
51+
type: 'illegal_argument_exception',
52+
reason: 'invalid field',
53+
});
54+
55+
renderChartSectionSearchError(
56+
<ChartSectionSearchError error={error} title="Unable to retrieve search results" />
57+
);
58+
59+
expect(screen.getByTestId('discoverErrorCalloutMessage')).toHaveTextContent(
60+
'illegal_argument_exception: invalid field'
61+
);
62+
});
63+
64+
it('renders ES|QL reference link when externalServices provides docLinks', () => {
65+
renderChartSectionSearchError(
66+
<ChartSectionSearchError error={new Error('x')} title="Unable to retrieve search results" />,
67+
{
68+
docLinks: {
69+
links: { query: { queryESQL: 'https://www.elastic.co/docs/reference/esql' } },
70+
},
71+
} as ExternalServices
72+
);
73+
74+
expect(screen.getByTestId('discoverErrorCalloutESQLReferenceButton')).toHaveAttribute(
75+
'href',
76+
'https://www.elastic.co/docs/reference/esql'
77+
);
78+
});
79+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import React from 'react';
11+
import { ErrorCallout } from '@kbn/discover-utils';
12+
import { normalizeChartSectionSearchError } from '../../common/errors/normalize_chart_section_search_error';
13+
import { useExternalServices } from '../../context/external_services';
14+
15+
export interface ChartSectionSearchErrorProps {
16+
error: unknown;
17+
title: string;
18+
}
19+
20+
/**
21+
* Chart-section fetch failures (METRICS_INFO, Traces, etc.) using Discover's ErrorCallout.
22+
* Host injects notifications and doc links via `ExternalServicesProvider`.
23+
*/
24+
export const ChartSectionSearchError = ({ error, title }: ChartSectionSearchErrorProps) => {
25+
const services = useExternalServices();
26+
27+
return (
28+
<ErrorCallout
29+
title={title}
30+
error={normalizeChartSectionSearchError(error)}
31+
isEsqlMode
32+
showErrorDialog={services?.notifications?.showErrorDialog}
33+
esqlReferenceHref={services?.docLinks?.links.query.queryESQL}
34+
/>
35+
);
36+
};

src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_fetch_metrics_data.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import type { ChartSectionProps } from '@kbn/unified-histogram/types';
5959
import type { Dimension, ParsedMetricsWithTelemetry } from '../../../../types';
6060
import { useFetchMetricsData } from './use_fetch_metrics_data';
6161
import { executeEsqlQuery } from '../utils/execute_esql_query';
62+
import { EsqlResponseError } from '../../../../common/errors/esql_response_error';
6263
import { parseMetricsWithTelemetry } from '../utils/parse_metrics_response_with_telemetry';
6364
import { getFetchParamsMock } from '@kbn/unified-histogram/__mocks__/fetch_params';
6465

@@ -468,11 +469,40 @@ describe('useFetchMetricsData', () => {
468469
expect(result.current.error).toBeTruthy();
469470
});
470471

472+
expect(result.current.error).toBe(fetchError);
471473
expect(result.current.metricItems).toEqual([]);
472474
expect(result.current.allDimensions).toEqual([]);
473475
expect(result.current.activeDimensions).toEqual([]);
474476
});
475477

478+
it('returns EsqlResponseError when ES|QL responds with HTTP 200 and embedded error', async () => {
479+
const embeddedError = new EsqlResponseError(
480+
{
481+
type: 'remote_transport_exception',
482+
reason: 'ccs query failed',
483+
root_cause: [{ type: 'index_not_found_exception', reason: 'no such index [metrics-*]' }],
484+
},
485+
{ status: 400 }
486+
);
487+
mockExecuteEsqlQuery.mockRejectedValue(embeddedError);
488+
489+
const params = createDefaultParams();
490+
const { result } = renderHook(() => useFetchMetricsData(params));
491+
492+
await waitFor(() => {
493+
expect(result.current.loading).toBe(false);
494+
expect(result.current.error).toBe(embeddedError);
495+
});
496+
497+
expect(result.current.error).toBeInstanceOf(EsqlResponseError);
498+
expect(result.current.error).toMatchObject({
499+
message: 'remote_transport_exception: ccs query failed',
500+
status: 400,
501+
rootCause: [{ type: 'index_not_found_exception', reason: 'no such index [metrics-*]' }],
502+
});
503+
expect(result.current.metricItems).toEqual([]);
504+
});
505+
476506
it('returns empty arrays and null error in initial state', () => {
477507
// Delay fetch indefinitely so we can inspect the initial state
478508
mockExecuteEsqlQuery.mockReturnValue(new Promise(() => {}));

0 commit comments

Comments
 (0)