From 70e30f2e7e4ce2ba9100a503abdbd3f93e886dc5 Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Fri, 5 Sep 2025 11:35:50 -0400 Subject: [PATCH 01/12] forward SLO filters to overview service --- .../routes/slo/get_slo_stats_overview.ts | 3 +- .../server/services/get_slo_stats_overview.ts | 92 ++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/x-pack/solutions/observability/plugins/slo/server/routes/slo/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/routes/slo/get_slo_stats_overview.ts index 68d1d673d26a9..41de40ae94fe4 100644 --- a/x-pack/solutions/observability/plugins/slo/server/routes/slo/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/routes/slo/get_slo_stats_overview.ts @@ -21,7 +21,7 @@ export const getSLOStatsOverview = createSloServerRoute({ params: getSLOStatsOverviewParamsSchema, handler: async ({ request, logger, params, plugins, getScopedClients }) => { await assertPlatinumLicense(plugins); - const { scopedClusterClient, spaceId, soClient, rulesClient, racClient } = + const { scopedClusterClient, repository, spaceId, soClient, rulesClient, racClient } = await getScopedClients({ request, logger, @@ -29,6 +29,7 @@ export const getSLOStatsOverview = createSloServerRoute({ const slosOverview = new GetSLOStatsOverview( soClient, + repository, scopedClusterClient, spaceId, logger, diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts index d000d6ec1d5d5..8673fc90610fe 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts @@ -12,14 +12,21 @@ import type { Logger } from '@kbn/logging'; import { AlertConsumers, SLO_RULE_TYPE_IDS } from '@kbn/rule-data-utils'; import type { AlertsClient } from '@kbn/rule-registry-plugin/server'; import type { GetSLOStatsOverviewParams, GetSLOStatsOverviewResponse } from '@kbn/slo-schema'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import moment from 'moment'; import { typedSearch } from '../utils/queries'; import { getSummaryIndices, getSloSettings } from './slo_settings'; import { getElasticsearchQueryOrThrow, parseStringFilters } from './transform_generators'; +import { FindSLO } from './find_slo'; +import type { SLORepository } from './slo_repository'; +import { DefaultSummarySearchClient } from './summary_search_client/summary_search_client'; + +const SLO_PAGESIZE_LIMIT = 1000; export class GetSLOStatsOverview { constructor( private soClient: SavedObjectsClientContract, + private repository: SLORepository, private scopedClusterClient: IScopedClusterClient, private spaceId: string, private logger: Logger, @@ -31,10 +38,81 @@ export class GetSLOStatsOverview { const settings = await getSloSettings(this.soClient); const { indices } = await getSummaryIndices(this.scopedClusterClient.asInternalUser, settings); - const kqlQuery = params.kqlQuery ?? ''; - const filters = params.filters ?? ''; + const kqlQuery = params?.kqlQuery ?? ''; + const filters = params?.filters ?? ''; const parsedFilters = parseStringFilters(filters, this.logger); + const summarySearchClient = new DefaultSummarySearchClient( + this.scopedClusterClient, + this.soClient, + this.logger, + this.spaceId + ); + + let ruleFilters: string = ''; + let burnRateFilters: QueryDslQueryContainer[] = []; + + let querySLOsForIds = false; + + try { + querySLOsForIds = !!( + (params?.filters && + Object.values(JSON.parse(params.filters)).some( + (value) => Array.isArray(value) && value.length > 0 + )) || + (params?.kqlQuery && params?.kqlQuery?.length > 0) + ); + } catch (error) { + querySLOsForIds = !!(params?.kqlQuery && params?.kqlQuery?.length > 0); + this.logger.error(`Error parsing filters: ${error}`); + } + + if (querySLOsForIds) { + const findSLO = new FindSLO(this.repository, summarySearchClient); + const sloIds = new Set(); + + const findSLOQueryParams = { + filters: params?.filters, + kqlQuery: params?.kqlQuery, + perPage: String(SLO_PAGESIZE_LIMIT), + }; + + const findSLOResponse = await findSLO.execute(findSLOQueryParams); + const total = findSLOResponse.total; + const numCalls = Math.ceil(total / SLO_PAGESIZE_LIMIT); + findSLOResponse.results.forEach((slo) => sloIds.add(slo.id)); + + for (let i = 1; i < numCalls; i++) { + const additionalCallResponse = await findSLO.execute({ + ...findSLOQueryParams, + page: String(i + 1), + }); + additionalCallResponse.results.forEach((slo) => sloIds.add(slo.id)); + } + + const sloIdsArray = Array.from(sloIds); + + const resultString = sloIdsArray.length + ? sloIdsArray.reduce((accumulator, currentValue, index) => { + const conditionString = `alert.attributes.params.sloId:${currentValue}`; + if (index === 0) { + return conditionString; + } else { + return accumulator + ' OR ' + conditionString; + } + }, '') + : 'alert.attributes.params.sloId:NO_MATCHES'; + + ruleFilters = resultString; + burnRateFilters = [ + { + terms: { + 'kibana.alert.rule.parameters.sloId': sloIdsArray, + }, + }, + ]; + } + const response = await typedSearch(this.scopedClusterClient.asCurrentUser, { index: indices, size: 0, @@ -105,6 +183,11 @@ export class GetSLOStatsOverview { options: { ruleTypeIds: SLO_RULE_TYPE_IDS, consumers: [AlertConsumers.SLO, AlertConsumers.ALERTS, AlertConsumers.OBSERVABILITY], + ...(ruleFilters + ? { + filter: ruleFilters, + } + : {}), }, }), @@ -113,6 +196,11 @@ export class GetSLOStatsOverview { consumers: [AlertConsumers.SLO, AlertConsumers.ALERTS, AlertConsumers.OBSERVABILITY], gte: moment().subtract(24, 'hours').toISOString(), lte: moment().toISOString(), + ...(burnRateFilters?.length + ? { + filter: burnRateFilters, + } + : {}), }), ]); From 82a40357a5449c644987ced89ab2477ca03ea366 Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Tue, 9 Sep 2025 10:45:28 -0400 Subject: [PATCH 02/12] switch to es query --- .../routes/slo/get_slo_stats_overview.ts | 3 +- .../server/services/get_slo_stats_overview.ts | 180 ++++++++++++------ 2 files changed, 126 insertions(+), 57 deletions(-) diff --git a/x-pack/solutions/observability/plugins/slo/server/routes/slo/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/routes/slo/get_slo_stats_overview.ts index 41de40ae94fe4..68d1d673d26a9 100644 --- a/x-pack/solutions/observability/plugins/slo/server/routes/slo/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/routes/slo/get_slo_stats_overview.ts @@ -21,7 +21,7 @@ export const getSLOStatsOverview = createSloServerRoute({ params: getSLOStatsOverviewParamsSchema, handler: async ({ request, logger, params, plugins, getScopedClients }) => { await assertPlatinumLicense(plugins); - const { scopedClusterClient, repository, spaceId, soClient, rulesClient, racClient } = + const { scopedClusterClient, spaceId, soClient, rulesClient, racClient } = await getScopedClients({ request, logger, @@ -29,7 +29,6 @@ export const getSLOStatsOverview = createSloServerRoute({ const slosOverview = new GetSLOStatsOverview( soClient, - repository, scopedClusterClient, spaceId, logger, diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts index 8673fc90610fe..7ef8ca6dde1f0 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts @@ -12,21 +12,28 @@ import type { Logger } from '@kbn/logging'; import { AlertConsumers, SLO_RULE_TYPE_IDS } from '@kbn/rule-data-utils'; import type { AlertsClient } from '@kbn/rule-registry-plugin/server'; import type { GetSLOStatsOverviewParams, GetSLOStatsOverviewResponse } from '@kbn/slo-schema'; -import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { + FieldValue, + QueryDslQueryContainer, + SearchTotalHits, +} from '@elastic/elasticsearch/lib/api/types'; import moment from 'moment'; import { typedSearch } from '../utils/queries'; import { getSummaryIndices, getSloSettings } from './slo_settings'; import { getElasticsearchQueryOrThrow, parseStringFilters } from './transform_generators'; -import { FindSLO } from './find_slo'; -import type { SLORepository } from './slo_repository'; -import { DefaultSummarySearchClient } from './summary_search_client/summary_search_client'; -const SLO_PAGESIZE_LIMIT = 1000; +const ES_PAGESIZE_LIMIT = 5000; + +function getAfterKey(agg: unknown): Record | undefined { + if (agg && typeof agg === 'object' && 'after_key' in agg && agg.after_key) { + return agg.after_key as Record; + } + return undefined; +} export class GetSLOStatsOverview { constructor( private soClient: SavedObjectsClientContract, - private repository: SLORepository, private scopedClusterClient: IScopedClusterClient, private spaceId: string, private logger: Logger, @@ -42,13 +49,6 @@ export class GetSLOStatsOverview { const filters = params?.filters ?? ''; const parsedFilters = parseStringFilters(filters, this.logger); - const summarySearchClient = new DefaultSummarySearchClient( - this.scopedClusterClient, - this.soClient, - this.logger, - this.spaceId - ); - let ruleFilters: string = ''; let burnRateFilters: QueryDslQueryContainer[] = []; @@ -67,50 +67,120 @@ export class GetSLOStatsOverview { this.logger.error(`Error parsing filters: ${error}`); } - if (querySLOsForIds) { - const findSLO = new FindSLO(this.repository, summarySearchClient); - const sloIds = new Set(); - - const findSLOQueryParams = { - filters: params?.filters, - kqlQuery: params?.kqlQuery, - perPage: String(SLO_PAGESIZE_LIMIT), - }; - - const findSLOResponse = await findSLO.execute(findSLOQueryParams); - const total = findSLOResponse.total; - const numCalls = Math.ceil(total / SLO_PAGESIZE_LIMIT); - findSLOResponse.results.forEach((slo) => sloIds.add(slo.id)); - - for (let i = 1; i < numCalls; i++) { - const additionalCallResponse = await findSLO.execute({ - ...findSLOQueryParams, - page: String(i + 1), - }); - additionalCallResponse.results.forEach((slo) => sloIds.add(slo.id)); - } + let sloKeysFromES: QueryDslQueryContainer[] = []; + const sloRuleKeysFromES: string[] = []; + let afterKey: Record | undefined; - const sloIdsArray = Array.from(sloIds); + let totalHits = 0; - const resultString = sloIdsArray.length - ? sloIdsArray.reduce((accumulator, currentValue, index) => { - const conditionString = `alert.attributes.params.sloId:${currentValue}`; - if (index === 0) { - return conditionString; - } else { - return accumulator + ' OR ' + conditionString; + const boolFilters = JSON.parse(params.filters || '{}'); + if (params.kqlQuery) { + boolFilters.must.push({ + kql: { query: params.kqlQuery }, + }); + } + + const instanceIdIncluded = Object.values(params).find( + (value) => typeof value === 'string' && value.includes('slo.instanceId') + ); + + try { + if (querySLOsForIds) { + do { + const sloIdCompositeQueryResponse = await this.scopedClusterClient.asCurrentUser.search({ + size: ES_PAGESIZE_LIMIT, + aggs: { + sloIds: { + composite: { + after: afterKey, + size: ES_PAGESIZE_LIMIT, + sources: [ + { + sloId: { terms: { field: 'slo.id' } }, + }, + ...(instanceIdIncluded + ? [ + { + sloInstanceId: { terms: { field: 'slo.instanceId' } }, + }, + ] + : []), + ], + }, + }, + }, + index: '.slo-observability.summary-*', + _source: ['slo.id', 'slo.instanceId'], + ...(Object.values(boolFilters).some((value) => Array.isArray(value) && value.length > 0) + ? { + query: { + bool: boolFilters, + }, + } + : {}), + }); + + totalHits = (sloIdCompositeQueryResponse.hits?.total as SearchTotalHits).value || 0; + afterKey = getAfterKey(sloIdCompositeQueryResponse.aggregations?.sloIds); + + const buckets = ( + sloIdCompositeQueryResponse.aggregations?.sloIds as { + buckets?: Array<{ key: { sloId: string; sloInstanceId: string } }>; } - }, '') - : 'alert.attributes.params.sloId:NO_MATCHES'; - - ruleFilters = resultString; - burnRateFilters = [ - { - terms: { - 'kibana.alert.rule.parameters.sloId': sloIdsArray, + )?.buckets; + if (buckets && buckets.length > 0) { + sloKeysFromES = sloKeysFromES.concat( + ...buckets.map((bucket) => { + sloRuleKeysFromES.push(bucket.key.sloId); + return { + bool: { + must: [ + { term: { 'kibana.alert.rule.parameters.sloId': bucket.key.sloId } }, + ...(instanceIdIncluded + ? [ + { + term: { + 'kibana.alert.instance.id': bucket.key.sloInstanceId, + }, + }, + ] + : []), + ], + }, + }; + }) + ); + } + } while (afterKey); + + const sloIdsArray = Array.from(sloRuleKeysFromES); + + const resultString = sloIdsArray.length + ? sloIdsArray.reduce((accumulator, currentValue, index) => { + const conditionString = `(alert.attributes.params.sloId:${currentValue} )`; + if (index === 0 || ruleFilters.length === 0) { + return conditionString; + } else { + return accumulator + ' OR ' + conditionString; + } + }, ruleFilters) + : 'alert.attributes.params.sloId:NO_MATCHES'; + + ruleFilters = resultString; + + burnRateFilters = [ + { + bool: { + should: [...sloKeysFromES], + }, }, - }, - ]; + ]; + } else { + totalHits = -1; + } + } catch (error) { + this.logger.error(`Error querying SLOs for IDs: ${error}`); + throw error; } const response = await typedSearch(this.scopedClusterClient.asCurrentUser, { @@ -213,8 +283,8 @@ export class GetSLOStatsOverview { noData: aggs?.not_stale?.noData.doc_count ?? 0, stale: aggs?.stale.doc_count ?? 0, burnRateRules: rules.total, - burnRateActiveAlerts: alerts.activeAlertCount, - burnRateRecoveredAlerts: alerts.recoveredAlertCount, + burnRateActiveAlerts: totalHits ? alerts.activeAlertCount : 0, + burnRateRecoveredAlerts: totalHits ? alerts.recoveredAlertCount : 0, }; } } From 9a742b255cf1f423a54360404c138352ab2b183b Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Thu, 11 Sep 2025 11:10:08 -0400 Subject: [PATCH 03/12] Update x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts Co-authored-by: Shahzad --- .../plugins/slo/server/services/get_slo_stats_overview.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts index 7ef8ca6dde1f0..46165a743a94d 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts @@ -88,7 +88,7 @@ export class GetSLOStatsOverview { if (querySLOsForIds) { do { const sloIdCompositeQueryResponse = await this.scopedClusterClient.asCurrentUser.search({ - size: ES_PAGESIZE_LIMIT, + size: 0, aggs: { sloIds: { composite: { From f4693e25a03a30ebb15c750c1f6374f5543ded4e Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Mon, 15 Sep 2025 14:59:08 -0400 Subject: [PATCH 04/12] switch string builder to kuerynode --- .../server/services/get_slo_stats_overview.ts | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts index 46165a743a94d..80e556180f384 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts @@ -8,6 +8,7 @@ import type { RulesClientApi } from '@kbn/alerting-plugin/server/types'; import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import { type KueryNode, nodeBuilder } from '@kbn/es-query'; import type { Logger } from '@kbn/logging'; import { AlertConsumers, SLO_RULE_TYPE_IDS } from '@kbn/rule-data-utils'; import type { AlertsClient } from '@kbn/rule-registry-plugin/server'; @@ -49,7 +50,10 @@ export class GetSLOStatsOverview { const filters = params?.filters ?? ''; const parsedFilters = parseStringFilters(filters, this.logger); - let ruleFilters: string = ''; + let ruleFilters: KueryNode = { + type: 'literal', + def: true, + }; let burnRateFilters: QueryDslQueryContainer[] = []; let querySLOsForIds = false; @@ -155,18 +159,14 @@ export class GetSLOStatsOverview { const sloIdsArray = Array.from(sloRuleKeysFromES); - const resultString = sloIdsArray.length - ? sloIdsArray.reduce((accumulator, currentValue, index) => { - const conditionString = `(alert.attributes.params.sloId:${currentValue} )`; - if (index === 0 || ruleFilters.length === 0) { - return conditionString; - } else { - return accumulator + ' OR ' + conditionString; - } - }, ruleFilters) - : 'alert.attributes.params.sloId:NO_MATCHES'; + const resultNodes = + sloIdsArray.length > 0 + ? nodeBuilder.or( + sloIdsArray.map((sloId) => nodeBuilder.is(`alert.attributes.params.sloId`, sloId)) + ) + : nodeBuilder.is(`alert.attributes.params.sloId`, '%NO%MATCHES%'); - ruleFilters = resultString; + ruleFilters = resultNodes; burnRateFilters = [ { @@ -253,11 +253,7 @@ export class GetSLOStatsOverview { options: { ruleTypeIds: SLO_RULE_TYPE_IDS, consumers: [AlertConsumers.SLO, AlertConsumers.ALERTS, AlertConsumers.OBSERVABILITY], - ...(ruleFilters - ? { - filter: ruleFilters, - } - : {}), + ...(ruleFilters?.def ? {} : { filter: ruleFilters }), }, }), From bfe1282e9c5c592a6e9559127fad90f6d728e927 Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Tue, 16 Sep 2025 18:19:25 -0400 Subject: [PATCH 05/12] cleanup --- .../server/services/get_slo_stats_overview.ts | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts index 80e556180f384..e53a927569217 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts @@ -14,6 +14,7 @@ import { AlertConsumers, SLO_RULE_TYPE_IDS } from '@kbn/rule-data-utils'; import type { AlertsClient } from '@kbn/rule-registry-plugin/server'; import type { GetSLOStatsOverviewParams, GetSLOStatsOverviewResponse } from '@kbn/slo-schema'; import type { + AggregationsAggregate, FieldValue, QueryDslQueryContainer, SearchTotalHits, @@ -25,7 +26,9 @@ import { getElasticsearchQueryOrThrow, parseStringFilters } from './transform_ge const ES_PAGESIZE_LIMIT = 5000; -function getAfterKey(agg: unknown): Record | undefined { +function getAfterKey( + agg: AggregationsAggregate | undefined +): Record | undefined { if (agg && typeof agg === 'object' && 'after_key' in agg && agg.after_key) { return agg.after_key as Record; } @@ -58,26 +61,26 @@ export class GetSLOStatsOverview { let querySLOsForIds = false; + const kqlQueriesProvided = !!params?.kqlQuery && params?.kqlQuery?.length > 0; + try { querySLOsForIds = !!( - (params?.filters && - Object.values(JSON.parse(params.filters)).some( - (value) => Array.isArray(value) && value.length > 0 - )) || - (params?.kqlQuery && params?.kqlQuery?.length > 0) + (!!parsedFilters && + parsedFilters.some((value: Array) => Array.isArray(value) && value.length > 0)) || + kqlQueriesProvided ); } catch (error) { - querySLOsForIds = !!(params?.kqlQuery && params?.kqlQuery?.length > 0); + querySLOsForIds = kqlQueriesProvided; this.logger.error(`Error parsing filters: ${error}`); } let sloKeysFromES: QueryDslQueryContainer[] = []; const sloRuleKeysFromES: string[] = []; - let afterKey: Record | undefined; + let afterKey: AggregationsAggregate | undefined; let totalHits = 0; - const boolFilters = JSON.parse(params.filters || '{}'); + const boolFilters = parsedFilters; if (params.kqlQuery) { boolFilters.must.push({ kql: { query: params.kqlQuery }, @@ -96,7 +99,7 @@ export class GetSLOStatsOverview { aggs: { sloIds: { composite: { - after: afterKey, + after: afterKey as Record, size: ES_PAGESIZE_LIMIT, sources: [ { @@ -157,12 +160,12 @@ export class GetSLOStatsOverview { } } while (afterKey); - const sloIdsArray = Array.from(sloRuleKeysFromES); - const resultNodes = - sloIdsArray.length > 0 + sloRuleKeysFromES.length > 0 ? nodeBuilder.or( - sloIdsArray.map((sloId) => nodeBuilder.is(`alert.attributes.params.sloId`, sloId)) + sloRuleKeysFromES.map((sloId) => + nodeBuilder.is(`alert.attributes.params.sloId`, sloId) + ) ) : nodeBuilder.is(`alert.attributes.params.sloId`, '%NO%MATCHES%'); From 5a7949525100be8f70a42bc133e70558b16bc761 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:15:21 +0000 Subject: [PATCH 06/12] [CI] Auto-commit changed files from 'node scripts/generate codeowners' --- .github/CODEOWNERS | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e8d36bed8df42..65441deb7a5b5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -448,6 +448,7 @@ src/platform/packages/shared/kbn-avc-banner @elastic/security-defend-workflows src/platform/packages/shared/kbn-axe-config @elastic/appex-qa src/platform/packages/shared/kbn-babel-register @elastic/kibana-operations src/platform/packages/shared/kbn-background-search @elastic/kibana-data-discovery +src/platform/packages/shared/kbn-bench @elastic/obs-ui-devex-team src/platform/packages/shared/kbn-cache-cli @elastic/kibana-operations src/platform/packages/shared/kbn-calculate-auto @elastic/obs-ux-management-team src/platform/packages/shared/kbn-calculate-width-from-char-count @elastic/kibana-visualizations @@ -460,6 +461,7 @@ src/platform/packages/shared/kbn-coloring @elastic/kibana-visualizations src/platform/packages/shared/kbn-config @elastic/kibana-core src/platform/packages/shared/kbn-config-schema @elastic/kibana-core src/platform/packages/shared/kbn-content-management-utils @elastic/kibana-data-discovery +src/platform/packages/shared/kbn-core-server-benchmarks @elastic/kibana-core src/platform/packages/shared/kbn-crypto @elastic/kibana-security src/platform/packages/shared/kbn-crypto-browser @elastic/kibana-core src/platform/packages/shared/kbn-css-utils @elastic/appex-sharedux @@ -509,6 +511,7 @@ src/platform/packages/shared/kbn-i18n @elastic/kibana-core src/platform/packages/shared/kbn-i18n-react @elastic/kibana-core src/platform/packages/shared/kbn-interpreter @elastic/kibana-visualizations src/platform/packages/shared/kbn-io-ts-utils @elastic/obs-knowledge-team +src/platform/packages/shared/kbn-jest-benchmarks @elastic/obs-ui-devex-team src/platform/packages/shared/kbn-lazy-object @elastic/kibana-operations src/platform/packages/shared/kbn-lens-embeddable-utils @elastic/obs-ux-infra_services-team @elastic/kibana-visualizations src/platform/packages/shared/kbn-licensing-types @elastic/kibana-core @@ -540,6 +543,7 @@ src/platform/packages/shared/kbn-opentelemetry-utils @elastic/kibana-core src/platform/packages/shared/kbn-osquery-io-ts-types @elastic/security-defend-workflows src/platform/packages/shared/kbn-otel-semantic-conventions @elastic/obs-ux-logs-team src/platform/packages/shared/kbn-palettes @elastic/kibana-visualizations +src/platform/packages/shared/kbn-profiler @elastic/obs-knowledge-team src/platform/packages/shared/kbn-profiling-utils @elastic/obs-ux-infra_services-team src/platform/packages/shared/kbn-react-field @elastic/kibana-data-discovery src/platform/packages/shared/kbn-react-hooks @elastic/obs-ux-logs-team @@ -611,6 +615,7 @@ src/platform/packages/shared/kbn-utils @elastic/kibana-operations src/platform/packages/shared/kbn-visualization-ui-components @elastic/kibana-visualizations src/platform/packages/shared/kbn-visualization-utils @elastic/kibana-visualizations src/platform/packages/shared/kbn-workflows @elastic/workflows-eng +src/platform/packages/shared/kbn-workspaces @elastic/obs-ui-devex-team src/platform/packages/shared/kbn-xstate-utils @elastic/obs-ux-logs-team src/platform/packages/shared/kbn-zod @elastic/kibana-core src/platform/packages/shared/kbn-zod-helpers @elastic/security-detection-rule-management From 398d1772cff438f381beefb96e75d6e6a0ff10e2 Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Wed, 1 Oct 2025 17:49:47 -0400 Subject: [PATCH 07/12] cleanup, add tests --- .../services/get_slo_stats_overview.test.ts | 624 ++++++++++++++++++ .../server/services/get_slo_stats_overview.ts | 151 +++-- 2 files changed, 706 insertions(+), 69 deletions(-) create mode 100644 x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts new file mode 100644 index 0000000000000..096551d3785ad --- /dev/null +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts @@ -0,0 +1,624 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import type { RulesClientApi } from '@kbn/alerting-plugin/server/types'; +import type { AlertsClient } from '@kbn/rule-registry-plugin/server'; +import { loggerMock } from '@kbn/logging-mocks'; +import { GetSLOStatsOverview } from './get_slo_stats_overview'; +import { getSloSettings, getSummaryIndices } from './slo_settings'; +import { getElasticsearchQueryOrThrow } from './transform_generators/common'; +import { typedSearch } from '../utils/queries'; + +const mockSoClient = { + get: jest.fn(), +} as unknown as SavedObjectsClientContract; + +const mockEsClient = { + search: jest.fn(), +} as unknown as ElasticsearchClient; + +const mockScopedClusterClient = { + asCurrentUser: mockEsClient, + asInternalUser: mockEsClient, +}; + +const mockRulesClient = { + find: jest.fn(), +} as unknown as RulesClientApi; + +const mockRacClient = { + getAlertSummary: jest.fn(), +} as unknown as AlertsClient; + +const mockLogger = loggerMock.create(); + +// Mock problematic dependencies first +jest.mock('@kbn/data-views-plugin/common', () => ({ + DataView: jest.fn(), +})); + +jest.mock('./transform_generators/transform_generator', () => ({ + TransformGenerator: class { + constructor() {} + }, +})); + +// Mock the utility functions +jest.mock('./slo_settings', () => ({ + getSloSettings: jest.fn().mockResolvedValue({ + staleThresholdInHours: 24, + }), + getSummaryIndices: jest.fn().mockResolvedValue({ + indices: ['.slo-observability.summary-v3'], + }), +})); + +jest.mock('./transform_generators/common', () => { + const actual = jest.requireActual('./transform_generators/common'); + return { + ...actual, + getElasticsearchQueryOrThrow: jest.fn().mockReturnValue({ match_all: {} }), + // Use the real parseStringFilters implementation + parseStringFilters: actual.parseStringFilters, + }; +}); + +jest.mock('../utils/queries', () => ({ + typedSearch: jest.fn(), +})); + +// Helper functions to build expected typedSearch payloads +const buildExpectedTypedSearchPayload = (filters: any[] = []) => ({ + index: ['.slo-observability.summary-v3'], + size: 0, + query: { + bool: { + filter: [ + { term: { spaceId: 'default' } }, + { match_all: {} }, // KQL query (always mocked to match_all) + ...filters, + ], + must_not: [], + }, + }, + aggs: buildSLOStatsAggregations(), +}); + +const buildSLOStatsAggregations = () => ({ + stale: { + filter: { + range: { + summaryUpdatedAt: { + lt: 'now-24h', + }, + }, + }, + }, + not_stale: { + filter: { + range: { + summaryUpdatedAt: { + gte: 'now-24h', + }, + }, + }, + aggs: { + violated: { + filter: { + term: { + status: 'VIOLATED', + }, + }, + }, + healthy: { + filter: { + term: { + status: 'HEALTHY', + }, + }, + }, + degrading: { + filter: { + term: { + status: 'DEGRADING', + }, + }, + }, + noData: { + filter: { + term: { + status: 'NO_DATA', + }, + }, + }, + }, + }, +}); + +const expectTypedSearchCalledWith = (additionalFilters: any[] = []) => { + expect(typedSearch).toHaveBeenCalledWith( + mockEsClient, + buildExpectedTypedSearchPayload(additionalFilters) + ); +}; + +// Helper function to build expected composite query payload for asCurrentUser.search +const buildExpectedCompositeQueryPayload = (boolFilters: any, includeInstanceId = false) => ({ + size: 0, + aggs: { + sloIds: { + composite: { + size: 5000, + sources: [ + { + sloId: { terms: { field: 'slo.id' } }, + }, + ...(includeInstanceId + ? [ + { + sloInstanceId: { terms: { field: 'slo.instanceId' } }, + }, + ] + : []), + ], + }, + }, + }, + index: '.slo-observability.summary-*', + _source: ['slo.id', 'slo.instanceId'], + ...(Object.values(boolFilters).some((value: any) => Array.isArray(value) && value.length > 0) + ? { + query: { + bool: boolFilters, + }, + } + : {}), +}); + +const expectCompositeQueryCalledWith = ( + boolFilters: any, + includeInstanceId = false, + callIndex = 0 +) => { + expect(mockEsClient.search).toHaveBeenNthCalledWith( + callIndex + 1, + buildExpectedCompositeQueryPayload(boolFilters, includeInstanceId) + ); +}; + +describe('GetSLOStatsOverview', () => { + let service: GetSLOStatsOverview; + + beforeEach(() => { + jest.clearAllMocks(); + + // Reset all mocks to their default values + (mockEsClient.search as jest.Mock).mockResolvedValue({ + hits: { total: { value: 0 } }, + aggregations: { sloIds: { buckets: [] } }, + }); + + service = new GetSLOStatsOverview( + mockSoClient, + mockScopedClusterClient as any, + 'default', + mockLogger, + mockRulesClient, + mockRacClient + ); + }); + + describe('execute', () => { + const mockSearchResponse = { + aggregations: { + stale: { doc_count: 5 }, + not_stale: { + violated: { doc_count: 2 }, + degrading: { doc_count: 3 }, + healthy: { doc_count: 10 }, + noData: { doc_count: 1 }, + }, + }, + }; + + const mockRulesResponse = { + total: 15, + data: [], + }; + + const mockAlertsResponse = { + activeAlertCount: 8, + recoveredAlertCount: 12, + }; + + beforeEach(() => { + (typedSearch as jest.Mock).mockResolvedValue(mockSearchResponse); + (mockRulesClient.find as jest.Mock).mockResolvedValue(mockRulesResponse); + (mockRacClient.getAlertSummary as jest.Mock).mockResolvedValue(mockAlertsResponse); + }); + + it('should return SLO stats overview with default parameters', async () => { + const result = await service.execute({}); + + expect(result).toEqual({ + violated: 2, + degrading: 3, + healthy: 10, + noData: 1, + stale: 5, + burnRateRules: 15, + burnRateActiveAlerts: 8, // totalHits is truthy when no filters are provided + burnRateRecoveredAlerts: 12, // totalHits is truthy when no filters are provided + }); + + expect(getSloSettings).toHaveBeenCalledWith(mockSoClient); + expect(getSummaryIndices).toHaveBeenCalled(); + expectTypedSearchCalledWith(); // No additional filters for default parameters + expect(mockRulesClient.find).toHaveBeenCalled(); + expect(mockRacClient.getAlertSummary).toHaveBeenCalled(); + }); + + it('should handle KQL query parameter', async () => { + // Configure mocks for SLO ID composite query + (mockEsClient.search as jest.Mock).mockResolvedValueOnce({ + hits: { total: { value: 2 } }, + aggregations: { + sloIds: { + buckets: [ + { key: { sloId: 'slo-1', sloInstanceId: '*' } }, + { key: { sloId: 'slo-2', sloInstanceId: '*' } }, + ], + }, + }, + }); + + const params = { + kqlQuery: 'slo.name: "test"', + }; + + const result = await service.execute(params); + + expect(getElasticsearchQueryOrThrow).toHaveBeenCalledWith('slo.name: "test"'); + expectTypedSearchCalledWith(); // No additional filters (KQL is handled by getElasticsearchQueryOrThrow mock) + // Verify the service executed successfully with KQL query + expect(result).toEqual({ + violated: 2, + degrading: 3, + healthy: 10, + noData: 1, + stale: 5, + burnRateRules: 15, + burnRateActiveAlerts: 8, // Should be included when SLOs are found + burnRateRecoveredAlerts: 12, // Should be included when SLOs are found + }); + }); + + it('should handle filters parameter with valid JSON', async () => { + // Configure mocks for SLO ID composite query + (mockEsClient.search as jest.Mock).mockResolvedValueOnce({ + hits: { total: { value: 2 } }, + aggregations: { + sloIds: { + buckets: [ + { key: { sloId: 'slo-1', sloInstanceId: '*' } }, + { key: { sloId: 'slo-2', sloInstanceId: '*' } }, + ], + }, + }, + }); + + const params = { + filters: '{"filter": [{"term": {"slo.name": "test"}}]}', // Valid JSON filter + }; + + const result = await service.execute(params); + + // Verify composite query was called with expected payload + expectCompositeQueryCalledWith({ + filter: [{ term: { 'slo.name': 'test' } }], + }); + + expectTypedSearchCalledWith([{ term: { 'slo.name': 'test' } }]); // From parsed filters + // Verify the service executed successfully with valid JSON filters + expect(result).toEqual({ + violated: 2, + degrading: 3, + healthy: 10, + noData: 1, + stale: 5, + burnRateRules: 15, + burnRateActiveAlerts: 8, + burnRateRecoveredAlerts: 12, + }); + }); + + it('should handle both KQL query and filters', async () => { + // Configure mocks for SLO ID composite query + (mockEsClient.search as jest.Mock).mockResolvedValueOnce({ + hits: { total: { value: 2 } }, + aggregations: { + sloIds: { + buckets: [ + { key: { sloId: 'slo-1', sloInstanceId: '*' } }, + { key: { sloId: 'slo-2', sloInstanceId: '*' } }, + ], + }, + }, + }); + + const params = { + kqlQuery: 'slo.name: "test"', + filters: '{"filter": [{"term": {"slo.tags": "prod"}}]}', // Valid JSON filter + }; + + const result = await service.execute(params); + + expect(getElasticsearchQueryOrThrow).toHaveBeenCalledWith('slo.name: "test"'); + + // Verify composite query was called with expected payload + expectCompositeQueryCalledWith({ + filter: [{ term: { 'slo.tags': 'prod' } }], + must: [{ kql: { query: 'slo.name: "test"' } }], + }); + + expectTypedSearchCalledWith([{ term: { 'slo.tags': 'prod' } }]); // From parsed filters + // Verify the service executed successfully with both KQL and filters + expect(result).toEqual({ + violated: 2, + degrading: 3, + healthy: 10, + noData: 1, + stale: 5, + burnRateRules: 15, + burnRateActiveAlerts: 8, + burnRateRecoveredAlerts: 12, + }); + }); + + it('should handle SLO ID composite queries when filters are provided', async () => { + const mockCompositeResponse = { + hits: { total: { value: 100 } }, + aggregations: { + sloIds: { + buckets: [ + { key: { sloId: 'slo-1', sloInstanceId: '*' } }, + { key: { sloId: 'slo-2', sloInstanceId: '*' } }, + ], + }, + }, + }; + + (mockEsClient.search as jest.Mock).mockResolvedValue(mockCompositeResponse); + + const params = { + filters: '{"filter": [{"term": {"slo.name": "test"}}]}', + }; + + await service.execute(params); + + // Verify composite query was called with expected payload + expectCompositeQueryCalledWith({ + filter: [{ term: { 'slo.name': 'test' } }], + }); + + // Verify typedSearch was called with correct payload + expectTypedSearchCalledWith([{ term: { 'slo.name': 'test' } }]); + }); + + it('should handle pagination for composite queries', async () => { + const mockFirstResponse = { + hits: { total: { value: 3 } }, + aggregations: { + sloIds: { + buckets: [ + { key: { sloId: 'slo-1', sloInstanceId: '*' } }, + { key: { sloId: 'slo-2', sloInstanceId: '*' } }, + ], + after_key: { sloId: 'slo-2', sloInstanceId: '*' }, + }, + }, + }; + + const mockSecondResponse = { + hits: { total: { value: 3 } }, + aggregations: { + sloIds: { + buckets: [{ key: { sloId: 'slo-3', sloInstanceId: '*' } }], + // No after_key means end of pagination + }, + }, + }; + + (mockEsClient.search as jest.Mock) + .mockResolvedValueOnce(mockFirstResponse) + .mockResolvedValueOnce(mockSecondResponse); + + const params = { + filters: '{"filter": [{"term": {"slo.name": "test"}}]}', + }; + + await service.execute(params); + + // Verify first composite query call + expectCompositeQueryCalledWith( + { + filter: [{ term: { 'slo.name': 'test' } }], + }, + false, + 0 + ); + + // Verify pagination worked correctly - should have been called twice + expect(mockEsClient.search).toHaveBeenCalledTimes(2); + + // Second call should include the after_key from first response + const secondCallArgs = (mockEsClient.search as jest.Mock).mock.calls[1][0]; + expect(secondCallArgs.aggs.sloIds.composite.after).toEqual({ + sloId: 'slo-2', + sloInstanceId: '*', + }); + + // Verify typedSearch was called with correct payload + expectTypedSearchCalledWith([{ term: { 'slo.name': 'test' } }]); + }); + + it('should skip rules and alerts queries when no SLOs match filters', async () => { + // Mock empty SLO ID response + (mockEsClient.search as jest.Mock).mockResolvedValue({ + hits: { total: { value: 0 } }, + aggregations: { sloIds: { buckets: [] } }, + }); + + const params = { + filters: '{"filter": [{"term": {"slo.name": "nonexistent"}}]}', + }; + + const result = await service.execute(params); + + // Verify composite query was called with expected payload + expectCompositeQueryCalledWith({ + filter: [{ term: { 'slo.name': 'nonexistent' } }], + }); + + expectTypedSearchCalledWith([{ term: { 'slo.name': 'nonexistent' } }]); + + // Should still return results from the main query + expect(result).toEqual({ + violated: 2, + degrading: 3, + healthy: 10, + noData: 1, + stale: 5, + burnRateRules: 0, // Should be 0 when no SLOs match + burnRateActiveAlerts: 0, // Should be 0 when no SLOs match + burnRateRecoveredAlerts: 0, // Should be 0 when no SLOs match + }); + }); + + it('should handle instanceId in queries', async () => { + const mockCompositeResponse = { + hits: { total: { value: 2 } }, + aggregations: { + sloIds: { + buckets: [{ key: { sloId: 'slo-1', sloInstanceId: 'instance-1' } }], + }, + }, + }; + + (mockEsClient.search as jest.Mock).mockResolvedValue(mockCompositeResponse); + + const params = { + filters: '{"filter": [{"term": {"slo.instanceId": "instance-1"}}]}', + }; + + await service.execute(params); + + // Verify composite query was called with instanceId included + expectCompositeQueryCalledWith( + { + filter: [{ term: { 'slo.instanceId': 'instance-1' } }], + }, + true + ); // includeInstanceId = true + + // Verify typedSearch was called with instanceId filter + expectTypedSearchCalledWith([{ term: { 'slo.instanceId': 'instance-1' } }]); + }); + + it('should handle errors in filter parsing gracefully', async () => { + // Mock no SLO IDs found to avoid rules/alerts queries + (mockEsClient.search as jest.Mock).mockResolvedValue({ + hits: { total: { value: 0 } }, + aggregations: { sloIds: { buckets: [] } }, + }); + + const params = { + kqlQuery: 'slo.name: "test"', + filters: 'invalid-json-filter', // This will fail to parse and return {} + }; + + // The service should continue execution despite filter parsing errors + const result = await service.execute(params); + + expectTypedSearchCalledWith(); // No additional filters (invalid JSON parsing returns empty) + + // Should still return results from the main query + expect(result).toEqual({ + violated: 2, + degrading: 3, + healthy: 10, + noData: 1, + stale: 5, + burnRateRules: 0, // Should be 0 when no SLOs match the query + burnRateActiveAlerts: 0, // Should be 0 when no SLOs match the query + burnRateRecoveredAlerts: 0, // Should be 0 when no SLOs match the query + }); + }); + + it('should handle errors in SLO ID querying gracefully', async () => { + // Configure mocks to fail on the SLO ID composite query + (mockEsClient.search as jest.Mock).mockRejectedValue(new Error('ES query failed')); + + const params = { + filters: '{"filter": [{"term": {"slo.name": "test"}}]}', // Valid JSON filter + }; + + // The service should throw the error since it can't handle ES failures gracefully + await expect(service.execute(params)).rejects.toThrow('ES query failed'); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Error querying SLOs for IDs') + ); + }); + + it('should handle missing aggregations gracefully', async () => { + // Mock response without aggregations + (typedSearch as jest.Mock).mockResolvedValue({ + hits: { total: { value: 0 } }, + // No aggregations + }); + + const result = await service.execute({}); + + expectTypedSearchCalledWith(); // No additional filters for default params + + // Should handle missing aggregations by defaulting to 0 + expect(result).toEqual({ + violated: 0, + degrading: 0, + healthy: 0, + noData: 0, + stale: 0, + burnRateRules: 15, + burnRateActiveAlerts: 8, // totalHits is truthy for default params + burnRateRecoveredAlerts: 12, // totalHits is truthy for default params + }); + }); + + it('should handle rules client errors gracefully', async () => { + (mockRulesClient.find as jest.Mock).mockRejectedValue(new Error('Rules client error')); + + await expect(service.execute({})).rejects.toThrow('Rules client error'); + + // Verify typedSearch was still called despite rules client error + expectTypedSearchCalledWith(); // No additional filters for default params + }); + + it('should handle alerts client errors gracefully', async () => { + (mockRacClient.getAlertSummary as jest.Mock).mockRejectedValue( + new Error('Alerts client error') + ); + + await expect(service.execute({})).rejects.toThrow('Alerts client error'); + + // Verify typedSearch was still called despite alerts client error + expectTypedSearchCalledWith(); // No additional filters for default params + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts index e53a927569217..722e141d61e75 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts @@ -17,7 +17,6 @@ import type { AggregationsAggregate, FieldValue, QueryDslQueryContainer, - SearchTotalHits, } from '@elastic/elasticsearch/lib/api/types'; import moment from 'moment'; import { typedSearch } from '../utils/queries'; @@ -45,6 +44,12 @@ export class GetSLOStatsOverview { private racClient: AlertsClient ) {} + /* + This service retrieves stats from alert and rule indices to display on the SLO landing page. + When filters are applied to SLOs, we want to forward those filters onto the searches performed on alerts and rules so the overview stats actively reflect viewable SLO data. + To achieve this, we need to retrieve a list of all SLO ids and instanceIds that may appear across all SLO list pages + to use them as filter conditions on the alert and rule stats that we want to count. + */ public async execute(params: GetSLOStatsOverviewParams): Promise { const settings = await getSloSettings(this.soClient); const { indices } = await getSummaryIndices(this.scopedClusterClient.asInternalUser, settings); @@ -52,44 +57,40 @@ export class GetSLOStatsOverview { const kqlQuery = params?.kqlQuery ?? ''; const filters = params?.filters ?? ''; const parsedFilters = parseStringFilters(filters, this.logger); - - let ruleFilters: KueryNode = { - type: 'literal', - def: true, - }; - let burnRateFilters: QueryDslQueryContainer[] = []; - - let querySLOsForIds = false; - const kqlQueriesProvided = !!params?.kqlQuery && params?.kqlQuery?.length > 0; - try { - querySLOsForIds = !!( - (!!parsedFilters && - parsedFilters.some((value: Array) => Array.isArray(value) && value.length > 0)) || - kqlQueriesProvided - ); - } catch (error) { - querySLOsForIds = kqlQueriesProvided; - this.logger.error(`Error parsing filters: ${error}`); - } - - let sloKeysFromES: QueryDslQueryContainer[] = []; - const sloRuleKeysFromES: string[] = []; - let afterKey: AggregationsAggregate | undefined; - - let totalHits = 0; + const querySLOsForIds = !!( + (!!parsedFilters && + Object.keys(parsedFilters).some( + (key) => Array.isArray(parsedFilters[key]) && parsedFilters[key].length > 0 + )) || + kqlQueriesProvided + ); - const boolFilters = parsedFilters; - if (params.kqlQuery) { - boolFilters.must.push({ + if (params.kqlQuery && parsedFilters.must) { + parsedFilters.must.push({ kql: { query: params.kqlQuery }, }); + } else if (params.kqlQuery) { + parsedFilters.must = [ + { + kql: { query: params.kqlQuery }, + }, + ]; } + const sloRuleQueryKeys: string[] = []; const instanceIdIncluded = Object.values(params).find( (value) => typeof value === 'string' && value.includes('slo.instanceId') ); + let alertFilters: QueryDslQueryContainer[] = []; + let alertFilterTerms: QueryDslQueryContainer[] = []; + let afterKey: AggregationsAggregate | undefined; + // instantiate ruleFilters with dummy value + let ruleFilters: KueryNode = { + type: 'literal', + def: true, + }; try { if (querySLOsForIds) { @@ -118,16 +119,17 @@ export class GetSLOStatsOverview { }, index: '.slo-observability.summary-*', _source: ['slo.id', 'slo.instanceId'], - ...(Object.values(boolFilters).some((value) => Array.isArray(value) && value.length > 0) + ...(Object.values(parsedFilters).some( + (value) => Array.isArray(value) && value.length > 0 + ) ? { query: { - bool: boolFilters, + bool: parsedFilters, }, } : {}), }); - totalHits = (sloIdCompositeQueryResponse.hits?.total as SearchTotalHits).value || 0; afterKey = getAfterKey(sloIdCompositeQueryResponse.aggregations?.sloIds); const buckets = ( @@ -136,9 +138,9 @@ export class GetSLOStatsOverview { } )?.buckets; if (buckets && buckets.length > 0) { - sloKeysFromES = sloKeysFromES.concat( + alertFilterTerms = alertFilterTerms.concat( ...buckets.map((bucket) => { - sloRuleKeysFromES.push(bucket.key.sloId); + sloRuleQueryKeys.push(bucket.key.sloId); return { bool: { must: [ @@ -160,26 +162,18 @@ export class GetSLOStatsOverview { } } while (afterKey); - const resultNodes = - sloRuleKeysFromES.length > 0 - ? nodeBuilder.or( - sloRuleKeysFromES.map((sloId) => - nodeBuilder.is(`alert.attributes.params.sloId`, sloId) - ) - ) - : nodeBuilder.is(`alert.attributes.params.sloId`, '%NO%MATCHES%'); + const resultNodes = nodeBuilder.or( + sloRuleQueryKeys.map((sloId) => nodeBuilder.is(`alert.attributes.params.sloId`, sloId)) + ); ruleFilters = resultNodes; - - burnRateFilters = [ + alertFilters = [ { bool: { - should: [...sloKeysFromES], + should: [...alertFilterTerms], }, }, ]; - } else { - totalHits = -1; } } catch (error) { this.logger.error(`Error querying SLOs for IDs: ${error}`); @@ -251,27 +245,46 @@ export class GetSLOStatsOverview { }, }); - const [rules, alerts] = await Promise.all([ - this.rulesClient.find({ - options: { - ruleTypeIds: SLO_RULE_TYPE_IDS, - consumers: [AlertConsumers.SLO, AlertConsumers.ALERTS, AlertConsumers.OBSERVABILITY], - ...(ruleFilters?.def ? {} : { filter: ruleFilters }), - }, - }), + /* + If we know there are no SLOs that match the provided filters, we can skip querying for rules and alerts + */ + const [rules, alerts] = await Promise.all( + querySLOsForIds && sloRuleQueryKeys.length === 0 + ? [ + { + total: 0, + }, + { + activeAlertCount: 0, + recoveredAlertCount: 0, + }, + ] + : [ + this.rulesClient.find({ + options: { + ruleTypeIds: SLO_RULE_TYPE_IDS, + consumers: [ + AlertConsumers.SLO, + AlertConsumers.ALERTS, + AlertConsumers.OBSERVABILITY, + ], + ...(ruleFilters?.def ? {} : { filter: ruleFilters }), + }, + }), - this.racClient.getAlertSummary({ - ruleTypeIds: SLO_RULE_TYPE_IDS, - consumers: [AlertConsumers.SLO, AlertConsumers.ALERTS, AlertConsumers.OBSERVABILITY], - gte: moment().subtract(24, 'hours').toISOString(), - lte: moment().toISOString(), - ...(burnRateFilters?.length - ? { - filter: burnRateFilters, - } - : {}), - }), - ]); + this.racClient.getAlertSummary({ + ruleTypeIds: SLO_RULE_TYPE_IDS, + consumers: [AlertConsumers.SLO, AlertConsumers.ALERTS, AlertConsumers.OBSERVABILITY], + gte: moment().subtract(24, 'hours').toISOString(), + lte: moment().toISOString(), + ...(alertFilters?.length + ? { + filter: alertFilters, + } + : {}), + }), + ] + ); const aggs = response.aggregations; @@ -282,8 +295,8 @@ export class GetSLOStatsOverview { noData: aggs?.not_stale?.noData.doc_count ?? 0, stale: aggs?.stale.doc_count ?? 0, burnRateRules: rules.total, - burnRateActiveAlerts: totalHits ? alerts.activeAlertCount : 0, - burnRateRecoveredAlerts: totalHits ? alerts.recoveredAlertCount : 0, + burnRateActiveAlerts: alerts.activeAlertCount, + burnRateRecoveredAlerts: alerts.recoveredAlertCount, }; } } From 83705674bf6ea701fec0ccd15b30f4810f72219b Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Mon, 6 Oct 2025 11:40:14 -0400 Subject: [PATCH 08/12] simplify ruleFilter default --- .../plugins/slo/server/services/get_slo_stats_overview.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts index 722e141d61e75..9b3cb60c7aede 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts @@ -86,11 +86,7 @@ export class GetSLOStatsOverview { let alertFilters: QueryDslQueryContainer[] = []; let alertFilterTerms: QueryDslQueryContainer[] = []; let afterKey: AggregationsAggregate | undefined; - // instantiate ruleFilters with dummy value - let ruleFilters: KueryNode = { - type: 'literal', - def: true, - }; + let ruleFilters: KueryNode | undefined; try { if (querySLOsForIds) { @@ -268,7 +264,7 @@ export class GetSLOStatsOverview { AlertConsumers.ALERTS, AlertConsumers.OBSERVABILITY, ], - ...(ruleFilters?.def ? {} : { filter: ruleFilters }), + ...(ruleFilters ? { filter: ruleFilters } : {}), }, }), From 22d2c96d358dd94b583c421bbf9c793d3863b16b Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Tue, 14 Oct 2025 17:11:31 -0400 Subject: [PATCH 09/12] simplify query --- .../services/get_slo_stats_overview.test.ts | 1 - .../server/services/get_slo_stats_overview.ts | 70 ++++++++----------- 2 files changed, 30 insertions(+), 41 deletions(-) diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts index 096551d3785ad..3f790215c2906 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts @@ -171,7 +171,6 @@ const buildExpectedCompositeQueryPayload = (boolFilters: any, includeInstanceId }, }, index: '.slo-observability.summary-*', - _source: ['slo.id', 'slo.instanceId'], ...(Object.values(boolFilters).some((value: any) => Array.isArray(value) && value.length > 0) ? { query: { diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts index 9b3cb60c7aede..cb07824610006 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts @@ -25,15 +25,6 @@ import { getElasticsearchQueryOrThrow, parseStringFilters } from './transform_ge const ES_PAGESIZE_LIMIT = 5000; -function getAfterKey( - agg: AggregationsAggregate | undefined -): Record | undefined { - if (agg && typeof agg === 'object' && 'after_key' in agg && agg.after_key) { - return agg.after_key as Record; - } - return undefined; -} - export class GetSLOStatsOverview { constructor( private soClient: SavedObjectsClientContract, @@ -44,6 +35,15 @@ export class GetSLOStatsOverview { private racClient: AlertsClient ) {} + private getAfterKey( + agg: AggregationsAggregate | undefined + ): Record | undefined { + if (agg && typeof agg === 'object' && 'after_key' in agg && agg.after_key) { + return agg.after_key as Record; + } + return undefined; + } + /* This service retrieves stats from alert and rule indices to display on the SLO landing page. When filters are applied to SLOs, we want to forward those filters onto the searches performed on alerts and rules so the overview stats actively reflect viewable SLO data. @@ -59,25 +59,15 @@ export class GetSLOStatsOverview { const parsedFilters = parseStringFilters(filters, this.logger); const kqlQueriesProvided = !!params?.kqlQuery && params?.kqlQuery?.length > 0; - const querySLOsForIds = !!( - (!!parsedFilters && - Object.keys(parsedFilters).some( - (key) => Array.isArray(parsedFilters[key]) && parsedFilters[key].length > 0 - )) || - kqlQueriesProvided - ); - - if (params.kqlQuery && parsedFilters.must) { - parsedFilters.must.push({ - kql: { query: params.kqlQuery }, - }); - } else if (params.kqlQuery) { - parsedFilters.must = [ - { - kql: { query: params.kqlQuery }, - }, - ]; - } + /* + If there are any filters or KQL queries provided, we need to query for SLO ids and instanceIds to use as filter conditions on the alert and rule searches. + */ + const filtersProvided = + !!parsedFilters && + Object.keys(parsedFilters).some( + (key) => Array.isArray(parsedFilters[key]) && parsedFilters[key].length > 0 + ); + const querySLOsForIds = filtersProvided || kqlQueriesProvided; const sloRuleQueryKeys: string[] = []; const instanceIdIncluded = Object.values(params).find( @@ -92,6 +82,7 @@ export class GetSLOStatsOverview { if (querySLOsForIds) { do { const sloIdCompositeQueryResponse = await this.scopedClusterClient.asCurrentUser.search({ + index: '.slo-observability.summary-*', size: 0, aggs: { sloIds: { @@ -113,20 +104,19 @@ export class GetSLOStatsOverview { }, }, }, - index: '.slo-observability.summary-*', - _source: ['slo.id', 'slo.instanceId'], - ...(Object.values(parsedFilters).some( - (value) => Array.isArray(value) && value.length > 0 - ) - ? { - query: { - bool: parsedFilters, - }, - } - : {}), + query: { + bool: { + ...parsedFilters, + ...(params.kqlQuery + ? { + must: [...(parsedFilters.must ?? []), { kql: { query: params.kqlQuery } }], + } + : {}), + }, + }, }); - afterKey = getAfterKey(sloIdCompositeQueryResponse.aggregations?.sloIds); + afterKey = this.getAfterKey(sloIdCompositeQueryResponse.aggregations?.sloIds); const buckets = ( sloIdCompositeQueryResponse.aggregations?.sloIds as { From 04e46d27db9fd60746030d77aed7c9e4d95f3e40 Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Wed, 15 Oct 2025 13:06:36 -0400 Subject: [PATCH 10/12] use provided indices --- .../plugins/slo/server/services/get_slo_stats_overview.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts index cb07824610006..d37c26888fd0a 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts @@ -82,7 +82,7 @@ export class GetSLOStatsOverview { if (querySLOsForIds) { do { const sloIdCompositeQueryResponse = await this.scopedClusterClient.asCurrentUser.search({ - index: '.slo-observability.summary-*', + index: indices, size: 0, aggs: { sloIds: { From a75910f5f153a21a77b0e2ebda50423f274cf716 Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Wed, 15 Oct 2025 13:12:30 -0400 Subject: [PATCH 11/12] use provided indices --- .../plugins/slo/server/services/get_slo_stats_overview.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts index 3f790215c2906..8eccf72ff6422 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts @@ -170,7 +170,7 @@ const buildExpectedCompositeQueryPayload = (boolFilters: any, includeInstanceId }, }, }, - index: '.slo-observability.summary-*', + index: ['.slo-observability.summary-v3'], ...(Object.values(boolFilters).some((value: any) => Array.isArray(value) && value.length > 0) ? { query: { From 9e285bb745ec79d0166ab9e1522990af4ea715de Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Mon, 20 Oct 2025 15:28:16 -0400 Subject: [PATCH 12/12] readability improvements --- .../services/get_slo_stats_overview.test.ts | 4 +- .../server/services/get_slo_stats_overview.ts | 193 +++++++++++------- 2 files changed, 117 insertions(+), 80 deletions(-) diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts index 8eccf72ff6422..28403323f0980 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts @@ -10,7 +10,7 @@ import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-ser import type { RulesClientApi } from '@kbn/alerting-plugin/server/types'; import type { AlertsClient } from '@kbn/rule-registry-plugin/server'; import { loggerMock } from '@kbn/logging-mocks'; -import { GetSLOStatsOverview } from './get_slo_stats_overview'; +import { ES_PAGESIZE_LIMIT, GetSLOStatsOverview } from './get_slo_stats_overview'; import { getSloSettings, getSummaryIndices } from './slo_settings'; import { getElasticsearchQueryOrThrow } from './transform_generators/common'; import { typedSearch } from '../utils/queries'; @@ -154,7 +154,7 @@ const buildExpectedCompositeQueryPayload = (boolFilters: any, includeInstanceId aggs: { sloIds: { composite: { - size: 5000, + size: ES_PAGESIZE_LIMIT, sources: [ { sloId: { terms: { field: 'slo.id' } }, diff --git a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts index d37c26888fd0a..9d5e89b3b8e16 100644 --- a/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.ts @@ -23,7 +23,14 @@ import { typedSearch } from '../utils/queries'; import { getSummaryIndices, getSloSettings } from './slo_settings'; import { getElasticsearchQueryOrThrow, parseStringFilters } from './transform_generators'; -const ES_PAGESIZE_LIMIT = 5000; +export const ES_PAGESIZE_LIMIT = 1000; + +/* + This service retrieves stats from alert and rule indices to display on the SLO landing page. + When filters are applied to SLOs, we want to forward those filters onto the searches performed on alerts and rules so the overview stats actively reflect viewable SLO data. + To achieve this, we need to retrieve a list of all SLO ids and instanceIds that may appear across all SLO list pages + to use them as filter conditions on the alert and rule stats that we want to count. +*/ export class GetSLOStatsOverview { constructor( @@ -44,12 +51,86 @@ export class GetSLOStatsOverview { return undefined; } + private processSloQueryBuckets( + buckets: Array<{ key: { sloId: string; sloInstanceId: string } }>, + instanceId?: string + ): Array<{ bucketKey: string; query: QueryDslQueryContainer }> { + return buckets.map((bucket) => { + return { + bucketKey: bucket.key.sloId, + query: { + bool: { + must: [ + { term: { 'kibana.alert.rule.parameters.sloId': bucket.key.sloId } }, + ...(instanceId + ? [ + { + term: { + 'kibana.alert.instance.id': bucket.key.sloInstanceId, + }, + }, + ] + : []), + ], + }, + }, + }; + }); + } + /* - This service retrieves stats from alert and rule indices to display on the SLO landing page. - When filters are applied to SLOs, we want to forward those filters onto the searches performed on alerts and rules so the overview stats actively reflect viewable SLO data. - To achieve this, we need to retrieve a list of all SLO ids and instanceIds that may appear across all SLO list pages - to use them as filter conditions on the alert and rule stats that we want to count. - */ + If we know there are no SLOs that match the provided filters, we can skip querying for rules and alerts + */ + private async fetchRulesAndAlerts({ + querySLOsForIds, + sloRuleQueryKeys, + ruleFilters, + alertFilters, + }: { + querySLOsForIds: boolean; + sloRuleQueryKeys: string[]; + ruleFilters?: KueryNode; + alertFilters?: QueryDslQueryContainer[]; + }) { + return await Promise.all( + querySLOsForIds && sloRuleQueryKeys.length === 0 + ? [ + { + total: 0, + }, + { + activeAlertCount: 0, + recoveredAlertCount: 0, + }, + ] + : [ + this.rulesClient.find({ + options: { + ruleTypeIds: SLO_RULE_TYPE_IDS, + consumers: [ + AlertConsumers.SLO, + AlertConsumers.ALERTS, + AlertConsumers.OBSERVABILITY, + ], + ...(ruleFilters ? { filter: ruleFilters } : {}), + }, + }), + + this.racClient.getAlertSummary({ + ruleTypeIds: SLO_RULE_TYPE_IDS, + consumers: [AlertConsumers.SLO, AlertConsumers.ALERTS, AlertConsumers.OBSERVABILITY], + gte: moment().subtract(24, 'hours').toISOString(), + lte: moment().toISOString(), + ...(alertFilters?.length + ? { + filter: alertFilters, + } + : {}), + }), + ] + ); + } + public async execute(params: GetSLOStatsOverviewParams): Promise { const settings = await getSloSettings(this.soClient); const { indices } = await getSummaryIndices(this.scopedClusterClient.asInternalUser, settings); @@ -73,10 +154,8 @@ export class GetSLOStatsOverview { const instanceIdIncluded = Object.values(params).find( (value) => typeof value === 'string' && value.includes('slo.instanceId') ); - let alertFilters: QueryDslQueryContainer[] = []; - let alertFilterTerms: QueryDslQueryContainer[] = []; + const alertFilterTerms: QueryDslQueryContainer[] = []; let afterKey: AggregationsAggregate | undefined; - let ruleFilters: KueryNode | undefined; try { if (querySLOsForIds) { @@ -123,43 +202,18 @@ export class GetSLOStatsOverview { buckets?: Array<{ key: { sloId: string; sloInstanceId: string } }>; } )?.buckets; - if (buckets && buckets.length > 0) { - alertFilterTerms = alertFilterTerms.concat( - ...buckets.map((bucket) => { - sloRuleQueryKeys.push(bucket.key.sloId); - return { - bool: { - must: [ - { term: { 'kibana.alert.rule.parameters.sloId': bucket.key.sloId } }, - ...(instanceIdIncluded - ? [ - { - term: { - 'kibana.alert.instance.id': bucket.key.sloInstanceId, - }, - }, - ] - : []), - ], - }, - }; - }) + + if (buckets) { + const processedBuckets = this.processSloQueryBuckets( + buckets, + instanceIdIncluded as string | undefined ); + for (const { bucketKey, query } of processedBuckets) { + alertFilterTerms.push(query); + sloRuleQueryKeys.push(bucketKey); + } } } while (afterKey); - - const resultNodes = nodeBuilder.or( - sloRuleQueryKeys.map((sloId) => nodeBuilder.is(`alert.attributes.params.sloId`, sloId)) - ); - - ruleFilters = resultNodes; - alertFilters = [ - { - bool: { - should: [...alertFilterTerms], - }, - }, - ]; } } catch (error) { this.logger.error(`Error querying SLOs for IDs: ${error}`); @@ -231,46 +285,29 @@ export class GetSLOStatsOverview { }, }); - /* - If we know there are no SLOs that match the provided filters, we can skip querying for rules and alerts - */ - const [rules, alerts] = await Promise.all( - querySLOsForIds && sloRuleQueryKeys.length === 0 + const ruleFilters: KueryNode | undefined = + sloRuleQueryKeys.length > 0 + ? nodeBuilder.or( + sloRuleQueryKeys.map((sloId) => nodeBuilder.is(`alert.attributes.params.sloId`, sloId)) + ) + : undefined; + const alertFilters = + alertFilterTerms.length > 0 ? [ { - total: 0, - }, - { - activeAlertCount: 0, - recoveredAlertCount: 0, + bool: { + should: [...alertFilterTerms], + }, }, ] - : [ - this.rulesClient.find({ - options: { - ruleTypeIds: SLO_RULE_TYPE_IDS, - consumers: [ - AlertConsumers.SLO, - AlertConsumers.ALERTS, - AlertConsumers.OBSERVABILITY, - ], - ...(ruleFilters ? { filter: ruleFilters } : {}), - }, - }), + : []; - this.racClient.getAlertSummary({ - ruleTypeIds: SLO_RULE_TYPE_IDS, - consumers: [AlertConsumers.SLO, AlertConsumers.ALERTS, AlertConsumers.OBSERVABILITY], - gte: moment().subtract(24, 'hours').toISOString(), - lte: moment().toISOString(), - ...(alertFilters?.length - ? { - filter: alertFilters, - } - : {}), - }), - ] - ); + const [rules, alerts] = await this.fetchRulesAndAlerts({ + querySLOsForIds, + sloRuleQueryKeys, + ruleFilters, + alertFilters, + }); const aggs = response.aggregations;