Skip to content
Open
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 @@ -79,6 +79,30 @@ describe('buildEpisodesBaseQuery', () => {
});
});

describe('duration lower bound flag', () => {
it('computes the start event and the first series event in the aggregations', () => {
const queryString = buildEpisodesBaseQuery(SPACE_ID).print('basic');

expect(queryString).toContain(
'start_event_timestamp = MIN(@timestamp) WHERE `episode.status` == "pending" AND `episode.status_count` == 1'
);
expect(queryString).toContain(
'first_series_event_timestamp = MIN(@timestamp) WHERE type == "alert"'
);
});

it('flags episodes whose start was not seen, in the list query only', () => {
const listQuery = buildEpisodesQuery(SPACE_ID).print('basic');
const baseQuery = buildEpisodesBaseQuery(SPACE_ID).print('basic');

expect(listQuery).toContain(
'EVAL duration_is_lower_bound = ((start_event_timestamp IS NULL OR start_event_timestamp != first_timestamp) AND first_series_event_timestamp >= first_timestamp)'
);
expect(listQuery).toMatch(/KEEP .*duration_is_lower_bound/);
expect(baseQuery).not.toContain('duration_is_lower_bound');
});
});

describe('buildEpisodesQuery', () => {
it('should join both data streams', () => {
const query = buildEpisodesQuery(SPACE_ID);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export const addEpisodeAggregation = (query: ComposerQuery) => {
// prettier-ignore
query
.pipe`EVAL extracted_data = JSON_EXTRACT(_source, "data")`
.pipe`INLINE STATS first_timestamp = MIN(@timestamp), last_timestamp = MAX(@timestamp), triggered_at = MIN(@timestamp) WHERE \`episode.status\` == "active", episode_data = LAST(extracted_data, @timestamp) WHERE extracted_data != "{}", severity = LAST(severity, @timestamp) WHERE status == "breached" AND severity IS NOT NULL BY episode.id`
.pipe`INLINE STATS first_timestamp = MIN(@timestamp), last_timestamp = MAX(@timestamp), triggered_at = MIN(@timestamp) WHERE \`episode.status\` == "active", start_event_timestamp = MIN(@timestamp) WHERE \`episode.status\` == "pending" AND \`episode.status_count\` == 1, episode_data = LAST(extracted_data, @timestamp) WHERE extracted_data != "{}", severity = LAST(severity, @timestamp) WHERE status == "breached" AND severity IS NOT NULL BY episode.id`
.pipe`EVAL duration = DATE_DIFF("ms", first_timestamp, last_timestamp)`
.pipe`WHERE @timestamp == last_timestamp`;
};
Expand All @@ -128,7 +128,8 @@ const addGroupHashActionStats = (query: ComposerQuery) => {
query
.pipe`INLINE STATS last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze"),
snooze_expiry = LAST(expiry, @timestamp) WHERE action_type == "snooze",
last_tags = LAST(tags, @timestamp) WHERE action_type == "tag"
last_tags = LAST(tags, @timestamp) WHERE action_type == "tag",
first_series_event_timestamp = MIN(@timestamp) WHERE type == "alert"
BY group_hash`;
};

Expand Down Expand Up @@ -272,6 +273,24 @@ export const buildEpisodesBaseQuery = (
return query;
};

export const DURATION_LOWER_BOUND_FIELD = 'duration_is_lower_bound';

/**
* Flags the episodes whose first event was not part of the scanned rows, so
* `first_timestamp` and `duration` only cover the selected time range. The
* start was seen when the earliest row is the event that opened the episode
* (`pending` with `status_count` 1), or when an earlier alert event of the
* same series is present, which can only belong to a previous episode. Rules
* that skip the pending state and have no earlier episode in range still get
* the flag: showing a lower bound is always true, hiding a truncation is not.
*/
const addDurationLowerBoundFlag = (query: ComposerQuery) => {
// prettier-ignore
query.pipe(
`EVAL ${DURATION_LOWER_BOUND_FIELD} = (start_event_timestamp IS NULL OR start_event_timestamp != first_timestamp) AND first_series_event_timestamp >= first_timestamp`
);
};

/**
* Builds an ES|QL query for episodes request with sorting and filtering.
*
Expand Down Expand Up @@ -299,7 +318,12 @@ export const buildEpisodesQuery = (

const sortField = resolveSortField(sortState.sortField);

addDurationLowerBoundFlag(query);

return asTypedEsqlQuery<AlertEpisodeEsqlRow>(
query.sort([sortField, sortDir]).pipe`LIMIT ${pageSizeParam}`.keep(...ALERT_EPISODE_FIELDS)
query.sort([sortField, sortDir]).pipe`LIMIT ${pageSizeParam}`.keep(
...ALERT_EPISODE_FIELDS,
DURATION_LOWER_BOUND_FIELD
)
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type { TypedEsqlQuery } from './typed_esql_query';

export {
ALERT_EPISODE_FIELDS,
DURATION_LOWER_BOUND_FIELD,
buildEpisodesBaseQuery,
buildEpisodesQuery,
addEpisodeAggregation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { ESQLVariableType } from '@kbn/esql-types';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import { executeEsqlQuery } from '../utils/execute_esql_query';
import type { Filter } from '@kbn/es-query';
import { buildEpisodesQuery } from '@kbn/alerting-v2-common-queries';
import { fetchAlertingEpisodes } from './fetch_alerting_episodes';

Expand Down Expand Up @@ -121,6 +122,42 @@ describe('fetchAlertingEpisodes', () => {
});
});

it('should apply the time range to the alert events only, as a request filter', async () => {
await fetchAlertingEpisodes({
spaceId: SPACE_ID,
pageSize: 10,
timeRange: { from: '2026-09-10T10:00:00.000Z', to: '2026-09-10T12:00:00.000Z' },
services: { expressions: mockExpressions },
});

const { input, timeField } = mockExecuteEsqlQuery.mock.calls[0][0] as {
input: { timeRange?: unknown; filters?: Filter[] };
timeField?: string;
};
expect(timeField).toBeUndefined();
expect(input.timeRange).toBeUndefined();
expect(input.filters).toHaveLength(1);
expect(input.filters?.[0].query?.bool.should).toEqual([
{
bool: {
filter: [
{ term: { type: 'alert' } },
{
range: {
'@timestamp': {
format: 'strict_date_optional_time',
gte: '2026-09-10T10:00:00.000Z',
lte: '2026-09-10T12:00:00.000Z',
},
},
},
],
},
},
{ exists: { field: 'action_type' } },
]);
});

it('should call executeEsqlQuery with custom sort parameters', async () => {
const pageSize = 25;
const sortState = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import type { ESQLControlVariable } from '@kbn/esql-types';
import { ESQLVariableType } from '@kbn/esql-types';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import type { TimeRange } from '@kbn/es-query';
import type { Filter, TimeRange } from '@kbn/es-query';
import {
asEsqlRows,
buildEpisodesQuery,
Expand All @@ -18,6 +18,7 @@ import {
type EpisodesSortState,
} from '@kbn/alerting-v2-common-queries';
import { executeEsqlQuery } from '../utils/execute_esql_query';
import { buildAlertEventsTimeRangeFilter } from '../utils/build_alert_events_time_range_filter';

export interface FetchAlertingEpisodesOptions {
spaceId: string;
Expand Down Expand Up @@ -47,16 +48,17 @@ export const fetchAlertingEpisodes = ({
const input: {
type: 'kibana_context';
esqlVariables: ESQLControlVariable[];
timeRange?: TimeRange;
filters?: Filter[];
} = {
type: 'kibana_context',
esqlVariables: [
{ key: PAGE_SIZE_ESQL_VARIABLE, value: pageSize, type: ESQLVariableType.VALUES },
],
};

if (timeRange) {
input.timeRange = timeRange;
const timeRangeFilter = timeRange ? buildAlertEventsTimeRangeFilter(timeRange) : undefined;
if (timeRangeFilter) {
input.filters = [timeRangeFilter];
}

return executeEsqlQuery({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import type { TimeRange } from '@kbn/es-query';
import type { ESQLControlVariable } from '@kbn/esql-types';
import { DEFAULT_TIME_FIELD } from '@kbn/alerting-v2-constants';
import {
buildEpisodeTagOptionsQuery,
type EpisodeTagOptionRow,
Expand Down Expand Up @@ -50,5 +51,6 @@ export const fetchEpisodeTagOptions = ({
query,
input,
abortSignal,
timeField: DEFAULT_TIME_FIELD,
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import userEvent from '@testing-library/user-event';
import { I18nProvider } from '@kbn/i18n-react';
import type { FindRulesResponse } from '@kbn/alerting-v2-schemas';
import {
EpisodeDurationCell,
EpisodeStatusCell,
EpisodeTagsCell,
EpisodeRuleCell,
Expand Down Expand Up @@ -75,6 +76,45 @@ describe('EpisodeStatusCell', () => {
});
});

describe('EpisodeDurationCell', () => {
const mockDataView = {
getFieldByName: jest.fn().mockReturnValue({ name: 'duration' }),
getFormatterForField: jest
.fn()
.mockReturnValue({ convertToText: (value: number) => `${value} ms` }),
} as never;
const durationCellProps = { ...baseCellProps, columnId: 'duration', dataView: mockDataView };

it('renders the formatted duration when the episode start was seen', () => {
renderWithI18n(
<EpisodeDurationCell
{...durationCellProps}
row={makeRow({ duration: 840000, duration_is_lower_bound: false })}
/>
);

expect(screen.getByText('840000 ms')).toBeInTheDocument();
expect(screen.queryByTestId('episodeDurationLowerBound')).not.toBeInTheDocument();
});

it('marks the duration as a lower bound when the episode started before the time range', () => {
renderWithI18n(
<EpisodeDurationCell
{...durationCellProps}
row={makeRow({ duration: 840000, duration_is_lower_bound: true })}
/>
);

expect(screen.getByTestId('episodeDurationLowerBound')).toHaveTextContent('β‰₯ 840000 ms');
});

it('renders an empty value when the row has no duration', () => {
renderWithI18n(<EpisodeDurationCell {...durationCellProps} row={makeRow({})} />);

expect(screen.getByText('β€”')).toBeInTheDocument();
});
});

describe('EpisodeTagsCell', () => {
it('renders a badge for each tag in the row last_tags field', () => {
const row = makeRow({ group_hash: 'gh3', last_tags: ['foo', 'bar'] });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { AlertEpisodeStatusBadges } from './status/status_badges';
import { TagBadges } from './actions/tags';
import { AlertEpisodeSeverityBadge } from './severity/episode_severity_badge';
import type { EpisodeSeverity } from './severity/severity_utils';
import { EMPTY_VALUE } from '../constants';
import * as i18n from './translations';

type Rule = FindRulesResponse['items'][number];
Expand Down Expand Up @@ -65,6 +66,31 @@ export const EpisodeStatusCell = ({ row, columnId }: CellRendererProps) => {
);
};

/**
* Renders the episode duration, marked as a lower bound when the query did not
* see the episode start because it predates the selected time range.
*/
export const EpisodeDurationCell = ({ row, columnId, dataView }: CellRendererProps) => {
const duration = row.flattened[columnId] as number | null | undefined;
if (duration == null) {
return <>{EMPTY_VALUE}</>;
}
const field = dataView.getFieldByName(columnId);
const formatted = field
? dataView.getFormatterForField(field).convertToText(duration)
: `${duration}`;
if (!row.flattened.duration_is_lower_bound) {
return <>{formatted}</>;
}
return (
<EuiToolTip content={i18n.DURATION_LOWER_BOUND_TOOLTIP}>
<span tabIndex={0} data-test-subj="episodeDurationLowerBound">
{i18n.getDurationLowerBoundLabel(formatted)}
</span>
</EuiToolTip>
);
};

export const EpisodeTagsCell = ({ row }: CellRendererProps) => {
const tags = (row.flattened.last_tags as string[] | undefined) ?? [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ export const getRuleCellCopyRuleIdTooltip = (ruleId: string) =>
values: { ruleId },
});

export const getDurationLowerBoundLabel = (duration: string) =>
i18n.translate('xpack.alertingV2EpisodesUi.durationCell.lowerBoundLabel', {
defaultMessage: 'β‰₯ {duration}',
values: { duration },
});

export const DURATION_LOWER_BOUND_TOOLTIP = i18n.translate(
'xpack.alertingV2EpisodesUi.durationCell.lowerBoundTooltip',
{
defaultMessage:
'The episode started before the selected time range, so its actual duration is longer. Widen the time range or open the episode to see it.',
}
);

export const RULE_CELL_RULE_ID_COPIED = i18n.translate(
'xpack.alertingV2EpisodesUi.ruleCell.ruleIdCopied',
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import React from 'react';
import type { Filter } from '@kbn/es-query';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@kbn/react-query';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
Expand Down Expand Up @@ -190,7 +191,7 @@ describe('useEpisodesHistogramQuery', () => {
expect(secondBucket?.count).toBe(0);
});

it('includes timeRange in the executeEsqlQuery input', async () => {
it('sends the time range as an alert-events-only request filter', async () => {
mockExecuteEsqlQuery.mockResolvedValue([]);

renderHook(
Expand All @@ -206,9 +207,18 @@ describe('useEpisodesHistogramQuery', () => {

await waitFor(() => expect(mockExecuteEsqlQuery).toHaveBeenCalled());
const inputArg = mockExecuteEsqlQuery.mock.calls[0][0].input as {
timeRange?: typeof mockTimeRange;
timeRange?: unknown;
filters?: Filter[];
};
expect(inputArg.timeRange).toEqual(mockTimeRange);
// The range is sent as a request filter on the alert events only, so the
// action documents are kept whatever their timestamp.
expect(inputArg.timeRange).toBeUndefined();
expect(inputArg.filters).toHaveLength(1);
const should = inputArg.filters?.[0].query?.bool.should;
expect(should[0].bool.filter[1].range['@timestamp']).toEqual(
expect.objectContaining({ gte: mockTimeRange.from, lte: mockTimeRange.to })
);
expect(should[1]).toEqual({ exists: { field: 'action_type' } });
});

it('concatenates source histogram rows with v2 rows', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useSpaceId } from './use_space_id';
import { queryKeys } from '../query_keys';
import { buildEpisodesHistogramQuery } from '../queries/episodes_query';
import { executeEsqlQuery } from '../utils/execute_esql_query';
import { buildAlertEventsTimeRangeFilter } from '../utils/build_alert_events_time_range_filter';
import { fetchFromSource } from '../utils/fetch_from_sources';
import { useAdditionalEpisodesDataSource } from '../context/episode_data_source_context';
import {
Expand Down Expand Up @@ -78,14 +79,15 @@ export const useEpisodesHistogramQuery = ({
additionalEpisodesDataSource?.id
),
queryFn: async ({ signal }) => {
const timeRangeFilter = timeRange ? buildAlertEventsTimeRangeFilter(timeRange) : undefined;
const [v2Rows, sourceHistograms] = await Promise.all([
executeEsqlQuery<HistogramEpisodeRow>({
expressions: services.expressions,
query: buildEpisodesHistogramQuery(spaceId, filterState, breakdownField).print('basic'),
input: {
type: 'kibana_context' as const,
esqlVariables: [],
...(timeRange ? { timeRange } : {}),
...(timeRangeFilter ? { filters: [timeRangeFilter] } : {}),
},
abortSignal: signal,
}),
Expand Down
Loading
Loading