Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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';
Comment thread
jorgeoliveira117 marked this conversation as resolved.

export type EsqlResponseErrorCause = Partial<estypes.ErrorCause>;

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];
Comment thread
jorgeoliveira117 marked this conversation as resolved.
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 {
Comment thread
jorgeoliveira117 marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably fine for now, but we should not implement custom error messages. If such handlers are needed, they should be handled at the ESQL level rather than on our side.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally agree, we created this issue to handle things like this and more

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;
Comment thread
jorgeoliveira117 marked this conversation as resolved.
this.rootCause = errorCause.root_cause;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import {
MetricsExecutionContextAction,
MetricsExecutionContextName,
} from './execution_context_enums';
import { executeEsqlQuery } 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', () => ({
Expand Down Expand Up @@ -177,4 +182,103 @@ describe('executeEsqlQuery', () => {
})
);
});

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

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<ReturnType<typeof getESQLResults>>);

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<ReturnType<typeof getESQLResults>>);

await expect(
fetchEsqlResponseOrThrow({} as Parameters<typeof getESQLResults>[0])
).rejects.toThrow(EsqlResponseError);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -33,8 +34,21 @@ export interface ExecuteEsqlParams {
uiSettings: IUiSettingsClient;
}

export const fetchEsqlResponseOrThrow = async (
params: Parameters<typeof getESQLResults>[0]
): Promise<Awaited<ReturnType<typeof getESQLResults>>['response']> => {
const { response } = await getESQLResults(params);
const errorCause = extractEsqlResponseErrorCause(response);
if (errorCause) {
throw new EsqlResponseError(errorCause);
}

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<TDocument extends object = Record<string, unknown>>({
esqlQuery,
Expand All @@ -57,7 +71,7 @@ export async function executeEsqlQuery<TDocument extends object = Record<string,
? buildEsQuery(undefined, [], filtersWithTime, esQueryConfig)
: undefined;

const { response } = await getESQLResults({
const response = await fetchEsqlResponseOrThrow({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A try/catch handler would likely handle the same

esqlQuery,
search,
signal,
Expand All @@ -70,7 +84,5 @@ export async function executeEsqlQuery<TDocument extends object = Record<string,
),
});

const plainObjects = esqlResultToPlainObjects<TDocument>(response);

return plainObjects;
return esqlResultToPlainObjects<TDocument>(response);
}
Loading