Skip to content

Commit 43ca950

Browse files
committed
Move episodes time filter inside query, include previous actions
1 parent 8019c7d commit 43ca950

19 files changed

Lines changed: 351 additions & 20 deletions

x-pack/platform/packages/shared/response-ops/alerting-v2-common-queries/episodes/episodes_query.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,30 @@ describe('buildEpisodesBaseQuery', () => {
7979
});
8080
});
8181

82+
describe('duration lower bound flag', () => {
83+
it('computes the start event and the first series event in the aggregations', () => {
84+
const queryString = buildEpisodesBaseQuery(SPACE_ID).print('basic');
85+
86+
expect(queryString).toContain(
87+
'start_event_timestamp = MIN(@timestamp) WHERE `episode.status` == "pending" AND `episode.status_count` == 1'
88+
);
89+
expect(queryString).toContain(
90+
'first_series_event_timestamp = MIN(@timestamp) WHERE type == "alert"'
91+
);
92+
});
93+
94+
it('flags episodes whose start was not seen, in the list query only', () => {
95+
const listQuery = buildEpisodesQuery(SPACE_ID).print('basic');
96+
const baseQuery = buildEpisodesBaseQuery(SPACE_ID).print('basic');
97+
98+
expect(listQuery).toContain(
99+
'EVAL duration_is_lower_bound = ((start_event_timestamp IS NULL OR start_event_timestamp != first_timestamp) AND first_series_event_timestamp >= first_timestamp)'
100+
);
101+
expect(listQuery).toMatch(/KEEP .*duration_is_lower_bound/);
102+
expect(baseQuery).not.toContain('duration_is_lower_bound');
103+
});
104+
});
105+
82106
describe('buildEpisodesQuery', () => {
83107
it('should join both data streams', () => {
84108
const query = buildEpisodesQuery(SPACE_ID);

x-pack/platform/packages/shared/response-ops/alerting-v2-common-queries/episodes/episodes_query.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export const addEpisodeAggregation = (query: ComposerQuery) => {
118118
// prettier-ignore
119119
query
120120
.pipe`EVAL extracted_data = JSON_EXTRACT(_source, "data")`
121-
.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`
121+
.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`
122122
.pipe`EVAL duration = DATE_DIFF("ms", first_timestamp, last_timestamp)`
123123
.pipe`WHERE @timestamp == last_timestamp`;
124124
};
@@ -128,7 +128,8 @@ const addGroupHashActionStats = (query: ComposerQuery) => {
128128
query
129129
.pipe`INLINE STATS last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze"),
130130
snooze_expiry = LAST(expiry, @timestamp) WHERE action_type == "snooze",
131-
last_tags = LAST(tags, @timestamp) WHERE action_type == "tag"
131+
last_tags = LAST(tags, @timestamp) WHERE action_type == "tag",
132+
first_series_event_timestamp = MIN(@timestamp) WHERE type == "alert"
132133
BY group_hash`;
133134
};
134135

@@ -272,6 +273,24 @@ export const buildEpisodesBaseQuery = (
272273
return query;
273274
};
274275

276+
export const DURATION_LOWER_BOUND_FIELD = 'duration_is_lower_bound';
277+
278+
/**
279+
* Flags the episodes whose first event was not part of the scanned rows, so
280+
* `first_timestamp` and `duration` only cover the selected time range. The
281+
* start was seen when the earliest row is the event that opened the episode
282+
* (`pending` with `status_count` 1), or when an earlier alert event of the
283+
* same series is present, which can only belong to a previous episode. Rules
284+
* that skip the pending state and have no earlier episode in range still get
285+
* the flag: showing a lower bound is always true, hiding a truncation is not.
286+
*/
287+
const addDurationLowerBoundFlag = (query: ComposerQuery) => {
288+
// prettier-ignore
289+
query.pipe(
290+
`EVAL ${DURATION_LOWER_BOUND_FIELD} = (start_event_timestamp IS NULL OR start_event_timestamp != first_timestamp) AND first_series_event_timestamp >= first_timestamp`
291+
);
292+
};
293+
275294
/**
276295
* Builds an ES|QL query for episodes request with sorting and filtering.
277296
*
@@ -299,7 +318,12 @@ export const buildEpisodesQuery = (
299318

300319
const sortField = resolveSortField(sortState.sortField);
301320

321+
addDurationLowerBoundFlag(query);
322+
302323
return asTypedEsqlQuery<AlertEpisodeEsqlRow>(
303-
query.sort([sortField, sortDir]).pipe`LIMIT ${pageSizeParam}`.keep(...ALERT_EPISODE_FIELDS)
324+
query.sort([sortField, sortDir]).pipe`LIMIT ${pageSizeParam}`.keep(
325+
...ALERT_EPISODE_FIELDS,
326+
DURATION_LOWER_BOUND_FIELD
327+
)
304328
);
305329
};

x-pack/platform/packages/shared/response-ops/alerting-v2-common-queries/episodes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type { TypedEsqlQuery } from './typed_esql_query';
2121

2222
export {
2323
ALERT_EPISODE_FIELDS,
24+
DURATION_LOWER_BOUND_FIELD,
2425
buildEpisodesBaseQuery,
2526
buildEpisodesQuery,
2627
addEpisodeAggregation,

x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/apis/fetch_alerting_episodes.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { ESQLVariableType } from '@kbn/esql-types';
99
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
1010
import { executeEsqlQuery } from '../utils/execute_esql_query';
11+
import type { Filter } from '@kbn/es-query';
1112
import { buildEpisodesQuery } from '@kbn/alerting-v2-common-queries';
1213
import { fetchAlertingEpisodes } from './fetch_alerting_episodes';
1314

@@ -121,6 +122,42 @@ describe('fetchAlertingEpisodes', () => {
121122
});
122123
});
123124

125+
it('should apply the time range to the alert events only, as a request filter', async () => {
126+
await fetchAlertingEpisodes({
127+
spaceId: SPACE_ID,
128+
pageSize: 10,
129+
timeRange: { from: '2026-09-10T10:00:00.000Z', to: '2026-09-10T12:00:00.000Z' },
130+
services: { expressions: mockExpressions },
131+
});
132+
133+
const { input, timeField } = mockExecuteEsqlQuery.mock.calls[0][0] as {
134+
input: { timeRange?: unknown; filters?: Filter[] };
135+
timeField?: string;
136+
};
137+
expect(timeField).toBeUndefined();
138+
expect(input.timeRange).toBeUndefined();
139+
expect(input.filters).toHaveLength(1);
140+
expect(input.filters?.[0].query?.bool.should).toEqual([
141+
{
142+
bool: {
143+
filter: [
144+
{ term: { type: 'alert' } },
145+
{
146+
range: {
147+
'@timestamp': {
148+
format: 'strict_date_optional_time',
149+
gte: '2026-09-10T10:00:00.000Z',
150+
lte: '2026-09-10T12:00:00.000Z',
151+
},
152+
},
153+
},
154+
],
155+
},
156+
},
157+
{ exists: { field: 'action_type' } },
158+
]);
159+
});
160+
124161
it('should call executeEsqlQuery with custom sort parameters', async () => {
125162
const pageSize = 25;
126163
const sortState = {

x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/apis/fetch_alerting_episodes.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import type { ESQLControlVariable } from '@kbn/esql-types';
99
import { ESQLVariableType } from '@kbn/esql-types';
1010
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
11-
import type { TimeRange } from '@kbn/es-query';
11+
import type { Filter, TimeRange } from '@kbn/es-query';
1212
import {
1313
asEsqlRows,
1414
buildEpisodesQuery,
@@ -18,6 +18,7 @@ import {
1818
type EpisodesSortState,
1919
} from '@kbn/alerting-v2-common-queries';
2020
import { executeEsqlQuery } from '../utils/execute_esql_query';
21+
import { buildAlertEventsTimeRangeFilter } from '../utils/build_alert_events_time_range_filter';
2122

2223
export interface FetchAlertingEpisodesOptions {
2324
spaceId: string;
@@ -47,16 +48,17 @@ export const fetchAlertingEpisodes = ({
4748
const input: {
4849
type: 'kibana_context';
4950
esqlVariables: ESQLControlVariable[];
50-
timeRange?: TimeRange;
51+
filters?: Filter[];
5152
} = {
5253
type: 'kibana_context',
5354
esqlVariables: [
5455
{ key: PAGE_SIZE_ESQL_VARIABLE, value: pageSize, type: ESQLVariableType.VALUES },
5556
],
5657
};
5758

58-
if (timeRange) {
59-
input.timeRange = timeRange;
59+
const timeRangeFilter = timeRange ? buildAlertEventsTimeRangeFilter(timeRange) : undefined;
60+
if (timeRangeFilter) {
61+
input.filters = [timeRangeFilter];
6062
}
6163

6264
return executeEsqlQuery({

x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/apis/fetch_episode_tag_options.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
99
import type { TimeRange } from '@kbn/es-query';
1010
import type { ESQLControlVariable } from '@kbn/esql-types';
11+
import { DEFAULT_TIME_FIELD } from '@kbn/alerting-v2-constants';
1112
import {
1213
buildEpisodeTagOptionsQuery,
1314
type EpisodeTagOptionRow,
@@ -50,5 +51,6 @@ export const fetchEpisodeTagOptions = ({
5051
query,
5152
input,
5253
abortSignal,
54+
timeField: DEFAULT_TIME_FIELD,
5355
});
5456
};

x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/episodes_table_cell_renderers.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import userEvent from '@testing-library/user-event';
1111
import { I18nProvider } from '@kbn/i18n-react';
1212
import type { FindRulesResponse } from '@kbn/alerting-v2-schemas';
1313
import {
14+
EpisodeDurationCell,
1415
EpisodeStatusCell,
1516
EpisodeTagsCell,
1617
EpisodeRuleCell,
@@ -75,6 +76,45 @@ describe('EpisodeStatusCell', () => {
7576
});
7677
});
7778

79+
describe('EpisodeDurationCell', () => {
80+
const mockDataView = {
81+
getFieldByName: jest.fn().mockReturnValue({ name: 'duration' }),
82+
getFormatterForField: jest
83+
.fn()
84+
.mockReturnValue({ convertToText: (value: number) => `${value} ms` }),
85+
} as never;
86+
const durationCellProps = { ...baseCellProps, columnId: 'duration', dataView: mockDataView };
87+
88+
it('renders the formatted duration when the episode start was seen', () => {
89+
renderWithI18n(
90+
<EpisodeDurationCell
91+
{...durationCellProps}
92+
row={makeRow({ duration: 840000, duration_is_lower_bound: false })}
93+
/>
94+
);
95+
96+
expect(screen.getByText('840000 ms')).toBeInTheDocument();
97+
expect(screen.queryByTestId('episodeDurationLowerBound')).not.toBeInTheDocument();
98+
});
99+
100+
it('marks the duration as a lower bound when the episode started before the time range', () => {
101+
renderWithI18n(
102+
<EpisodeDurationCell
103+
{...durationCellProps}
104+
row={makeRow({ duration: 840000, duration_is_lower_bound: true })}
105+
/>
106+
);
107+
108+
expect(screen.getByTestId('episodeDurationLowerBound')).toHaveTextContent('≥ 840000 ms');
109+
});
110+
111+
it('renders an empty value when the row has no duration', () => {
112+
renderWithI18n(<EpisodeDurationCell {...durationCellProps} row={makeRow({})} />);
113+
114+
expect(screen.getByText('—')).toBeInTheDocument();
115+
});
116+
});
117+
78118
describe('EpisodeTagsCell', () => {
79119
it('renders a badge for each tag in the row last_tags field', () => {
80120
const row = makeRow({ group_hash: 'gh3', last_tags: ['foo', 'bar'] });

x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/episodes_table_cell_renderers.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { AlertEpisodeStatusBadges } from './status/status_badges';
3131
import { TagBadges } from './actions/tags';
3232
import { AlertEpisodeSeverityBadge } from './severity/episode_severity_badge';
3333
import type { EpisodeSeverity } from './severity/severity_utils';
34+
import { EMPTY_VALUE } from '../constants';
3435
import * as i18n from './translations';
3536

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

69+
/**
70+
* Renders the episode duration, marked as a lower bound when the query did not
71+
* see the episode start because it predates the selected time range.
72+
*/
73+
export const EpisodeDurationCell = ({ row, columnId, dataView }: CellRendererProps) => {
74+
const duration = row.flattened[columnId] as number | null | undefined;
75+
if (duration == null) {
76+
return <>{EMPTY_VALUE}</>;
77+
}
78+
const field = dataView.getFieldByName(columnId);
79+
const formatted = field
80+
? dataView.getFormatterForField(field).convertToText(duration)
81+
: `${duration}`;
82+
if (!row.flattened.duration_is_lower_bound) {
83+
return <>{formatted}</>;
84+
}
85+
return (
86+
<EuiToolTip content={i18n.DURATION_LOWER_BOUND_TOOLTIP}>
87+
<span tabIndex={0} data-test-subj="episodeDurationLowerBound">
88+
{i18n.getDurationLowerBoundLabel(formatted)}
89+
</span>
90+
</EuiToolTip>
91+
);
92+
};
93+
6894
export const EpisodeTagsCell = ({ row }: CellRendererProps) => {
6995
const tags = (row.flattened.last_tags as string[] | undefined) ?? [];
7096

x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/components/translations.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ export const getRuleCellCopyRuleIdTooltip = (ruleId: string) =>
6363
values: { ruleId },
6464
});
6565

66+
export const getDurationLowerBoundLabel = (duration: string) =>
67+
i18n.translate('xpack.alertingV2EpisodesUi.durationCell.lowerBoundLabel', {
68+
defaultMessage: '≥ {duration}',
69+
values: { duration },
70+
});
71+
72+
export const DURATION_LOWER_BOUND_TOOLTIP = i18n.translate(
73+
'xpack.alertingV2EpisodesUi.durationCell.lowerBoundTooltip',
74+
{
75+
defaultMessage:
76+
'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.',
77+
}
78+
);
79+
6680
export const RULE_CELL_RULE_ID_COPIED = i18n.translate(
6781
'xpack.alertingV2EpisodesUi.ruleCell.ruleIdCopied',
6882
{

x-pack/platform/packages/shared/response-ops/alerting-v2-episodes-ui/hooks/use_episodes_histogram_query.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

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

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

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

207208
await waitFor(() => expect(mockExecuteEsqlQuery).toHaveBeenCalled());
208209
const inputArg = mockExecuteEsqlQuery.mock.calls[0][0].input as {
209-
timeRange?: typeof mockTimeRange;
210+
timeRange?: unknown;
211+
filters?: Filter[];
210212
};
211-
expect(inputArg.timeRange).toEqual(mockTimeRange);
213+
// The range is sent as a request filter on the alert events only, so the
214+
// action documents are kept whatever their timestamp.
215+
expect(inputArg.timeRange).toBeUndefined();
216+
expect(inputArg.filters).toHaveLength(1);
217+
const should = inputArg.filters?.[0].query?.bool.should;
218+
expect(should[0].bool.filter[1].range['@timestamp']).toEqual(
219+
expect.objectContaining({ gte: mockTimeRange.from, lte: mockTimeRange.to })
220+
);
221+
expect(should[1]).toEqual({ exists: { field: 'action_type' } });
212222
});
213223

214224
it('concatenates source histogram rows with v2 rows', async () => {

0 commit comments

Comments
 (0)