Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -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<string, string | null>;
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<DiscoverAppLocatorParams>),
},
},
} 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<DiscoverAppLocatorParams>),
},
},
} 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<DiscoverAppLocatorParams>),
},
},
} 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', () => {
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -158,3 +168,24 @@ export function generateLink(

return redirectUrl;
}

function getPartialResultsWarning(response: EsqlTable) {
const clusters = response?._clusters?.details ?? {};
const shardFailures = Object.keys(clusters).reduce<EsqlEsqlShardFailure[]>((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) },
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.

since failures can be an empty array, it would be nice to optionally show Failures: in the message since then the warning will look incomplete. So if we can't extract any useful message from the details, it will just show The query returned partial results. Some clusters may have been skipped due to timeouts or other issues.

});
}