diff --git a/x-pack/platform/plugins/shared/stack_alerts/common/es_query/esql_query_utils.ts b/x-pack/platform/plugins/shared/stack_alerts/common/es_query/esql_query_utils.ts index daea91c06673f..e4ed92cd1a262 100644 --- a/x-pack/platform/plugins/shared/stack_alerts/common/es_query/esql_query_utils.ts +++ b/x-pack/platform/plugins/shared/stack_alerts/common/es_query/esql_query_utils.ts @@ -17,6 +17,7 @@ import { isFunctionExpression, } from '@kbn/esql-ast'; import { getArgsFromRenameFunction } from '@kbn/esql-utils'; +import type { EsqlEsqlShardFailure } from '@elastic/elasticsearch/lib/api/types'; import { ActionGroupId } from './constants'; type EsqlDocument = Record; @@ -48,6 +49,14 @@ const ESQL_DOCUMENT_ID = 'esql_query_document'; export interface EsqlTable { columns: EsqlResultColumn[]; values: EsqlResultRow[]; + is_partial?: boolean; + _clusters?: { + details?: { + [key: string]: { + failures?: EsqlEsqlShardFailure[]; + }; + }; + }; } export const ALERT_ID_COLUMN = 'Alert ID'; diff --git a/x-pack/platform/plugins/shared/stack_alerts/server/rule_types/es_query/lib/fetch_esql_query.test.ts b/x-pack/platform/plugins/shared/stack_alerts/server/rule_types/es_query/lib/fetch_esql_query.test.ts index c16087100151b..0eb8612e4372c 100644 --- a/x-pack/platform/plugins/shared/stack_alerts/server/rule_types/es_query/lib/fetch_esql_query.test.ts +++ b/x-pack/platform/plugins/shared/stack_alerts/server/rule_types/es_query/lib/fetch_esql_query.test.ts @@ -16,6 +16,7 @@ import { publicRuleResultServiceMock } from '@kbn/alerting-plugin/server/monitor import { getEsqlQueryHits } from '../../../../common'; import type { LocatorPublic } from '@kbn/share-plugin/common'; import type { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; +import type { EsqlEsqlShardFailure } from '@elastic/elasticsearch/lib/api/types'; const getTimeRange = () => { const date = Date.now(); @@ -62,6 +63,10 @@ describe('fetchEsqlQuery', () => { global.Date.now = jest.fn(() => fakeNow.getTime()); }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('fetch', () => { it('should throw a user error when the error is a verification_exception error', async () => { const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); @@ -100,6 +105,181 @@ describe('fetchEsqlQuery', () => { expect(getErrorSource(e)).toBe(TaskErrorSource.USER); } }); + + it('should add a warning when is_partial is true', async () => { + const shardFailure: EsqlEsqlShardFailure = { + reason: { type: 'test_failure', reason: 'too big data' }, + shard: 0, + index: 'test-index', + }; + + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + scopedClusterClient.asCurrentUser.transport.request.mockResolvedValueOnce({ + columns: [], + values: [], + is_partial: true, // is_partial is true + _clusters: { + details: { + 'cluster-1': { + failures: [shardFailure], + }, + }, + }, + }); + + (getEsqlQueryHits as jest.Mock).mockReturnValue({ + results: { + esResult: { + _shards: { failed: 0, successful: 0, total: 0 }, + aggregations: {}, + hits: { hits: [] }, + timed_out: false, + took: 0, + }, + isCountAgg: false, + isGroupAgg: true, + }, + }); + + await fetchEsqlQuery({ + ruleId: 'testRuleId', + alertLimit: 1, + params: { ...defaultParams, groupBy: 'row' }, + services: { + logger, + scopedClusterClient, + // @ts-expect-error + share: { + url: { + locators: { + get: jest.fn().mockReturnValue({ + getRedirectUrl: jest.fn(() => '/app/r?l=DISCOVER_APP_LOCATOR'), + } as unknown as LocatorPublic), + }, + }, + } as SharePluginStart, + ruleResultService: mockRuleResultService, + }, + spacePrefix: '', + dateStart: new Date().toISOString(), + dateEnd: new Date().toISOString(), + }); + + const warning = + 'The query returned partial results. Some clusters may have been skipped due to timeouts or other issues. Failures: [{"reason":{"type":"test_failure","reason":"too big data"},"shard":0,"index":"test-index"}]'; + expect(mockRuleResultService.addLastRunWarning).toHaveBeenCalledWith(warning); + expect(mockRuleResultService.setLastRunOutcomeMessage).toHaveBeenCalledWith(warning); + }); + + it('should add a warning when is_partial is true but there is no shard failure', async () => { + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + scopedClusterClient.asCurrentUser.transport.request.mockResolvedValueOnce({ + columns: [], + values: [], + is_partial: true, // is_partial is true + _clusters: { + details: {}, + }, + }); + + (getEsqlQueryHits as jest.Mock).mockReturnValue({ + results: { + esResult: { + _shards: { failed: 0, successful: 0, total: 0 }, + aggregations: {}, + hits: { hits: [] }, + timed_out: false, + took: 0, + }, + isCountAgg: false, + isGroupAgg: true, + }, + }); + + await fetchEsqlQuery({ + ruleId: 'testRuleId', + alertLimit: 1, + params: { ...defaultParams, groupBy: 'row' }, + services: { + logger, + scopedClusterClient, + // @ts-expect-error + share: { + url: { + locators: { + get: jest.fn().mockReturnValue({ + getRedirectUrl: jest.fn(() => '/app/r?l=DISCOVER_APP_LOCATOR'), + } as unknown as LocatorPublic), + }, + }, + } as SharePluginStart, + ruleResultService: mockRuleResultService, + }, + spacePrefix: '', + dateStart: new Date().toISOString(), + dateEnd: new Date().toISOString(), + }); + + const warning = + 'The query returned partial results. Some clusters may have been skipped due to timeouts or other issues.'; + expect(mockRuleResultService.addLastRunWarning).toHaveBeenCalledWith(warning); + expect(mockRuleResultService.setLastRunOutcomeMessage).toHaveBeenCalledWith(warning); + }); + + it('should not add a warning when is_partial is false', async () => { + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + scopedClusterClient.asCurrentUser.transport.request.mockResolvedValueOnce({ + columns: [], + values: [], + is_partial: false, // is_partial is true + }); + + (getEsqlQueryHits as jest.Mock).mockReturnValue({ + results: { + esResult: { + _shards: { failed: 0, successful: 0, total: 0 }, + aggregations: {}, + hits: { hits: [{ foo: 'bar' }] }, // has data + timed_out: false, + took: 0, + }, + isCountAgg: false, + isGroupAgg: true, + }, + }); + + const result = await fetchEsqlQuery({ + ruleId: 'testRuleId', + alertLimit: 1, + params: defaultParams, + services: { + logger, + scopedClusterClient, + // @ts-expect-error + share: { + url: { + locators: { + get: jest.fn().mockReturnValue({ + getRedirectUrl: jest.fn(() => '/app/r?l=DISCOVER_APP_LOCATOR'), + } as unknown as LocatorPublic), + }, + }, + } as SharePluginStart, + ruleResultService: mockRuleResultService, + }, + spacePrefix: '', + dateStart: new Date().toISOString(), + dateEnd: new Date().toISOString(), + }); + + expect(result).toEqual({ + index: null, + link: '/app/r?l=DISCOVER_APP_LOCATOR', + parsedResults: { results: [], truncated: false }, + }); + expect(mockRuleResultService.addLastRunWarning).not.toHaveBeenCalled(); + expect(mockRuleResultService.setLastRunOutcomeMessage).not.toHaveBeenCalled(); + }); }); describe('getEsqlQuery', () => { @@ -289,6 +469,7 @@ describe('fetchEsqlQuery', () => { 'The query returned multiple rows with the same alert ID. There are duplicate results for alert IDs: 1.2.0' ); }); + describe('generateLink', () => { it('should generate a link', () => { const { dateStart, dateEnd } = getTimeRange(); diff --git a/x-pack/platform/plugins/shared/stack_alerts/server/rule_types/es_query/lib/fetch_esql_query.ts b/x-pack/platform/plugins/shared/stack_alerts/server/rule_types/es_query/lib/fetch_esql_query.ts index cfbac7bb7c313..e1e412b39b3d6 100644 --- a/x-pack/platform/plugins/shared/stack_alerts/server/rule_types/es_query/lib/fetch_esql_query.ts +++ b/x-pack/platform/plugins/shared/stack_alerts/server/rule_types/es_query/lib/fetch_esql_query.ts @@ -17,6 +17,8 @@ import { ecsFieldMap, alertFieldMap } from '@kbn/alerts-as-data-utils'; import { createTaskRunError, TaskErrorSource } from '@kbn/task-manager-plugin/server'; import type { LocatorPublic } from '@kbn/share-plugin/common'; import type { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; +import { i18n } from '@kbn/i18n'; +import type { EsqlEsqlShardFailure } from '@elastic/elasticsearch/lib/api/types'; import type { EsqlTable } from '../../../../common'; import { getEsqlQueryHits } from '../../../../common'; import type { OnlyEsqlQueryRuleParams } from '../types'; @@ -82,6 +84,14 @@ export async function fetchEsqlQuery({ ruleResultService.setLastRunOutcomeMessage(warning); } + const isPartial = response.is_partial ?? false; + + if (ruleResultService && isPartial) { + const warning = getPartialResultsWarning(response); + ruleResultService.addLastRunWarning(warning); + ruleResultService.setLastRunOutcomeMessage(warning); + } + const link = generateLink(params, discoverLocator, dateStart, dateEnd, spacePrefix); return { @@ -158,3 +168,24 @@ export function generateLink( return redirectUrl; } + +function getPartialResultsWarning(response: EsqlTable) { + const clusters = response?._clusters?.details ?? {}; + const shardFailures = Object.keys(clusters).reduce((acc, cluster) => { + const failures = clusters[cluster]?.failures ?? []; + + if (failures.length > 0) { + acc.push(...failures); + } + + return acc; + }, []); + + return i18n.translate('xpack.stackAlerts.esQuery.partialResultsWarning', { + defaultMessage: + shardFailures.length > 0 + ? 'The query returned partial results. Some clusters may have been skipped due to timeouts or other issues. Failures: {failures}' + : 'The query returned partial results. Some clusters may have been skipped due to timeouts or other issues.', + values: { failures: JSON.stringify(shardFailures) }, + }); +}