diff --git a/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc b/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc index 9b8284808a657..e105cacf75d05 100644 --- a/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc +++ b/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc @@ -23,7 +23,8 @@ "security", "observability", "licensing", - "ruleRegistry" + "ruleRegistry", + "usageCollection" ], "requiredBundles": [ "esql", diff --git a/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/fetcher.test.ts b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/fetcher.test.ts new file mode 100644 index 0000000000000..e13ae951975bf --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/fetcher.test.ts @@ -0,0 +1,71 @@ +/* + * 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 { ElasticsearchClientMock, savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; +import { CollectorFetchContext } from '@kbn/usage-collection-plugin/server'; +import { fetcher } from './fetcher'; + +let savedObjectClient: ReturnType; + +let closeMock: jest.Mock; +let esClient: ElasticsearchClientMock; + +describe('Investigation usage collector fetcher', () => { + beforeEach(() => { + savedObjectClient = savedObjectsRepositoryMock.create(); + closeMock = jest.fn(); + }); + + it('without any existing investigation', async () => { + savedObjectClient.createPointInTimeFinder.mockReturnValue({ + find: async function* find() { + return { + [Symbol.asyncIterator]: async () => {}, + next: () => {}, + }; + }, + close: closeMock, + }); + + const results = await fetcher({ + soClient: savedObjectClient, + esClient, + } as CollectorFetchContext); + + expect(closeMock).toHaveBeenCalled(); + expect(results.investigation).toMatchInlineSnapshot(` + Object { + "by_origin": Object { + "alert": 0, + "blank": 0, + }, + "by_status": Object { + "active": 0, + "cancelled": 0, + "mitigated": 0, + "resolved": 0, + "triage": 0, + }, + "items": Object { + "avg": 0, + "max": 0, + "min": 0, + "p90": 0, + "p95": 0, + }, + "notes": Object { + "avg": 0, + "max": 0, + "min": 0, + "p90": 0, + "p95": 0, + }, + "total": 0, + } + `); + }); +}); diff --git a/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/fetcher.ts b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/fetcher.ts new file mode 100644 index 0000000000000..9f21e39e999a0 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/fetcher.ts @@ -0,0 +1,84 @@ +/* + * 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 { CollectorFetchContext } from '@kbn/usage-collection-plugin/server'; +import { StoredInvestigation } from '../../models/investigation'; +import { SO_INVESTIGATION_TYPE } from '../../saved_objects/investigation'; +import { computeMetrics } from './helpers/metrics'; +import { Usage } from './type'; + +export const fetcher = async (context: CollectorFetchContext) => { + const finder = context.soClient.createPointInTimeFinder({ + type: SO_INVESTIGATION_TYPE, + perPage: 10, + }); + + let usage: Usage['investigation'] = { + total: 0, + by_status: { + triage: 0, + active: 0, + mitigated: 0, + resolved: 0, + cancelled: 0, + }, + by_origin: { + alert: 0, + blank: 0, + }, + items: { + avg: 0, + p90: 0, + p95: 0, + max: 0, + min: 0, + }, + notes: { + avg: 0, + p90: 0, + p95: 0, + max: 0, + min: 0, + }, + }; + + const items: number[] = []; + const notes: number[] = []; + + for await (const response of finder.find()) { + usage = response.saved_objects.reduce((acc, so) => { + items.push(so.attributes.items.length); + notes.push(so.attributes.notes.length); + + return { + ...acc, + total: acc.total + 1, + by_status: { + ...acc.by_status, + ...(so.attributes.status === 'triage' && { triage: acc.by_status.triage + 1 }), + ...(so.attributes.status === 'active' && { active: acc.by_status.active + 1 }), + ...(so.attributes.status === 'mitigated' && { mitigated: acc.by_status.mitigated + 1 }), + ...(so.attributes.status === 'resolved' && { resolved: acc.by_status.resolved + 1 }), + ...(so.attributes.status === 'cancelled' && { cancelled: acc.by_status.cancelled + 1 }), + }, + by_origin: { + ...acc.by_origin, + ...(so.attributes.origin.type === 'alert' && { alert: acc.by_origin.alert + 1 }), + ...(so.attributes.origin.type === 'blank' && { blank: acc.by_origin.blank + 1 }), + }, + }; + }, usage); + } + + usage.items = computeMetrics(items.sort()); + usage.notes = computeMetrics(notes.sort()); + + await finder.close(); + + return { + investigation: usage, + }; +}; diff --git a/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/helpers/metrics.test.ts b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/helpers/metrics.test.ts new file mode 100644 index 0000000000000..d4e8964b95751 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/helpers/metrics.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { computeMetrics } from './metrics'; + +describe('ComputeMetrics', () => { + it('computes the metrics correctly', async () => { + expect(computeMetrics([])).toMatchInlineSnapshot(` + Object { + "avg": 0, + "max": 0, + "min": 0, + "p90": 0, + "p95": 0, + } + `); + expect(computeMetrics([10, 10, 100])).toMatchInlineSnapshot(` + Object { + "avg": 40, + "max": 100, + "min": 10, + "p90": 100, + "p95": 100, + } + `); + + const arr = Array.from({ length: 100 }, (_, i) => i); + expect(computeMetrics(arr)).toMatchInlineSnapshot(` + Object { + "avg": 49.5, + "max": 99, + "min": 0, + "p90": 90, + "p95": 95, + } + `); + }); +}); diff --git a/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/helpers/metrics.ts b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/helpers/metrics.ts new file mode 100644 index 0000000000000..a6a4a0b28760d --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/helpers/metrics.ts @@ -0,0 +1,32 @@ +/* + * 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 { sum } from 'lodash'; + +export function computeMetrics(arr: number[]) { + if (arr.length === 0) { + return { + avg: 0, + p90: 0, + p95: 0, + max: 0, + min: 0, + }; + } + + const total = sum(arr); + const r90 = (90 / 100) * (arr.length - 1) + 1; + const r95 = (95 / 100) * (arr.length - 1) + 1; + + return { + avg: total / arr.length, + p90: arr[Math.floor(r90)], + p95: arr[Math.floor(r95)], + max: arr[arr.length - 1], + min: arr[0], + }; +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/register.ts b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/register.ts new file mode 100644 index 0000000000000..56c88eb322807 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/register.ts @@ -0,0 +1,147 @@ +/* + * 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 { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import { fetcher } from './fetcher'; +import type { Usage } from './type'; + +export function registerUsageCollector(usageCollection?: UsageCollectionSetup): void { + if (!usageCollection) { + return; + } + + const usageCollector = usageCollection.makeUsageCollector({ + type: 'investigation', + schema: { + investigation: { + total: { + type: 'long', + _meta: { + description: 'The total number of investigations in the cluster', + }, + }, + by_status: { + triage: { + type: 'long', + _meta: { + description: 'The number of investigations in triage status in the cluster', + }, + }, + active: { + type: 'long', + _meta: { description: 'The number of investigations in active status in the cluster' }, + }, + mitigated: { + type: 'long', + _meta: { + description: 'The number of investigations in mitigated status in the cluster', + }, + }, + resolved: { + type: 'long', + _meta: { + description: 'The number of investigations in resolved status in the cluster', + }, + }, + cancelled: { + type: 'long', + _meta: { + description: 'The number of investigations in cancelled status in the cluster', + }, + }, + }, + by_origin: { + alert: { + type: 'long', + _meta: { + description: 'The number of investigations created from alerts in the cluster', + }, + }, + blank: { + type: 'long', + _meta: { + description: 'The number of investigations created from scratch in the cluster', + }, + }, + }, + items: { + avg: { + type: 'long', + _meta: { + description: 'The average number of items across all investigations in the cluster', + }, + }, + p90: { + type: 'long', + _meta: { + description: + 'The 90th percentile of the number of items across all investigations in the cluster', + }, + }, + p95: { + type: 'long', + _meta: { + description: + 'The 95th percentile of the number of items across all investigations in the cluster', + }, + }, + max: { + type: 'long', + _meta: { + description: 'The maximum number of items across all investigations in the cluster', + }, + }, + min: { + type: 'long', + _meta: { + description: 'The minimum number of items across all investigations in the cluster', + }, + }, + }, + notes: { + avg: { + type: 'long', + _meta: { + description: 'The average number of notes across all investigations in the cluster', + }, + }, + p90: { + type: 'long', + _meta: { + description: + 'The 90th percentile of the number of notes across all investigations in the cluster', + }, + }, + p95: { + type: 'long', + _meta: { + description: + 'The 95th percentile of the number of notes across all investigations in the cluster', + }, + }, + max: { + type: 'long', + _meta: { + description: 'The maximum number of notes across all investigations in the cluster', + }, + }, + min: { + type: 'long', + _meta: { + description: 'The minimum number of notes across all investigations in the cluster', + }, + }, + }, + }, + }, + isReady: () => true, + fetch: fetcher, + }); + + // register usage collector + usageCollection.registerCollector(usageCollector); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/type.ts b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/type.ts new file mode 100644 index 0000000000000..b7d4215195b4d --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/lib/collectors/type.ts @@ -0,0 +1,37 @@ +/* + * 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. + */ + +export interface Usage { + investigation: { + total: number; + by_status: { + triage: number; + active: number; + mitigated: number; + resolved: number; + cancelled: number; + }; + by_origin: { + alert: number; + blank: number; + }; + items: { + avg: number; + p90: number; + p95: number; + max: number; + min: number; + }; + notes: { + avg: number; + p90: number; + p95: number; + max: number; + min: number; + }; + }; +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/plugin.ts b/x-pack/plugins/observability_solution/investigate_app/server/plugin.ts index f1ee1cacd155b..ec710cffa3b8d 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/plugin.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/plugin.ts @@ -19,6 +19,7 @@ import type { } from './types'; import { investigation } from './saved_objects/investigation'; import { InvestigateAppConfig } from './config'; +import { registerUsageCollector } from './lib/collectors/register'; export class InvestigateAppPlugin implements @@ -53,6 +54,7 @@ export class InvestigateAppPlugin if (this.config.enabled === true) { coreSetup.savedObjects.registerType(investigation); + registerUsageCollector(pluginsSetup.usageCollection); registerServerRoutes({ core: coreSetup, diff --git a/x-pack/plugins/observability_solution/investigate_app/server/types.ts b/x-pack/plugins/observability_solution/investigate_app/server/types.ts index fa4db6ccfcb05..8803221000d5b 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/types.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/types.ts @@ -10,6 +10,7 @@ import { RuleRegistryPluginSetupContract, RuleRegistryPluginStartContract, } from '@kbn/rule-registry-plugin/server'; +import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; /* eslint-disable @typescript-eslint/no-empty-interface*/ @@ -18,6 +19,7 @@ export interface ConfigSchema {} export interface InvestigateAppSetupDependencies { observability: ObservabilityPluginSetup; ruleRegistry: RuleRegistryPluginSetupContract; + usageCollection: UsageCollectionSetup; } export interface InvestigateAppStartDependencies { diff --git a/x-pack/plugins/observability_solution/investigate_app/tsconfig.json b/x-pack/plugins/observability_solution/investigate_app/tsconfig.json index a853456b1156c..d3974c0c0ed49 100644 --- a/x-pack/plugins/observability_solution/investigate_app/tsconfig.json +++ b/x-pack/plugins/observability_solution/investigate_app/tsconfig.json @@ -69,5 +69,6 @@ "@kbn/charts-plugin", "@kbn/observability-utils", "@kbn/observability-alerting-rule-utils", + "@kbn/usage-collection-plugin", ], } diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 79f9a373a92ba..257ad9c3af07b 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -13643,6 +13643,138 @@ } } }, + "investigation": { + "properties": { + "investigation": { + "properties": { + "total": { + "type": "long", + "_meta": { + "description": "The total number of investigations in the cluster" + } + }, + "by_status": { + "properties": { + "triage": { + "type": "long", + "_meta": { + "description": "The number of investigations in triage status in the cluster" + } + }, + "active": { + "type": "long", + "_meta": { + "description": "The number of investigations in active status in the cluster" + } + }, + "mitigated": { + "type": "long", + "_meta": { + "description": "The number of investigations in mitigated status in the cluster" + } + }, + "resolved": { + "type": "long", + "_meta": { + "description": "The number of investigations in resolved status in the cluster" + } + }, + "cancelled": { + "type": "long", + "_meta": { + "description": "The number of investigations in cancelled status in the cluster" + } + } + } + }, + "by_origin": { + "properties": { + "alert": { + "type": "long", + "_meta": { + "description": "The number of investigations created from alerts in the cluster" + } + }, + "blank": { + "type": "long", + "_meta": { + "description": "The number of investigations created from scratch in the cluster" + } + } + } + }, + "items": { + "properties": { + "avg": { + "type": "long", + "_meta": { + "description": "The average number of items across all investigations in the cluster" + } + }, + "p90": { + "type": "long", + "_meta": { + "description": "The 90th percentile of the number of items across all investigations in the cluster" + } + }, + "p95": { + "type": "long", + "_meta": { + "description": "The 95th percentile of the number of items across all investigations in the cluster" + } + }, + "max": { + "type": "long", + "_meta": { + "description": "The maximum number of items across all investigations in the cluster" + } + }, + "min": { + "type": "long", + "_meta": { + "description": "The minimum number of items across all investigations in the cluster" + } + } + } + }, + "notes": { + "properties": { + "avg": { + "type": "long", + "_meta": { + "description": "The average number of notes across all investigations in the cluster" + } + }, + "p90": { + "type": "long", + "_meta": { + "description": "The 90th percentile of the number of notes across all investigations in the cluster" + } + }, + "p95": { + "type": "long", + "_meta": { + "description": "The 95th percentile of the number of notes across all investigations in the cluster" + } + }, + "max": { + "type": "long", + "_meta": { + "description": "The maximum number of notes across all investigations in the cluster" + } + }, + "min": { + "type": "long", + "_meta": { + "description": "The minimum number of notes across all investigations in the cluster" + } + } + } + } + } + } + } + }, "kibana_settings": { "properties": { "xpack": {