Skip to content

Commit 9eee9e6

Browse files
refactor(streams): rename verdict to status in sig events UI labels
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0b19460 commit 9eee9e6

15 files changed

Lines changed: 147 additions & 141 deletions

File tree

x-pack/platform/packages/shared/kbn-streams-schema/src/sig_events/discoveries/index.ts

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,19 @@ import {
1313
evidenceSchema,
1414
} from '../common_schemas';
1515

16+
import { MAX_STREAM_NAME_LENGTH } from '../../helpers/stream_name_validation';
17+
18+
const MAX_ID_LENGTH = 256;
19+
const MAX_RULE_NAME_LENGTH = 256;
20+
const MAX_TITLE_LENGTH = 512;
21+
const MAX_TEXT_LENGTH = 10_000;
22+
1623
const discoveryDetectionSchema = z.object({
17-
detection_id: z.string().optional(),
18-
rule_name: z.string().optional(),
19-
rule_uuid: z.string().optional(),
20-
stream_name: z.string().optional(),
21-
change_point_type: z.string().optional(),
24+
detection_id: z.string().max(MAX_ID_LENGTH).optional(),
25+
rule_name: z.string().max(MAX_RULE_NAME_LENGTH).optional(),
26+
rule_uuid: z.string().max(MAX_ID_LENGTH).optional(),
27+
stream_name: z.string().max(MAX_STREAM_NAME_LENGTH).optional(),
28+
change_point_type: z.string().max(MAX_ID_LENGTH).optional(),
2229
event_count: z.number().optional(),
2330
alert_count: z.number().optional(),
2431
detected_at: z.string().optional(),
@@ -27,31 +34,30 @@ const discoveryDetectionSchema = z.object({
2734
export const discoverySchema = z.object({
2835
'@timestamp': z.iso.datetime(),
2936
kind: z.enum(['finding', 'clearance']),
30-
discovery_id: z.string(),
31-
discovery_slug: z.string(),
37+
discovery_id: z.string().max(MAX_ID_LENGTH),
38+
discovery_slug: z.string().max(MAX_ID_LENGTH),
3239
discovered_at: z.iso.datetime().optional(),
33-
rule_names: z.array(z.string()),
34-
stream_names: z.array(z.string()),
35-
title: z.string(),
36-
summary: z.string(),
37-
root_cause: z.string(),
40+
rule_names: z.array(z.string().max(MAX_RULE_NAME_LENGTH)),
41+
stream_names: z.array(z.string().max(MAX_STREAM_NAME_LENGTH)),
42+
title: z.string().max(MAX_TITLE_LENGTH),
43+
summary: z.string().max(MAX_TEXT_LENGTH),
44+
root_cause: z.string().max(MAX_TEXT_LENGTH),
3845
criticality: z.number(),
3946
confidence: z.number(),
40-
impact: z.string(),
47+
impact: z.string().max(MAX_TEXT_LENGTH),
4148
detections: z.array(discoveryDetectionSchema),
4249
dependency_edges: z.array(dependencyEdgeSchema).optional(),
4350
infra_components: z.array(infraComponentSchema).optional(),
4451
cause_kis: z.array(causeKiSchema).optional(),
4552
evidences: z.array(evidenceSchema).optional(),
46-
closes: z.string().optional(),
47-
grouped_into: z.string().optional(),
48-
grouped_discovery_ids: z.array(z.string()).optional(),
49-
grouping_rationale: z.string().optional(),
50-
previous_discovery_id: z.string().optional(),
51-
change_point_occurrence: z.string().optional(),
52-
workflow_execution_id: z.string().optional(),
53-
conversation_id: z.string().optional(),
54-
closed_by_execution_id: z.string().optional(),
53+
closes_discovery_id: z.string().max(MAX_ID_LENGTH).optional(),
54+
grouped_discovery_ids: z.array(z.string().max(MAX_ID_LENGTH)).optional(),
55+
grouping_rationale: z.string().max(MAX_TEXT_LENGTH).optional(),
56+
previous_discovery_id: z.string().max(MAX_ID_LENGTH).optional(),
57+
change_point_occurrence: z.string().max(MAX_ID_LENGTH).optional(),
58+
workflow_execution_id: z.string().max(MAX_ID_LENGTH).optional(),
59+
conversation_id: z.string().max(MAX_ID_LENGTH).optional(),
60+
closed_by_execution_id: z.string().max(MAX_ID_LENGTH).optional(),
5561
});
5662

5763
export type Discovery = z.infer<typeof discoverySchema>;

x-pack/platform/packages/shared/kbn-streams-schema/src/sig_events/events/index.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const sigEventSchema = z.object({
2020
discovery_id: z.string(),
2121
discovery_slug: z.string(),
2222
previous_event_id: z.string().optional(),
23+
// TODO: rename to status once the data stream field is renamed
2324
verdict: z.string(),
2425
workflow_execution_id: z.string(),
2526
rule_names: z.array(z.string()),
@@ -37,13 +38,10 @@ export const sigEventSchema = z.object({
3738
cause_kis: z.array(causeKiSchema).optional(),
3839
evidences: z.array(evidenceSchema).optional(),
3940
grouped_into: z.string().optional(),
40-
grouped_discovery_ids: z.array(z.string()).optional(),
41+
// TODO: rename once the data stream fields are renamed
4142
// Audit fields merged from verdict docs
4243
verdict_summary: z.string().optional(),
4344
assessment_note: z.string().optional(),
44-
verdict_source: z.string().optional(),
45-
original_verdict: z.string().optional(),
46-
conversation_id: z.string().optional(),
4745
});
4846

4947
export type SigEvent = z.infer<typeof sigEventSchema>;

x-pack/platform/plugins/shared/streams/server/lib/sig_events/detections/detection_client.ts

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77

88
import type { IDataStreamClient } from '@kbn/data-streams';
99
import { esql } from '@elastic/esql';
10+
import type { ESQLAstExpression } from '@elastic/esql/types';
1011
import type { ElasticsearchClient } from '@kbn/core/server';
1112
import {
1213
type CommonSearchOptions,
1314
type PaginatedSearchOptions,
1415
type PaginatedResponse,
1516
} from '../query_utils';
1617
import {
17-
type LatestSourceWhereCondition,
18+
andWhere,
1819
runLatestSourceEsqlQuery,
1920
runPaginatedLatestSourceEsqlQuery,
2021
runFindByIdEsqlQuery,
@@ -27,6 +28,7 @@ import {
2728
type StoredDetection,
2829
type detectionsMappings,
2930
} from './data_stream';
31+
import { FIELD_DETECTION_ID } from '../field_names';
3032

3133
export type DetectionDataStreamClient = IDataStreamClient<
3234
typeof detectionsMappings,
@@ -47,14 +49,6 @@ export interface DetectionsPaginatedSearchOptions extends PaginatedSearchOptions
4749
rule_name?: string;
4850
}
4951

50-
const andWhere = (
51-
current: LatestSourceWhereCondition | undefined,
52-
next: LatestSourceWhereCondition
53-
): LatestSourceWhereCondition => {
54-
return current ? esql.exp`${current} AND ${next}` : next;
55-
};
56-
57-
const GROUP_BY_FIELD = 'detection_id';
5852
const KIND_HANDLED = 'handled' satisfies Detection['kind'];
5953
const KIND_QUIET = 'quiet' satisfies Detection['kind'];
6054
const PROCESSED_IDS_CHUNK_SIZE = 250;
@@ -77,10 +71,8 @@ export class DetectionClient {
7771

7872
// Exclude kind:handled from the main query — handled docs are pipeline stamps,
7973
// not anomaly state. processed is derived separately via getProcessedIds.
80-
private buildWhere(options: DetectionsSearchOptions): LatestSourceWhereCondition {
81-
let where: LatestSourceWhereCondition = esql.exp`${esql.col('kind')} != ${esql.str(
82-
KIND_HANDLED
83-
)}`;
74+
private buildWhere(options: DetectionsSearchOptions): ESQLAstExpression {
75+
let where: ESQLAstExpression = esql.exp`${esql.col('kind')} != ${esql.str(KIND_HANDLED)}`;
8476

8577
const ruleUuidLiterals = options.rule_uuid?.map((ruleUuid) => esql.str(ruleUuid));
8678
if (ruleUuidLiterals?.length) {
@@ -136,7 +128,7 @@ export class DetectionClient {
136128
options,
137129
index: DETECTIONS_DATA_STREAM,
138130
where: this.buildWhere(options),
139-
groupBy: GROUP_BY_FIELD,
131+
groupBy: FIELD_DETECTION_ID,
140132
});
141133
const processedIds = await this.getProcessedIds(
142134
result.hits.map((h) => h.detection_id).filter((id): id is string => Boolean(id))
@@ -157,8 +149,8 @@ export class DetectionClient {
157149
options,
158150
index: DETECTIONS_DATA_STREAM,
159151
where: this.buildWhere(options),
160-
groupBy: GROUP_BY_FIELD,
161-
sort: [['detected_at', 'DESC']],
152+
groupBy: FIELD_DETECTION_ID,
153+
sort: [['@timestamp', 'DESC']],
162154
});
163155
const processedIds = await this.getProcessedIds(
164156
result.hits.map((h) => h.detection_id).filter((id): id is string => Boolean(id))
@@ -176,7 +168,7 @@ export class DetectionClient {
176168
esClient: this.clients.esClient,
177169
space: this.clients.space,
178170
index: DETECTIONS_DATA_STREAM,
179-
idField: GROUP_BY_FIELD,
171+
idField: FIELD_DETECTION_ID,
180172
idValue: detectionId,
181173
});
182174
// History returns all doc kinds including kind:handled.

x-pack/platform/plugins/shared/streams/server/lib/sig_events/discoveries/discovery_client.ts

Lines changed: 73 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,23 @@ import {
1919
runFindByIdEsqlQuery,
2020
queryEsql,
2121
esqlToObjects,
22+
runFindByIdsEsqlQuery,
2223
} from '../latest_source_query';
2324
import {
2425
DISCOVERIES_DATA_STREAM,
2526
type Discovery,
2627
type StoredDiscovery,
2728
type discoveriesMappings,
2829
} from './data_stream';
30+
import { FIELD_DISCOVERY_ID, FIELD_DISCOVERY_SLUG } from '../field_names';
31+
32+
const CLEARED_IDS_CHUNK_SIZE = 250;
2933

3034
export type DiscoveryDataStreamClient = IDataStreamClient<
3135
typeof discoveriesMappings,
3236
StoredDiscovery
3337
>;
3438

35-
const GROUP_BY_FIELD = 'discovery_slug';
36-
3739
export class DiscoveryClient {
3840
constructor(
3941
private readonly clients: {
@@ -56,7 +58,7 @@ export class DiscoveryClient {
5658
space: this.clients.space,
5759
options,
5860
index: DISCOVERIES_DATA_STREAM,
59-
groupBy: GROUP_BY_FIELD,
61+
groupBy: FIELD_DISCOVERY_ID,
6062
});
6163
}
6264

@@ -68,53 +70,96 @@ export class DiscoveryClient {
6870
space: this.clients.space,
6971
options,
7072
index: DISCOVERIES_DATA_STREAM,
71-
groupBy: GROUP_BY_FIELD,
73+
groupBy: FIELD_DISCOVERY_ID,
74+
where: esql.exp`${esql.col('kind')} == ${esql.str('finding')}`,
7275
});
7376

74-
if (result.hits.length === 0) return result;
77+
if (!result.hits.length) return result;
78+
79+
const clearedIds = await this.getClearedIds(
80+
result.hits.map((h) => h.discovery_id).filter((id): id is string => Boolean(id))
81+
);
7582

76-
const discoveredAtMap = await this.getDiscoveredAtMap(options);
7783
return {
7884
...result,
7985
hits: result.hits.map((h) => ({
8086
...h,
81-
discovered_at: discoveredAtMap.get(h.discovery_slug) ?? h['@timestamp'],
87+
kind: clearedIds.has(h.discovery_id ?? '') ? ('clearance' as const) : h.kind,
8288
})),
8389
};
8490
}
8591

86-
// Returns MIN(@timestamp) of kind:finding documents per discovery_slug for the given time range.
87-
// A finding always precedes any clearance for the same slug, so MIN(@timestamp) = first investigation time.
88-
private async getDiscoveredAtMap(options: CommonSearchOptions): Promise<Map<string, string>> {
89-
try {
90-
let query = esql.from([DISCOVERIES_DATA_STREAM]).where`${esql.col('kibana.space_ids')} == ${
91-
this.clients.space
92-
} OR ${esql.col('kibana.space_ids')} IS NULL`;
92+
// Returns the set of finding IDs that have been cleared.
93+
// Mirrors getProcessedIds in detection_client: a finding is cleared only when the latest
94+
// clearance doc timestamp is on or after the latest finding doc timestamp, so re-opened
95+
// findings (no newer clearance) are not reported as cleared.
96+
// Chunked at CLEARED_IDS_CHUNK_SIZE to match the getProcessedIds IN-clause guard.
97+
private async getClearedIds(findingIds: string[]): Promise<Set<string>> {
98+
if (!findingIds.length) return new Set();
9399

94-
if (options.from !== undefined) {
95-
query = query.where`@timestamp >= TO_DATETIME(${esql.str(options.from)})`;
96-
}
97-
if (options.to !== undefined) {
98-
query = query.where`@timestamp <= TO_DATETIME(${esql.str(options.to)})`;
100+
const cleared = new Set<string>();
101+
for (let i = 0; i < findingIds.length; i += CLEARED_IDS_CHUNK_SIZE) {
102+
const batch = findingIds.slice(i, i + CLEARED_IDS_CHUNK_SIZE);
103+
const idLiterals = batch.map((id) => esql.str(id));
104+
const kindFinding = esql.str('finding');
105+
const kindClearance = esql.str('clearance');
106+
// Use EVAL to normalize both doc types to the same "finding ID" for grouping:
107+
// finding docs: unified_id = discovery_id
108+
// clearance docs: unified_id = closes_discovery_id (references the original finding)
109+
const query = esql`FROM ${DISCOVERIES_DATA_STREAM}
110+
| WHERE ${esql.col('kibana.space_ids')} == ${esql.str(this.clients.space)} OR ${esql.col(
111+
'kibana.space_ids'
112+
)} IS NULL
113+
| WHERE ${esql.col('kind')} IN (${[kindFinding, kindClearance]})
114+
| WHERE ${esql.col('discovery_id')} IN (${idLiterals}) OR ${esql.col(
115+
'closes_discovery_id'
116+
)} IN (${idLiterals})
117+
| EVAL unified_id = CASE(${esql.col('kind')} == ${kindFinding}, ${esql.col(
118+
'discovery_id'
119+
)}, ${esql.col('closes_discovery_id')})
120+
| STATS max_finding_ts = MAX(CASE(${esql.col('kind')} == ${kindFinding}, @timestamp, null)),
121+
max_clearance_ts = MAX(CASE(${esql.col(
122+
'kind'
123+
)} == ${kindClearance}, @timestamp, null))
124+
BY unified_id
125+
| WHERE max_clearance_ts >= max_finding_ts OR max_finding_ts IS NULL
126+
| WHERE unified_id IS NOT NULL
127+
| KEEP unified_id`;
128+
const response = await queryEsql({ esClient: this.clients.esClient, query });
129+
const rows = esqlToObjects<{ unified_id: string }>(response);
130+
for (const r of rows) {
131+
if (r.unified_id) cleared.add(r.unified_id);
99132
}
133+
}
134+
return cleared;
135+
}
100136

101-
query = query.where`${esql.col('kind')} == ${esql.str('finding')}`;
102-
query = query.pipe`STATS discovered_at = MIN(@timestamp) BY ${esql.col('discovery_slug')}`;
137+
async findById(discoveryId: string): Promise<{ hits: Discovery[] }> {
138+
return runFindByIdEsqlQuery<Discovery>({
139+
esClient: this.clients.esClient,
140+
space: this.clients.space,
141+
index: DISCOVERIES_DATA_STREAM,
142+
idField: FIELD_DISCOVERY_ID,
143+
idValue: discoveryId,
144+
});
145+
}
103146

104-
const response = await queryEsql({ esClient: this.clients.esClient, query });
105-
const rows = esqlToObjects<{ discovery_slug: string; discovered_at: string }>(response);
106-
return new Map(rows.map((r) => [r.discovery_slug, r.discovered_at]));
107-
} catch (error) {
108-
return new Map();
109-
}
147+
async findByIds(discoveryIds: string[]): Promise<{ hits: Discovery[] }> {
148+
return runFindByIdsEsqlQuery<Discovery>({
149+
esClient: this.clients.esClient,
150+
space: this.clients.space,
151+
index: DISCOVERIES_DATA_STREAM,
152+
idField: FIELD_DISCOVERY_ID,
153+
idValues: discoveryIds,
154+
});
110155
}
111156

112157
async findBySlug(slug: string): Promise<{ hits: Discovery[] }> {
113158
return runFindByIdEsqlQuery<Discovery>({
114159
esClient: this.clients.esClient,
115160
space: this.clients.space,
116161
index: DISCOVERIES_DATA_STREAM,
117-
idField: 'discovery_slug',
162+
idField: FIELD_DISCOVERY_SLUG,
118163
idValue: slug,
119164
});
120165
}

x-pack/platform/plugins/shared/streams/server/lib/sig_events/events/data_stream.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,6 @@ export const eventsMappings = {
2525
verdict: mappings.keyword(),
2626
verdict_summary: mappings.text(),
2727
assessment_note: mappings.text(),
28-
verdict_source: mappings.keyword(),
29-
original_verdict: mappings.keyword(),
30-
conversation_id: mappings.keyword(),
31-
grouped_discovery_ids: mappings.keyword(),
3228
impact: mappings.keyword(),
3329
criticality: mappings.integer(),
3430
confidence: mappings.integer(),
@@ -37,9 +33,6 @@ export const eventsMappings = {
3733
root_cause: mappings.text(),
3834
recommended_action: mappings.text(),
3935
recommendations: mappings.text(),
40-
workflow_execution_id: mappings.keyword(),
41-
created_at: mappings.date({ format: 'strict_date_optional_time' }),
42-
grouped_into: mappings.keyword(),
4336
},
4437
} satisfies MappingsDefinition;
4538

x-pack/platform/plugins/shared/streams/server/lib/sig_events/events/event_client.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
type eventsMappings,
2929
} from './data_stream';
3030
import { FIELD_EVENT_ID, FIELD_DISCOVERY_SLUG } from '../field_names';
31+
import { enrichFromEvidences } from '../utils';
3132

3233
export type EventDataStreamClient = IDataStreamClient<typeof eventsMappings, StoredEvent>;
3334

@@ -39,19 +40,6 @@ export interface EventsFilterOptions {
3940

4041
export interface EventsPaginatedSearchOptions extends PaginatedSearchOptions, EventsFilterOptions {}
4142

42-
function enrichFromEvidences(e: SigEvent): SigEvent {
43-
const evidences = e.evidences ?? [];
44-
const streamNames = e.stream_names?.length
45-
? e.stream_names
46-
: [...new Set(evidences.map((ev) => ev.stream_name).filter((s): s is string => !!s))];
47-
const ruleNames = e.rule_names?.length
48-
? e.rule_names
49-
: [...new Set(evidences.map((ev) => ev.rule_name).filter((s): s is string => !!s))];
50-
51-
if (streamNames === e.stream_names && ruleNames === e.rule_names) return e;
52-
return { ...e, stream_names: streamNames, rule_names: ruleNames };
53-
}
54-
5543
export class EventClient {
5644
constructor(
5745
private readonly clients: {

x-pack/platform/plugins/shared/streams/server/routes/internal/sig_events/events/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ const eventsLifecycleRoute = createServerRoute({
143143
access: 'internal',
144144
summary: 'Get event lifecycle',
145145
description:
146-
'Get the full lifecycle chain for a significant event: detections, discoveries, verdicts, and event versions.',
146+
'Get the full lifecycle chain for a significant event: detections, discoveries, and event versions.',
147147
},
148148
security: {
149149
authz: {

0 commit comments

Comments
 (0)