[Cases] Add telemetry to cases saved objects (#126254)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2022-03-16 15:06:06 +02:00 committed by GitHub
parent 865d0ed503
commit 2f07644a2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 3104 additions and 35 deletions

View file

@ -34,6 +34,7 @@ const previouslyRegisteredTypes = [
'cases-connector-mappings',
'cases-sub-case',
'cases-user-actions',
'cases-telemetry',
'config',
'connector_token',
'core-usage-stats',

View file

@ -13,6 +13,7 @@ import { sortOrderSchema } from './common_schemas';
* Schemas for the Bucket aggregations.
*
* Currently supported:
* - date_range
* - filter
* - histogram
* - nested
@ -29,7 +30,6 @@ import { sortOrderSchema } from './common_schemas';
* - children
* - composite
* - date_histogram
* - date_range
* - diversified_sampler
* - geo_distance
* - geohash_grid
@ -64,6 +64,11 @@ const boolSchema = s.object({
});
export const bucketAggsSchemas: Record<string, ObjectType> = {
date_range: s.object({
field: s.string(),
format: s.string(),
ranges: s.arrayOf(s.object({ from: s.maybe(s.string()), to: s.maybe(s.string()) })),
}),
filter: termSchema,
filters: s.object({
filters: s.recordOf(s.string(), s.oneOf([termSchema, boolSchema])),

View file

@ -8,8 +8,10 @@
import { bucketAggsSchemas } from './bucket_aggs';
import { metricsAggsSchemas } from './metrics_aggs';
import { pipelineAggsSchemas } from './pipeline_aggs';
export const aggregationSchemas = {
...metricsAggsSchemas,
...bucketAggsSchemas,
...pipelineAggsSchemas,
};

View file

@ -0,0 +1,45 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { schema as s, ObjectType } from '@kbn/config-schema';
/**
* Schemas for the Bucket aggregations.
*
* Currently supported:
* - max_bucket
*
* Not implemented:
* - avg_bucket
* - bucket_script
* - bucket_count_ks_test
* - bucket_correlation
* - bucket_selector
* - bucket_sort
* - cumulative_cardinality
* - cumulative_sum
* - derivative
* - extended_stats_bucket
* - inference
* - min_bucket
* - moving_fn
* - moving_percentiles
* - normalize
* - percentiles_bucket
* - serial_diff
* - stats_bucket
* - sum_bucket
*/
export const pipelineAggsSchemas: Record<string, ObjectType> = {
max_bucket: s.object({
buckets_path: s.string(),
gap: s.maybe(s.oneOf([s.literal('skip'), s.literal('insert_zeros'), s.literal('keep_values')])),
format: s.maybe(s.string()),
}),
};

View file

@ -121,3 +121,15 @@ export const DEFAULT_FEATURES: CasesFeaturesAllRequired = Object.freeze({
alerts: { sync: true, enabled: true },
metrics: [],
});
/**
* Task manager
*/
export const CASES_TELEMETRY_TASK_NAME = 'cases-telemetry-task';
/**
* Telemetry
*/
export const CASE_TELEMETRY_SAVED_OBJECT = 'cases-telemetry';
export const CASE_TELEMETRY_SAVED_OBJECT_ID = 'cases-telemetry';

View file

@ -13,7 +13,7 @@
"home",
"security",
"spaces",
"features",
"taskManager",
"usageCollection"
],
"owner":{

View file

@ -252,9 +252,6 @@ export const useGetCaseUserActions = (
const response = await getCaseUserActions(thisCaseId, abortCtrlRef.current.signal);
if (!isCancelledRef.current) {
// Attention Future developer
// We are removing the first item because it will always be the creation of the case
// and we do not want it to simplify our life
const participants = !isEmpty(response)
? uniqBy('createdBy.username', response).map((cau) => cau.createdBy)
: [];

View file

@ -8,7 +8,6 @@
import { IContextProvider, KibanaRequest, Logger, PluginInitializerContext } from 'kibana/server';
import { CoreSetup, CoreStart } from 'src/core/server';
import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server';
import { SecurityPluginSetup, SecurityPluginStart } from '../../security/server';
import {
PluginSetupContract as ActionsPluginSetup,
@ -22,6 +21,7 @@ import {
caseConnectorMappingsSavedObjectType,
createCaseSavedObjectType,
caseUserActionSavedObjectType,
casesTelemetrySavedObjectType,
} from './saved_object_types';
import { CasesClient } from './client';
@ -36,20 +36,25 @@ import { LensServerPluginSetup } from '../../lens/server';
import { getCasesKibanaFeature } from './features';
import { registerRoutes } from './routes/api/register_routes';
import { getExternalRoutes } from './routes/api/get_external_routes';
import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server';
import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server';
import { createCasesTelemetry, scheduleCasesTelemetryTask } from './telemetry';
export interface PluginsSetup {
actions: ActionsPluginSetup;
lens: LensServerPluginSetup;
features: FeaturesPluginSetup;
usageCollection?: UsageCollectionSetup;
security?: SecurityPluginSetup;
taskManager?: TaskManagerSetupContract;
usageCollection?: UsageCollectionSetup;
}
export interface PluginsStart {
security?: SecurityPluginStart;
features: FeaturesPluginStart;
spaces?: SpacesPluginStart;
actions: ActionsPluginStart;
features: FeaturesPluginStart;
taskManager?: TaskManagerStartContract;
security?: SecurityPluginStart;
spaces?: SpacesPluginStart;
}
/**
@ -66,7 +71,7 @@ export interface PluginStartContract {
}
export class CasePlugin {
private readonly log: Logger;
private readonly logger: Logger;
private readonly kibanaVersion: PluginInitializerContext['env']['packageInfo']['version'];
private clientFactory: CasesClientFactory;
private securityPluginSetup?: SecurityPluginSetup;
@ -74,11 +79,17 @@ export class CasePlugin {
constructor(private readonly initializerContext: PluginInitializerContext) {
this.kibanaVersion = initializerContext.env.packageInfo.version;
this.log = this.initializerContext.logger.get();
this.clientFactory = new CasesClientFactory(this.log);
this.logger = this.initializerContext.logger.get();
this.clientFactory = new CasesClientFactory(this.logger);
}
public setup(core: CoreSetup, plugins: PluginsSetup) {
this.logger.debug(
`Setting up Case Workflow with core contract [${Object.keys(
core
)}] and plugins [${Object.keys(plugins)}]`
);
this.securityPluginSetup = plugins.security;
this.lensEmbeddableFactory = plugins.lens.lensEmbeddableFactory;
@ -93,14 +104,9 @@ export class CasePlugin {
);
core.savedObjects.registerType(caseConfigureSavedObjectType);
core.savedObjects.registerType(caseConnectorMappingsSavedObjectType);
core.savedObjects.registerType(createCaseSavedObjectType(core, this.log));
core.savedObjects.registerType(createCaseSavedObjectType(core, this.logger));
core.savedObjects.registerType(caseUserActionSavedObjectType);
this.log.debug(
`Setting up Case Workflow with core contract [${Object.keys(
core
)}] and plugins [${Object.keys(plugins)}]`
);
core.savedObjects.registerType(casesTelemetrySavedObjectType);
core.http.registerRouteHandlerContext<CasesRequestHandlerContext, 'cases'>(
APP_ID,
@ -109,20 +115,34 @@ export class CasePlugin {
})
);
if (plugins.taskManager && plugins.usageCollection) {
createCasesTelemetry({
core,
taskManager: plugins.taskManager,
usageCollection: plugins.usageCollection,
logger: this.logger,
kibanaVersion: this.kibanaVersion,
});
}
const router = core.http.createRouter<CasesRequestHandlerContext>();
const telemetryUsageCounter = plugins.usageCollection?.createUsageCounter(APP_ID);
registerRoutes({
router,
routes: getExternalRoutes(),
logger: this.log,
logger: this.logger,
kibanaVersion: this.kibanaVersion,
telemetryUsageCounter,
});
}
public start(core: CoreStart, plugins: PluginsStart): PluginStartContract {
this.log.debug(`Starting Case Workflow`);
this.logger.debug(`Starting Case Workflow`);
if (plugins.taskManager) {
scheduleCasesTelemetryTask(plugins.taskManager, this.logger);
}
this.clientFactory.initialize({
securityPluginSetup: this.securityPluginSetup,
@ -156,7 +176,7 @@ export class CasePlugin {
}
public stop() {
this.log.debug(`Stopping Case Workflow`);
this.logger.debug(`Stopping Case Workflow`);
}
private createRouteHandlerContext = ({

View file

@ -10,3 +10,4 @@ export { caseConfigureSavedObjectType } from './configure';
export { createCaseCommentSavedObjectType } from './comments';
export { caseUserActionSavedObjectType } from './user_actions';
export { caseConnectorMappingsSavedObjectType } from './connector_mappings';
export { casesTelemetrySavedObjectType } from './telemetry';

View file

@ -0,0 +1,19 @@
/*
* 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 { SavedObjectsType } from 'src/core/server';
import { CASE_TELEMETRY_SAVED_OBJECT } from '../../common/constants';
export const casesTelemetrySavedObjectType: SavedObjectsType = {
name: CASE_TELEMETRY_SAVED_OBJECT,
hidden: false,
namespaceType: 'agnostic',
mappings: {
dynamic: false,
properties: {},
},
};

View file

@ -0,0 +1,53 @@
/*
* 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 { getAlertsTelemetryData } from './queries/alerts';
import { getCasesTelemetryData } from './queries/cases';
import { getUserCommentsTelemetryData } from './queries/comments';
import { getConfigurationTelemetryData } from './queries/configuration';
import { getConnectorsTelemetryData } from './queries/connectors';
import { getPushedTelemetryData } from './queries/pushes';
import { getUserActionsTelemetryData } from './queries/user_actions';
import { CasesTelemetry, CollectTelemetryDataParams } from './types';
export const collectTelemetryData = async ({
savedObjectsClient,
logger,
}: CollectTelemetryDataParams): Promise<Partial<CasesTelemetry>> => {
try {
const [cases, userActions, comments, alerts, connectors, pushes, configuration] =
await Promise.all([
getCasesTelemetryData({ savedObjectsClient, logger }),
getUserActionsTelemetryData({ savedObjectsClient, logger }),
getUserCommentsTelemetryData({ savedObjectsClient, logger }),
getAlertsTelemetryData({ savedObjectsClient, logger }),
getConnectorsTelemetryData({ savedObjectsClient, logger }),
getPushedTelemetryData({ savedObjectsClient, logger }),
getConfigurationTelemetryData({ savedObjectsClient, logger }),
]);
return {
cases,
userActions,
comments,
alerts,
connectors,
pushes,
configuration,
};
} catch (err) {
logger.debug('Failed collecting Cases telemetry data');
logger.debug(err);
/**
* Return an empty object instead of an empty state to distinguish between
* clusters that they do not use cases thus all counts will be zero
* and clusters where an error occurred.
* */
return {};
}
};

View file

@ -0,0 +1,96 @@
/*
* 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 {
CoreSetup,
ISavedObjectsRepository,
Logger,
PluginInitializerContext,
SavedObjectsErrorHelpers,
} from '../../../../../src/core/server';
import { TaskManagerSetupContract } from '../../../task_manager/server';
import { UsageCollectionSetup } from '../../../../../src/plugins/usage_collection/server';
import { collectTelemetryData } from './collect_telemetry_data';
import {
CASE_TELEMETRY_SAVED_OBJECT,
CASES_TELEMETRY_TASK_NAME,
CASE_TELEMETRY_SAVED_OBJECT_ID,
SAVED_OBJECT_TYPES,
} from '../../common/constants';
import { CasesTelemetry } from './types';
import { casesSchema } from './schema';
export { scheduleCasesTelemetryTask } from './schedule_telemetry_task';
interface CreateCasesTelemetryArgs {
core: CoreSetup;
taskManager: TaskManagerSetupContract;
usageCollection: UsageCollectionSetup;
logger: Logger;
kibanaVersion: PluginInitializerContext['env']['packageInfo']['version'];
}
export const createCasesTelemetry = async ({
core,
taskManager,
usageCollection,
logger,
}: CreateCasesTelemetryArgs) => {
const getInternalSavedObjectClient = async (): Promise<ISavedObjectsRepository> => {
const [coreStart] = await core.getStartServices();
return coreStart.savedObjects.createInternalRepository(SAVED_OBJECT_TYPES);
};
taskManager.registerTaskDefinitions({
[CASES_TELEMETRY_TASK_NAME]: {
title: 'Collect Cases telemetry data',
createTaskRunner: () => {
return {
run: async () => {
await collectAndStore();
},
cancel: async () => {},
};
},
},
});
const collectAndStore = async () => {
const savedObjectsClient = await getInternalSavedObjectClient();
const telemetryData = await collectTelemetryData({ savedObjectsClient, logger });
await savedObjectsClient.create(CASE_TELEMETRY_SAVED_OBJECT, telemetryData, {
id: CASE_TELEMETRY_SAVED_OBJECT_ID,
overwrite: true,
});
};
const collector = usageCollection.makeUsageCollector<CasesTelemetry | {}>({
type: 'cases',
schema: casesSchema,
isReady: () => true,
fetch: async () => {
try {
const savedObjectsClient = await getInternalSavedObjectClient();
const data = (
await savedObjectsClient.get(CASE_TELEMETRY_SAVED_OBJECT, CASE_TELEMETRY_SAVED_OBJECT_ID)
).attributes as CasesTelemetry;
return data;
} catch (err) {
if (SavedObjectsErrorHelpers.isNotFoundError(err)) {
// task has not run yet, so no saved object to return
return {};
}
throw err;
}
},
});
usageCollection.registerCollector(collector);
};

View file

@ -0,0 +1,128 @@
/*
* 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 {
loggingSystemMock,
savedObjectsRepositoryMock,
} from '../../../../../../src/core/server/mocks';
import { getAlertsTelemetryData } from './alerts';
describe('alerts', () => {
const logger = loggingSystemMock.createLogger();
describe('getAlertsTelemetryData', () => {
const savedObjectsClient = savedObjectsRepositoryMock.create();
savedObjectsClient.find.mockResolvedValue({
total: 5,
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 },
],
},
references: { cases: { max: { value: 1 } } },
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('it returns the correct res', async () => {
const res = await getAlertsTelemetryData({ savedObjectsClient, logger });
expect(res).toEqual({
all: {
total: 5,
daily: 3,
weekly: 2,
monthly: 1,
maxOnACase: 1,
},
});
});
it('should call find with correct arguments', async () => {
await getAlertsTelemetryData({ savedObjectsClient, logger });
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',
},
],
},
},
references: {
aggregations: {
cases: {
aggregations: {
ids: {
terms: {
field: 'cases-comments.references.id',
},
},
max: {
max_bucket: {
buckets_path: 'ids._count',
},
},
},
filter: {
term: {
'cases-comments.references.type': 'cases',
},
},
},
},
nested: {
path: 'cases-comments.references',
},
},
},
filter: {
arguments: [
{
type: 'literal',
value: 'cases-comments.attributes.type',
},
{
type: 'literal',
value: 'alert',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
},
page: 0,
perPage: 0,
type: 'cases-comments',
});
});
});
});

View file

@ -0,0 +1,22 @@
/*
* 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 { CASE_COMMENT_SAVED_OBJECT } from '../../../common/constants';
import { CasesTelemetry, CollectTelemetryDataParams } from '../types';
import { getCountsAndMaxData, getOnlyAlertsCommentsFilter } from './utils';
export const getAlertsTelemetryData = async ({
savedObjectsClient,
}: CollectTelemetryDataParams): Promise<CasesTelemetry['comments']> => {
const res = await getCountsAndMaxData({
savedObjectsClient,
savedObjectType: CASE_COMMENT_SAVED_OBJECT,
filter: getOnlyAlertsCommentsFilter(),
});
return res;
};

View file

@ -0,0 +1,432 @@
/*
* 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 { SavedObjectsFindResponse } from 'kibana/server';
import {
savedObjectsRepositoryMock,
loggingSystemMock,
} from '../../../../../../src/core/server/mocks';
import { getCasesTelemetryData } from './cases';
describe('getCasesTelemetryData', () => {
describe('getCasesTelemetryData', () => {
const logger = loggingSystemMock.createLogger();
const savedObjectsClient = savedObjectsRepositoryMock.create();
const mockFind = (
aggs: Record<string, unknown> = {},
so: SavedObjectsFindResponse['saved_objects'] = []
) => {
savedObjectsClient.find.mockResolvedValueOnce({
total: 5,
saved_objects: so,
per_page: 1,
page: 1,
aggregations: {
...aggs,
},
});
};
const mockSavedObjectResponse = (attributes: Record<string, unknown>) => {
mockFind({}, [
{
attributes: { ...attributes },
score: 1,
id: 'test',
references: [],
type: 'cases',
},
]);
};
const mockResponse = () => {
const counts = {
buckets: [
{ doc_count: 1, key: 1 },
{ doc_count: 2, key: 2 },
{ doc_count: 3, key: 3 },
],
};
mockFind({
users: { value: 1 },
tags: { value: 2 },
counts,
securitySolution: { counts },
observability: { counts },
cases: { counts },
syncAlerts: {
buckets: [
{
key: 0,
doc_count: 1,
},
{
key: 1,
doc_count: 1,
},
],
},
status: {
buckets: [
{
key: 'open',
doc_count: 2,
},
],
},
totalsByOwner: {
buckets: [
{
key: 'observability',
doc_count: 1,
},
{
key: 'securitySolution',
doc_count: 1,
},
{
key: 'cases',
doc_count: 1,
},
],
},
});
mockFind({ participants: { value: 2 } });
mockFind({ references: { referenceType: { referenceAgg: { value: 3 } } } });
mockFind({ references: { referenceType: { referenceAgg: { value: 4 } } } });
mockSavedObjectResponse({
created_at: '2022-03-08T12:24:11.429Z',
});
mockSavedObjectResponse({
updated_at: '2022-03-08T12:24:11.429Z',
});
mockSavedObjectResponse({
closed_at: '2022-03-08T12:24:11.429Z',
});
};
beforeEach(() => {
jest.clearAllMocks();
});
it('it returns the correct res', async () => {
mockResponse();
const res = await getCasesTelemetryData({ savedObjectsClient, logger });
expect(res).toEqual({
all: {
total: 5,
daily: 3,
weekly: 2,
monthly: 1,
latestDates: {
closedAt: '2022-03-08T12:24:11.429Z',
createdAt: '2022-03-08T12:24:11.429Z',
updatedAt: '2022-03-08T12:24:11.429Z',
},
status: {
closed: 0,
inProgress: 0,
open: 2,
},
syncAlertsOff: 1,
syncAlertsOn: 1,
totalParticipants: 2,
totalTags: 2,
totalUsers: 1,
totalWithAlerts: 3,
totalWithConnectors: 4,
},
main: {
total: 1,
daily: 3,
weekly: 2,
monthly: 1,
},
obs: {
total: 1,
daily: 3,
weekly: 2,
monthly: 1,
},
sec: {
total: 1,
daily: 3,
weekly: 2,
monthly: 1,
},
});
});
it('should call find with correct arguments', async () => {
mockResponse();
await getCasesTelemetryData({ savedObjectsClient, logger });
expect(savedObjectsClient.find.mock.calls[0][0]).toEqual({
aggs: {
cases: {
aggs: {
counts: {
date_range: {
field: 'cases.attributes.created_at',
format: 'dd/MM/YYYY',
ranges: [
{
from: 'now-1d',
to: 'now',
},
{
from: 'now-1w',
to: 'now',
},
{
from: 'now-1M',
to: 'now',
},
],
},
},
},
filter: {
term: {
'cases.attributes.owner': 'cases',
},
},
},
counts: {
date_range: {
field: 'cases.attributes.created_at',
format: 'dd/MM/YYYY',
ranges: [
{
from: 'now-1d',
to: 'now',
},
{
from: 'now-1w',
to: 'now',
},
{
from: 'now-1M',
to: 'now',
},
],
},
},
observability: {
aggs: {
counts: {
date_range: {
field: 'cases.attributes.created_at',
format: 'dd/MM/YYYY',
ranges: [
{
from: 'now-1d',
to: 'now',
},
{
from: 'now-1w',
to: 'now',
},
{
from: 'now-1M',
to: 'now',
},
],
},
},
},
filter: {
term: {
'cases.attributes.owner': 'observability',
},
},
},
securitySolution: {
aggs: {
counts: {
date_range: {
field: 'cases.attributes.created_at',
format: 'dd/MM/YYYY',
ranges: [
{
from: 'now-1d',
to: 'now',
},
{
from: 'now-1w',
to: 'now',
},
{
from: 'now-1M',
to: 'now',
},
],
},
},
},
filter: {
term: {
'cases.attributes.owner': 'securitySolution',
},
},
},
status: {
terms: {
field: 'cases.attributes.status',
},
},
syncAlerts: {
terms: {
field: 'cases.attributes.settings.syncAlerts',
},
},
tags: {
cardinality: {
field: 'cases.attributes.tags',
},
},
totalsByOwner: {
terms: {
field: 'cases.attributes.owner',
},
},
users: {
cardinality: {
field: 'cases.attributes.created_by.username',
},
},
},
page: 0,
perPage: 0,
type: 'cases',
});
expect(savedObjectsClient.find.mock.calls[1][0]).toEqual({
aggs: {
participants: {
cardinality: {
field: 'cases-comments.attributes.created_by.username',
},
},
},
page: 0,
perPage: 0,
type: 'cases-comments',
});
expect(savedObjectsClient.find.mock.calls[2][0]).toEqual({
aggs: {
references: {
aggregations: {
referenceType: {
aggregations: {
referenceAgg: {
cardinality: {
field: 'cases-comments.references.id',
},
},
},
filter: {
term: {
'cases-comments.references.type': 'cases',
},
},
},
},
nested: {
path: 'cases-comments.references',
},
},
},
filter: {
arguments: [
{
type: 'literal',
value: 'cases-comments.attributes.type',
},
{
type: 'literal',
value: 'alert',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
},
page: 0,
perPage: 0,
type: 'cases-comments',
});
expect(savedObjectsClient.find.mock.calls[3][0]).toEqual({
aggs: {
references: {
aggregations: {
referenceType: {
aggregations: {
referenceAgg: {
cardinality: {
field: 'cases-user-actions.references.id',
},
},
},
filter: {
term: {
'cases-user-actions.references.type': 'cases',
},
},
},
},
nested: {
path: 'cases-user-actions.references',
},
},
},
filter: {
arguments: [
{
type: 'literal',
value: 'cases-user-actions.attributes.type',
},
{
type: 'literal',
value: 'connector',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
},
page: 0,
perPage: 0,
type: 'cases-user-actions',
});
for (const [index, sortField] of ['created_at', 'updated_at', 'closed_at'].entries()) {
const callIndex = index + 4;
expect(savedObjectsClient.find.mock.calls[callIndex][0]).toEqual({
page: 1,
perPage: 1,
sortField,
sortOrder: 'desc',
type: 'cases',
});
}
});
});
});

View file

@ -0,0 +1,212 @@
/*
* 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 {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
} from '../../../common/constants';
import { ESCaseAttributes } from '../../services/cases/types';
import {
CollectTelemetryDataParams,
Buckets,
CasesTelemetry,
Cardinality,
ReferencesAggregation,
LatestDates,
} from '../types';
import {
findValueInBuckets,
getAggregationsBuckets,
getCountsAggregationQuery,
getCountsFromBuckets,
getOnlyAlertsCommentsFilter,
getOnlyConnectorsFilter,
getReferencesAggregationQuery,
} from './utils';
export const getLatestCasesDates = async ({
savedObjectsClient,
}: CollectTelemetryDataParams): Promise<LatestDates> => {
const find = async (sortField: string) =>
savedObjectsClient.find<ESCaseAttributes>({
page: 1,
perPage: 1,
sortField,
sortOrder: 'desc',
type: CASE_SAVED_OBJECT,
});
const savedObjects = await Promise.all([
find('created_at'),
find('updated_at'),
find('closed_at'),
]);
return {
createdAt: savedObjects?.[0].saved_objects?.[0].attributes?.created_at ?? null,
updatedAt: savedObjects?.[1].saved_objects?.[0].attributes?.updated_at ?? null,
closedAt: savedObjects?.[2].saved_objects?.[0].attributes?.closed_at ?? null,
};
};
export const getCasesTelemetryData = async ({
savedObjectsClient,
logger,
}: CollectTelemetryDataParams): Promise<CasesTelemetry['cases']> => {
const owners = ['observability', 'securitySolution', 'cases'] as const;
const byOwnerAggregationQuery = owners.reduce(
(aggQuery, owner) => ({
...aggQuery,
[owner]: {
filter: {
term: {
[`${CASE_SAVED_OBJECT}.attributes.owner`]: owner,
},
},
aggs: getCountsAggregationQuery(CASE_SAVED_OBJECT),
},
}),
{}
);
const casesRes = await savedObjectsClient.find<
unknown,
Record<typeof owners[number], { counts: Buckets }> & {
counts: Buckets;
syncAlerts: Buckets;
status: Buckets;
users: Cardinality;
tags: Cardinality;
}
>({
page: 0,
perPage: 0,
type: CASE_SAVED_OBJECT,
aggs: {
...byOwnerAggregationQuery,
...getCountsAggregationQuery(CASE_SAVED_OBJECT),
totalsByOwner: {
terms: { field: `${CASE_SAVED_OBJECT}.attributes.owner` },
},
syncAlerts: {
terms: { field: `${CASE_SAVED_OBJECT}.attributes.settings.syncAlerts` },
},
status: {
terms: {
field: `${CASE_SAVED_OBJECT}.attributes.status`,
},
},
users: {
cardinality: {
field: `${CASE_SAVED_OBJECT}.attributes.created_by.username`,
},
},
tags: {
cardinality: {
field: `${CASE_SAVED_OBJECT}.attributes.tags`,
},
},
},
});
const commentsRes = await savedObjectsClient.find<
unknown,
Record<typeof owners[number], { counts: Buckets }> & {
participants: Cardinality;
} & ReferencesAggregation
>({
page: 0,
perPage: 0,
type: CASE_COMMENT_SAVED_OBJECT,
aggs: {
participants: {
cardinality: {
field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.created_by.username`,
},
},
},
});
const totalAlertsRes = await savedObjectsClient.find<unknown, ReferencesAggregation>({
page: 0,
perPage: 0,
type: CASE_COMMENT_SAVED_OBJECT,
filter: getOnlyAlertsCommentsFilter(),
aggs: {
...getReferencesAggregationQuery({
savedObjectType: CASE_COMMENT_SAVED_OBJECT,
referenceType: 'cases',
agg: 'cardinality',
}),
},
});
const totalConnectorsRes = await savedObjectsClient.find<unknown, ReferencesAggregation>({
page: 0,
perPage: 0,
type: CASE_USER_ACTION_SAVED_OBJECT,
filter: getOnlyConnectorsFilter(),
aggs: {
...getReferencesAggregationQuery({
savedObjectType: CASE_USER_ACTION_SAVED_OBJECT,
referenceType: 'cases',
agg: 'cardinality',
}),
},
});
const latestDates = await getLatestCasesDates({ savedObjectsClient, logger });
const aggregationsBuckets = getAggregationsBuckets({
aggs: casesRes.aggregations,
keys: [
'counts',
'observability.counts',
'securitySolution.counts',
'cases.counts',
'syncAlerts',
'status',
'totalsByOwner',
'users',
],
});
return {
all: {
total: casesRes.total,
...getCountsFromBuckets(aggregationsBuckets.counts),
status: {
open: findValueInBuckets(aggregationsBuckets.status, 'open'),
inProgress: findValueInBuckets(aggregationsBuckets.status, 'in-progress'),
closed: findValueInBuckets(aggregationsBuckets.status, '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,
},
sec: {
total: findValueInBuckets(aggregationsBuckets.totalsByOwner, 'securitySolution'),
...getCountsFromBuckets(aggregationsBuckets['securitySolution.counts']),
},
obs: {
total: findValueInBuckets(aggregationsBuckets.totalsByOwner, 'observability'),
...getCountsFromBuckets(aggregationsBuckets['observability.counts']),
},
main: {
total: findValueInBuckets(aggregationsBuckets.totalsByOwner, 'cases'),
...getCountsFromBuckets(aggregationsBuckets['cases.counts']),
},
};
};

View file

@ -0,0 +1,127 @@
/*
* 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 {
loggingSystemMock,
savedObjectsRepositoryMock,
} from '../../../../../../src/core/server/mocks';
import { getUserCommentsTelemetryData } from './comments';
describe('comments', () => {
describe('getUserCommentsTelemetryData', () => {
const logger = loggingSystemMock.createLogger();
const savedObjectsClient = savedObjectsRepositoryMock.create();
savedObjectsClient.find.mockResolvedValue({
total: 5,
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 },
],
},
references: { cases: { max: { value: 1 } } },
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('it returns the correct res', async () => {
const res = await getUserCommentsTelemetryData({ savedObjectsClient, logger });
expect(res).toEqual({
all: {
total: 5,
daily: 3,
weekly: 2,
monthly: 1,
maxOnACase: 1,
},
});
});
it('should call find with correct arguments', async () => {
await getUserCommentsTelemetryData({ savedObjectsClient, logger });
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',
},
],
},
},
references: {
aggregations: {
cases: {
aggregations: {
ids: {
terms: {
field: 'cases-comments.references.id',
},
},
max: {
max_bucket: {
buckets_path: 'ids._count',
},
},
},
filter: {
term: {
'cases-comments.references.type': 'cases',
},
},
},
},
nested: {
path: 'cases-comments.references',
},
},
},
filter: {
arguments: [
{
type: 'literal',
value: 'cases-comments.attributes.type',
},
{
type: 'literal',
value: 'user',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
},
page: 0,
perPage: 0,
type: 'cases-comments',
});
});
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 { CASE_COMMENT_SAVED_OBJECT } from '../../../common/constants';
import { buildFilter } from '../../client/utils';
import { CasesTelemetry, CollectTelemetryDataParams } from '../types';
import { getCountsAndMaxData } from './utils';
export const getUserCommentsTelemetryData = async ({
savedObjectsClient,
}: CollectTelemetryDataParams): Promise<CasesTelemetry['comments']> => {
const onlyUserCommentsFilter = buildFilter({
filters: ['user'],
field: 'type',
operator: 'or',
type: CASE_COMMENT_SAVED_OBJECT,
});
const res = await getCountsAndMaxData({
savedObjectsClient,
savedObjectType: CASE_COMMENT_SAVED_OBJECT,
filter: onlyUserCommentsFilter,
});
return res;
};

View file

@ -0,0 +1,66 @@
/*
* 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 {
loggingSystemMock,
savedObjectsRepositoryMock,
} from '../../../../../../src/core/server/mocks';
import { getConfigurationTelemetryData } from './configuration';
describe('configuration', () => {
describe('getConfigurationTelemetryData', () => {
const logger = loggingSystemMock.createLogger();
const savedObjectsClient = savedObjectsRepositoryMock.create();
savedObjectsClient.find.mockResolvedValue({
total: 5,
saved_objects: [],
per_page: 1,
page: 1,
aggregations: {
closureType: {
buckets: [
{ doc_count: 1, key: 'close-by-user' },
{ doc_count: 2, key: 'close-by-pushing' },
],
},
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('it returns the correct res', async () => {
const res = await getConfigurationTelemetryData({ savedObjectsClient, logger });
expect(res).toEqual({
all: {
closure: {
manually: 1,
automatic: 2,
},
},
});
});
it('should call find with correct arguments', async () => {
await getConfigurationTelemetryData({ savedObjectsClient, logger });
expect(savedObjectsClient.find).toBeCalledWith({
aggs: {
closureType: {
terms: {
field: 'cases-configure.attributes.closure_type',
},
},
},
filter: undefined,
page: 0,
perPage: 0,
type: 'cases-configure',
});
});
});
});

View file

@ -0,0 +1,41 @@
/*
* 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 { CASE_CONFIGURE_SAVED_OBJECT } from '../../../common/constants';
import { Buckets, CasesTelemetry, CollectTelemetryDataParams } from '../types';
import { findValueInBuckets } from './utils';
export const getConfigurationTelemetryData = async ({
savedObjectsClient,
}: CollectTelemetryDataParams): Promise<CasesTelemetry['configuration']> => {
const res = await savedObjectsClient.find<
unknown,
{
closureType: Buckets;
}
>({
page: 0,
perPage: 0,
type: CASE_CONFIGURE_SAVED_OBJECT,
aggs: {
closureType: {
terms: { field: `${CASE_CONFIGURE_SAVED_OBJECT}.attributes.closure_type` },
},
},
});
const closureBuckets = res.aggregations?.closureType?.buckets ?? [];
return {
all: {
closure: {
manually: findValueInBuckets(closureBuckets, 'close-by-user'),
automatic: findValueInBuckets(closureBuckets, 'close-by-pushing'),
},
},
};
};

View file

@ -0,0 +1,219 @@
/*
* 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 {
savedObjectsRepositoryMock,
loggingSystemMock,
} from '../../../../../../src/core/server/mocks';
import { getConnectorsTelemetryData } from './connectors';
describe('getConnectorsTelemetryData', () => {
describe('getConnectorsTelemetryData', () => {
const logger = loggingSystemMock.createLogger();
const savedObjectsClient = savedObjectsRepositoryMock.create();
const mockFind = (aggs: Record<string, unknown>) => {
savedObjectsClient.find.mockResolvedValueOnce({
total: 5,
saved_objects: [],
per_page: 1,
page: 1,
aggregations: {
...aggs,
},
});
};
const mockResponse = () => {
mockFind({ references: { referenceType: { referenceAgg: { value: 1 } } } });
mockFind({ references: { cases: { max: { value: 2 } } } });
mockFind({ references: { referenceType: { referenceAgg: { value: 3 } } } });
mockFind({ references: { referenceType: { referenceAgg: { value: 4 } } } });
mockFind({ references: { referenceType: { referenceAgg: { value: 5 } } } });
mockFind({ references: { referenceType: { referenceAgg: { value: 6 } } } });
mockFind({ references: { referenceType: { referenceAgg: { value: 7 } } } });
};
beforeEach(() => {
jest.clearAllMocks();
});
it('it returns the correct res', async () => {
mockResponse();
const res = await getConnectorsTelemetryData({ savedObjectsClient, logger });
expect(res).toEqual({
all: {
all: {
totalAttached: 1,
},
itsm: {
totalAttached: 3,
},
sir: {
totalAttached: 4,
},
jira: {
totalAttached: 5,
},
resilient: {
totalAttached: 6,
},
swimlane: {
totalAttached: 7,
},
maxAttachedToACase: 2,
},
});
});
it('should call find with correct arguments', async () => {
mockResponse();
await getConnectorsTelemetryData({ savedObjectsClient, logger });
expect(savedObjectsClient.find.mock.calls[0][0]).toEqual({
aggs: {
references: {
aggregations: {
referenceType: {
aggregations: {
referenceAgg: {
cardinality: {
field: 'cases-user-actions.references.id',
},
},
},
filter: {
term: {
'cases-user-actions.references.type': 'action',
},
},
},
},
nested: {
path: 'cases-user-actions.references',
},
},
},
filter: undefined,
page: 0,
perPage: 0,
type: 'cases-user-actions',
});
expect(savedObjectsClient.find.mock.calls[1][0]).toEqual({
aggs: {
references: {
aggregations: {
cases: {
aggregations: {
ids: {
terms: {
field: 'cases-user-actions.references.id',
},
},
max: {
max_bucket: {
buckets_path: 'ids._count',
},
},
},
filter: {
term: {
'cases-user-actions.references.type': 'cases',
},
},
},
},
nested: {
path: 'cases-user-actions.references',
},
},
},
filter: {
arguments: [
{
type: 'literal',
value: 'cases-user-actions.attributes.type',
},
{
type: 'literal',
value: 'connector',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
},
page: 0,
perPage: 0,
type: 'cases-user-actions',
});
for (const [index, connector] of [
'.servicenow',
'.servicenow-sir',
'.jira',
'.resilient',
'.swimlane',
].entries()) {
const callIndex = index + 2;
expect(savedObjectsClient.find.mock.calls[callIndex][0]).toEqual({
aggs: {
references: {
aggregations: {
referenceType: {
aggregations: {
referenceAgg: {
cardinality: {
field: 'cases-user-actions.references.id',
},
},
},
filter: {
term: {
'cases-user-actions.references.type': 'action',
},
},
},
},
nested: {
path: 'cases-user-actions.references',
},
},
},
filter: {
arguments: [
{
type: 'literal',
value: 'cases-user-actions.attributes.payload.connector.type',
},
{
type: 'literal',
value: connector,
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
},
page: 0,
perPage: 0,
type: 'cases-user-actions',
});
}
});
});
});

View file

@ -0,0 +1,118 @@
/*
* 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 { AggregationsAggregationContainer } from '@elastic/elasticsearch/lib/api/types';
import { KueryNode } from '@kbn/es-query';
import { SavedObjectsFindResponse } from 'kibana/server';
import { CASE_USER_ACTION_SAVED_OBJECT } from '../../../common/constants';
import { buildFilter } from '../../client/utils';
import {
CasesTelemetry,
CollectTelemetryDataParams,
MaxBucketOnCaseAggregation,
ReferencesAggregation,
} from '../types';
import {
getConnectorsCardinalityAggregationQuery,
getMaxBucketOnCaseAggregationQuery,
getOnlyConnectorsFilter,
} from './utils';
export const getConnectorsTelemetryData = async ({
savedObjectsClient,
}: CollectTelemetryDataParams): Promise<CasesTelemetry['connectors']> => {
const getData = async <A>({
filter,
aggs,
}: {
filter?: KueryNode;
aggs?: Record<string, AggregationsAggregationContainer>;
} = {}) => {
const res = await savedObjectsClient.find<unknown, A>({
page: 0,
perPage: 0,
filter,
type: CASE_USER_ACTION_SAVED_OBJECT,
aggs: {
...aggs,
},
});
return res;
};
const getConnectorData = async (connectorType: string) => {
const connectorFilter = buildFilter({
filters: [connectorType],
field: 'payload.connector.type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
});
const res = await getData<ReferencesAggregation>({
filter: connectorFilter,
aggs: getConnectorsCardinalityAggregationQuery(),
});
return res;
};
const connectorTypes = [
'.servicenow',
'.servicenow-sir',
'.jira',
'.resilient',
'.swimlane',
] as const;
const all = await Promise.all([
getData<ReferencesAggregation>({ aggs: getConnectorsCardinalityAggregationQuery() }),
getData<MaxBucketOnCaseAggregation>({
filter: getOnlyConnectorsFilter(),
aggs: getMaxBucketOnCaseAggregationQuery(CASE_USER_ACTION_SAVED_OBJECT),
}),
...connectorTypes.map((connectorType) => getConnectorData(connectorType)),
]);
const connectorData = all.slice(2) as Array<
SavedObjectsFindResponse<unknown, ReferencesAggregation>
>;
const data = connectorData.reduce(
(acc, res, currentIndex) => ({
...acc,
[connectorTypes[currentIndex]]:
res.aggregations?.references?.referenceType?.referenceAgg?.value ?? 0,
}),
{} as Record<typeof connectorTypes[number], number>
);
const allAttached = all[0].aggregations?.references?.referenceType?.referenceAgg?.value ?? 0;
const maxAttachedToACase = all[1].aggregations?.references?.cases?.max?.value ?? 0;
return {
all: {
all: { totalAttached: allAttached },
itsm: { totalAttached: data['.servicenow'] },
sir: { totalAttached: data['.servicenow-sir'] },
jira: { totalAttached: data['.jira'] },
resilient: { totalAttached: data['.resilient'] },
swimlane: { totalAttached: data['.swimlane'] },
/**
* This metric is not 100% accurate. To get this metric we
* we do a term aggregation based on the the case reference id.
* Each bucket corresponds to a case and contains the total user actions
* of type connector. Then from all buckets we take the maximum bucket.
* A user actions of type connectors will be created if the connector is attached
* to a case or the user updates the fields of the connector. This metric
* contains also the updates on the fields of the connector. Ideally we would
* like to filter for unique connector ids on each bucket.
*/
maxAttachedToACase,
},
};
};

View file

@ -0,0 +1,97 @@
/*
* 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 {
savedObjectsRepositoryMock,
loggingSystemMock,
} from '../../../../../../src/core/server/mocks';
import { getPushedTelemetryData } from './pushes';
describe('pushes', () => {
describe('getPushedTelemetryData', () => {
const logger = loggingSystemMock.createLogger();
const savedObjectsClient = savedObjectsRepositoryMock.create();
savedObjectsClient.find.mockResolvedValue({
total: 5,
saved_objects: [],
per_page: 1,
page: 1,
aggregations: {
references: { cases: { max: { value: 1 } } },
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('it returns the correct res', async () => {
const res = await getPushedTelemetryData({ savedObjectsClient, logger });
expect(res).toEqual({
all: {
maxOnACase: 1,
total: 5,
},
});
});
it('should call find with correct arguments', async () => {
await getPushedTelemetryData({ savedObjectsClient, logger });
expect(savedObjectsClient.find).toBeCalledWith({
aggs: {
references: {
nested: {
path: 'cases-user-actions.references',
},
aggregations: {
cases: {
aggregations: {
ids: {
terms: {
field: 'cases-user-actions.references.id',
},
},
max: {
max_bucket: {
buckets_path: 'ids._count',
},
},
},
filter: {
term: {
'cases-user-actions.references.type': 'cases',
},
},
},
},
},
},
filter: {
arguments: [
{
type: 'literal',
value: 'cases-user-actions.attributes.type',
},
{
type: 'literal',
value: 'pushed',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
},
page: 0,
perPage: 0,
type: 'cases-user-actions',
});
});
});
});

View file

@ -0,0 +1,36 @@
/*
* 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 { CASE_USER_ACTION_SAVED_OBJECT } from '../../../common/constants';
import { buildFilter } from '../../client/utils';
import { CasesTelemetry, CollectTelemetryDataParams, MaxBucketOnCaseAggregation } from '../types';
import { getMaxBucketOnCaseAggregationQuery } from './utils';
export const getPushedTelemetryData = async ({
savedObjectsClient,
}: CollectTelemetryDataParams): Promise<CasesTelemetry['pushes']> => {
const pushFilter = buildFilter({
filters: ['pushed'],
field: 'type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
});
const res = await savedObjectsClient.find<unknown, MaxBucketOnCaseAggregation>({
page: 0,
perPage: 0,
filter: pushFilter,
type: CASE_USER_ACTION_SAVED_OBJECT,
aggs: { ...getMaxBucketOnCaseAggregationQuery(CASE_USER_ACTION_SAVED_OBJECT) },
});
const maxOnACase = res.aggregations?.references?.cases?.max?.value ?? 0;
return {
all: { total: res.total, maxOnACase },
};
};

View file

@ -0,0 +1,110 @@
/*
* 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 {
savedObjectsRepositoryMock,
loggingSystemMock,
} from '../../../../../../src/core/server/mocks';
import { getUserActionsTelemetryData } from './user_actions';
describe('user_actions', () => {
describe('getUserActionsTelemetryData', () => {
const logger = loggingSystemMock.createLogger();
const savedObjectsClient = savedObjectsRepositoryMock.create();
savedObjectsClient.find.mockResolvedValue({
total: 5,
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 },
],
},
references: { cases: { max: { value: 1 } } },
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('it returns the correct res', async () => {
const res = await getUserActionsTelemetryData({ savedObjectsClient, logger });
expect(res).toEqual({
all: {
total: 5,
daily: 3,
weekly: 2,
monthly: 1,
maxOnACase: 1,
},
});
});
it('should call find with correct arguments', async () => {
await getUserActionsTelemetryData({ savedObjectsClient, logger });
expect(savedObjectsClient.find).toBeCalledWith({
aggs: {
counts: {
date_range: {
field: 'cases-user-actions.attributes.created_at',
format: 'dd/MM/YYYY',
ranges: [
{
from: 'now-1d',
to: 'now',
},
{
from: 'now-1w',
to: 'now',
},
{
from: 'now-1M',
to: 'now',
},
],
},
},
references: {
aggregations: {
cases: {
aggregations: {
ids: {
terms: {
field: 'cases-user-actions.references.id',
},
},
max: {
max_bucket: {
buckets_path: 'ids._count',
},
},
},
filter: {
term: {
'cases-user-actions.references.type': 'cases',
},
},
},
},
nested: {
path: 'cases-user-actions.references',
},
},
},
filter: undefined,
page: 0,
perPage: 0,
type: 'cases-user-actions',
});
});
});
});

View file

@ -0,0 +1,21 @@
/*
* 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 { CASE_USER_ACTION_SAVED_OBJECT } from '../../../common/constants';
import { CasesTelemetry, CollectTelemetryDataParams } from '../types';
import { getCountsAndMaxData } from './utils';
export const getUserActionsTelemetryData = async ({
savedObjectsClient,
}: CollectTelemetryDataParams): Promise<CasesTelemetry['userActions']> => {
const res = await getCountsAndMaxData({
savedObjectsClient,
savedObjectType: CASE_USER_ACTION_SAVED_OBJECT,
});
return res;
};

View file

@ -0,0 +1,423 @@
/*
* 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 { savedObjectsRepositoryMock } from '../../../../../../src/core/server/mocks';
import {
findValueInBuckets,
getAggregationsBuckets,
getBucketFromAggregation,
getConnectorsCardinalityAggregationQuery,
getCountsAggregationQuery,
getCountsAndMaxData,
getCountsFromBuckets,
getMaxBucketOnCaseAggregationQuery,
getOnlyAlertsCommentsFilter,
getOnlyConnectorsFilter,
getReferencesAggregationQuery,
} from './utils';
describe('utils', () => {
describe('getCountsAggregationQuery', () => {
it('returns the correct query', () => {
expect(getCountsAggregationQuery('test')).toEqual({
counts: {
date_range: {
field: 'test.attributes.created_at',
format: 'dd/MM/YYYY',
ranges: [
{ from: 'now-1d', to: 'now' },
{ from: 'now-1w', to: 'now' },
{ from: 'now-1M', to: 'now' },
],
},
},
});
});
});
describe('getMaxBucketOnCaseAggregationQuery', () => {
it('returns the correct query', () => {
expect(getMaxBucketOnCaseAggregationQuery('test')).toEqual({
references: {
nested: {
path: 'test.references',
},
aggregations: {
cases: {
filter: {
term: {
'test.references.type': 'cases',
},
},
aggregations: {
ids: {
terms: {
field: 'test.references.id',
},
},
max: {
max_bucket: {
buckets_path: 'ids._count',
},
},
},
},
},
},
});
});
});
describe('getReferencesAggregationQuery', () => {
it('returns the correct query', () => {
expect(
getReferencesAggregationQuery({ savedObjectType: 'test', referenceType: 'cases' })
).toEqual({
references: {
nested: {
path: 'test.references',
},
aggregations: {
referenceType: {
filter: {
term: {
'test.references.type': 'cases',
},
},
aggregations: {
referenceAgg: {
terms: {
field: 'test.references.id',
},
},
},
},
},
},
});
});
it('returns the correct query when changing the agg', () => {
expect(
getReferencesAggregationQuery({
savedObjectType: 'test',
referenceType: 'cases',
agg: 'cardinality',
})
).toEqual({
references: {
nested: {
path: 'test.references',
},
aggregations: {
referenceType: {
filter: {
term: {
'test.references.type': 'cases',
},
},
aggregations: {
referenceAgg: {
cardinality: {
field: 'test.references.id',
},
},
},
},
},
},
});
});
});
describe('getConnectorsCardinalityAggregationQuery', () => {
it('returns the correct query', () => {
expect(getConnectorsCardinalityAggregationQuery()).toEqual({
references: {
nested: {
path: 'cases-user-actions.references',
},
aggregations: {
referenceType: {
filter: {
term: {
'cases-user-actions.references.type': 'action',
},
},
aggregations: {
referenceAgg: {
cardinality: {
field: 'cases-user-actions.references.id',
},
},
},
},
},
},
});
});
});
describe('getCountsFromBuckets', () => {
it('returns the correct counts', () => {
const buckets = [
{ doc_count: 1, key: 1 },
{ doc_count: 2, key: 2 },
{ doc_count: 3, key: 3 },
];
expect(getCountsFromBuckets(buckets)).toEqual({
daily: 3,
weekly: 2,
monthly: 1,
});
});
it('returns zero counts when the bucket do not have the doc_count field', () => {
const buckets = [{}];
// @ts-expect-error
expect(getCountsFromBuckets(buckets)).toEqual({
daily: 0,
weekly: 0,
monthly: 0,
});
});
it('returns zero counts when the bucket is undefined', () => {
// @ts-expect-error
expect(getCountsFromBuckets(undefined)).toEqual({
daily: 0,
weekly: 0,
monthly: 0,
});
});
it('returns zero counts when the doc_count field is missing in some buckets', () => {
const buckets = [{ doc_count: 1, key: 1 }, {}, {}];
// @ts-expect-error
expect(getCountsFromBuckets(buckets)).toEqual({
daily: 0,
weekly: 0,
monthly: 1,
});
});
});
describe('getCountsAndMaxData', () => {
const savedObjectsClient = savedObjectsRepositoryMock.create();
savedObjectsClient.find.mockResolvedValue({
total: 5,
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 },
],
},
references: { cases: { max: { value: 1 } } },
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('returns the correct counts and max data', async () => {
const res = await getCountsAndMaxData({ savedObjectsClient, savedObjectType: 'test' });
expect(res).toEqual({
all: {
total: 5,
daily: 3,
weekly: 2,
monthly: 1,
maxOnACase: 1,
},
});
});
it('returns zero data if the response aggregation is not as expected', async () => {
savedObjectsClient.find.mockResolvedValue({
total: 5,
saved_objects: [],
per_page: 1,
page: 1,
});
const res = await getCountsAndMaxData({ savedObjectsClient, savedObjectType: 'test' });
expect(res).toEqual({
all: {
total: 5,
daily: 0,
weekly: 0,
monthly: 0,
maxOnACase: 0,
},
});
});
it('should call find with correct arguments', async () => {
await getCountsAndMaxData({ savedObjectsClient, savedObjectType: 'test' });
expect(savedObjectsClient.find).toBeCalledWith({
aggs: {
counts: {
date_range: {
field: 'test.attributes.created_at',
format: 'dd/MM/YYYY',
ranges: [
{
from: 'now-1d',
to: 'now',
},
{
from: 'now-1w',
to: 'now',
},
{
from: 'now-1M',
to: 'now',
},
],
},
},
references: {
aggregations: {
cases: {
aggregations: {
ids: {
terms: {
field: 'test.references.id',
},
},
max: {
max_bucket: {
buckets_path: 'ids._count',
},
},
},
filter: {
term: {
'test.references.type': 'cases',
},
},
},
},
nested: {
path: 'test.references',
},
},
},
filter: undefined,
page: 0,
perPage: 0,
type: 'test',
});
});
});
describe('getBucketFromAggregation', () => {
it('returns the buckets', () => {
expect(
getBucketFromAggregation({
aggs: { test: { deep: { buckets: [{ doc_count: 1, key: 1 }] } } },
key: 'test.deep',
})
).toEqual([{ doc_count: 1, key: 1 }]);
});
it('returns an empty array if the path does not exist', () => {
expect(
getBucketFromAggregation({
key: 'test.deep',
})
).toEqual([]);
});
});
describe('findValueInBuckets', () => {
it('find the value in the bucket', () => {
const buckets = [
{ doc_count: 1, key: 'test' },
{ doc_count: 2, key: 'not' },
];
expect(findValueInBuckets(buckets, 'test')).toBe(1);
});
it('return zero if the value is not found', () => {
const buckets = [{ doc_count: 1, key: 'test' }];
expect(findValueInBuckets(buckets, 'not-in-the-bucket')).toBe(0);
});
});
describe('getAggregationsBuckets', () => {
it('return aggregation buckets', () => {
const buckets = [
{ doc_count: 1, key: 'test' },
{ doc_count: 2, key: 'not' },
];
const aggs = {
foo: { baz: { buckets } },
bar: { buckets },
};
expect(getAggregationsBuckets({ aggs, keys: ['foo.baz', 'bar'] })).toEqual({
'foo.baz': buckets,
bar: buckets,
});
});
});
describe('getOnlyAlertsCommentsFilter', () => {
it('return the correct filter', () => {
expect(getOnlyAlertsCommentsFilter()).toEqual({
arguments: [
{
type: 'literal',
value: 'cases-comments.attributes.type',
},
{
type: 'literal',
value: 'alert',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
});
});
});
describe('getOnlyConnectorsFilter', () => {
it('return the correct filter', () => {
expect(getOnlyConnectorsFilter()).toEqual({
arguments: [
{
type: 'literal',
value: 'cases-user-actions.attributes.type',
},
{
type: 'literal',
value: 'connector',
},
{
type: 'literal',
value: false,
},
],
function: 'is',
type: 'function',
});
});
});
});

View file

@ -0,0 +1,238 @@
/*
* 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 { get } from 'lodash';
import { KueryNode } from '@kbn/es-query';
import { ISavedObjectsRepository } from 'kibana/server';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
} from '../../../common/constants';
import { Buckets, CasesTelemetry, MaxBucketOnCaseAggregation } from '../types';
import { buildFilter } from '../../client/utils';
export const getCountsAggregationQuery = (savedObjectType: string) => ({
counts: {
date_range: {
field: `${savedObjectType}.attributes.created_at`,
format: 'dd/MM/YYYY',
ranges: [
{ from: 'now-1d', to: 'now' },
{ from: 'now-1w', to: 'now' },
{ from: 'now-1M', to: 'now' },
],
},
},
});
export const getMaxBucketOnCaseAggregationQuery = (savedObjectType: string) => ({
references: {
nested: {
path: `${savedObjectType}.references`,
},
aggregations: {
cases: {
filter: {
term: {
[`${savedObjectType}.references.type`]: CASE_SAVED_OBJECT,
},
},
aggregations: {
ids: {
terms: {
field: `${savedObjectType}.references.id`,
},
},
max: {
max_bucket: {
buckets_path: 'ids._count',
},
},
},
},
},
},
});
export const getReferencesAggregationQuery = ({
savedObjectType,
referenceType,
agg = 'terms',
}: {
savedObjectType: string;
referenceType: string;
agg?: string;
}) => ({
references: {
nested: {
path: `${savedObjectType}.references`,
},
aggregations: {
referenceType: {
filter: {
term: {
[`${savedObjectType}.references.type`]: referenceType,
},
},
aggregations: {
referenceAgg: {
[agg]: {
field: `${savedObjectType}.references.id`,
},
},
},
},
},
},
});
export const getConnectorsCardinalityAggregationQuery = () =>
getReferencesAggregationQuery({
savedObjectType: CASE_USER_ACTION_SAVED_OBJECT,
referenceType: 'action',
agg: 'cardinality',
});
export const getCountsFromBuckets = (buckets: Buckets['buckets']) => ({
daily: buckets?.[2]?.doc_count ?? 0,
weekly: buckets?.[1]?.doc_count ?? 0,
monthly: buckets?.[0]?.doc_count ?? 0,
});
export const getCountsAndMaxData = async ({
savedObjectsClient,
savedObjectType,
filter,
}: {
savedObjectsClient: ISavedObjectsRepository;
savedObjectType: string;
filter?: KueryNode;
}) => {
const res = await savedObjectsClient.find<
unknown,
{ counts: Buckets; references: MaxBucketOnCaseAggregation['references'] }
>({
page: 0,
perPage: 0,
filter,
type: savedObjectType,
aggs: {
...getCountsAggregationQuery(savedObjectType),
...getMaxBucketOnCaseAggregationQuery(savedObjectType),
},
});
const countsBuckets = res.aggregations?.counts?.buckets ?? [];
const maxOnACase = res.aggregations?.references?.cases?.max?.value ?? 0;
return {
all: {
total: res.total,
...getCountsFromBuckets(countsBuckets),
maxOnACase,
},
};
};
export const getBucketFromAggregation = ({
aggs,
key,
}: {
key: string;
aggs?: Record<string, unknown>;
}): Buckets['buckets'] => (get(aggs, `${key}.buckets`) ?? []) as Buckets['buckets'];
export const findValueInBuckets = (buckets: Buckets['buckets'], value: string | number): number =>
buckets.find(({ key }) => key === value)?.doc_count ?? 0;
export const getAggregationsBuckets = ({
aggs,
keys,
}: {
keys: string[];
aggs?: Record<string, unknown>;
}): Record<string, Buckets['buckets']> =>
keys.reduce(
(acc, key) => ({
...acc,
[key]: getBucketFromAggregation({ aggs, key }),
}),
{}
);
export const getOnlyAlertsCommentsFilter = () =>
buildFilter({
filters: ['alert'],
field: 'type',
operator: 'or',
type: CASE_COMMENT_SAVED_OBJECT,
});
export const getOnlyConnectorsFilter = () =>
buildFilter({
filters: ['connector'],
field: 'type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
});
export const getTelemetryDataEmptyState = (): CasesTelemetry => ({
cases: {
all: {
total: 0,
monthly: 0,
weekly: 0,
daily: 0,
status: {
open: 0,
inProgress: 0,
closed: 0,
},
syncAlertsOn: 0,
syncAlertsOff: 0,
totalUsers: 0,
totalParticipants: 0,
totalTags: 0,
totalWithAlerts: 0,
totalWithConnectors: 0,
latestDates: {
createdAt: null,
updatedAt: null,
closedAt: null,
},
},
sec: { total: 0, monthly: 0, weekly: 0, daily: 0 },
obs: { total: 0, monthly: 0, weekly: 0, daily: 0 },
main: { total: 0, monthly: 0, weekly: 0, daily: 0 },
},
userActions: { all: { total: 0, monthly: 0, weekly: 0, daily: 0, maxOnACase: 0 } },
comments: { all: { total: 0, monthly: 0, weekly: 0, daily: 0, maxOnACase: 0 } },
alerts: { all: { total: 0, monthly: 0, weekly: 0, daily: 0, maxOnACase: 0 } },
connectors: {
all: {
all: { totalAttached: 0 },
itsm: { totalAttached: 0 },
sir: { totalAttached: 0 },
jira: { totalAttached: 0 },
resilient: { totalAttached: 0 },
swimlane: { totalAttached: 0 },
maxAttachedToACase: 0,
},
},
pushes: {
all: { total: 0, maxOnACase: 0 },
},
configuration: {
all: {
closure: {
manually: 0,
automatic: 0,
},
},
},
});

View file

@ -0,0 +1,34 @@
/*
* 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 { Logger } from 'kibana/server';
import { TaskManagerStartContract } from '../../../task_manager/server';
import { CASES_TELEMETRY_TASK_NAME } from '../../common/constants';
const MINUTES_ON_HALF_DAY = 60 * 12;
export const scheduleCasesTelemetryTask = (
taskManager: TaskManagerStartContract,
logger: Logger
) => {
taskManager
.ensureScheduled({
id: CASES_TELEMETRY_TASK_NAME,
taskType: CASES_TELEMETRY_TASK_NAME,
schedule: {
interval: `${MINUTES_ON_HALF_DAY}m`,
},
scope: ['cases'],
params: {},
state: {},
})
.catch((err) =>
logger.debug(
`Error scheduling cases task with ID ${CASES_TELEMETRY_TASK_NAME} and type ${CASES_TELEMETRY_TASK_NAME}. Received ${err.message}`
)
);
};

View file

@ -0,0 +1,82 @@
/*
* 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 {
CasesTelemetrySchema,
TypeLong,
CountSchema,
StatusSchema,
LatestDatesSchema,
TypeString,
} from './types';
const long: TypeLong = { type: 'long' };
const string: TypeString = { type: 'keyword' };
const countSchema: CountSchema = {
total: long,
monthly: long,
weekly: long,
daily: long,
};
const statusSchema: StatusSchema = {
open: long,
inProgress: long,
closed: long,
};
const latestDatesSchema: LatestDatesSchema = {
createdAt: string,
updatedAt: string,
closedAt: string,
};
export const casesSchema: CasesTelemetrySchema = {
cases: {
all: {
...countSchema,
status: statusSchema,
syncAlertsOn: long,
syncAlertsOff: long,
totalUsers: long,
totalParticipants: long,
totalTags: long,
totalWithAlerts: long,
totalWithConnectors: long,
latestDates: latestDatesSchema,
},
sec: countSchema,
obs: countSchema,
main: countSchema,
},
userActions: { all: { ...countSchema, maxOnACase: long } },
comments: { all: { ...countSchema, maxOnACase: long } },
alerts: { all: { ...countSchema, maxOnACase: long } },
connectors: {
all: {
all: { totalAttached: long },
itsm: { totalAttached: long },
sir: { totalAttached: long },
jira: { totalAttached: long },
resilient: { totalAttached: long },
swimlane: { totalAttached: long },
maxAttachedToACase: long,
},
},
pushes: {
all: { total: long, maxOnACase: long },
},
configuration: {
all: {
closure: {
manually: long,
automatic: long,
},
},
},
};

View file

@ -0,0 +1,109 @@
/*
* 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 { ISavedObjectsRepository, Logger } from 'kibana/server';
import { MakeSchemaFrom } from 'src/plugins/usage_collection/server';
export interface Buckets {
buckets: Array<{
doc_count: number;
key: number | string;
}>;
}
export interface Cardinality {
value: number;
}
export interface MaxBucketOnCaseAggregation {
references: { cases: { max: { value: number } } };
}
export interface ReferencesAggregation {
references: { referenceType: { referenceAgg: { value: number } } };
}
export interface CollectTelemetryDataParams {
savedObjectsClient: ISavedObjectsRepository;
logger: Logger;
}
export interface TypeLong {
type: 'long';
}
export interface TypeString {
type: 'keyword';
}
export interface Count {
total: number;
monthly: number;
weekly: number;
daily: number;
}
export interface Status {
open: number;
inProgress: number;
closed: number;
}
export interface LatestDates {
createdAt: string | null;
updatedAt: string | null;
closedAt: string | null;
}
export interface CasesTelemetry {
cases: {
all: Count & {
status: Status;
syncAlertsOn: number;
syncAlertsOff: number;
totalUsers: number;
totalParticipants: number;
totalTags: number;
totalWithAlerts: number;
totalWithConnectors: number;
latestDates: LatestDates;
};
sec: Count;
obs: Count;
main: Count;
};
userActions: { all: Count & { maxOnACase: number } };
comments: { all: Count & { maxOnACase: number } };
alerts: { all: Count & { maxOnACase: number } };
connectors: {
all: {
all: { totalAttached: number };
itsm: { totalAttached: number };
sir: { totalAttached: number };
jira: { totalAttached: number };
resilient: { totalAttached: number };
swimlane: { totalAttached: number };
maxAttachedToACase: number;
};
};
pushes: {
all: { total: number; maxOnACase: number };
};
configuration: {
all: {
closure: {
manually: number;
automatic: number;
};
};
};
}
export type CountSchema = MakeSchemaFrom<Count>;
export type StatusSchema = MakeSchemaFrom<Status>;
export type LatestDatesSchema = MakeSchemaFrom<LatestDates>;
export type CasesTelemetrySchema = MakeSchemaFrom<CasesTelemetry>;

View file

@ -3413,6 +3413,279 @@
}
}
},
"cases": {
"properties": {
"cases": {
"properties": {
"all": {
"properties": {
"total": {
"type": "long"
},
"monthly": {
"type": "long"
},
"weekly": {
"type": "long"
},
"daily": {
"type": "long"
},
"status": {
"properties": {
"open": {
"type": "long"
},
"inProgress": {
"type": "long"
},
"closed": {
"type": "long"
}
}
},
"syncAlertsOn": {
"type": "long"
},
"syncAlertsOff": {
"type": "long"
},
"totalUsers": {
"type": "long"
},
"totalParticipants": {
"type": "long"
},
"totalTags": {
"type": "long"
},
"totalWithAlerts": {
"type": "long"
},
"totalWithConnectors": {
"type": "long"
},
"latestDates": {
"properties": {
"createdAt": {
"type": "keyword"
},
"updatedAt": {
"type": "keyword"
},
"closedAt": {
"type": "keyword"
}
}
}
}
},
"sec": {
"properties": {
"total": {
"type": "long"
},
"monthly": {
"type": "long"
},
"weekly": {
"type": "long"
},
"daily": {
"type": "long"
}
}
},
"obs": {
"properties": {
"total": {
"type": "long"
},
"monthly": {
"type": "long"
},
"weekly": {
"type": "long"
},
"daily": {
"type": "long"
}
}
},
"main": {
"properties": {
"total": {
"type": "long"
},
"monthly": {
"type": "long"
},
"weekly": {
"type": "long"
},
"daily": {
"type": "long"
}
}
}
}
},
"userActions": {
"properties": {
"all": {
"properties": {
"total": {
"type": "long"
},
"monthly": {
"type": "long"
},
"weekly": {
"type": "long"
},
"daily": {
"type": "long"
},
"maxOnACase": {
"type": "long"
}
}
}
}
},
"comments": {
"properties": {
"all": {
"properties": {
"total": {
"type": "long"
},
"monthly": {
"type": "long"
},
"weekly": {
"type": "long"
},
"daily": {
"type": "long"
},
"maxOnACase": {
"type": "long"
}
}
}
}
},
"alerts": {
"properties": {
"all": {
"properties": {
"total": {
"type": "long"
},
"monthly": {
"type": "long"
},
"weekly": {
"type": "long"
},
"daily": {
"type": "long"
},
"maxOnACase": {
"type": "long"
}
}
}
}
},
"connectors": {
"properties": {
"all": {
"properties": {
"all": {
"properties": {
"totalAttached": {
"type": "long"
}
}
},
"itsm": {
"properties": {
"totalAttached": {
"type": "long"
}
}
},
"sir": {
"properties": {
"totalAttached": {
"type": "long"
}
}
},
"jira": {
"properties": {
"totalAttached": {
"type": "long"
}
}
},
"resilient": {
"properties": {
"totalAttached": {
"type": "long"
}
}
},
"swimlane": {
"properties": {
"totalAttached": {
"type": "long"
}
}
},
"maxAttachedToACase": {
"type": "long"
}
}
}
}
},
"pushes": {
"properties": {
"all": {
"properties": {
"total": {
"type": "long"
},
"maxOnACase": {
"type": "long"
}
}
}
}
},
"configuration": {
"properties": {
"all": {
"properties": {
"closure": {
"properties": {
"manually": {
"type": "long"
},
"automatic": {
"type": "long"
}
}
}
}
}
}
}
}
},
"cloud": {
"properties": {
"isCloudEnabled": {
@ -3714,18 +3987,18 @@
"description": "Number of times the user opened the in-product formula help popover."
}
},
"toggle_fullscreen_formula": {
"type": "long",
"_meta": {
"description": "Number of times the user toggled fullscreen mode on formula."
}
},
"toggle_autoapply": {
"type": "long",
"_meta": {
"description": "Number of times the user toggled auto-apply."
}
},
"toggle_fullscreen_formula": {
"type": "long",
"_meta": {
"description": "Number of times the user toggled fullscreen mode on formula."
}
},
"indexpattern_field_info_click": {
"type": "long"
},
@ -3967,18 +4240,18 @@
"description": "Number of times the user opened the in-product formula help popover."
}
},
"toggle_fullscreen_formula": {
"type": "long",
"_meta": {
"description": "Number of times the user toggled fullscreen mode on formula."
}
},
"toggle_autoapply": {
"type": "long",
"_meta": {
"description": "Number of times the user toggled auto-apply."
}
},
"toggle_fullscreen_formula": {
"type": "long",
"_meta": {
"description": "Number of times the user toggled fullscreen mode on formula."
}
},
"indexpattern_field_info_click": {
"type": "long"
},