diff --git a/x-pack/plugins/cases/server/telemetry/constants.ts b/x-pack/plugins/cases/server/telemetry/constants.ts deleted file mode 100644 index 705321e3f1fa0..0000000000000 --- a/x-pack/plugins/cases/server/telemetry/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * 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 { GENERAL_CASES_OWNER, OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../common'; - -/** - * This should only be used within telemetry - */ -export const OWNERS = [OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER, GENERAL_CASES_OWNER] as const; diff --git a/x-pack/plugins/cases/server/telemetry/index.ts b/x-pack/plugins/cases/server/telemetry/index.ts index 6cd796eca2c18..7ef14541ced09 100644 --- a/x-pack/plugins/cases/server/telemetry/index.ts +++ b/x-pack/plugins/cases/server/telemetry/index.ts @@ -14,6 +14,7 @@ import type { import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import { FILE_SO_TYPE } from '@kbn/files-plugin/common'; import { collectTelemetryData } from './collect_telemetry_data'; import { CASE_TELEMETRY_SAVED_OBJECT, @@ -42,7 +43,7 @@ export const createCasesTelemetry = async ({ }: CreateCasesTelemetryArgs) => { const getInternalSavedObjectClient = async (): Promise => { const [coreStart] = await core.getStartServices(); - return coreStart.savedObjects.createInternalRepository(SAVED_OBJECT_TYPES); + return coreStart.savedObjects.createInternalRepository([...SAVED_OBJECT_TYPES, FILE_SO_TYPE]); }; taskManager.registerTaskDefinitions({ diff --git a/x-pack/plugins/cases/server/telemetry/queries/cases.ts b/x-pack/plugins/cases/server/telemetry/queries/cases.ts index 128c0a09b5554..29868c5129cdb 100644 --- a/x-pack/plugins/cases/server/telemetry/queries/cases.ts +++ b/x-pack/plugins/cases/server/telemetry/queries/cases.ts @@ -5,28 +5,32 @@ * 2.0. */ +import type { ISavedObjectsRepository, SavedObjectsFindResponse } from '@kbn/core/server'; +import { FILE_SO_TYPE } from '@kbn/files-plugin/common'; +import { fromKueryExpression } from '@kbn/es-query'; import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, CASE_USER_ACTION_SAVED_OBJECT, + OWNERS, } from '../../../common/constants'; import { ESCaseStatus } from '../../services/cases/types'; import type { ESCaseAttributes } from '../../services/cases/types'; -import { OWNERS } from '../constants'; import type { CollectTelemetryDataParams, - Buckets, CasesTelemetry, - Cardinality, ReferencesAggregation, LatestDates, CaseAggregationResult, + AttachmentAggregationResult, + FileAttachmentAggregationResult, } from '../types'; import { findValueInBuckets, getAggregationsBuckets, getCountsAggregationQuery, getCountsFromBuckets, + getMaxBucketOnCaseAggregationQuery, getOnlyAlertsCommentsFilter, getOnlyConnectorsFilter, getReferencesAggregationQuery, @@ -62,7 +66,83 @@ export const getCasesTelemetryData = async ({ savedObjectsClient, logger, }: CollectTelemetryDataParams): Promise => { - const byOwnerAggregationQuery = OWNERS.reduce( + console.log('*****collecting telemetry'); + + try { + const [casesRes, commentsRes, totalAlertsRes, totalConnectorsRes, latestDates, filesRes] = + await Promise.all([ + getCasesSavedObjectTelemetry(savedObjectsClient), + getCommentsSavedObjectTelemetry(savedObjectsClient), + getAlertsTelemetry(savedObjectsClient), + getConnectorsTelemetry(savedObjectsClient), + getLatestCasesDates({ savedObjectsClient, logger }), + getFilesTelemetry(savedObjectsClient), + ]); + + console.log('****finished collection telemetry'); + console.log('casesRes ', JSON.stringify(casesRes, null, 2)); + console.log('filesRes ', JSON.stringify(filesRes, null, 2)); + + const aggregationsBuckets = getAggregationsBuckets({ + aggs: casesRes.aggregations, + keys: ['counts', 'syncAlerts', 'status', 'users', 'totalAssignees'], + }); + + return { + all: { + total: casesRes.total, + ...getCountsFromBuckets(aggregationsBuckets.counts), + status: { + open: findValueInBuckets(aggregationsBuckets.status, ESCaseStatus.OPEN), + inProgress: findValueInBuckets(aggregationsBuckets.status, ESCaseStatus.IN_PROGRESS), + closed: findValueInBuckets(aggregationsBuckets.status, ESCaseStatus.CLOSED), + }, + syncAlertsOn: findValueInBuckets(aggregationsBuckets.syncAlerts, 1), + syncAlertsOff: findValueInBuckets(aggregationsBuckets.syncAlerts, 0), + totalUsers: casesRes.aggregations?.users?.value ?? 0, + totalParticipants: commentsRes.aggregations?.participants?.value ?? 0, + totalTags: casesRes.aggregations?.tags?.value ?? 0, + totalWithAlerts: + totalAlertsRes.aggregations?.references?.referenceType?.referenceAgg?.value ?? 0, + totalWithConnectors: + totalConnectorsRes.aggregations?.references?.referenceType?.referenceAgg?.value ?? 0, + latestDates, + assignees: { + total: casesRes.aggregations?.totalAssignees.value ?? 0, + totalWithZero: casesRes.aggregations?.assigneeFilters.buckets.zero.doc_count ?? 0, + totalWithAtLeastOne: + casesRes.aggregations?.assigneeFilters.buckets.atLeastOne.doc_count ?? 0, + }, + }, + sec: getSolutionValues({ + caseAggregations: casesRes.aggregations, + attachmentAggregations: commentsRes.aggregations, + filesAggregations: filesRes.aggregations, + owner: 'securitySolution', + }), + obs: getSolutionValues({ + caseAggregations: casesRes.aggregations, + attachmentAggregations: commentsRes.aggregations, + filesAggregations: filesRes.aggregations, + owner: 'observability', + }), + main: getSolutionValues({ + caseAggregations: casesRes.aggregations, + attachmentAggregations: commentsRes.aggregations, + filesAggregations: filesRes.aggregations, + owner: 'cases', + }), + }; + } catch (error) { + logger.error(`Cases telemetry failed with error: ${error}`); + throw error; + } +}; + +const getCasesSavedObjectTelemetry = async ( + savedObjectsClient: ISavedObjectsRepository +): Promise> => { + const caseByOwnerAggregationQuery = OWNERS.reduce( (aggQuery, owner) => ({ ...aggQuery, [owner]: { @@ -80,12 +160,12 @@ export const getCasesTelemetryData = async ({ {} ); - const casesRes = await savedObjectsClient.find({ + return savedObjectsClient.find({ page: 0, perPage: 0, type: CASE_SAVED_OBJECT, aggs: { - ...byOwnerAggregationQuery, + ...caseByOwnerAggregationQuery, ...getCountsAggregationQuery(CASE_SAVED_OBJECT), ...getAssigneesAggregations(), totalsByOwner: { @@ -111,17 +191,83 @@ export const getCasesTelemetryData = async ({ }, }, }); +}; + +const getAssigneesAggregations = () => ({ + totalAssignees: { + value_count: { + field: `${CASE_SAVED_OBJECT}.attributes.assignees.uid`, + }, + }, + assigneeFilters: { + filters: { + filters: { + zero: { + bool: { + must_not: { + exists: { + field: `${CASE_SAVED_OBJECT}.attributes.assignees.uid`, + }, + }, + }, + }, + atLeastOne: { + bool: { + filter: { + exists: { + field: `${CASE_SAVED_OBJECT}.attributes.assignees.uid`, + }, + }, + }, + }, + }, + }, + }, +}); + +const getCommentsSavedObjectTelemetry = async ( + savedObjectsClient: ISavedObjectsRepository +): Promise> => { + const attachmentsByOwnerAggregationQuery = OWNERS.reduce( + (aggQuery, owner) => ({ + ...aggQuery, + [owner]: { + filter: { + term: { + [`${CASE_COMMENT_SAVED_OBJECT}.attributes.owner`]: owner, + }, + }, + aggs: { + externalReferenceTypes: { + terms: { + field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.externalReferenceAttachmentTypeId`, + }, + aggs: { + ...getMaxBucketOnCaseAggregationQuery(CASE_COMMENT_SAVED_OBJECT), + }, + }, + persistableReferenceTypes: { + terms: { + field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.persistableStateAttachmentTypeId`, + }, + aggs: { + ...getMaxBucketOnCaseAggregationQuery(CASE_COMMENT_SAVED_OBJECT), + }, + }, + }, + }, + }), + {} + ); + + console.log('****collecting attachments telemetry'); - const commentsRes = await savedObjectsClient.find< - unknown, - Record & { - participants: Cardinality; - } & ReferencesAggregation - >({ + return savedObjectsClient.find({ page: 0, perPage: 0, type: CASE_COMMENT_SAVED_OBJECT, aggs: { + ...attachmentsByOwnerAggregationQuery, participants: { cardinality: { field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.created_by.username`, @@ -129,8 +275,47 @@ export const getCasesTelemetryData = async ({ }, }, }); +}; + +const getFilesTelemetry = async ( + savedObjectsClient: ISavedObjectsRepository +): Promise> => { + const filesByOwnerAggregationQuery = OWNERS.reduce( + (aggQuery, owner) => ({ + ...aggQuery, + [owner]: { + filter: { + term: { + [`${FILE_SO_TYPE}.attributes.Meta.owner`]: owner, + }, + }, + aggs: { + averageSize: { + avg: { + field: `${FILE_SO_TYPE}.attributes.size`, + }, + }, + }, + }, + }), + {} + ); - const totalAlertsRes = await savedObjectsClient.find({ + const filterCaseIdExists = fromKueryExpression(`${FILE_SO_TYPE}.attributes.Meta.caseId: *`); + + return savedObjectsClient.find({ + page: 0, + perPage: 0, + type: FILE_SO_TYPE, + // filter: filterCaseIdExists, + aggs: filesByOwnerAggregationQuery, + }); +}; + +const getAlertsTelemetry = async ( + savedObjectsClient: ISavedObjectsRepository +): Promise> => { + return savedObjectsClient.find({ page: 0, perPage: 0, type: CASE_COMMENT_SAVED_OBJECT, @@ -143,8 +328,12 @@ export const getCasesTelemetryData = async ({ }), }, }); +}; - const totalConnectorsRes = await savedObjectsClient.find({ +const getConnectorsTelemetry = async ( + savedObjectsClient: ISavedObjectsRepository +): Promise> => { + return savedObjectsClient.find({ page: 0, perPage: 0, type: CASE_USER_ACTION_SAVED_OBJECT, @@ -157,74 +346,4 @@ export const getCasesTelemetryData = async ({ }), }, }); - - const latestDates = await getLatestCasesDates({ savedObjectsClient, logger }); - - const aggregationsBuckets = getAggregationsBuckets({ - aggs: casesRes.aggregations, - keys: ['counts', 'syncAlerts', 'status', 'users', 'totalAssignees'], - }); - - return { - all: { - total: casesRes.total, - ...getCountsFromBuckets(aggregationsBuckets.counts), - status: { - open: findValueInBuckets(aggregationsBuckets.status, ESCaseStatus.OPEN), - inProgress: findValueInBuckets(aggregationsBuckets.status, ESCaseStatus.IN_PROGRESS), - closed: findValueInBuckets(aggregationsBuckets.status, ESCaseStatus.CLOSED), - }, - syncAlertsOn: findValueInBuckets(aggregationsBuckets.syncAlerts, 1), - syncAlertsOff: findValueInBuckets(aggregationsBuckets.syncAlerts, 0), - totalUsers: casesRes.aggregations?.users?.value ?? 0, - totalParticipants: commentsRes.aggregations?.participants?.value ?? 0, - totalTags: casesRes.aggregations?.tags?.value ?? 0, - totalWithAlerts: - totalAlertsRes.aggregations?.references?.referenceType?.referenceAgg?.value ?? 0, - totalWithConnectors: - totalConnectorsRes.aggregations?.references?.referenceType?.referenceAgg?.value ?? 0, - latestDates, - assignees: { - total: casesRes.aggregations?.totalAssignees.value ?? 0, - totalWithZero: casesRes.aggregations?.assigneeFilters.buckets.zero.doc_count ?? 0, - totalWithAtLeastOne: - casesRes.aggregations?.assigneeFilters.buckets.atLeastOne.doc_count ?? 0, - }, - }, - sec: getSolutionValues(casesRes.aggregations, 'securitySolution'), - obs: getSolutionValues(casesRes.aggregations, 'observability'), - main: getSolutionValues(casesRes.aggregations, 'cases'), - }; }; - -const getAssigneesAggregations = () => ({ - totalAssignees: { - value_count: { - field: `${CASE_SAVED_OBJECT}.attributes.assignees.uid`, - }, - }, - assigneeFilters: { - filters: { - filters: { - zero: { - bool: { - must_not: { - exists: { - field: `${CASE_SAVED_OBJECT}.attributes.assignees.uid`, - }, - }, - }, - }, - atLeastOne: { - bool: { - filter: { - exists: { - field: `${CASE_SAVED_OBJECT}.attributes.assignees.uid`, - }, - }, - }, - }, - }, - }, - }, -}); 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 e466ba597108a..a9a8732bd8982 100644 --- a/x-pack/plugins/cases/server/telemetry/queries/utils.test.ts +++ b/x-pack/plugins/cases/server/telemetry/queries/utils.test.ts @@ -6,10 +6,16 @@ */ import { savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; -import type { CaseAggregationResult } from '../types'; +import type { + AttachmentAggregationResult, + AttachmentFrameworkAggsResult, + CaseAggregationResult, + FileAttachmentAggregationResult, +} from '../types'; import { findValueInBuckets, getAggregationsBuckets, + getAttachmentsFrameworkStats, getBucketFromAggregation, getConnectorsCardinalityAggregationQuery, getCountsAggregationQuery, @@ -46,19 +52,19 @@ describe('utils', () => { totalAssignees: { value: 5 }, }; - const solutionValues = { + const caseSolutionValues = { counts, ...assignees, }; - const aggsResult: CaseAggregationResult = { + const caseAggsResult: CaseAggregationResult = { users: { value: 1 }, tags: { value: 2 }, ...assignees, counts, - securitySolution: { ...solutionValues }, - observability: { ...solutionValues }, - cases: { ...solutionValues }, + securitySolution: { ...caseSolutionValues }, + observability: { ...caseSolutionValues }, + cases: { ...caseSolutionValues }, syncAlerts: { buckets: [ { @@ -87,7 +93,7 @@ describe('utils', () => { }, { key: 'securitySolution', - doc_count: 1, + doc_count: 5, }, { key: 'cases', @@ -97,40 +103,245 @@ describe('utils', () => { }, }; + const attachmentFramework: AttachmentFrameworkAggsResult = { + externalReferenceTypes: { + buckets: [ + { + doc_count: 5, + key: '.osquery', + references: { + cases: { + max: { + value: 10, + }, + }, + }, + }, + { + doc_count: 5, + key: '.files', + references: { + cases: { + max: { + value: 10, + }, + }, + }, + }, + ], + }, + persistableReferenceTypes: { + buckets: [ + { + doc_count: 5, + key: '.ml', + references: { + cases: { + max: { + value: 10, + }, + }, + }, + }, + { + doc_count: 5, + key: '.files', + references: { + cases: { + max: { + value: 10, + }, + }, + }, + }, + ], + }, + }; + + const attachmentAggsResult: AttachmentAggregationResult = { + securitySolution: { ...attachmentFramework }, + observability: { ...attachmentFramework }, + cases: { ...attachmentFramework }, + participants: { + value: 5, + }, + }; + + const filesRes: FileAttachmentAggregationResult = { + securitySolution: { + averageSize: 500, + }, + observability: { + averageSize: 500, + }, + cases: { + averageSize: 500, + }, + }; + it('constructs the solution values correctly', () => { - expect(getSolutionValues(aggsResult, 'securitySolution')).toMatchInlineSnapshot(` + expect( + getSolutionValues({ + caseAggregations: caseAggsResult, + attachmentAggregations: attachmentAggsResult, + filesAggregations: filesRes, + owner: 'securitySolution', + }) + ).toMatchInlineSnapshot(` Object { "assignees": Object { "total": 5, "totalWithAtLeastOne": 0, "totalWithZero": 100, }, + "attachmentFramework": Object { + "externalAttachments": Array [ + Object { + "average": 1, + "maxOnACase": 10, + "total": 5, + "type": ".osquery", + }, + Object { + "average": 1, + "maxOnACase": 10, + "total": 5, + "type": ".files", + }, + ], + "files": Object { + "average": 1, + "averageSize": 500, + "maxOnACase": 10, + "total": 5, + }, + "persistableAttachments": Array [ + Object { + "average": 1, + "maxOnACase": 10, + "total": 5, + "type": ".ml", + }, + Object { + "average": 1, + "maxOnACase": 10, + "total": 5, + "type": ".files", + }, + ], + }, "daily": 3, "monthly": 1, - "total": 1, + "total": 5, "weekly": 2, } `); - expect(getSolutionValues(aggsResult, 'cases')).toMatchInlineSnapshot(` + expect( + getSolutionValues({ + caseAggregations: caseAggsResult, + attachmentAggregations: attachmentAggsResult, + filesAggregations: filesRes, + owner: 'cases', + }) + ).toMatchInlineSnapshot(` Object { "assignees": Object { "total": 5, "totalWithAtLeastOne": 0, "totalWithZero": 100, }, + "attachmentFramework": Object { + "externalAttachments": Array [ + Object { + "average": 5, + "maxOnACase": 10, + "total": 5, + "type": ".osquery", + }, + Object { + "average": 5, + "maxOnACase": 10, + "total": 5, + "type": ".files", + }, + ], + "files": Object { + "average": 5, + "averageSize": 500, + "maxOnACase": 10, + "total": 5, + }, + "persistableAttachments": Array [ + Object { + "average": 5, + "maxOnACase": 10, + "total": 5, + "type": ".ml", + }, + Object { + "average": 5, + "maxOnACase": 10, + "total": 5, + "type": ".files", + }, + ], + }, "daily": 3, "monthly": 1, "total": 1, "weekly": 2, } `); - expect(getSolutionValues(aggsResult, 'observability')).toMatchInlineSnapshot(` + expect( + getSolutionValues({ + caseAggregations: caseAggsResult, + attachmentAggregations: attachmentAggsResult, + filesAggregations: filesRes, + owner: 'observability', + }) + ).toMatchInlineSnapshot(` Object { "assignees": Object { "total": 5, "totalWithAtLeastOne": 0, "totalWithZero": 100, }, + "attachmentFramework": Object { + "externalAttachments": Array [ + Object { + "average": 5, + "maxOnACase": 10, + "total": 5, + "type": ".osquery", + }, + Object { + "average": 5, + "maxOnACase": 10, + "total": 5, + "type": ".files", + }, + ], + "files": Object { + "average": 5, + "averageSize": 500, + "maxOnACase": 10, + "total": 5, + }, + "persistableAttachments": Array [ + Object { + "average": 5, + "maxOnACase": 10, + "total": 5, + "type": ".ml", + }, + Object { + "average": 5, + "maxOnACase": 10, + "total": 5, + "type": ".files", + }, + ], + }, "daily": 3, "monthly": 1, "total": 1, @@ -140,6 +351,282 @@ describe('utils', () => { }); }); + describe('getAttachmentsFrameworkStats', () => { + it('returns empty stats if the aggregation is undefined', () => { + expect(getAttachmentsFrameworkStats({ totalCasesForOwner: 0, owner: 'securitySolution' })) + .toMatchInlineSnapshot(` + Object { + "attachmentFramework": Object { + "externalAttachments": Array [], + "files": Object { + "average": 0, + "averageSize": 0, + "maxOnACase": 0, + "total": 0, + }, + "persistableAttachments": Array [], + }, + } + `); + }); + + describe('externalAttachments', () => { + const attachmentFramework: AttachmentFrameworkAggsResult = { + externalReferenceTypes: { + buckets: [ + { + doc_count: 5, + key: '.osquery', + references: { + cases: { + max: { + value: 10, + }, + }, + }, + }, + { + doc_count: 10, + key: '.files', + references: { + cases: { + max: { + value: 10, + }, + }, + }, + }, + ], + }, + persistableReferenceTypes: { + buckets: [], + }, + }; + + const aggs: AttachmentAggregationResult = { + securitySolution: { ...attachmentFramework }, + observability: { ...attachmentFramework }, + cases: { ...attachmentFramework }, + participants: { + value: 1, + }, + }; + + it('populates the externalAttachments array', () => { + const stats = getAttachmentsFrameworkStats({ + attachmentAggregations: aggs, + totalCasesForOwner: 5, + owner: 'securitySolution', + }); + + expect(stats.attachmentFramework.externalAttachments[0]).toEqual({ + // the average is 5 from the aggs result / 5 from the function parameter + average: 1, + maxOnACase: 10, + total: 5, + type: '.osquery', + }); + + expect(stats.attachmentFramework.externalAttachments[1]).toEqual({ + // the average is 10 from the aggs result / 5 from the function parameter + average: 2, + maxOnACase: 10, + total: 10, + type: '.files', + }); + }); + }); + + describe('persistableAttachments', () => { + const attachmentFramework: AttachmentFrameworkAggsResult = { + persistableReferenceTypes: { + buckets: [ + { + doc_count: 5, + key: '.osquery', + references: { + cases: { + max: { + value: 10, + }, + }, + }, + }, + { + doc_count: 10, + key: '.files', + references: { + cases: { + max: { + value: 10, + }, + }, + }, + }, + ], + }, + externalReferenceTypes: { + buckets: [], + }, + }; + + const aggs: AttachmentAggregationResult = { + securitySolution: { ...attachmentFramework }, + observability: { ...attachmentFramework }, + cases: { ...attachmentFramework }, + participants: { + value: 1, + }, + }; + + it('populates the externalAttachments array', () => { + const stats = getAttachmentsFrameworkStats({ + attachmentAggregations: aggs, + totalCasesForOwner: 5, + owner: 'securitySolution', + }); + + expect(stats.attachmentFramework.persistableAttachments[0]).toEqual({ + // the average is 5 from the aggs result / 5 from the function parameter + average: 1, + maxOnACase: 10, + total: 5, + type: '.osquery', + }); + + expect(stats.attachmentFramework.persistableAttachments[1]).toEqual({ + // the average is 10 from the aggs result / 5 from the function parameter + average: 2, + maxOnACase: 10, + total: 10, + type: '.files', + }); + }); + }); + + describe('files', () => { + it('sets the files stats to empty when it cannot find a files entry', () => { + const attachmentFramework: AttachmentFrameworkAggsResult = { + externalReferenceTypes: { + buckets: [ + { + doc_count: 5, + key: '.osquery', + references: { + cases: { + max: { + value: 10, + }, + }, + }, + }, + ], + }, + persistableReferenceTypes: { + buckets: [], + }, + }; + + const aggs: AttachmentAggregationResult = { + securitySolution: { ...attachmentFramework }, + observability: { ...attachmentFramework }, + cases: { ...attachmentFramework }, + participants: { + value: 1, + }, + }; + + const filesRes: FileAttachmentAggregationResult = { + securitySolution: { + averageSize: 500, + }, + observability: { + averageSize: 500, + }, + cases: { + averageSize: 500, + }, + }; + + expect( + getAttachmentsFrameworkStats({ + attachmentAggregations: aggs, + totalCasesForOwner: 5, + filesAggregations: filesRes, + owner: 'securitySolution', + }).attachmentFramework.files + ).toMatchInlineSnapshot(` + Object { + "average": 0, + "averageSize": 0, + "maxOnACase": 0, + "total": 0, + } + `); + }); + + it('sets the files stats when it finds a files entry', () => { + const attachmentFramework: AttachmentFrameworkAggsResult = { + externalReferenceTypes: { + buckets: [ + { + doc_count: 5, + key: '.files', + references: { + cases: { + max: { + value: 10, + }, + }, + }, + }, + ], + }, + persistableReferenceTypes: { + buckets: [], + }, + }; + + const aggs: AttachmentAggregationResult = { + securitySolution: { ...attachmentFramework }, + observability: { ...attachmentFramework }, + cases: { ...attachmentFramework }, + participants: { + value: 1, + }, + }; + + const filesRes: FileAttachmentAggregationResult = { + securitySolution: { + averageSize: 500, + }, + observability: { + averageSize: 500, + }, + cases: { + averageSize: 500, + }, + }; + + expect( + getAttachmentsFrameworkStats({ + attachmentAggregations: aggs, + filesAggregations: filesRes, + totalCasesForOwner: 5, + owner: 'securitySolution', + }).attachmentFramework.files + ).toMatchInlineSnapshot(` + Object { + "average": 1, + "averageSize": 500, + "maxOnACase": 10, + "total": 5, + } + `); + }); + }); + }); + describe('getCountsAggregationQuery', () => { it('returns the correct query', () => { expect(getCountsAggregationQuery('test')).toEqual({ diff --git a/x-pack/plugins/cases/server/telemetry/queries/utils.ts b/x-pack/plugins/cases/server/telemetry/queries/utils.ts index 82bdef3ebe825..de578cc27013c 100644 --- a/x-pack/plugins/cases/server/telemetry/queries/utils.ts +++ b/x-pack/plugins/cases/server/telemetry/queries/utils.ts @@ -19,9 +19,16 @@ import type { CasesTelemetry, MaxBucketOnCaseAggregation, SolutionTelemetry, + AttachmentFramework, + AttachmentAggregationResult, + BucketsWithMaxOnCase, + AttachmentStats, + FileAttachmentStats, + FileAttachmentAggregationResult, } from '../types'; import { buildFilter } from '../../client/utils'; -import type { OWNERS } from '../constants'; +import type { Owner } from '../../../common/constants/types'; +import { FILE_ATTACHMENT_TYPE } from '../../../common/api'; export const getCountsAggregationQuery = (savedObjectType: string) => ({ counts: { @@ -154,22 +161,38 @@ export const getBucketFromAggregation = ({ aggs?: Record; }): Buckets['buckets'] => (get(aggs, `${key}.buckets`) ?? []) as Buckets['buckets']; -export const getSolutionValues = ( - aggregations: CaseAggregationResult | undefined, - owner: typeof OWNERS[number] -): SolutionTelemetry => { +export const getSolutionValues = ({ + caseAggregations, + attachmentAggregations, + filesAggregations, + owner, +}: { + caseAggregations?: CaseAggregationResult; + attachmentAggregations?: AttachmentAggregationResult; + filesAggregations?: FileAttachmentAggregationResult; + owner: Owner; +}): SolutionTelemetry => { const aggregationsBuckets = getAggregationsBuckets({ - aggs: aggregations, + aggs: caseAggregations, keys: ['totalsByOwner', 'securitySolution.counts', 'observability.counts', 'cases.counts'], }); + const totalCasesForOwner = findValueInBuckets(aggregationsBuckets.totalsByOwner, owner); + return { - total: findValueInBuckets(aggregationsBuckets.totalsByOwner, owner), + total: totalCasesForOwner, ...getCountsFromBuckets(aggregationsBuckets[`${owner}.counts`]), + ...getAttachmentsFrameworkStats({ + attachmentAggregations, + filesAggregations, + totalCasesForOwner, + owner, + }), assignees: { - total: aggregations?.[owner].totalAssignees.value ?? 0, - totalWithZero: aggregations?.[owner].assigneeFilters.buckets.zero.doc_count ?? 0, - totalWithAtLeastOne: aggregations?.[owner].assigneeFilters.buckets.atLeastOne.doc_count ?? 0, + total: caseAggregations?.[owner].totalAssignees.value ?? 0, + totalWithZero: caseAggregations?.[owner].assigneeFilters.buckets.zero.doc_count ?? 0, + totalWithAtLeastOne: + caseAggregations?.[owner].assigneeFilters.buckets.atLeastOne.doc_count ?? 0, }, }; }; @@ -192,6 +215,95 @@ export const getAggregationsBuckets = ({ {} ); +export const getAttachmentsFrameworkStats = ({ + attachmentAggregations, + filesAggregations, + totalCasesForOwner, + owner, +}: { + attachmentAggregations?: AttachmentAggregationResult; + filesAggregations?: FileAttachmentAggregationResult; + totalCasesForOwner: number; + owner: Owner; +}): AttachmentFramework => { + if (!attachmentAggregations) { + return emptyAttachmentFramework(); + } + const attachmentOwnerStats = attachmentAggregations[owner]; + const averageFileSize = filesAggregations?.[owner]?.averageSize; + + return { + attachmentFramework: { + externalAttachments: getAttachmentRegistryStats( + attachmentOwnerStats.externalReferenceTypes, + totalCasesForOwner + ), + persistableAttachments: getAttachmentRegistryStats( + attachmentOwnerStats.persistableReferenceTypes, + totalCasesForOwner + ), + files: getFileAttachmentStats({ + registryResults: attachmentOwnerStats.externalReferenceTypes, + averageFileSize, + totalCasesForOwner, + }), + }, + }; +}; + +const getAttachmentRegistryStats = ( + registryResults: BucketsWithMaxOnCase, + totalCasesForOwner: number +): AttachmentStats[] => { + const stats: AttachmentStats[] = []; + + for (const bucket of registryResults.buckets) { + const commonFields = { + average: calculateTypePerCaseAverage(bucket.doc_count, totalCasesForOwner), + maxOnACase: bucket.references.cases.max.value, + total: bucket.doc_count, + }; + + stats.push({ + type: bucket.key, + ...commonFields, + }); + } + + return stats; +}; + +const calculateTypePerCaseAverage = (typeDocCount: number, totalCases: number) => { + if (totalCases === 0) { + return 0; + } + + return Math.round(typeDocCount / totalCases); +}; + +const getFileAttachmentStats = ({ + registryResults, + averageFileSize, + totalCasesForOwner, +}: { + registryResults: BucketsWithMaxOnCase; + averageFileSize?: number; + totalCasesForOwner: number; +}): FileAttachmentStats => { + const fileBucket = registryResults.buckets.find((bucket) => bucket.key === FILE_ATTACHMENT_TYPE); + + if (!fileBucket || averageFileSize == null) { + return emptyFileAttachment(); + } + + return { + averageSize: averageFileSize, + average: calculateTypePerCaseAverage(fileBucket.doc_count, totalCasesForOwner), + maxOnACase: fileBucket.references.cases.max.value, + total: fileBucket.doc_count, + }; +}; + export const getOnlyAlertsCommentsFilter = () => buildFilter({ filters: ['alert'], @@ -244,6 +356,7 @@ export const getTelemetryDataEmptyState = (): CasesTelemetry => ({ weekly: 0, daily: 0, assignees: { total: 0, totalWithAtLeastOne: 0, totalWithZero: 0 }, + ...emptyAttachmentFramework(), }, obs: { total: 0, @@ -251,6 +364,7 @@ export const getTelemetryDataEmptyState = (): CasesTelemetry => ({ weekly: 0, daily: 0, assignees: { total: 0, totalWithAtLeastOne: 0, totalWithZero: 0 }, + ...emptyAttachmentFramework(), }, main: { total: 0, @@ -258,6 +372,7 @@ export const getTelemetryDataEmptyState = (): CasesTelemetry => ({ weekly: 0, daily: 0, assignees: { total: 0, totalWithAtLeastOne: 0, totalWithZero: 0 }, + ...emptyAttachmentFramework(), }, }, userActions: { all: { total: 0, monthly: 0, weekly: 0, daily: 0, maxOnACase: 0 } }, @@ -286,3 +401,18 @@ export const getTelemetryDataEmptyState = (): CasesTelemetry => ({ }, }, }); + +const emptyAttachmentFramework = (): AttachmentFramework => ({ + attachmentFramework: { + persistableAttachments: [], + externalAttachments: [], + files: emptyFileAttachment(), + }, +}); + +const emptyFileAttachment = (): FileAttachmentStats => ({ + average: 0, + averageSize: 0, + maxOnACase: 0, + total: 0, +}); diff --git a/x-pack/plugins/cases/server/telemetry/schema.ts b/x-pack/plugins/cases/server/telemetry/schema.ts index 1f51ca134b577..f21599e298237 100644 --- a/x-pack/plugins/cases/server/telemetry/schema.ts +++ b/x-pack/plugins/cases/server/telemetry/schema.ts @@ -14,6 +14,7 @@ import type { TypeString, SolutionTelemetrySchema, AssigneesSchema, + AttachmentFrameworkSchema, } from './types'; const long: TypeLong = { type: 'long' }; @@ -26,6 +27,27 @@ const countSchema: CountSchema = { daily: long, }; +const attachmentSchema = { + type: 'array' as const, + items: { + average: long, + maxOnACase: long, + total: long, + type: string, + }, +}; + +const attachmentFrameworkSchema: AttachmentFrameworkSchema = { + persistableAttachments: attachmentSchema, + externalAttachments: attachmentSchema, + files: { + average: long, + averageSize: long, + maxOnACase: long, + total: long, + }, +}; + const assigneesSchema: AssigneesSchema = { total: long, totalWithZero: long, @@ -35,6 +57,7 @@ const assigneesSchema: AssigneesSchema = { const solutionTelemetry: SolutionTelemetrySchema = { ...countSchema, assignees: assigneesSchema, + attachmentFramework: attachmentFrameworkSchema, }; const statusSchema: StatusSchema = { diff --git a/x-pack/plugins/cases/server/telemetry/types.ts b/x-pack/plugins/cases/server/telemetry/types.ts index 095b967d1addf..59a57d6fd655c 100644 --- a/x-pack/plugins/cases/server/telemetry/types.ts +++ b/x-pack/plugins/cases/server/telemetry/types.ts @@ -7,7 +7,7 @@ import type { ISavedObjectsRepository, Logger } from '@kbn/core/server'; import type { MakeSchemaFrom } from '@kbn/usage-collection-plugin/server'; -import type { OWNERS } from './constants'; +import type { Owner } from '../../common/constants/types'; export interface Buckets { buckets: Array<{ @@ -57,8 +57,32 @@ export interface AssigneesFilters { }; } +export interface FileAttachmentAverageSize { + averageSize: number; +} + +export type FileAttachmentAggregationResult = Record; + +export interface BucketsWithMaxOnCase { + buckets: Array< + { + doc_count: number; + key: string; + } & MaxBucketOnCaseAggregation + >; +} + +export interface AttachmentFrameworkAggsResult { + externalReferenceTypes: BucketsWithMaxOnCase; + persistableReferenceTypes: BucketsWithMaxOnCase; +} + +export type AttachmentAggregationResult = Record & { + participants: Cardinality; +}; + export type CaseAggregationResult = Record< - typeof OWNERS[number], + Owner, { counts: Buckets; totalAssignees: ValueCount; @@ -81,7 +105,29 @@ export interface Assignees { totalWithAtLeastOne: number; } -export interface SolutionTelemetry extends Count { +interface CommonAttachmentStats { + average: number; + maxOnACase: number; + total: number; +} + +export interface AttachmentStats extends CommonAttachmentStats { + type: string; +} + +export interface FileAttachmentStats extends CommonAttachmentStats { + averageSize: number; +} + +export interface AttachmentFramework { + attachmentFramework: { + externalAttachments: AttachmentStats[]; + persistableAttachments: AttachmentStats[]; + files: FileAttachmentStats; + }; +} + +export interface SolutionTelemetry extends Count, AttachmentFramework { assignees: Assignees; } @@ -147,4 +193,5 @@ export type StatusSchema = MakeSchemaFrom; export type LatestDatesSchema = MakeSchemaFrom; export type CasesTelemetrySchema = MakeSchemaFrom; export type AssigneesSchema = MakeSchemaFrom; +export type AttachmentFrameworkSchema = MakeSchemaFrom; export type SolutionTelemetrySchema = MakeSchemaFrom;