From 73c22a50fda298537f28bd000731b0584503e7ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Georgiana-Andreea=20Onolea=C8=9B=C4=83?= Date: Mon, 28 Oct 2024 13:39:32 +0200 Subject: [PATCH] [ResponseOps][Cases] Miscount of total numbers of alerts in telemetry (#196112) Closes https://github.com/elastic/kibana/issues/177208 ## Summary Problem: - the metrics collected in telemetry for alerts don't count the total number of alerts on a case correctly. Solution: - added new aggregation function: getUniqueAlertCommentsCountQuery, which is now responsible for defining the cardinality aggregation for counting unique alert comments by alertId. - in the aggs section of the savedObjectsClient.find, the new cardinality aggregation query was added - the total number of alerts is updated to be the result extracted from the new aggregation Example: ![Screenshot 2024-10-22 at 15 20 40](https://github.com/user-attachments/assets/c418c82e-2e35-4c7f-969d-7f4f25bdbc9d) - in the telemetry object, we have the following info: Screenshot 2024-10-22 at 15 21 40 --------- Co-authored-by: Antonio --- .../server/telemetry/queries/alerts.test.ts | 40 ++- .../cases/server/telemetry/queries/alerts.ts | 7 +- .../server/telemetry/queries/utils.test.ts | 228 ++++++++++++++++++ .../cases/server/telemetry/queries/utils.ts | 122 +++++++++- .../plugins/cases/server/telemetry/types.ts | 4 + .../tests/common/telemetry.ts | 51 +++- 6 files changed, 438 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/cases/server/telemetry/queries/alerts.test.ts b/x-pack/plugins/cases/server/telemetry/queries/alerts.test.ts index 11636b50ebd4e..fd00aea939dc8 100644 --- a/x-pack/plugins/cases/server/telemetry/queries/alerts.test.ts +++ b/x-pack/plugins/cases/server/telemetry/queries/alerts.test.ts @@ -17,19 +17,20 @@ describe('alerts', () => { const telemetrySavedObjectsClient = new TelemetrySavedObjectsClient(savedObjectsClient); savedObjectsClient.find.mockResolvedValue({ - total: 5, + total: 3, saved_objects: [], per_page: 1, page: 1, aggregations: { counts: { buckets: [ - { doc_count: 1, key: 1 }, - { doc_count: 2, key: 2 }, - { doc_count: 3, key: 3 }, + { topAlertsPerBucket: { value: 12 } }, + { topAlertsPerBucket: { value: 5 } }, + { topAlertsPerBucket: { value: 3 } }, ], }, references: { cases: { max: { value: 1 } } }, + uniqueAlertCommentsCount: { value: 5 }, }, }); @@ -42,12 +43,13 @@ describe('alerts', () => { savedObjectsClient: telemetrySavedObjectsClient, logger, }); + expect(res).toEqual({ all: { total: 5, daily: 3, - weekly: 2, - monthly: 1, + weekly: 5, + monthly: 12, maxOnACase: 1, }, }); @@ -76,6 +78,13 @@ describe('alerts', () => { }, ], }, + aggregations: { + topAlertsPerBucket: { + cardinality: { + field: 'cases-comments.attributes.alertId', + }, + }, + }, }, references: { aggregations: { @@ -85,10 +94,22 @@ describe('alerts', () => { terms: { field: 'cases-comments.references.id', }, + aggregations: { + reverse: { + reverse_nested: {}, + aggregations: { + topAlerts: { + cardinality: { + field: 'cases-comments.attributes.alertId', + }, + }, + }, + }, + }, }, max: { max_bucket: { - buckets_path: 'ids._count', + buckets_path: 'ids>reverse.topAlerts', }, }, }, @@ -103,6 +124,11 @@ describe('alerts', () => { path: 'cases-comments.references', }, }, + uniqueAlertCommentsCount: { + cardinality: { + field: 'cases-comments.attributes.alertId', + }, + }, }, filter: { arguments: [ diff --git a/x-pack/plugins/cases/server/telemetry/queries/alerts.ts b/x-pack/plugins/cases/server/telemetry/queries/alerts.ts index 96aaec211acb8..88a9c25c88c3d 100644 --- a/x-pack/plugins/cases/server/telemetry/queries/alerts.ts +++ b/x-pack/plugins/cases/server/telemetry/queries/alerts.ts @@ -5,17 +5,14 @@ * 2.0. */ -import { CASE_COMMENT_SAVED_OBJECT } from '../../../common/constants'; import type { CasesTelemetry, CollectTelemetryDataParams } from '../types'; -import { getCountsAndMaxData, getOnlyAlertsCommentsFilter } from './utils'; +import { getCountsAndMaxAlertsData } from './utils'; export const getAlertsTelemetryData = async ({ savedObjectsClient, }: CollectTelemetryDataParams): Promise => { - const res = await getCountsAndMaxData({ + const res = await getCountsAndMaxAlertsData({ savedObjectsClient, - savedObjectType: CASE_COMMENT_SAVED_OBJECT, - filter: getOnlyAlertsCommentsFilter(), }); return res; diff --git a/x-pack/plugins/cases/server/telemetry/queries/utils.test.ts b/x-pack/plugins/cases/server/telemetry/queries/utils.test.ts index 6c66c5aab81c7..b4b18f231eb6a 100644 --- a/x-pack/plugins/cases/server/telemetry/queries/utils.test.ts +++ b/x-pack/plugins/cases/server/telemetry/queries/utils.test.ts @@ -16,10 +16,12 @@ import type { import { findValueInBuckets, getAggregationsBuckets, + getAlertsCountsFromBuckets, getAttachmentsFrameworkStats, getBucketFromAggregation, getConnectorsCardinalityAggregationQuery, getCountsAggregationQuery, + getCountsAndMaxAlertsData, getCountsAndMaxData, getCountsFromBuckets, getCustomFieldsTelemetry, @@ -28,6 +30,7 @@ import { getOnlyConnectorsFilter, getReferencesAggregationQuery, getSolutionValues, + getUniqueAlertCommentsCountQuery, } from './utils'; import { TelemetrySavedObjectsClient } from '../telemetry_saved_objects_client'; @@ -994,6 +997,63 @@ describe('utils', () => { }); }); + describe('getAlertsCountsFromBuckets', () => { + it('returns the correct counts', () => { + const buckets = [ + { topAlertsPerBucket: { value: 12 } }, + { topAlertsPerBucket: { value: 5 } }, + { topAlertsPerBucket: { value: 3 } }, + ]; + + expect(getAlertsCountsFromBuckets(buckets)).toEqual({ + daily: 3, + weekly: 5, + monthly: 12, + }); + }); + + it('returns zero counts when the bucket does not have the topAlertsPerBucket field', () => { + const buckets = [{}]; + // @ts-expect-error + expect(getAlertsCountsFromBuckets(buckets)).toEqual({ + daily: 0, + weekly: 0, + monthly: 0, + }); + }); + + it('returns zero counts when the bucket is undefined', () => { + // @ts-expect-error + expect(getAlertsCountsFromBuckets(undefined)).toEqual({ + daily: 0, + weekly: 0, + monthly: 0, + }); + }); + + it('returns zero counts when the topAlertsPerBucket field is missing in some buckets', () => { + const buckets = [{ doc_count: 1, key: 1, topAlertsPerBucket: { value: 5 } }, {}, {}]; + // @ts-expect-error + expect(getAlertsCountsFromBuckets(buckets)).toEqual({ + daily: 0, + weekly: 0, + monthly: 5, + }); + }); + }); + + describe('getUniqueAlertCommentsCountQuery', () => { + it('returns the correct query', () => { + expect(getUniqueAlertCommentsCountQuery()).toEqual({ + uniqueAlertCommentsCount: { + cardinality: { + field: 'cases-comments.attributes.alertId', + }, + }, + }); + }); + }); + describe('getCountsAndMaxData', () => { const savedObjectsClient = savedObjectsRepositoryMock.create(); savedObjectsClient.find.mockResolvedValue({ @@ -1125,6 +1185,174 @@ describe('utils', () => { }); }); + describe('getCountsAndMaxAlertsData', () => { + const savedObjectsClient = savedObjectsRepositoryMock.create(); + savedObjectsClient.find.mockResolvedValue({ + total: 3, + saved_objects: [], + per_page: 1, + page: 1, + aggregations: { + counts: { + buckets: [ + { doc_count: 1, key: 1, topAlertsPerBucket: { value: 5 } }, + { doc_count: 2, key: 2, topAlertsPerBucket: { value: 3 } }, + { doc_count: 3, key: 3, topAlertsPerBucket: { value: 1 } }, + ], + }, + references: { cases: { max: { value: 1 } } }, + uniqueAlertCommentsCount: { value: 5 }, + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the correct counts and max data', async () => { + const telemetrySavedObjectsClient = new TelemetrySavedObjectsClient(savedObjectsClient); + + const res = await getCountsAndMaxAlertsData({ + savedObjectsClient: telemetrySavedObjectsClient, + }); + expect(res).toEqual({ + all: { + total: 5, + daily: 1, + weekly: 3, + monthly: 5, + maxOnACase: 1, + }, + }); + }); + + it('returns zero data if the response aggregation is not as expected', async () => { + const telemetrySavedObjectsClient = new TelemetrySavedObjectsClient(savedObjectsClient); + savedObjectsClient.find.mockResolvedValue({ + total: 5, + saved_objects: [], + per_page: 1, + page: 1, + }); + + const res = await getCountsAndMaxAlertsData({ + savedObjectsClient: telemetrySavedObjectsClient, + }); + expect(res).toEqual({ + all: { + total: 0, + daily: 0, + weekly: 0, + monthly: 0, + maxOnACase: 0, + }, + }); + }); + + it('should call find with correct arguments', async () => { + const telemetrySavedObjectsClient = new TelemetrySavedObjectsClient(savedObjectsClient); + + await getCountsAndMaxAlertsData({ + savedObjectsClient: telemetrySavedObjectsClient, + }); + + expect(savedObjectsClient.find).toBeCalledWith({ + aggs: { + counts: { + date_range: { + field: 'cases-comments.attributes.created_at', + format: 'dd/MM/YYYY', + ranges: [ + { + from: 'now-1d', + to: 'now', + }, + { + from: 'now-1w', + to: 'now', + }, + { + from: 'now-1M', + to: 'now', + }, + ], + }, + aggregations: { + topAlertsPerBucket: { + cardinality: { + field: 'cases-comments.attributes.alertId', + }, + }, + }, + }, + references: { + aggregations: { + cases: { + aggregations: { + ids: { + terms: { + field: 'cases-comments.references.id', + }, + aggregations: { + reverse: { + reverse_nested: {}, + aggregations: { + topAlerts: { + cardinality: { + field: 'cases-comments.attributes.alertId', + }, + }, + }, + }, + }, + }, + max: { + max_bucket: { + buckets_path: 'ids>reverse.topAlerts', + }, + }, + }, + filter: { + term: { + 'cases-comments.references.type': 'cases', + }, + }, + }, + }, + nested: { + path: 'cases-comments.references', + }, + }, + uniqueAlertCommentsCount: { + cardinality: { + field: 'cases-comments.attributes.alertId', + }, + }, + }, + filter: { + arguments: [ + { + isQuoted: false, + type: 'literal', + value: 'cases-comments.attributes.type', + }, + { + isQuoted: false, + type: 'literal', + value: 'alert', + }, + ], + function: 'is', + type: 'function', + }, + page: 0, + perPage: 0, + type: 'cases-comments', + namespaces: ['*'], + }); + }); + }); + describe('getBucketFromAggregation', () => { it('returns the buckets', () => { expect( diff --git a/x-pack/plugins/cases/server/telemetry/queries/utils.ts b/x-pack/plugins/cases/server/telemetry/queries/utils.ts index 65b81e3362300..6992ed8f7ac06 100644 --- a/x-pack/plugins/cases/server/telemetry/queries/utils.ts +++ b/x-pack/plugins/cases/server/telemetry/queries/utils.ts @@ -27,6 +27,7 @@ import type { FileAttachmentAggsResult, AttachmentFrameworkAggsResult, CustomFieldsTelemetry, + AlertBuckets, } from '../types'; import { buildFilter } from '../../client/utils'; import type { Owner } from '../../../common/constants/types'; @@ -47,6 +48,27 @@ export const getCountsAggregationQuery = (savedObjectType: string) => ({ }, }); +export const getAlertsCountsAggregationQuery = () => ({ + counts: { + date_range: { + field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.created_at`, + format: 'dd/MM/YYYY', + ranges: [ + { from: 'now-1d', to: 'now' }, + { from: 'now-1w', to: 'now' }, + { from: 'now-1M', to: 'now' }, + ], + }, + aggregations: { + topAlertsPerBucket: { + cardinality: { + field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, + }, + }, + }, + }, +}); + export const getMaxBucketOnCaseAggregationQuery = (savedObjectType: string) => ({ references: { nested: { @@ -76,6 +98,55 @@ export const getMaxBucketOnCaseAggregationQuery = (savedObjectType: string) => ( }, }); +export const getAlertsMaxBucketOnCaseAggregationQuery = () => ({ + references: { + nested: { + path: `${CASE_COMMENT_SAVED_OBJECT}.references`, + }, + aggregations: { + cases: { + filter: { + term: { + [`${CASE_COMMENT_SAVED_OBJECT}.references.type`]: CASE_SAVED_OBJECT, + }, + }, + aggregations: { + ids: { + terms: { + field: `${CASE_COMMENT_SAVED_OBJECT}.references.id`, + }, + aggregations: { + reverse: { + reverse_nested: {}, + aggregations: { + topAlerts: { + cardinality: { + field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, + }, + }, + }, + }, + }, + }, + max: { + max_bucket: { + buckets_path: 'ids>reverse.topAlerts', + }, + }, + }, + }, + }, + }, +}); + +export const getUniqueAlertCommentsCountQuery = () => ({ + uniqueAlertCommentsCount: { + cardinality: { + field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, + }, + }, +}); + export const getReferencesAggregationQuery = ({ savedObjectType, referenceType, @@ -121,6 +192,52 @@ export const getCountsFromBuckets = (buckets: Buckets['buckets']) => ({ monthly: buckets?.[0]?.doc_count ?? 0, }); +export const getAlertsCountsFromBuckets = (buckets: AlertBuckets['buckets']) => ({ + daily: buckets?.[2]?.topAlertsPerBucket?.value ?? 0, + weekly: buckets?.[1]?.topAlertsPerBucket?.value ?? 0, + monthly: buckets?.[0]?.topAlertsPerBucket?.value ?? 0, +}); + +export const getCountsAndMaxAlertsData = async ({ + savedObjectsClient, +}: { + savedObjectsClient: TelemetrySavedObjectsClient; +}) => { + const filter = getOnlyAlertsCommentsFilter(); + + const res = await savedObjectsClient.find< + unknown, + { + counts: AlertBuckets; + references: MaxBucketOnCaseAggregation['references']; + uniqueAlertCommentsCount: { value: number }; + } + >({ + page: 0, + perPage: 0, + filter, + type: CASE_COMMENT_SAVED_OBJECT, + namespaces: ['*'], + aggs: { + ...getAlertsCountsAggregationQuery(), + ...getAlertsMaxBucketOnCaseAggregationQuery(), + ...getUniqueAlertCommentsCountQuery(), + }, + }); + + const countsBuckets = res.aggregations?.counts?.buckets ?? []; + const totalAlerts = res.aggregations?.uniqueAlertCommentsCount.value ?? 0; + const maxOnACase = res.aggregations?.references?.cases?.max?.value ?? 0; + + return { + all: { + total: totalAlerts, + ...getAlertsCountsFromBuckets(countsBuckets), + maxOnACase, + }, + }; +}; + export const getCountsAndMaxData = async ({ savedObjectsClient, savedObjectType, @@ -132,7 +249,10 @@ export const getCountsAndMaxData = async ({ }) => { const res = await savedObjectsClient.find< unknown, - { counts: Buckets; references: MaxBucketOnCaseAggregation['references'] } + { + counts: Buckets; + references: MaxBucketOnCaseAggregation['references']; + } >({ page: 0, perPage: 0, diff --git a/x-pack/plugins/cases/server/telemetry/types.ts b/x-pack/plugins/cases/server/telemetry/types.ts index b4996da27f234..228aa0c7ae397 100644 --- a/x-pack/plugins/cases/server/telemetry/types.ts +++ b/x-pack/plugins/cases/server/telemetry/types.ts @@ -17,6 +17,10 @@ export interface Bucket { key: T; } +export interface AlertBuckets { + buckets: Array<{ topAlertsPerBucket: { value: number } }>; +} + export interface Buckets { buckets: Array>; } diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/telemetry.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/telemetry.ts index 0c47e62fae79c..c83210e51e5d5 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/telemetry.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/telemetry.ts @@ -6,12 +6,14 @@ */ import expect from 'expect'; -import { getPostCaseRequest } from '../../../common/lib/mock'; +import { getPostCaseRequest, postCommentAlertReq } from '../../../common/lib/mock'; import { deleteAllCaseItems, createCase, getTelemetry, runTelemetryTask, + createComment, + bulkCreateAttachments, } from '../../../common/lib/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { superUser } from '../../../common/lib/authentication/users'; @@ -49,5 +51,52 @@ export default ({ getService }: FtrProviderContext): void => { expect(res.stats.stack_stats.kibana.plugins.cases.cases.all.total).toBe(2); }); }); + + it('should return the corect total number of alerts attached to cases', async () => { + const firstCase = await createCase(supertest, getPostCaseRequest()); + const secondCase = await createCase(supertest, getPostCaseRequest()); + + const firstCaseAlerts = [...Array(3).keys()].map((num) => `test-case-1-${num}`); + const secondCaseAlerts = [...Array(2).keys()].map((num) => `test-case-2-${num}`); + + await bulkCreateAttachments({ + supertest, + caseId: firstCase.id, + params: [ + { + ...postCommentAlertReq, + alertId: firstCaseAlerts, + index: firstCaseAlerts, + }, + ], + expectedHttpCode: 200, + }); + + await bulkCreateAttachments({ + supertest, + caseId: firstCase.id, + params: [ + { + ...postCommentAlertReq, + alertId: secondCaseAlerts, + index: secondCaseAlerts, + }, + ], + expectedHttpCode: 200, + }); + + await createComment({ + supertest, + caseId: secondCase.id, + params: { ...postCommentAlertReq, alertId: 'test-case-2-3', index: 'test-case-2-3' }, + }); + + await runTelemetryTask(supertest); + + await retry.try(async () => { + const res = await getTelemetry(supertest); + expect(res.stats.stack_stats.kibana.plugins.cases.alerts.all.total).toBe(6); + }); + }); }); };