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..28403323f0980 --- /dev/null +++ b/x-pack/solutions/observability/plugins/slo/server/services/get_slo_stats_overview.test.ts @@ -0,0 +1,623 @@ +/* + * 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 { 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'; + +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: ES_PAGESIZE_LIMIT, + sources: [ + { + sloId: { terms: { field: 'slo.id' } }, + }, + ...(includeInstanceId + ? [ + { + sloInstanceId: { terms: { field: 'slo.instanceId' } }, + }, + ] + : []), + ], + }, + }, + }, + index: ['.slo-observability.summary-v3'], + ...(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 d000d6ec1d5d5..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 @@ -8,15 +8,30 @@ 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'; import type { GetSLOStatsOverviewParams, GetSLOStatsOverviewResponse } from '@kbn/slo-schema'; +import type { + AggregationsAggregate, + FieldValue, + 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'; +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( private soClient: SavedObjectsClientContract, @@ -27,13 +42,183 @@ 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; + } + + 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, + }, + }, + ] + : []), + ], + }, + }, + }; + }); + } + + /* + 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); - const kqlQuery = params.kqlQuery ?? ''; - const filters = params.filters ?? ''; + const kqlQuery = params?.kqlQuery ?? ''; + const filters = params?.filters ?? ''; const parsedFilters = parseStringFilters(filters, this.logger); + const kqlQueriesProvided = !!params?.kqlQuery && params?.kqlQuery?.length > 0; + + /* + 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( + (value) => typeof value === 'string' && value.includes('slo.instanceId') + ); + const alertFilterTerms: QueryDslQueryContainer[] = []; + let afterKey: AggregationsAggregate | undefined; + + try { + if (querySLOsForIds) { + do { + const sloIdCompositeQueryResponse = await this.scopedClusterClient.asCurrentUser.search({ + index: indices, + size: 0, + aggs: { + sloIds: { + composite: { + after: afterKey as Record, + size: ES_PAGESIZE_LIMIT, + sources: [ + { + sloId: { terms: { field: 'slo.id' } }, + }, + ...(instanceIdIncluded + ? [ + { + sloInstanceId: { terms: { field: 'slo.instanceId' } }, + }, + ] + : []), + ], + }, + }, + }, + query: { + bool: { + ...parsedFilters, + ...(params.kqlQuery + ? { + must: [...(parsedFilters.must ?? []), { kql: { query: params.kqlQuery } }], + } + : {}), + }, + }, + }); + + afterKey = this.getAfterKey(sloIdCompositeQueryResponse.aggregations?.sloIds); + + const buckets = ( + sloIdCompositeQueryResponse.aggregations?.sloIds as { + buckets?: Array<{ key: { sloId: string; sloInstanceId: string } }>; + } + )?.buckets; + + 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); + } + } catch (error) { + this.logger.error(`Error querying SLOs for IDs: ${error}`); + throw error; + } const response = await typedSearch(this.scopedClusterClient.asCurrentUser, { index: indices, @@ -100,21 +285,29 @@ 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], - }, - }), - - this.racClient.getAlertSummary({ - ruleTypeIds: SLO_RULE_TYPE_IDS, - consumers: [AlertConsumers.SLO, AlertConsumers.ALERTS, AlertConsumers.OBSERVABILITY], - gte: moment().subtract(24, 'hours').toISOString(), - lte: moment().toISOString(), - }), - ]); + 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 + ? [ + { + bool: { + should: [...alertFilterTerms], + }, + }, + ] + : []; + + const [rules, alerts] = await this.fetchRulesAndAlerts({ + querySLOsForIds, + sloRuleQueryKeys, + ruleFilters, + alertFilters, + }); const aggs = response.aggregations;