Co-authored-by: Alejandro Fernández Haro <afharo@gmail.com> Co-authored-by: Ahmad Bamieh <ahmadbamieh@gmail.com> Co-authored-by: Alejandro Fernández Haro <afharo@gmail.com>
This commit is contained in:
parent
3f5e0d7c1b
commit
630d4bbfcf
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
start(core: CoreStart, { fieldFormats, logger }: IndexPatternsServiceStartDeps): {
|
start(core: CoreStart, { fieldFormats, logger }: IndexPatternsServiceStartDeps): {
|
||||||
indexPatternsServiceFactory: (savedObjectsClient: Pick<import("../../../../core/server").SavedObjectsClient, "get" | "delete" | "create" | "bulkCreate" | "checkConflicts" | "find" | "bulkGet" | "resolve" | "update" | "addToNamespaces" | "deleteFromNamespaces" | "bulkUpdate" | "removeReferencesTo" | "openPointInTimeForType" | "closePointInTime" | "createPointInTimeFinder" | "errors">, elasticsearchClient: ElasticsearchClient) => Promise<IndexPatternsCommonService>;
|
indexPatternsServiceFactory: (savedObjectsClient: Pick<import("../../../../core/server").SavedObjectsClient, "update" | "get" | "delete" | "create" | "bulkCreate" | "checkConflicts" | "find" | "bulkGet" | "resolve" | "addToNamespaces" | "deleteFromNamespaces" | "bulkUpdate" | "removeReferencesTo" | "openPointInTimeForType" | "closePointInTime" | "createPointInTimeFinder" | "errors">, elasticsearchClient: ElasticsearchClient) => Promise<IndexPatternsCommonService>;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -22,6 +22,6 @@ start(core: CoreStart, { fieldFormats, logger }: IndexPatternsServiceStartDeps):
|
||||||
<b>Returns:</b>
|
<b>Returns:</b>
|
||||||
|
|
||||||
`{
|
`{
|
||||||
indexPatternsServiceFactory: (savedObjectsClient: Pick<import("../../../../core/server").SavedObjectsClient, "get" | "delete" | "create" | "bulkCreate" | "checkConflicts" | "find" | "bulkGet" | "resolve" | "update" | "addToNamespaces" | "deleteFromNamespaces" | "bulkUpdate" | "removeReferencesTo" | "openPointInTimeForType" | "closePointInTime" | "createPointInTimeFinder" | "errors">, elasticsearchClient: ElasticsearchClient) => Promise<IndexPatternsCommonService>;
|
indexPatternsServiceFactory: (savedObjectsClient: Pick<import("../../../../core/server").SavedObjectsClient, "update" | "get" | "delete" | "create" | "bulkCreate" | "checkConflicts" | "find" | "bulkGet" | "resolve" | "addToNamespaces" | "deleteFromNamespaces" | "bulkUpdate" | "removeReferencesTo" | "openPointInTimeForType" | "closePointInTime" | "createPointInTimeFinder" | "errors">, elasticsearchClient: ElasticsearchClient) => Promise<IndexPatternsCommonService>;
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
|
|
@ -56,6 +56,7 @@ import { PublicMethodsOf } from '@kbn/utility-types';
|
||||||
import { RecursiveReadonly } from '@kbn/utility-types';
|
import { RecursiveReadonly } from '@kbn/utility-types';
|
||||||
import { RequestAdapter } from 'src/plugins/inspector/common';
|
import { RequestAdapter } from 'src/plugins/inspector/common';
|
||||||
import { RequestHandlerContext } from 'src/core/server';
|
import { RequestHandlerContext } from 'src/core/server';
|
||||||
|
import * as Rx from 'rxjs';
|
||||||
import { SavedObject } from 'kibana/server';
|
import { SavedObject } from 'kibana/server';
|
||||||
import { SavedObject as SavedObject_2 } from 'src/core/server';
|
import { SavedObject as SavedObject_2 } from 'src/core/server';
|
||||||
import { SavedObjectsClientContract } from 'src/core/server';
|
import { SavedObjectsClientContract } from 'src/core/server';
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`kibana_usage_collection Runs the setup method without issues 1`] = `true`;
|
|
||||||
|
|
||||||
exports[`kibana_usage_collection Runs the setup method without issues 2`] = `false`;
|
|
||||||
|
|
||||||
exports[`kibana_usage_collection Runs the setup method without issues 3`] = `true`;
|
|
||||||
|
|
||||||
exports[`kibana_usage_collection Runs the setup method without issues 4`] = `false`;
|
|
||||||
|
|
||||||
exports[`kibana_usage_collection Runs the setup method without issues 5`] = `false`;
|
|
||||||
|
|
||||||
exports[`kibana_usage_collection Runs the setup method without issues 6`] = `false`;
|
|
||||||
|
|
||||||
exports[`kibana_usage_collection Runs the setup method without issues 7`] = `false`;
|
|
||||||
|
|
||||||
exports[`kibana_usage_collection Runs the setup method without issues 8`] = `true`;
|
|
||||||
|
|
||||||
exports[`kibana_usage_collection Runs the setup method without issues 9`] = `false`;
|
|
||||||
|
|
||||||
exports[`kibana_usage_collection Runs the setup method without issues 10`] = `true`;
|
|
|
@ -10,7 +10,7 @@ import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../co
|
||||||
import {
|
import {
|
||||||
Collector,
|
Collector,
|
||||||
createUsageCollectionSetupMock,
|
createUsageCollectionSetupMock,
|
||||||
} from '../../../../usage_collection/server/usage_collection.mock';
|
} from '../../../../usage_collection/server/mocks';
|
||||||
import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants';
|
import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants';
|
||||||
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
|
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -12,25 +12,23 @@ import {
|
||||||
Collector,
|
Collector,
|
||||||
createUsageCollectionSetupMock,
|
createUsageCollectionSetupMock,
|
||||||
createCollectorFetchContextMock,
|
createCollectorFetchContextMock,
|
||||||
} from '../../../../usage_collection/server/usage_collection.mock';
|
} from '../../../../usage_collection/server/mocks';
|
||||||
|
|
||||||
import { registerCloudProviderUsageCollector } from './cloud_provider_collector';
|
import { registerCloudProviderUsageCollector } from './cloud_provider_collector';
|
||||||
|
|
||||||
describe('registerCloudProviderUsageCollector', () => {
|
describe('registerCloudProviderUsageCollector', () => {
|
||||||
let collector: Collector<unknown>;
|
let collector: Collector<unknown>;
|
||||||
const logger = loggingSystemMock.createLogger();
|
const logger = loggingSystemMock.createLogger();
|
||||||
|
|
||||||
const usageCollectionMock = createUsageCollectionSetupMock();
|
|
||||||
usageCollectionMock.makeUsageCollector.mockImplementation((config) => {
|
|
||||||
collector = new Collector(logger, config);
|
|
||||||
return createUsageCollectionSetupMock().makeUsageCollector(config);
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockedFetchContext = createCollectorFetchContextMock();
|
const mockedFetchContext = createCollectorFetchContextMock();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cloudDetailsMock.mockClear();
|
cloudDetailsMock.mockClear();
|
||||||
detectCloudServiceMock.mockClear();
|
detectCloudServiceMock.mockClear();
|
||||||
|
const usageCollectionMock = createUsageCollectionSetupMock();
|
||||||
|
usageCollectionMock.makeUsageCollector.mockImplementation((config) => {
|
||||||
|
collector = new Collector(logger, config);
|
||||||
|
return createUsageCollectionSetupMock().makeUsageCollector(config);
|
||||||
|
});
|
||||||
registerCloudProviderUsageCollector(usageCollectionMock);
|
registerCloudProviderUsageCollector(usageCollectionMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import {
|
import {
|
||||||
Collector,
|
Collector,
|
||||||
createUsageCollectionSetupMock,
|
createUsageCollectionSetupMock,
|
||||||
} from '../../../../usage_collection/server/usage_collection.mock';
|
} from '../../../../usage_collection/server/mocks';
|
||||||
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
|
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
|
||||||
import { registerCoreUsageCollector } from '.';
|
import { registerCoreUsageCollector } from '.';
|
||||||
import { coreUsageDataServiceMock, loggingSystemMock } from '../../../../../core/server/mocks';
|
import { coreUsageDataServiceMock, loggingSystemMock } from '../../../../../core/server/mocks';
|
||||||
|
|
|
@ -20,3 +20,7 @@ export {
|
||||||
registerUiCounterSavedObjectType,
|
registerUiCounterSavedObjectType,
|
||||||
registerUiCountersRollups,
|
registerUiCountersRollups,
|
||||||
} from './ui_counters';
|
} from './ui_counters';
|
||||||
|
export {
|
||||||
|
registerUsageCountersRollups,
|
||||||
|
registerUsageCountersUsageCollector,
|
||||||
|
} from './usage_counters';
|
||||||
|
|
|
@ -15,7 +15,7 @@ import {
|
||||||
Collector,
|
Collector,
|
||||||
createCollectorFetchContextMock,
|
createCollectorFetchContextMock,
|
||||||
createUsageCollectionSetupMock,
|
createUsageCollectionSetupMock,
|
||||||
} from '../../../../usage_collection/server/usage_collection.mock';
|
} from '../../../../usage_collection/server/mocks';
|
||||||
import { registerKibanaUsageCollector } from './';
|
import { registerKibanaUsageCollector } from './';
|
||||||
|
|
||||||
const logger = loggingSystemMock.createLogger();
|
const logger = loggingSystemMock.createLogger();
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
Collector,
|
Collector,
|
||||||
createUsageCollectionSetupMock,
|
createUsageCollectionSetupMock,
|
||||||
createCollectorFetchContextMock,
|
createCollectorFetchContextMock,
|
||||||
} from '../../../../usage_collection/server/usage_collection.mock';
|
} from '../../../../usage_collection/server/mocks';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
registerManagementUsageCollector,
|
registerManagementUsageCollector,
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
Collector,
|
Collector,
|
||||||
createUsageCollectionSetupMock,
|
createUsageCollectionSetupMock,
|
||||||
createCollectorFetchContextMock,
|
createCollectorFetchContextMock,
|
||||||
} from '../../../../usage_collection/server/usage_collection.mock';
|
} from '../../../../usage_collection/server/mocks';
|
||||||
|
|
||||||
import { registerOpsStatsCollector } from './';
|
import { registerOpsStatsCollector } from './';
|
||||||
import { OpsMetrics } from '../../../../../core/server';
|
import { OpsMetrics } from '../../../../../core/server';
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
* 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 type { UICounterSavedObject } from '../ui_counter_saved_object_type';
|
||||||
|
export const rawUiCounters: UICounterSavedObject[] = [
|
||||||
|
{
|
||||||
|
type: 'ui-counter',
|
||||||
|
id: 'Kibana_home:23102020:click:different_type',
|
||||||
|
attributes: {
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
updated_at: '2020-11-24T11:27:57.067Z',
|
||||||
|
version: 'WzI5NDRd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'ui-counter',
|
||||||
|
id: 'Kibana_home:25102020:loaded:intersecting_event',
|
||||||
|
attributes: {
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
updated_at: '2020-10-25T11:27:57.067Z',
|
||||||
|
version: 'WzI5NDRd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'ui-counter',
|
||||||
|
id: 'Kibana_home:23102020:loaded:intersecting_event',
|
||||||
|
attributes: {
|
||||||
|
count: 3,
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
updated_at: '2020-10-23T11:27:57.067Z',
|
||||||
|
version: 'WzI5NDRd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'ui-counter',
|
||||||
|
id: 'Kibana_home:24112020:click:only_reported_in_ui_counters',
|
||||||
|
attributes: {
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
updated_at: '2020-11-24T11:27:57.067Z',
|
||||||
|
version: 'WzI5NDRd',
|
||||||
|
},
|
||||||
|
];
|
|
@ -0,0 +1,104 @@
|
||||||
|
/*
|
||||||
|
* 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 type { UsageCountersSavedObject } from '../../../../../usage_collection/server';
|
||||||
|
|
||||||
|
export const rawUsageCounters: UsageCountersSavedObject[] = [
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'uiCounter:09042021:count:myApp:my_event',
|
||||||
|
attributes: {
|
||||||
|
count: 1,
|
||||||
|
counterName: 'myApp:my_event',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'uiCounter',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-09T08:17:57.693Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'uiCounter:23102020:loaded:Kibana_home:intersecting_event',
|
||||||
|
attributes: {
|
||||||
|
count: 60,
|
||||||
|
counterName: 'Kibana_home:intersecting_event',
|
||||||
|
counterType: 'loaded',
|
||||||
|
domainId: 'uiCounter',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2020-10-23T11:27:57.067Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'uiCounter:09042021:count:myApp:my_event_4457914848544',
|
||||||
|
attributes: {
|
||||||
|
count: 0,
|
||||||
|
counterName: 'myApp:my_event_4457914848544',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'uiCounter',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-09T08:18:03.030Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'uiCounter:09042021:count:myApp:my_event_malformed',
|
||||||
|
attributes: {
|
||||||
|
// @ts-expect-error
|
||||||
|
count: 'malformed',
|
||||||
|
counterName: 'myApp:my_event_malformed',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'uiCounter',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-09T08:18:03.030Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'anotherDomainId:09042021:count:some_event_name',
|
||||||
|
attributes: {
|
||||||
|
count: 4,
|
||||||
|
counterName: 'some_event_name',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'anotherDomainId',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-09T08:18:03.030Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'uiCounter:09042021:count:myApp:my_event_4457914848544_2',
|
||||||
|
attributes: {
|
||||||
|
count: 8,
|
||||||
|
counterName: 'myApp:my_event_4457914848544_2',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'uiCounter',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-09T08:18:03.031Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'uiCounter:09042021:count:myApp:only_reported_in_usage_counters',
|
||||||
|
attributes: {
|
||||||
|
count: 1,
|
||||||
|
counterName: 'myApp:only_reported_in_usage_counters',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'uiCounter',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-09T08:18:03.031Z',
|
||||||
|
},
|
||||||
|
];
|
|
@ -6,70 +6,208 @@
|
||||||
* Side Public License, v 1.
|
* Side Public License, v 1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { transformRawCounter } from './register_ui_counters_collector';
|
import {
|
||||||
import { UICounterSavedObject } from './ui_counter_saved_object_type';
|
transformRawUiCounterObject,
|
||||||
|
transformRawUsageCounterObject,
|
||||||
|
createFetchUiCounters,
|
||||||
|
} from './register_ui_counters_collector';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
import { rawUiCounters } from './__fixtures__/ui_counter_saved_objects';
|
||||||
|
import { rawUsageCounters } from './__fixtures__/usage_counter_saved_objects';
|
||||||
|
import { savedObjectsClientMock } from '../../../../../core/server/mocks';
|
||||||
|
import { UI_COUNTER_SAVED_OBJECT_TYPE } from './ui_counter_saved_object_type';
|
||||||
|
import { USAGE_COUNTERS_SAVED_OBJECT_TYPE } from '../../../../usage_collection/server';
|
||||||
|
|
||||||
describe('transformRawCounter', () => {
|
describe('transformRawUsageCounterObject', () => {
|
||||||
const mockRawUiCounters = [
|
it('transforms usage counters savedObject raw entries', () => {
|
||||||
{
|
const result = rawUsageCounters.map(transformRawUsageCounterObject);
|
||||||
type: 'ui-counter',
|
expect(result).toMatchInlineSnapshot(`
|
||||||
id: 'Kibana_home:24112020:click:ingest_data_card_home_tutorial_directory',
|
Array [
|
||||||
attributes: {
|
Object {
|
||||||
count: 3,
|
"appName": "myApp",
|
||||||
},
|
"counterType": "count",
|
||||||
references: [],
|
"eventName": "my_event",
|
||||||
updated_at: '2020-11-24T11:27:57.067Z',
|
"fromTimestamp": "2021-04-09T00:00:00Z",
|
||||||
version: 'WzI5LDFd',
|
"lastUpdatedAt": "2021-04-09T08:17:57.693Z",
|
||||||
},
|
"total": 1,
|
||||||
{
|
},
|
||||||
type: 'ui-counter',
|
Object {
|
||||||
id: 'Kibana_home:24112020:click:home_tutorial_directory',
|
"appName": "Kibana_home",
|
||||||
attributes: {
|
"counterType": "loaded",
|
||||||
count: 1,
|
"eventName": "intersecting_event",
|
||||||
},
|
"fromTimestamp": "2020-10-23T00:00:00Z",
|
||||||
references: [],
|
"lastUpdatedAt": "2020-10-23T11:27:57.067Z",
|
||||||
updated_at: '2020-11-24T11:27:57.067Z',
|
"total": 60,
|
||||||
version: 'WzI5NDRd',
|
},
|
||||||
},
|
undefined,
|
||||||
{
|
undefined,
|
||||||
type: 'ui-counter',
|
undefined,
|
||||||
id: 'Kibana_home:24112020:loaded:home_tutorial_directory',
|
Object {
|
||||||
attributes: {
|
"appName": "myApp",
|
||||||
count: 3,
|
"counterType": "count",
|
||||||
},
|
"eventName": "my_event_4457914848544_2",
|
||||||
references: [],
|
"fromTimestamp": "2021-04-09T00:00:00Z",
|
||||||
updated_at: '2020-10-23T11:27:57.067Z',
|
"lastUpdatedAt": "2021-04-09T08:18:03.031Z",
|
||||||
version: 'WzI5NDRd',
|
"total": 8,
|
||||||
},
|
},
|
||||||
] as UICounterSavedObject[];
|
Object {
|
||||||
|
"appName": "myApp",
|
||||||
it('transforms saved object raw entries', () => {
|
"counterType": "count",
|
||||||
const result = mockRawUiCounters.map(transformRawCounter);
|
"eventName": "only_reported_in_usage_counters",
|
||||||
expect(result).toEqual([
|
"fromTimestamp": "2021-04-09T00:00:00Z",
|
||||||
{
|
"lastUpdatedAt": "2021-04-09T08:18:03.031Z",
|
||||||
appName: 'Kibana_home',
|
"total": 1,
|
||||||
eventName: 'ingest_data_card_home_tutorial_directory',
|
},
|
||||||
lastUpdatedAt: '2020-11-24T11:27:57.067Z',
|
]
|
||||||
fromTimestamp: '2020-11-24T00:00:00Z',
|
`);
|
||||||
counterType: 'click',
|
});
|
||||||
total: 3,
|
});
|
||||||
},
|
|
||||||
{
|
describe('transformRawUiCounterObject', () => {
|
||||||
appName: 'Kibana_home',
|
it('transforms ui counters savedObject raw entries', () => {
|
||||||
eventName: 'home_tutorial_directory',
|
const result = rawUiCounters.map(transformRawUiCounterObject);
|
||||||
lastUpdatedAt: '2020-11-24T11:27:57.067Z',
|
expect(result).toMatchInlineSnapshot(`
|
||||||
fromTimestamp: '2020-11-24T00:00:00Z',
|
Array [
|
||||||
counterType: 'click',
|
Object {
|
||||||
total: 1,
|
"appName": "Kibana_home",
|
||||||
},
|
"counterType": "click",
|
||||||
{
|
"eventName": "different_type",
|
||||||
appName: 'Kibana_home',
|
"fromTimestamp": "2020-11-24T00:00:00Z",
|
||||||
eventName: 'home_tutorial_directory',
|
"lastUpdatedAt": "2020-11-24T11:27:57.067Z",
|
||||||
lastUpdatedAt: '2020-10-23T11:27:57.067Z',
|
"total": 1,
|
||||||
fromTimestamp: '2020-10-23T00:00:00Z',
|
},
|
||||||
counterType: 'loaded',
|
Object {
|
||||||
total: 3,
|
"appName": "Kibana_home",
|
||||||
},
|
"counterType": "loaded",
|
||||||
]);
|
"eventName": "intersecting_event",
|
||||||
|
"fromTimestamp": "2020-10-25T00:00:00Z",
|
||||||
|
"lastUpdatedAt": "2020-10-25T11:27:57.067Z",
|
||||||
|
"total": 1,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"appName": "Kibana_home",
|
||||||
|
"counterType": "loaded",
|
||||||
|
"eventName": "intersecting_event",
|
||||||
|
"fromTimestamp": "2020-10-23T00:00:00Z",
|
||||||
|
"lastUpdatedAt": "2020-10-23T11:27:57.067Z",
|
||||||
|
"total": 3,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"appName": "Kibana_home",
|
||||||
|
"counterType": "click",
|
||||||
|
"eventName": "only_reported_in_ui_counters",
|
||||||
|
"fromTimestamp": "2020-11-24T00:00:00Z",
|
||||||
|
"lastUpdatedAt": "2020-11-24T11:27:57.067Z",
|
||||||
|
"total": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createFetchUiCounters', () => {
|
||||||
|
let stopUsingUiCounterIndicies$: BehaviorSubject<boolean>;
|
||||||
|
const soClientMock = savedObjectsClientMock.create();
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
stopUsingUiCounterIndicies$ = new BehaviorSubject<boolean>(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not query ui_counters saved objects if stopUsingUiCounterIndicies$ is complete', async () => {
|
||||||
|
// @ts-expect-error incomplete mock implementation
|
||||||
|
soClientMock.find.mockImplementation(async ({ type }) => {
|
||||||
|
switch (type) {
|
||||||
|
case USAGE_COUNTERS_SAVED_OBJECT_TYPE:
|
||||||
|
return { saved_objects: rawUsageCounters };
|
||||||
|
default:
|
||||||
|
throw new Error(`unexpected type ${type}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stopUsingUiCounterIndicies$.complete();
|
||||||
|
// @ts-expect-error incomplete mock implementation
|
||||||
|
const { dailyEvents } = await createFetchUiCounters(stopUsingUiCounterIndicies$)({
|
||||||
|
soClient: soClientMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transforemdUsageCounters = rawUsageCounters.map(transformRawUsageCounterObject);
|
||||||
|
expect(soClientMock.find).toBeCalledTimes(1);
|
||||||
|
expect(dailyEvents).toEqual(transforemdUsageCounters.filter(Boolean));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges saved objects from both ui_counters and usage_counters saved objects', async () => {
|
||||||
|
// @ts-expect-error incomplete mock implementation
|
||||||
|
soClientMock.find.mockImplementation(async ({ type }) => {
|
||||||
|
switch (type) {
|
||||||
|
case UI_COUNTER_SAVED_OBJECT_TYPE:
|
||||||
|
return { saved_objects: rawUiCounters };
|
||||||
|
case USAGE_COUNTERS_SAVED_OBJECT_TYPE:
|
||||||
|
return { saved_objects: rawUsageCounters };
|
||||||
|
default:
|
||||||
|
throw new Error(`unexpected type ${type}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-expect-error incomplete mock implementation
|
||||||
|
const { dailyEvents } = await createFetchUiCounters(stopUsingUiCounterIndicies$)({
|
||||||
|
soClient: soClientMock,
|
||||||
|
});
|
||||||
|
expect(dailyEvents).toHaveLength(7);
|
||||||
|
const intersectingEntry = dailyEvents.find(
|
||||||
|
({ eventName, fromTimestamp }) =>
|
||||||
|
eventName === 'intersecting_event' && fromTimestamp === '2020-10-23T00:00:00Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
const onlyFromUICountersEntry = dailyEvents.find(
|
||||||
|
({ eventName }) => eventName === 'only_reported_in_ui_counters'
|
||||||
|
);
|
||||||
|
|
||||||
|
const onlyFromUsageCountersEntry = dailyEvents.find(
|
||||||
|
({ eventName }) => eventName === 'only_reported_in_usage_counters'
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidCountEntry = dailyEvents.find(
|
||||||
|
({ eventName }) => eventName === 'my_event_malformed'
|
||||||
|
);
|
||||||
|
|
||||||
|
const zeroCountEntry = dailyEvents.find(
|
||||||
|
({ eventName }) => eventName === 'my_event_4457914848544'
|
||||||
|
);
|
||||||
|
|
||||||
|
const nonUiCountersEntry = dailyEvents.find(({ eventName }) => eventName === 'some_event_name');
|
||||||
|
|
||||||
|
expect(invalidCountEntry).toBe(undefined);
|
||||||
|
expect(nonUiCountersEntry).toBe(undefined);
|
||||||
|
expect(zeroCountEntry).toBe(undefined);
|
||||||
|
expect(onlyFromUICountersEntry).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"appName": "Kibana_home",
|
||||||
|
"counterType": "click",
|
||||||
|
"eventName": "only_reported_in_ui_counters",
|
||||||
|
"fromTimestamp": "2020-11-24T00:00:00Z",
|
||||||
|
"lastUpdatedAt": "2020-11-24T11:27:57.067Z",
|
||||||
|
"total": 1,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(onlyFromUsageCountersEntry).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"appName": "myApp",
|
||||||
|
"counterType": "count",
|
||||||
|
"eventName": "only_reported_in_usage_counters",
|
||||||
|
"fromTimestamp": "2021-04-09T00:00:00Z",
|
||||||
|
"lastUpdatedAt": "2021-04-09T08:18:03.031Z",
|
||||||
|
"total": 1,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(intersectingEntry).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"appName": "Kibana_home",
|
||||||
|
"counterType": "loaded",
|
||||||
|
"eventName": "intersecting_event",
|
||||||
|
"fromTimestamp": "2020-10-23T00:00:00Z",
|
||||||
|
"lastUpdatedAt": "2020-10-23T11:27:57.067Z",
|
||||||
|
"total": 63,
|
||||||
|
}
|
||||||
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,13 +7,28 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { CollectorFetchContext, UsageCollectionSetup } from 'src/plugins/usage_collection/server';
|
import { mergeWith } from 'lodash';
|
||||||
|
import type { Subject } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
UICounterSavedObject,
|
UICounterSavedObject,
|
||||||
UICounterSavedObjectAttributes,
|
UICounterSavedObjectAttributes,
|
||||||
UI_COUNTER_SAVED_OBJECT_TYPE,
|
UI_COUNTER_SAVED_OBJECT_TYPE,
|
||||||
} from './ui_counter_saved_object_type';
|
} from './ui_counter_saved_object_type';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CollectorFetchContext,
|
||||||
|
UsageCollectionSetup,
|
||||||
|
USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
UsageCountersSavedObject,
|
||||||
|
UsageCountersSavedObjectAttributes,
|
||||||
|
serializeCounterKey,
|
||||||
|
} from '../../../../usage_collection/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deserializeUiCounterName,
|
||||||
|
serializeUiCounterName,
|
||||||
|
} from '../../../../usage_collection/common/ui_counters';
|
||||||
|
|
||||||
interface UiCounterEvent {
|
interface UiCounterEvent {
|
||||||
appName: string;
|
appName: string;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
|
@ -27,12 +42,20 @@ export interface UiCountersUsage {
|
||||||
dailyEvents: UiCounterEvent[];
|
dailyEvents: UiCounterEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformRawCounter(rawUiCounter: UICounterSavedObject) {
|
export function transformRawUiCounterObject(
|
||||||
const { id, attributes, updated_at: lastUpdatedAt } = rawUiCounter;
|
rawUiCounter: UICounterSavedObject
|
||||||
|
): UiCounterEvent | undefined {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
attributes: { count },
|
||||||
|
updated_at: lastUpdatedAt,
|
||||||
|
} = rawUiCounter;
|
||||||
|
if (typeof count !== 'number' || count < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [appName, , counterType, ...restId] = id.split(':');
|
const [appName, , counterType, ...restId] = id.split(':');
|
||||||
const eventName = restId.join(':');
|
const eventName = restId.join(':');
|
||||||
const counterTotal: unknown = attributes.count;
|
|
||||||
const total = typeof counterTotal === 'number' ? counterTotal : 0;
|
|
||||||
const fromTimestamp = moment(lastUpdatedAt).utc().startOf('day').format();
|
const fromTimestamp = moment(lastUpdatedAt).utc().startOf('day').format();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -41,11 +64,110 @@ export function transformRawCounter(rawUiCounter: UICounterSavedObject) {
|
||||||
lastUpdatedAt,
|
lastUpdatedAt,
|
||||||
fromTimestamp,
|
fromTimestamp,
|
||||||
counterType,
|
counterType,
|
||||||
total,
|
total: count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerUiCountersUsageCollector(usageCollection: UsageCollectionSetup) {
|
export function transformRawUsageCounterObject(
|
||||||
|
rawUsageCounter: UsageCountersSavedObject
|
||||||
|
): UiCounterEvent | undefined {
|
||||||
|
const {
|
||||||
|
attributes: { count, counterName, counterType, domainId },
|
||||||
|
updated_at: lastUpdatedAt,
|
||||||
|
} = rawUsageCounter;
|
||||||
|
|
||||||
|
if (domainId !== 'uiCounter' || typeof count !== 'number' || count < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromTimestamp = moment(lastUpdatedAt).utc().startOf('day').format();
|
||||||
|
const { appName, eventName } = deserializeUiCounterName(counterName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appName,
|
||||||
|
eventName,
|
||||||
|
lastUpdatedAt,
|
||||||
|
fromTimestamp,
|
||||||
|
counterType,
|
||||||
|
total: count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createFetchUiCounters = (stopUsingUiCounterIndicies$: Subject<void>) =>
|
||||||
|
async function fetchUiCounters({ soClient }: CollectorFetchContext) {
|
||||||
|
const {
|
||||||
|
saved_objects: rawUsageCounters,
|
||||||
|
} = await soClient.find<UsageCountersSavedObjectAttributes>({
|
||||||
|
type: USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
fields: ['count', 'counterName', 'counterType', 'domainId'],
|
||||||
|
filter: `${USAGE_COUNTERS_SAVED_OBJECT_TYPE}.attributes.domainId: uiCounter`,
|
||||||
|
perPage: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const skipFetchingUiCounters = stopUsingUiCounterIndicies$.isStopped;
|
||||||
|
const result =
|
||||||
|
skipFetchingUiCounters ||
|
||||||
|
(await soClient.find<UICounterSavedObjectAttributes>({
|
||||||
|
type: UI_COUNTER_SAVED_OBJECT_TYPE,
|
||||||
|
fields: ['count'],
|
||||||
|
perPage: 10000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rawUiCounters = typeof result === 'object' ? result.saved_objects : [];
|
||||||
|
const dailyEventsFromUiCounters = rawUiCounters.reduce((acc, raw) => {
|
||||||
|
try {
|
||||||
|
const event = transformRawUiCounterObject(raw);
|
||||||
|
if (event) {
|
||||||
|
const { appName, eventName, counterType } = event;
|
||||||
|
const key = serializeCounterKey({
|
||||||
|
domainId: 'uiCounter',
|
||||||
|
counterName: serializeUiCounterName({ appName, eventName }),
|
||||||
|
counterType,
|
||||||
|
date: event.lastUpdatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
acc[key] = event;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// swallow error; allows sending successfully transformed objects.
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, UiCounterEvent>);
|
||||||
|
|
||||||
|
const dailyEventsFromUsageCounters = rawUsageCounters.reduce((acc, raw) => {
|
||||||
|
try {
|
||||||
|
const event = transformRawUsageCounterObject(raw);
|
||||||
|
if (event) {
|
||||||
|
acc[raw.id] = event;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// swallow error; allows sending successfully transformed objects.
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, UiCounterEvent>);
|
||||||
|
|
||||||
|
const mergedDailyCounters = mergeWith(
|
||||||
|
dailyEventsFromUsageCounters,
|
||||||
|
dailyEventsFromUiCounters,
|
||||||
|
(value: UiCounterEvent | undefined, srcValue: UiCounterEvent): UiCounterEvent => {
|
||||||
|
if (!value) {
|
||||||
|
return srcValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...srcValue,
|
||||||
|
total: srcValue.total + value.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { dailyEvents: Object.values(mergedDailyCounters) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerUiCountersUsageCollector(
|
||||||
|
usageCollection: UsageCollectionSetup,
|
||||||
|
stopUsingUiCounterIndicies$: Subject<void>
|
||||||
|
) {
|
||||||
const collector = usageCollection.makeUsageCollector<UiCountersUsage>({
|
const collector = usageCollection.makeUsageCollector<UiCountersUsage>({
|
||||||
type: 'ui_counters',
|
type: 'ui_counters',
|
||||||
schema: {
|
schema: {
|
||||||
|
@ -76,25 +198,7 @@ export function registerUiCountersUsageCollector(usageCollection: UsageCollectio
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fetch: async ({ soClient }: CollectorFetchContext) => {
|
fetch: createFetchUiCounters(stopUsingUiCounterIndicies$),
|
||||||
const { saved_objects: rawUiCounters } = await soClient.find<UICounterSavedObjectAttributes>({
|
|
||||||
type: UI_COUNTER_SAVED_OBJECT_TYPE,
|
|
||||||
fields: ['count'],
|
|
||||||
perPage: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
dailyEvents: rawUiCounters.reduce((acc, raw) => {
|
|
||||||
try {
|
|
||||||
const aggEvent = transformRawCounter(raw);
|
|
||||||
acc.push(aggEvent);
|
|
||||||
} catch (_) {
|
|
||||||
// swallow error; allows sending successfully transformed objects.
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, [] as UiCounterEvent[]),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
isReady: () => true,
|
isReady: () => true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -6,16 +6,20 @@
|
||||||
* Side Public License, v 1.
|
* Side Public License, v 1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { timer } from 'rxjs';
|
import { Subject, timer } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
import { Logger, ISavedObjectsRepository } from 'kibana/server';
|
import { Logger, ISavedObjectsRepository } from 'kibana/server';
|
||||||
import { ROLL_INDICES_INTERVAL, ROLL_INDICES_START } from './constants';
|
import { ROLL_INDICES_INTERVAL, ROLL_INDICES_START } from './constants';
|
||||||
import { rollUiCounterIndices } from './rollups';
|
import { rollUiCounterIndices } from './rollups';
|
||||||
|
|
||||||
export function registerUiCountersRollups(
|
export function registerUiCountersRollups(
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
|
stopRollingUiCounterIndicies$: Subject<void>,
|
||||||
getSavedObjectsClient: () => ISavedObjectsRepository | undefined
|
getSavedObjectsClient: () => ISavedObjectsRepository | undefined
|
||||||
) {
|
) {
|
||||||
timer(ROLL_INDICES_START, ROLL_INDICES_INTERVAL).subscribe(() =>
|
timer(ROLL_INDICES_START, ROLL_INDICES_INTERVAL)
|
||||||
rollUiCounterIndices(logger, getSavedObjectsClient())
|
.pipe(takeUntil(stopRollingUiCounterIndicies$))
|
||||||
);
|
.subscribe(() =>
|
||||||
|
rollUiCounterIndices(logger, stopRollingUiCounterIndicies$, getSavedObjectsClient())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,9 +7,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import * as Rx from 'rxjs';
|
||||||
import { isSavedObjectOlderThan, rollUiCounterIndices } from './rollups';
|
import { isSavedObjectOlderThan, rollUiCounterIndices } from './rollups';
|
||||||
import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../../core/server/mocks';
|
import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../../core/server/mocks';
|
||||||
import { SavedObjectsFindResult } from 'kibana/server';
|
import { SavedObjectsFindResult } from 'kibana/server';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
UICounterSavedObjectAttributes,
|
UICounterSavedObjectAttributes,
|
||||||
UI_COUNTER_SAVED_OBJECT_TYPE,
|
UI_COUNTER_SAVED_OBJECT_TYPE,
|
||||||
|
@ -70,14 +72,18 @@ describe('isSavedObjectOlderThan', () => {
|
||||||
describe('rollUiCounterIndices', () => {
|
describe('rollUiCounterIndices', () => {
|
||||||
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
|
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
|
||||||
let savedObjectClient: ReturnType<typeof savedObjectsRepositoryMock.create>;
|
let savedObjectClient: ReturnType<typeof savedObjectsRepositoryMock.create>;
|
||||||
|
let stopUsingUiCounterIndicies$: Rx.Subject<void>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
logger = loggingSystemMock.createLogger();
|
logger = loggingSystemMock.createLogger();
|
||||||
savedObjectClient = savedObjectsRepositoryMock.create();
|
savedObjectClient = savedObjectsRepositoryMock.create();
|
||||||
|
stopUsingUiCounterIndicies$ = new Rx.Subject();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined if no savedObjectsClient initialised yet', async () => {
|
it('returns undefined if no savedObjectsClient initialised yet', async () => {
|
||||||
await expect(rollUiCounterIndices(logger, undefined)).resolves.toBe(undefined);
|
await expect(
|
||||||
|
rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, undefined)
|
||||||
|
).resolves.toBe(undefined);
|
||||||
expect(logger.warn).toHaveBeenCalledTimes(0);
|
expect(logger.warn).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -90,11 +96,27 @@ describe('rollUiCounterIndices', () => {
|
||||||
throw new Error(`Unexpected type [${type}]`);
|
throw new Error(`Unexpected type [${type}]`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toEqual([]);
|
await expect(
|
||||||
|
rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient)
|
||||||
|
).resolves.toEqual([]);
|
||||||
expect(savedObjectClient.find).toBeCalled();
|
expect(savedObjectClient.find).toBeCalled();
|
||||||
expect(savedObjectClient.delete).not.toBeCalled();
|
expect(savedObjectClient.delete).not.toBeCalled();
|
||||||
expect(logger.warn).toHaveBeenCalledTimes(0);
|
expect(logger.warn).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
it('calls Subject complete() on empty saved objects', async () => {
|
||||||
|
savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => {
|
||||||
|
switch (type) {
|
||||||
|
case UI_COUNTER_SAVED_OBJECT_TYPE:
|
||||||
|
return { saved_objects: [], total: 0, page, per_page: perPage };
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected type [${type}]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient)
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
expect(stopUsingUiCounterIndicies$.isStopped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it(`deletes documents older than ${UI_COUNTERS_KEEP_DOCS_FOR_DAYS} days`, async () => {
|
it(`deletes documents older than ${UI_COUNTERS_KEEP_DOCS_FOR_DAYS} days`, async () => {
|
||||||
const mockSavedObjects = [
|
const mockSavedObjects = [
|
||||||
|
@ -111,7 +133,9 @@ describe('rollUiCounterIndices', () => {
|
||||||
throw new Error(`Unexpected type [${type}]`);
|
throw new Error(`Unexpected type [${type}]`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toHaveLength(2);
|
await expect(
|
||||||
|
rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient)
|
||||||
|
).resolves.toHaveLength(2);
|
||||||
expect(savedObjectClient.find).toBeCalled();
|
expect(savedObjectClient.find).toBeCalled();
|
||||||
expect(savedObjectClient.delete).toHaveBeenCalledTimes(2);
|
expect(savedObjectClient.delete).toHaveBeenCalledTimes(2);
|
||||||
expect(savedObjectClient.delete).toHaveBeenNthCalledWith(
|
expect(savedObjectClient.delete).toHaveBeenNthCalledWith(
|
||||||
|
@ -131,7 +155,9 @@ describe('rollUiCounterIndices', () => {
|
||||||
savedObjectClient.find.mockImplementation(async () => {
|
savedObjectClient.find.mockImplementation(async () => {
|
||||||
throw new Error(`Expected error!`);
|
throw new Error(`Expected error!`);
|
||||||
});
|
});
|
||||||
await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toEqual(undefined);
|
await expect(
|
||||||
|
rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient)
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
expect(savedObjectClient.find).toBeCalled();
|
expect(savedObjectClient.find).toBeCalled();
|
||||||
expect(savedObjectClient.delete).not.toBeCalled();
|
expect(savedObjectClient.delete).not.toBeCalled();
|
||||||
expect(logger.warn).toHaveBeenCalledTimes(2);
|
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||||
|
@ -151,7 +177,9 @@ describe('rollUiCounterIndices', () => {
|
||||||
savedObjectClient.delete.mockImplementation(async () => {
|
savedObjectClient.delete.mockImplementation(async () => {
|
||||||
throw new Error(`Expected error!`);
|
throw new Error(`Expected error!`);
|
||||||
});
|
});
|
||||||
await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toEqual(undefined);
|
await expect(
|
||||||
|
rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient)
|
||||||
|
).resolves.toEqual(undefined);
|
||||||
expect(savedObjectClient.find).toBeCalled();
|
expect(savedObjectClient.find).toBeCalled();
|
||||||
expect(savedObjectClient.delete).toHaveBeenCalledTimes(1);
|
expect(savedObjectClient.delete).toHaveBeenCalledTimes(1);
|
||||||
expect(savedObjectClient.delete).toHaveBeenNthCalledWith(
|
expect(savedObjectClient.delete).toHaveBeenNthCalledWith(
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import { ISavedObjectsRepository, Logger } from 'kibana/server';
|
import { ISavedObjectsRepository, Logger } from 'kibana/server';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import type { Subject } from 'rxjs';
|
||||||
|
|
||||||
import { UI_COUNTERS_KEEP_DOCS_FOR_DAYS } from './constants';
|
import { UI_COUNTERS_KEEP_DOCS_FOR_DAYS } from './constants';
|
||||||
import {
|
import {
|
||||||
|
@ -38,6 +39,7 @@ export function isSavedObjectOlderThan({
|
||||||
|
|
||||||
export async function rollUiCounterIndices(
|
export async function rollUiCounterIndices(
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
|
stopUsingUiCounterIndicies$: Subject<void>,
|
||||||
savedObjectsClient?: ISavedObjectsRepository
|
savedObjectsClient?: ISavedObjectsRepository
|
||||||
) {
|
) {
|
||||||
if (!savedObjectsClient) {
|
if (!savedObjectsClient) {
|
||||||
|
@ -54,6 +56,20 @@ export async function rollUiCounterIndices(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (rawUiCounterDocs.length === 0) {
|
||||||
|
/**
|
||||||
|
* @deprecated 7.13 to be removed in 8.0.0
|
||||||
|
* Stop triggering rollups when we've rolled up all documents.
|
||||||
|
*
|
||||||
|
* This Saved Object registry is no longer used.
|
||||||
|
* Migration from one SO registry to another is not yet supported.
|
||||||
|
* In a future release we can remove this piece of code and
|
||||||
|
* migrate any docs to the Usage Counters Saved object.
|
||||||
|
*/
|
||||||
|
|
||||||
|
stopUsingUiCounterIndicies$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
const docsToDelete = rawUiCounterDocs.filter((doc) =>
|
const docsToDelete = rawUiCounterDocs.filter((doc) =>
|
||||||
isSavedObjectOlderThan({
|
isSavedObjectOlderThan({
|
||||||
numberOfDays: UI_COUNTERS_KEEP_DOCS_FOR_DAYS,
|
numberOfDays: UI_COUNTERS_KEEP_DOCS_FOR_DAYS,
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
Collector,
|
Collector,
|
||||||
createUsageCollectionSetupMock,
|
createUsageCollectionSetupMock,
|
||||||
createCollectorFetchContextMock,
|
createCollectorFetchContextMock,
|
||||||
} from '../../../../usage_collection/server/usage_collection.mock';
|
} from '../../../../usage_collection/server/mocks';
|
||||||
|
|
||||||
import { registerUiMetricUsageCollector } from './';
|
import { registerUiMetricUsageCollector } from './';
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
/*
|
||||||
|
* 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 type { UsageCountersSavedObject } from '../../../../../usage_collection/server';
|
||||||
|
|
||||||
|
export const rawUsageCounters: UsageCountersSavedObject[] = [
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'uiCounter:09042021:count:myApp:my_event',
|
||||||
|
attributes: {
|
||||||
|
count: 13,
|
||||||
|
counterName: 'my_event',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'uiCounter',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-09T08:18:03.030Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'anotherDomainId:09042021:count:some_event_name',
|
||||||
|
attributes: {
|
||||||
|
count: 4,
|
||||||
|
counterName: 'some_event_name',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'anotherDomainId',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-09T08:18:03.030Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'anotherDomainId:09042021:count:some_event_name',
|
||||||
|
attributes: {
|
||||||
|
count: 4,
|
||||||
|
counterName: 'some_event_name',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'anotherDomainId',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-11T08:18:03.030Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'anotherDomainId2:09042021:count:some_event_name',
|
||||||
|
attributes: {
|
||||||
|
count: 1,
|
||||||
|
counterName: 'some_event_name',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'anotherDomainId2',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-20T08:18:03.030Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'anotherDomainId2:09042021:count:malformed_event',
|
||||||
|
attributes: {
|
||||||
|
// @ts-expect-error
|
||||||
|
count: 'malformed',
|
||||||
|
counterName: 'malformed_event',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'anotherDomainId2',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-20T08:18:03.030Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'anotherDomainId2:09042021:custom_type:some_event_name',
|
||||||
|
attributes: {
|
||||||
|
count: 3,
|
||||||
|
counterName: 'some_event_name',
|
||||||
|
counterType: 'custom_type',
|
||||||
|
domainId: 'anotherDomainId2',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-20T08:18:03.030Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'usage-counters',
|
||||||
|
id: 'anotherDomainId3:09042021:custom_type:zero_count',
|
||||||
|
attributes: {
|
||||||
|
count: 0,
|
||||||
|
counterName: 'zero_count',
|
||||||
|
counterType: 'custom_type',
|
||||||
|
domainId: 'anotherDomainId3',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
coreMigrationVersion: '8.0.0',
|
||||||
|
updated_at: '2021-04-20T08:18:03.030Z',
|
||||||
|
},
|
||||||
|
];
|
|
@ -0,0 +1,10 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { registerUsageCountersUsageCollector } from './register_usage_counters_collector';
|
||||||
|
export { registerUsageCountersRollups } from './rollups';
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* 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 { transformRawCounter } from './register_usage_counters_collector';
|
||||||
|
import { rawUsageCounters } from './__fixtures__/usage_counter_saved_objects';
|
||||||
|
|
||||||
|
describe('transformRawCounter', () => {
|
||||||
|
it('transforms saved object raw entries', () => {
|
||||||
|
const result = rawUsageCounters.map(transformRawCounter);
|
||||||
|
expect(result).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
undefined,
|
||||||
|
Object {
|
||||||
|
"counterName": "some_event_name",
|
||||||
|
"counterType": "count",
|
||||||
|
"domainId": "anotherDomainId",
|
||||||
|
"fromTimestamp": "2021-04-09T00:00:00Z",
|
||||||
|
"lastUpdatedAt": "2021-04-09T08:18:03.030Z",
|
||||||
|
"total": 4,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"counterName": "some_event_name",
|
||||||
|
"counterType": "count",
|
||||||
|
"domainId": "anotherDomainId",
|
||||||
|
"fromTimestamp": "2021-04-11T00:00:00Z",
|
||||||
|
"lastUpdatedAt": "2021-04-11T08:18:03.030Z",
|
||||||
|
"total": 4,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"counterName": "some_event_name",
|
||||||
|
"counterType": "count",
|
||||||
|
"domainId": "anotherDomainId2",
|
||||||
|
"fromTimestamp": "2021-04-20T00:00:00Z",
|
||||||
|
"lastUpdatedAt": "2021-04-20T08:18:03.030Z",
|
||||||
|
"total": 1,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
Object {
|
||||||
|
"counterName": "some_event_name",
|
||||||
|
"counterType": "custom_type",
|
||||||
|
"domainId": "anotherDomainId2",
|
||||||
|
"fromTimestamp": "2021-04-20T00:00:00Z",
|
||||||
|
"lastUpdatedAt": "2021-04-20T08:18:03.030Z",
|
||||||
|
"total": 3,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,116 @@
|
||||||
|
/*
|
||||||
|
* 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 moment from 'moment';
|
||||||
|
import {
|
||||||
|
CollectorFetchContext,
|
||||||
|
UsageCollectionSetup,
|
||||||
|
USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
UsageCountersSavedObject,
|
||||||
|
UsageCountersSavedObjectAttributes,
|
||||||
|
} from '../../../../usage_collection/server';
|
||||||
|
|
||||||
|
interface UsageCounterEvent {
|
||||||
|
domainId: string;
|
||||||
|
counterName: string;
|
||||||
|
counterType: string;
|
||||||
|
lastUpdatedAt?: string;
|
||||||
|
fromTimestamp?: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiCountersUsage {
|
||||||
|
dailyEvents: UsageCounterEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformRawCounter(
|
||||||
|
rawUsageCounter: UsageCountersSavedObject
|
||||||
|
): UsageCounterEvent | undefined {
|
||||||
|
const {
|
||||||
|
attributes: { count, counterName, counterType, domainId },
|
||||||
|
updated_at: lastUpdatedAt,
|
||||||
|
} = rawUsageCounter;
|
||||||
|
const fromTimestamp = moment(lastUpdatedAt).utc().startOf('day').format();
|
||||||
|
|
||||||
|
if (domainId === 'uiCounter' || typeof count !== 'number' || count < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
domainId,
|
||||||
|
counterName,
|
||||||
|
counterType,
|
||||||
|
lastUpdatedAt,
|
||||||
|
fromTimestamp,
|
||||||
|
total: count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUsageCountersUsageCollector(usageCollection: UsageCollectionSetup) {
|
||||||
|
const collector = usageCollection.makeUsageCollector<UiCountersUsage>({
|
||||||
|
type: 'usage_counters',
|
||||||
|
schema: {
|
||||||
|
dailyEvents: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
domainId: {
|
||||||
|
type: 'keyword',
|
||||||
|
_meta: { description: 'Domain name of the metric (ie plugin name).' },
|
||||||
|
},
|
||||||
|
counterName: {
|
||||||
|
type: 'keyword',
|
||||||
|
_meta: { description: 'Name of the counter that happened.' },
|
||||||
|
},
|
||||||
|
lastUpdatedAt: {
|
||||||
|
type: 'date',
|
||||||
|
_meta: { description: 'Time at which the metric was last updated.' },
|
||||||
|
},
|
||||||
|
fromTimestamp: {
|
||||||
|
type: 'date',
|
||||||
|
_meta: { description: 'Time at which the metric was captured.' },
|
||||||
|
},
|
||||||
|
counterType: {
|
||||||
|
type: 'keyword',
|
||||||
|
_meta: { description: 'The type of counter used.' },
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
type: 'integer',
|
||||||
|
_meta: { description: 'The total number of times the event happened.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetch: async ({ soClient }: CollectorFetchContext) => {
|
||||||
|
const {
|
||||||
|
saved_objects: rawUsageCounters,
|
||||||
|
} = await soClient.find<UsageCountersSavedObjectAttributes>({
|
||||||
|
type: USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
fields: ['count', 'counterName', 'counterType', 'domainId'],
|
||||||
|
filter: `NOT ${USAGE_COUNTERS_SAVED_OBJECT_TYPE}.attributes.domainId: uiCounter`,
|
||||||
|
perPage: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dailyEvents: rawUsageCounters.reduce((acc, rawUsageCounter) => {
|
||||||
|
try {
|
||||||
|
const event = transformRawCounter(rawUsageCounter);
|
||||||
|
if (event) {
|
||||||
|
acc.push(event);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// swallow error; allows sending successfully transformed objects.
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as UsageCounterEvent[]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
isReady: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
usageCollection.registerCollector(collector);
|
||||||
|
}
|
|
@ -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 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll indices every 24h
|
||||||
|
*/
|
||||||
|
export const ROLL_INDICES_INTERVAL = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start rolling indices after 5 minutes up
|
||||||
|
*/
|
||||||
|
export const ROLL_INDICES_START = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of days to keep the Usage counters saved object documents
|
||||||
|
*/
|
||||||
|
export const USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS = 5;
|
|
@ -0,0 +1,9 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { registerUsageCountersRollups } from './register_rollups';
|
|
@ -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 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 { timer } from 'rxjs';
|
||||||
|
import { Logger, ISavedObjectsRepository } from 'kibana/server';
|
||||||
|
import { ROLL_INDICES_INTERVAL, ROLL_INDICES_START } from './constants';
|
||||||
|
import { rollUsageCountersIndices } from './rollups';
|
||||||
|
|
||||||
|
export function registerUsageCountersRollups(
|
||||||
|
logger: Logger,
|
||||||
|
getSavedObjectsClient: () => ISavedObjectsRepository | undefined
|
||||||
|
) {
|
||||||
|
timer(ROLL_INDICES_START, ROLL_INDICES_INTERVAL).subscribe(() =>
|
||||||
|
rollUsageCountersIndices(logger, getSavedObjectsClient())
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,170 @@
|
||||||
|
/*
|
||||||
|
* 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 moment from 'moment';
|
||||||
|
import { isSavedObjectOlderThan, rollUsageCountersIndices } from './rollups';
|
||||||
|
import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../../core/server/mocks';
|
||||||
|
import { SavedObjectsFindResult } from '../../../../../../core/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
UsageCountersSavedObjectAttributes,
|
||||||
|
USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
} from '../../../../../usage_collection/server';
|
||||||
|
|
||||||
|
import { USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS } from './constants';
|
||||||
|
|
||||||
|
const createMockSavedObjectDoc = (updatedAt: moment.Moment, id: string) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
type: 'usage-counter',
|
||||||
|
attributes: {
|
||||||
|
count: 3,
|
||||||
|
counterName: 'testName',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'testDomain',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
updated_at: updatedAt.format(),
|
||||||
|
version: 'WzI5LDFd',
|
||||||
|
score: 0,
|
||||||
|
} as SavedObjectsFindResult<UsageCountersSavedObjectAttributes>);
|
||||||
|
|
||||||
|
describe('isSavedObjectOlderThan', () => {
|
||||||
|
it(`returns true if doc is older than x days`, () => {
|
||||||
|
const numberOfDays = 1;
|
||||||
|
const startDate = moment().format();
|
||||||
|
const doc = createMockSavedObjectDoc(moment().subtract(2, 'days'), 'some-id');
|
||||||
|
const result = isSavedObjectOlderThan({
|
||||||
|
numberOfDays,
|
||||||
|
startDate,
|
||||||
|
doc,
|
||||||
|
});
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`returns false if doc is exactly x days old`, () => {
|
||||||
|
const numberOfDays = 1;
|
||||||
|
const startDate = moment().format();
|
||||||
|
const doc = createMockSavedObjectDoc(moment().subtract(1, 'days'), 'some-id');
|
||||||
|
const result = isSavedObjectOlderThan({
|
||||||
|
numberOfDays,
|
||||||
|
startDate,
|
||||||
|
doc,
|
||||||
|
});
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`returns false if doc is younger than x days`, () => {
|
||||||
|
const numberOfDays = 2;
|
||||||
|
const startDate = moment().format();
|
||||||
|
const doc = createMockSavedObjectDoc(moment().subtract(1, 'days'), 'some-id');
|
||||||
|
const result = isSavedObjectOlderThan({
|
||||||
|
numberOfDays,
|
||||||
|
startDate,
|
||||||
|
doc,
|
||||||
|
});
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rollUsageCountersIndices', () => {
|
||||||
|
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
|
||||||
|
let savedObjectClient: ReturnType<typeof savedObjectsRepositoryMock.create>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
logger = loggingSystemMock.createLogger();
|
||||||
|
savedObjectClient = savedObjectsRepositoryMock.create();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if no savedObjectsClient initialised yet', async () => {
|
||||||
|
await expect(rollUsageCountersIndices(logger, undefined)).resolves.toBe(undefined);
|
||||||
|
expect(logger.warn).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not delete any documents on empty saved objects', async () => {
|
||||||
|
savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => {
|
||||||
|
switch (type) {
|
||||||
|
case USAGE_COUNTERS_SAVED_OBJECT_TYPE:
|
||||||
|
return { saved_objects: [], total: 0, page, per_page: perPage };
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected type [${type}]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toEqual([]);
|
||||||
|
expect(savedObjectClient.find).toBeCalled();
|
||||||
|
expect(savedObjectClient.delete).not.toBeCalled();
|
||||||
|
expect(logger.warn).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`deletes documents older than ${USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS} days`, async () => {
|
||||||
|
const mockSavedObjects = [
|
||||||
|
createMockSavedObjectDoc(moment().subtract(5, 'days'), 'doc-id-1'),
|
||||||
|
createMockSavedObjectDoc(moment().subtract(9, 'days'), 'doc-id-1'),
|
||||||
|
createMockSavedObjectDoc(moment().subtract(1, 'days'), 'doc-id-2'),
|
||||||
|
createMockSavedObjectDoc(moment().subtract(6, 'days'), 'doc-id-3'),
|
||||||
|
];
|
||||||
|
|
||||||
|
savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => {
|
||||||
|
switch (type) {
|
||||||
|
case USAGE_COUNTERS_SAVED_OBJECT_TYPE:
|
||||||
|
return { saved_objects: mockSavedObjects, total: 0, page, per_page: perPage };
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected type [${type}]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toHaveLength(2);
|
||||||
|
expect(savedObjectClient.find).toBeCalled();
|
||||||
|
expect(savedObjectClient.delete).toHaveBeenCalledTimes(2);
|
||||||
|
expect(savedObjectClient.delete).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
'doc-id-1'
|
||||||
|
);
|
||||||
|
expect(savedObjectClient.delete).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
'doc-id-3'
|
||||||
|
);
|
||||||
|
expect(logger.warn).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`logs warnings on savedObject.find failure`, async () => {
|
||||||
|
savedObjectClient.find.mockImplementation(async () => {
|
||||||
|
throw new Error(`Expected error!`);
|
||||||
|
});
|
||||||
|
await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toEqual(undefined);
|
||||||
|
expect(savedObjectClient.find).toBeCalled();
|
||||||
|
expect(savedObjectClient.delete).not.toBeCalled();
|
||||||
|
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`logs warnings on savedObject.delete failure`, async () => {
|
||||||
|
const mockSavedObjects = [createMockSavedObjectDoc(moment().subtract(7, 'days'), 'doc-id-1')];
|
||||||
|
|
||||||
|
savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => {
|
||||||
|
switch (type) {
|
||||||
|
case USAGE_COUNTERS_SAVED_OBJECT_TYPE:
|
||||||
|
return { saved_objects: mockSavedObjects, total: 0, page, per_page: perPage };
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected type [${type}]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
savedObjectClient.delete.mockImplementation(async () => {
|
||||||
|
throw new Error(`Expected error!`);
|
||||||
|
});
|
||||||
|
await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toEqual(undefined);
|
||||||
|
expect(savedObjectClient.find).toBeCalled();
|
||||||
|
expect(savedObjectClient.delete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(savedObjectClient.delete).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
'doc-id-1'
|
||||||
|
);
|
||||||
|
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
* 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 type { ISavedObjectsRepository, Logger } from 'kibana/server';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
import { USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS } from './constants';
|
||||||
|
|
||||||
|
import {
|
||||||
|
UsageCountersSavedObject,
|
||||||
|
USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
} from '../../../../../usage_collection/server';
|
||||||
|
|
||||||
|
export function isSavedObjectOlderThan({
|
||||||
|
numberOfDays,
|
||||||
|
startDate,
|
||||||
|
doc,
|
||||||
|
}: {
|
||||||
|
numberOfDays: number;
|
||||||
|
startDate: moment.Moment | string | number;
|
||||||
|
doc: Pick<UsageCountersSavedObject, 'updated_at'>;
|
||||||
|
}): boolean {
|
||||||
|
const { updated_at: updatedAt } = doc;
|
||||||
|
const today = moment(startDate).startOf('day');
|
||||||
|
const updateDay = moment(updatedAt).startOf('day');
|
||||||
|
|
||||||
|
const diffInDays = today.diff(updateDay, 'days');
|
||||||
|
if (diffInDays > numberOfDays) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollUsageCountersIndices(
|
||||||
|
logger: Logger,
|
||||||
|
savedObjectsClient?: ISavedObjectsRepository
|
||||||
|
) {
|
||||||
|
if (!savedObjectsClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = moment();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
saved_objects: rawUiCounterDocs,
|
||||||
|
} = await savedObjectsClient.find<UsageCountersSavedObject>({
|
||||||
|
type: USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
perPage: 1000, // Process 1000 at a time as a compromise of speed and overload
|
||||||
|
});
|
||||||
|
|
||||||
|
const docsToDelete = rawUiCounterDocs.filter((doc) =>
|
||||||
|
isSavedObjectOlderThan({
|
||||||
|
numberOfDays: USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS,
|
||||||
|
startDate: now,
|
||||||
|
doc,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
docsToDelete.map(({ id }) => savedObjectsClient.delete(USAGE_COUNTERS_SAVED_OBJECT_TYPE, id))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to rollup Usage Counters saved objects.`);
|
||||||
|
logger.warn(err);
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,8 +14,8 @@ import {
|
||||||
import {
|
import {
|
||||||
CollectorOptions,
|
CollectorOptions,
|
||||||
createUsageCollectionSetupMock,
|
createUsageCollectionSetupMock,
|
||||||
} from '../../usage_collection/server/usage_collection.mock';
|
} from '../../usage_collection/server/mocks';
|
||||||
import { cloudDetailsMock } from './index.test.mocks';
|
import { cloudDetailsMock } from './mocks';
|
||||||
|
|
||||||
import { plugin } from './';
|
import { plugin } from './';
|
||||||
|
|
||||||
|
@ -38,13 +38,67 @@ describe('kibana_usage_collection', () => {
|
||||||
cloudDetailsMock.mockClear();
|
cloudDetailsMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Runs the setup method without issues', () => {
|
test('Runs the setup method without issues', async () => {
|
||||||
const coreSetup = coreMock.createSetup();
|
const coreSetup = coreMock.createSetup();
|
||||||
|
|
||||||
expect(pluginInstance.setup(coreSetup, { usageCollection })).toBe(undefined);
|
expect(pluginInstance.setup(coreSetup, { usageCollection })).toBe(undefined);
|
||||||
usageCollectors.forEach(({ isReady }) => {
|
|
||||||
expect(isReady()).toMatchSnapshot(); // Some should return false at this stage
|
await expect(
|
||||||
});
|
Promise.all(
|
||||||
|
usageCollectors.map(async (usageCollector) => {
|
||||||
|
const isReady = await usageCollector.isReady();
|
||||||
|
const type = usageCollector.type;
|
||||||
|
return { type, isReady };
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).resolves.toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"isReady": true,
|
||||||
|
"type": "ui_counters",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"isReady": true,
|
||||||
|
"type": "usage_counters",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"isReady": false,
|
||||||
|
"type": "kibana_stats",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"isReady": true,
|
||||||
|
"type": "kibana",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"isReady": false,
|
||||||
|
"type": "stack_management",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"isReady": false,
|
||||||
|
"type": "ui_metric",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"isReady": false,
|
||||||
|
"type": "application_usage",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"isReady": false,
|
||||||
|
"type": "cloud_provider",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"isReady": true,
|
||||||
|
"type": "csp",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"isReady": false,
|
||||||
|
"type": "core",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"isReady": true,
|
||||||
|
"type": "localization",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Runs the start method without issues', () => {
|
test('Runs the start method without issues', () => {
|
|
@ -35,6 +35,8 @@ import {
|
||||||
registerUiCountersUsageCollector,
|
registerUiCountersUsageCollector,
|
||||||
registerUiCounterSavedObjectType,
|
registerUiCounterSavedObjectType,
|
||||||
registerUiCountersRollups,
|
registerUiCountersRollups,
|
||||||
|
registerUsageCountersRollups,
|
||||||
|
registerUsageCountersUsageCollector,
|
||||||
} from './collectors';
|
} from './collectors';
|
||||||
|
|
||||||
interface KibanaUsageCollectionPluginsDepsSetup {
|
interface KibanaUsageCollectionPluginsDepsSetup {
|
||||||
|
@ -50,18 +52,23 @@ export class KibanaUsageCollectionPlugin implements Plugin {
|
||||||
private uiSettingsClient?: IUiSettingsClient;
|
private uiSettingsClient?: IUiSettingsClient;
|
||||||
private metric$: Subject<OpsMetrics>;
|
private metric$: Subject<OpsMetrics>;
|
||||||
private coreUsageData?: CoreUsageDataStart;
|
private coreUsageData?: CoreUsageDataStart;
|
||||||
|
private stopUsingUiCounterIndicies$: Subject<void>;
|
||||||
|
|
||||||
constructor(initializerContext: PluginInitializerContext) {
|
constructor(initializerContext: PluginInitializerContext) {
|
||||||
this.logger = initializerContext.logger.get();
|
this.logger = initializerContext.logger.get();
|
||||||
this.legacyConfig$ = initializerContext.config.legacy.globalConfig$;
|
this.legacyConfig$ = initializerContext.config.legacy.globalConfig$;
|
||||||
this.metric$ = new Subject<OpsMetrics>();
|
this.metric$ = new Subject<OpsMetrics>();
|
||||||
|
this.stopUsingUiCounterIndicies$ = new Subject();
|
||||||
}
|
}
|
||||||
|
|
||||||
public setup(coreSetup: CoreSetup, { usageCollection }: KibanaUsageCollectionPluginsDepsSetup) {
|
public setup(coreSetup: CoreSetup, { usageCollection }: KibanaUsageCollectionPluginsDepsSetup) {
|
||||||
|
usageCollection.createUsageCounter('uiCounters');
|
||||||
|
|
||||||
this.registerUsageCollectors(
|
this.registerUsageCollectors(
|
||||||
usageCollection,
|
usageCollection,
|
||||||
coreSetup,
|
coreSetup,
|
||||||
this.metric$,
|
this.metric$,
|
||||||
|
this.stopUsingUiCounterIndicies$,
|
||||||
coreSetup.savedObjects.registerType.bind(coreSetup.savedObjects)
|
coreSetup.savedObjects.registerType.bind(coreSetup.savedObjects)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -77,12 +84,14 @@ export class KibanaUsageCollectionPlugin implements Plugin {
|
||||||
|
|
||||||
public stop() {
|
public stop() {
|
||||||
this.metric$.complete();
|
this.metric$.complete();
|
||||||
|
this.stopUsingUiCounterIndicies$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerUsageCollectors(
|
private registerUsageCollectors(
|
||||||
usageCollection: UsageCollectionSetup,
|
usageCollection: UsageCollectionSetup,
|
||||||
coreSetup: CoreSetup,
|
coreSetup: CoreSetup,
|
||||||
metric$: Subject<OpsMetrics>,
|
metric$: Subject<OpsMetrics>,
|
||||||
|
stopUsingUiCounterIndicies$: Subject<void>,
|
||||||
registerType: SavedObjectsRegisterType
|
registerType: SavedObjectsRegisterType
|
||||||
) {
|
) {
|
||||||
const getSavedObjectsClient = () => this.savedObjectsClient;
|
const getSavedObjectsClient = () => this.savedObjectsClient;
|
||||||
|
@ -90,8 +99,15 @@ export class KibanaUsageCollectionPlugin implements Plugin {
|
||||||
const getCoreUsageDataService = () => this.coreUsageData!;
|
const getCoreUsageDataService = () => this.coreUsageData!;
|
||||||
|
|
||||||
registerUiCounterSavedObjectType(coreSetup.savedObjects);
|
registerUiCounterSavedObjectType(coreSetup.savedObjects);
|
||||||
registerUiCountersRollups(this.logger.get('ui-counters'), getSavedObjectsClient);
|
registerUiCountersRollups(
|
||||||
registerUiCountersUsageCollector(usageCollection);
|
this.logger.get('ui-counters'),
|
||||||
|
stopUsingUiCounterIndicies$,
|
||||||
|
getSavedObjectsClient
|
||||||
|
);
|
||||||
|
registerUiCountersUsageCollector(usageCollection, stopUsingUiCounterIndicies$);
|
||||||
|
|
||||||
|
registerUsageCountersRollups(this.logger.get('usage-counters-rollup'), getSavedObjectsClient);
|
||||||
|
registerUsageCountersUsageCollector(usageCollection);
|
||||||
|
|
||||||
registerOpsStatsCollector(usageCollection, metric$);
|
registerOpsStatsCollector(usageCollection, metric$);
|
||||||
registerKibanaUsageCollector(usageCollection, this.legacyConfig$);
|
registerKibanaUsageCollector(usageCollection, this.legacyConfig$);
|
||||||
|
|
|
@ -9314,6 +9314,53 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"usage_counters": {
|
||||||
|
"properties": {
|
||||||
|
"dailyEvents": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"properties": {
|
||||||
|
"domainId": {
|
||||||
|
"type": "keyword",
|
||||||
|
"_meta": {
|
||||||
|
"description": "Domain name of the metric (ie plugin name)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"counterName": {
|
||||||
|
"type": "keyword",
|
||||||
|
"_meta": {
|
||||||
|
"description": "Name of the counter that happened."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lastUpdatedAt": {
|
||||||
|
"type": "date",
|
||||||
|
"_meta": {
|
||||||
|
"description": "Time at which the metric was last updated."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fromTimestamp": {
|
||||||
|
"type": "date",
|
||||||
|
"_meta": {
|
||||||
|
"description": "Time at which the metric was captured."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"counterType": {
|
||||||
|
"type": "keyword",
|
||||||
|
"_meta": {
|
||||||
|
"description": "The type of counter used."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer",
|
||||||
|
"_meta": {
|
||||||
|
"description": "The total number of times the event happened."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"telemetry": {
|
"telemetry": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"opt_in_status": {
|
"opt_in_status": {
|
||||||
|
|
|
@ -20,6 +20,7 @@ The way to report the usage of any feature depends on whether the actions to tra
|
||||||
|
|
||||||
In any case, to use any of these APIs, the plugin must optionally require the plugin `usageCollection`:
|
In any case, to use any of these APIs, the plugin must optionally require the plugin `usageCollection`:
|
||||||
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// plugin/kibana.json
|
// plugin/kibana.json
|
||||||
{
|
{
|
||||||
|
@ -112,6 +113,100 @@ Not an API as such. However, Data Telemetry collects the usage of known patterns
|
||||||
|
|
||||||
This collector does not report the name of the indices nor any content. It only provides stats about usage of known shippers/ingest tools.
|
This collector does not report the name of the indices nor any content. It only provides stats about usage of known shippers/ingest tools.
|
||||||
|
|
||||||
|
#### Usage Counters
|
||||||
|
|
||||||
|
Usage counters allows plugins to report user triggered events from the server. This api has feature parity with UI Counters on the `public` plugin side of usage_collection.
|
||||||
|
|
||||||
|
Usage counters provide instrumentation on the server to count triggered events such as "api called", "threshold reached", and miscellaneous events count.
|
||||||
|
|
||||||
|
It is useful for gathering _semi-aggregated_ events with a per day granularity.
|
||||||
|
This allows tracking trends in usage and provides enough granularity for this type of telemetry to provide insights such as
|
||||||
|
- "How many times this threshold has been reached?"
|
||||||
|
- "What is the trend in usage of this api?"
|
||||||
|
- "How frequent are users hitting this error per day?"
|
||||||
|
- "What is the success rate of this operation?"
|
||||||
|
- "Which option is being selected the most/least?"
|
||||||
|
|
||||||
|
##### How to use it
|
||||||
|
|
||||||
|
To create a usage counter for your plugin, use the API `usageCollection.createUsageCounter` as follows:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// server/plugin.ts
|
||||||
|
import type { Plugin, CoreStart } from '../../../core/server';
|
||||||
|
import type { UsageCollectionSetup, UsageCounter } from '../../../plugins/usage_collection/server';
|
||||||
|
|
||||||
|
export class MyPlugin implements Plugin {
|
||||||
|
private usageCounter?: UsageCounter;
|
||||||
|
public setup(
|
||||||
|
core: CoreStart,
|
||||||
|
{ usageCollection }: { usageCollection?: UsageCollectionSetup }
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a usage counter for this plugin. Domain ID must be unique.
|
||||||
|
* It is advised to use the plugin name as the domain ID for most cases.
|
||||||
|
*/
|
||||||
|
this.usageCounter = usageCollection?.createUsageCounter('<Domain ID>');
|
||||||
|
try {
|
||||||
|
doSomeOperation();
|
||||||
|
this.usageCounter?.incrementCounter({
|
||||||
|
counterName: 'doSomeOperation_success',
|
||||||
|
incrementBy: 1,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.usageCounter?.incrementCounter({
|
||||||
|
counterName: 'doSomeOperation_error',
|
||||||
|
counterType: 'error',
|
||||||
|
incrementBy: 1,
|
||||||
|
});
|
||||||
|
logger.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass the created `usageCounter` around in your service to instrument usage.
|
||||||
|
|
||||||
|
That's all you need to do! The Usage counters service will handle piping these counters all the way to the telemetry service.
|
||||||
|
|
||||||
|
##### Telemetry reported usage
|
||||||
|
|
||||||
|
Usage counters are reported inside the telemetry usage payload under `stack_stats.kibana.plugins.usage_counters`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
usage_counters: {
|
||||||
|
dailyEvents: [
|
||||||
|
{
|
||||||
|
domainId: '<Domain ID>',
|
||||||
|
counterName: 'doSomeOperation_success',
|
||||||
|
counterType: 'count',
|
||||||
|
lastUpdatedAt: '2021-11-20T11:43:00.961Z',
|
||||||
|
fromTimestamp: '2021-11-20T00:00:00Z',
|
||||||
|
total: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domainId: '<Domain ID>',
|
||||||
|
counterName: 'doSomeOperation_success',
|
||||||
|
counterType: 'count',
|
||||||
|
lastUpdatedAt: '2021-11-21T10:30:00.961Z',
|
||||||
|
fromTimestamp: '2021-11-21T00:00:00Z',
|
||||||
|
total: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domainId: '<Domain ID>',
|
||||||
|
counterName: 'doSomeOperation_error',
|
||||||
|
counterType: 'error',
|
||||||
|
lastUpdatedAt: '2021-11-20T11:43:00.961Z',
|
||||||
|
fromTimestamp: '2021-11-20T00:00:00Z',
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Custom collector
|
#### Custom collector
|
||||||
|
|
||||||
In many cases, plugins need to report the custom usage of a feature. In this cases, the plugins must complete the following 2 steps in the `setup` lifecycle step:
|
In many cases, plugins need to report the custom usage of a feature. In this cases, the plugins must complete the following 2 steps in the `setup` lifecycle step:
|
||||||
|
|
23
src/plugins/usage_collection/common/ui_counters.ts
Normal file
23
src/plugins/usage_collection/common/ui_counters.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const serializeUiCounterName = ({
|
||||||
|
appName,
|
||||||
|
eventName,
|
||||||
|
}: {
|
||||||
|
appName: string;
|
||||||
|
eventName: string;
|
||||||
|
}) => {
|
||||||
|
return `${appName}:${eventName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deserializeUiCounterName = (key: string) => {
|
||||||
|
const [appName, ...restKey] = key.split(':');
|
||||||
|
const eventName = restKey.join(':');
|
||||||
|
return { appName, eventName };
|
||||||
|
};
|
|
@ -25,22 +25,6 @@ interface CollectorSetConfig {
|
||||||
collectors?: AnyCollector[];
|
collectors?: AnyCollector[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Public interface of the CollectorSet (makes it easier to mock only the public methods)
|
|
||||||
*/
|
|
||||||
export type CollectorSetPublic = Pick<
|
|
||||||
CollectorSet,
|
|
||||||
| 'makeStatsCollector'
|
|
||||||
| 'makeUsageCollector'
|
|
||||||
| 'registerCollector'
|
|
||||||
| 'getCollectorByType'
|
|
||||||
| 'areAllCollectorsReady'
|
|
||||||
| 'bulkFetch'
|
|
||||||
| 'bulkFetchUsage'
|
|
||||||
| 'toObject'
|
|
||||||
| 'toApiFieldNames'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export class CollectorSet {
|
export class CollectorSet {
|
||||||
private _waitingForAllCollectorsTimestamp?: number;
|
private _waitingForAllCollectorsTimestamp?: number;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
|
@ -215,19 +199,19 @@ export class CollectorSet {
|
||||||
* Convert an array of fetched stats results into key/object
|
* Convert an array of fetched stats results into key/object
|
||||||
* @param statsData Array of fetched stats results
|
* @param statsData Array of fetched stats results
|
||||||
*/
|
*/
|
||||||
public toObject<Result extends Record<string, unknown>, T = unknown>(
|
public toObject = <Result extends Record<string, unknown>, T = unknown>(
|
||||||
statsData: Array<{ type: string; result: T }> = []
|
statsData: Array<{ type: string; result: T }> = []
|
||||||
): Result {
|
): Result => {
|
||||||
return Object.fromEntries(statsData.map(({ type, result }) => [type, result])) as Result;
|
return Object.fromEntries(statsData.map(({ type, result }) => [type, result])) as Result;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename fields to use API conventions
|
* Rename fields to use API conventions
|
||||||
* @param apiData Data to be normalized
|
* @param apiData Data to be normalized
|
||||||
*/
|
*/
|
||||||
public toApiFieldNames(
|
public toApiFieldNames = (
|
||||||
apiData: Record<string, unknown> | unknown[]
|
apiData: Record<string, unknown> | unknown[]
|
||||||
): Record<string, unknown> | unknown[] {
|
): Record<string, unknown> | unknown[] => {
|
||||||
// handle array and return early, or return a reduced object
|
// handle array and return early, or return a reduced object
|
||||||
if (Array.isArray(apiData)) {
|
if (Array.isArray(apiData)) {
|
||||||
return apiData.map((value) => this.getValueOrRecurse(value));
|
return apiData.map((value) => this.getValueOrRecurse(value));
|
||||||
|
@ -244,14 +228,14 @@ export class CollectorSet {
|
||||||
return [newName, this.getValueOrRecurse(value)];
|
return [newName, this.getValueOrRecurse(value)];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
private getValueOrRecurse(value: unknown) {
|
private getValueOrRecurse = (value: unknown) => {
|
||||||
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
|
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
|
||||||
return this.toApiFieldNames(value as Record<string, unknown> | unknown[]); // recurse
|
return this.toApiFieldNames(value as Record<string, unknown> | unknown[]); // recurse
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
};
|
||||||
|
|
||||||
private makeCollectorSetFromArray = (collectors: AnyCollector[]) => {
|
private makeCollectorSetFromArray = (collectors: AnyCollector[]) => {
|
||||||
return new CollectorSet({
|
return new CollectorSet({
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { CollectorSet } from './collector_set';
|
export { CollectorSet } from './collector_set';
|
||||||
export type { CollectorSetPublic } from './collector_set';
|
|
||||||
export { Collector } from './collector';
|
export { Collector } from './collector';
|
||||||
export type {
|
export type {
|
||||||
AllowedSchemaTypes,
|
AllowedSchemaTypes,
|
||||||
|
|
|
@ -11,6 +11,11 @@ import { PluginConfigDescriptor } from 'src/core/server';
|
||||||
import { DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S } from '../common/constants';
|
import { DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S } from '../common/constants';
|
||||||
|
|
||||||
export const configSchema = schema.object({
|
export const configSchema = schema.object({
|
||||||
|
usageCounters: schema.object({
|
||||||
|
enabled: schema.boolean({ defaultValue: true }),
|
||||||
|
retryCount: schema.number({ defaultValue: 1 }),
|
||||||
|
bufferDuration: schema.duration({ defaultValue: '5s' }),
|
||||||
|
}),
|
||||||
uiCounters: schema.object({
|
uiCounters: schema.object({
|
||||||
enabled: schema.boolean({ defaultValue: true }),
|
enabled: schema.boolean({ defaultValue: true }),
|
||||||
debug: schema.boolean({ defaultValue: schema.contextRef('dev') }),
|
debug: schema.boolean({ defaultValue: schema.contextRef('dev') }),
|
||||||
|
|
|
@ -18,6 +18,19 @@ export type {
|
||||||
UsageCollectorOptions,
|
UsageCollectorOptions,
|
||||||
CollectorFetchContext,
|
CollectorFetchContext,
|
||||||
} from './collector';
|
} from './collector';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
UsageCountersSavedObject,
|
||||||
|
UsageCountersSavedObjectAttributes,
|
||||||
|
IncrementCounterParams,
|
||||||
|
} from './usage_counters';
|
||||||
|
|
||||||
|
export {
|
||||||
|
USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
serializeCounterKey,
|
||||||
|
UsageCounter,
|
||||||
|
} from './usage_counters';
|
||||||
|
|
||||||
export type { UsageCollectionSetup } from './plugin';
|
export type { UsageCollectionSetup } from './plugin';
|
||||||
export { config } from './config';
|
export { config } from './config';
|
||||||
export const plugin = (initializerContext: PluginInitializerContext) =>
|
export const plugin = (initializerContext: PluginInitializerContext) =>
|
||||||
|
|
|
@ -6,20 +6,61 @@
|
||||||
* Side Public License, v 1.
|
* Side Public License, v 1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { loggingSystemMock } from '../../../core/server/mocks';
|
import {
|
||||||
import { UsageCollectionSetup } from './plugin';
|
elasticsearchServiceMock,
|
||||||
import { CollectorSet } from './collector';
|
httpServerMock,
|
||||||
export { Collector, createCollectorFetchContextMock } from './usage_collection.mock';
|
loggingSystemMock,
|
||||||
|
savedObjectsClientMock,
|
||||||
|
} from '../../../../src/core/server/mocks';
|
||||||
|
|
||||||
const createSetupContract = () => {
|
import { CollectorOptions, Collector, CollectorSet } from './collector';
|
||||||
return {
|
import { UsageCollectionSetup, CollectorFetchContext } from './index';
|
||||||
...new CollectorSet({
|
|
||||||
logger: loggingSystemMock.createLogger(),
|
export type { CollectorOptions };
|
||||||
maximumWaitTimeForAllCollectorsInS: 1,
|
export { Collector };
|
||||||
}),
|
|
||||||
} as UsageCollectionSetup;
|
export const createUsageCollectionSetupMock = () => {
|
||||||
|
const collectorSet = new CollectorSet({
|
||||||
|
logger: loggingSystemMock.createLogger(),
|
||||||
|
maximumWaitTimeForAllCollectorsInS: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageCollectionSetupMock: jest.Mocked<UsageCollectionSetup> = {
|
||||||
|
createUsageCounter: jest.fn(),
|
||||||
|
getUsageCounterByType: jest.fn(),
|
||||||
|
areAllCollectorsReady: jest.fn().mockImplementation(collectorSet.areAllCollectorsReady),
|
||||||
|
bulkFetch: jest.fn().mockImplementation(collectorSet.bulkFetch),
|
||||||
|
getCollectorByType: jest.fn().mockImplementation(collectorSet.getCollectorByType),
|
||||||
|
toApiFieldNames: jest.fn().mockImplementation(collectorSet.toApiFieldNames),
|
||||||
|
toObject: jest.fn().mockImplementation(collectorSet.toObject),
|
||||||
|
makeStatsCollector: jest.fn().mockImplementation(collectorSet.makeStatsCollector),
|
||||||
|
makeUsageCollector: jest.fn().mockImplementation(collectorSet.makeUsageCollector),
|
||||||
|
registerCollector: jest.fn().mockImplementation(collectorSet.registerCollector),
|
||||||
|
};
|
||||||
|
|
||||||
|
usageCollectionSetupMock.areAllCollectorsReady.mockResolvedValue(true);
|
||||||
|
return usageCollectionSetupMock;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function createCollectorFetchContextMock(): jest.Mocked<CollectorFetchContext<false>> {
|
||||||
|
const collectorFetchClientsMock: jest.Mocked<CollectorFetchContext<false>> = {
|
||||||
|
esClient: elasticsearchServiceMock.createClusterClient().asInternalUser,
|
||||||
|
soClient: savedObjectsClientMock.create(),
|
||||||
|
};
|
||||||
|
return collectorFetchClientsMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCollectorFetchContextWithKibanaMock(): jest.Mocked<
|
||||||
|
CollectorFetchContext<true>
|
||||||
|
> {
|
||||||
|
const collectorFetchClientsMock: jest.Mocked<CollectorFetchContext<true>> = {
|
||||||
|
esClient: elasticsearchServiceMock.createClusterClient().asInternalUser,
|
||||||
|
soClient: savedObjectsClientMock.create(),
|
||||||
|
kibanaRequest: httpServerMock.createKibanaRequest(),
|
||||||
|
};
|
||||||
|
return collectorFetchClientsMock;
|
||||||
|
}
|
||||||
|
|
||||||
export const usageCollectionPluginMock = {
|
export const usageCollectionPluginMock = {
|
||||||
createSetupContract,
|
createSetupContract: createUsageCollectionSetupMock,
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,30 +15,78 @@ import {
|
||||||
Plugin,
|
Plugin,
|
||||||
} from 'src/core/server';
|
} from 'src/core/server';
|
||||||
import { ConfigType } from './config';
|
import { ConfigType } from './config';
|
||||||
import { CollectorSet, CollectorSetPublic } from './collector';
|
import { CollectorSet } from './collector';
|
||||||
import { setupRoutes } from './routes';
|
import { setupRoutes } from './routes';
|
||||||
|
|
||||||
export type UsageCollectionSetup = CollectorSetPublic;
|
import { UsageCountersService } from './usage_counters';
|
||||||
export class UsageCollectionPlugin implements Plugin<CollectorSet> {
|
import type { UsageCountersServiceSetup } from './usage_counters';
|
||||||
|
|
||||||
|
export interface UsageCollectionSetup {
|
||||||
|
/**
|
||||||
|
* Creates and registers a usage counter to collect daily aggregated plugin counter events
|
||||||
|
*/
|
||||||
|
createUsageCounter: UsageCountersServiceSetup['createUsageCounter'];
|
||||||
|
/**
|
||||||
|
* Returns a usage counter by type
|
||||||
|
*/
|
||||||
|
getUsageCounterByType: UsageCountersServiceSetup['getUsageCounterByType'];
|
||||||
|
/**
|
||||||
|
* Creates a usage collector to collect plugin telemetry data.
|
||||||
|
* registerCollector must be called to connect the created collecter with the service.
|
||||||
|
*/
|
||||||
|
makeUsageCollector: CollectorSet['makeUsageCollector'];
|
||||||
|
/**
|
||||||
|
* Register a usage collector or a stats collector.
|
||||||
|
* Used to connect the created collector to telemetry.
|
||||||
|
*/
|
||||||
|
registerCollector: CollectorSet['registerCollector'];
|
||||||
|
/**
|
||||||
|
* Returns a usage collector by type
|
||||||
|
*/
|
||||||
|
getCollectorByType: CollectorSet['getCollectorByType'];
|
||||||
|
/* internal: telemetry use */
|
||||||
|
areAllCollectorsReady: CollectorSet['areAllCollectorsReady'];
|
||||||
|
/* internal: telemetry use */
|
||||||
|
bulkFetch: CollectorSet['bulkFetch'];
|
||||||
|
/* internal: telemetry use */
|
||||||
|
toObject: CollectorSet['toObject'];
|
||||||
|
/* internal: monitoring use */
|
||||||
|
toApiFieldNames: CollectorSet['toApiFieldNames'];
|
||||||
|
/* internal: telemtery and monitoring use */
|
||||||
|
makeStatsCollector: CollectorSet['makeStatsCollector'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsageCollectionPlugin implements Plugin<UsageCollectionSetup> {
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private savedObjects?: ISavedObjectsRepository;
|
private savedObjects?: ISavedObjectsRepository;
|
||||||
|
private usageCountersService?: UsageCountersService;
|
||||||
|
|
||||||
constructor(private readonly initializerContext: PluginInitializerContext) {
|
constructor(private readonly initializerContext: PluginInitializerContext) {
|
||||||
this.logger = this.initializerContext.logger.get();
|
this.logger = this.initializerContext.logger.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public setup(core: CoreSetup) {
|
public setup(core: CoreSetup): UsageCollectionSetup {
|
||||||
const config = this.initializerContext.config.get<ConfigType>();
|
const config = this.initializerContext.config.get<ConfigType>();
|
||||||
|
|
||||||
const collectorSet = new CollectorSet({
|
const collectorSet = new CollectorSet({
|
||||||
logger: this.logger.get('collector-set'),
|
logger: this.logger.get('usage-collection', 'collector-set'),
|
||||||
maximumWaitTimeForAllCollectorsInS: config.maximumWaitTimeForAllCollectorsInS,
|
maximumWaitTimeForAllCollectorsInS: config.maximumWaitTimeForAllCollectorsInS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalConfig = this.initializerContext.config.legacy.get();
|
this.usageCountersService = new UsageCountersService({
|
||||||
|
logger: this.logger.get('usage-collection', 'usage-counters-service'),
|
||||||
|
retryCount: config.usageCounters.retryCount,
|
||||||
|
bufferDurationMs: config.usageCounters.bufferDuration.asMilliseconds(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createUsageCounter, getUsageCounterByType } = this.usageCountersService.setup(core);
|
||||||
|
|
||||||
|
const uiCountersUsageCounter = createUsageCounter('uiCounter');
|
||||||
|
const globalConfig = this.initializerContext.config.legacy.get();
|
||||||
const router = core.http.createRouter();
|
const router = core.http.createRouter();
|
||||||
setupRoutes({
|
setupRoutes({
|
||||||
router,
|
router,
|
||||||
|
uiCountersUsageCounter,
|
||||||
getSavedObjects: () => this.savedObjects,
|
getSavedObjects: () => this.savedObjects,
|
||||||
collectorSet,
|
collectorSet,
|
||||||
config: {
|
config: {
|
||||||
|
@ -52,15 +100,38 @@ export class UsageCollectionPlugin implements Plugin<CollectorSet> {
|
||||||
overallStatus$: core.status.overall$,
|
overallStatus$: core.status.overall$,
|
||||||
});
|
});
|
||||||
|
|
||||||
return collectorSet;
|
return {
|
||||||
|
areAllCollectorsReady: collectorSet.areAllCollectorsReady,
|
||||||
|
bulkFetch: collectorSet.bulkFetch,
|
||||||
|
getCollectorByType: collectorSet.getCollectorByType,
|
||||||
|
makeStatsCollector: collectorSet.makeStatsCollector,
|
||||||
|
makeUsageCollector: collectorSet.makeUsageCollector,
|
||||||
|
registerCollector: collectorSet.registerCollector,
|
||||||
|
toApiFieldNames: collectorSet.toApiFieldNames,
|
||||||
|
toObject: collectorSet.toObject,
|
||||||
|
createUsageCounter,
|
||||||
|
getUsageCounterByType,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public start({ savedObjects }: CoreStart) {
|
public start({ savedObjects }: CoreStart) {
|
||||||
this.logger.debug('Starting plugin');
|
this.logger.debug('Starting plugin');
|
||||||
|
const config = this.initializerContext.config.get<ConfigType>();
|
||||||
|
if (!this.usageCountersService) {
|
||||||
|
throw new Error('plugin setup must be called first.');
|
||||||
|
}
|
||||||
|
|
||||||
this.savedObjects = savedObjects.createInternalRepository();
|
this.savedObjects = savedObjects.createInternalRepository();
|
||||||
|
if (config.usageCounters.enabled) {
|
||||||
|
this.usageCountersService.start({ savedObjects });
|
||||||
|
} else {
|
||||||
|
// call stop() to complete observers.
|
||||||
|
this.usageCountersService.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public stop() {
|
public stop() {
|
||||||
this.logger.debug('Stopping plugin');
|
this.logger.debug('Stopping plugin');
|
||||||
|
this.usageCountersService?.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,11 +12,11 @@ import { savedObjectsRepositoryMock } from '../../../../core/server/mocks';
|
||||||
import { storeReport } from './store_report';
|
import { storeReport } from './store_report';
|
||||||
import { ReportSchemaType } from './schema';
|
import { ReportSchemaType } from './schema';
|
||||||
import { METRIC_TYPE } from '@kbn/analytics';
|
import { METRIC_TYPE } from '@kbn/analytics';
|
||||||
import moment from 'moment';
|
import { usageCountersServiceMock } from '../usage_counters/usage_counters_service.mock';
|
||||||
|
|
||||||
describe('store_report', () => {
|
describe('store_report', () => {
|
||||||
const momentTimestamp = moment();
|
const usageCountersServiceSetup = usageCountersServiceMock.createSetupContract();
|
||||||
const date = momentTimestamp.format('DDMMYYYY');
|
const uiCountersUsageCounter = usageCountersServiceSetup.createUsageCounter('uiCounter');
|
||||||
|
|
||||||
let repository: ReturnType<typeof savedObjectsRepositoryMock.create>;
|
let repository: ReturnType<typeof savedObjectsRepositoryMock.create>;
|
||||||
|
|
||||||
|
@ -64,34 +64,56 @@ describe('store_report', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await storeReport(repository, report);
|
await storeReport(repository, uiCountersUsageCounter, report);
|
||||||
|
|
||||||
expect(repository.create).toHaveBeenCalledWith(
|
expect(repository.create.mock.calls).toMatchInlineSnapshot(`
|
||||||
'ui-metric',
|
Array [
|
||||||
{ count: 1 },
|
Array [
|
||||||
{
|
"ui-metric",
|
||||||
id: 'key-user-agent:test-user-agent',
|
Object {
|
||||||
overwrite: true,
|
"count": 1,
|
||||||
}
|
},
|
||||||
);
|
Object {
|
||||||
expect(repository.incrementCounter).toHaveBeenNthCalledWith(
|
"id": "key-user-agent:test-user-agent",
|
||||||
1,
|
"overwrite": true,
|
||||||
'ui-metric',
|
},
|
||||||
'test-app-name:test-event-name',
|
],
|
||||||
[{ fieldName: 'count', incrementBy: 3 }]
|
]
|
||||||
);
|
`);
|
||||||
expect(repository.incrementCounter).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
expect(repository.incrementCounter.mock.calls).toMatchInlineSnapshot(`
|
||||||
'ui-counter',
|
Array [
|
||||||
`test-app-name:${date}:${METRIC_TYPE.LOADED}:test-event-name`,
|
Array [
|
||||||
[{ fieldName: 'count', incrementBy: 1 }]
|
"ui-metric",
|
||||||
);
|
"test-app-name:test-event-name",
|
||||||
expect(repository.incrementCounter).toHaveBeenNthCalledWith(
|
Array [
|
||||||
3,
|
Object {
|
||||||
'ui-counter',
|
"fieldName": "count",
|
||||||
`test-app-name:${date}:${METRIC_TYPE.CLICK}:test-event-name`,
|
"incrementBy": 3,
|
||||||
[{ fieldName: 'count', incrementBy: 2 }]
|
},
|
||||||
);
|
],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
expect((uiCountersUsageCounter.incrementCounter as jest.Mock).mock.calls)
|
||||||
|
.toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"counterName": "test-app-name:test-event-name",
|
||||||
|
"counterType": "loaded",
|
||||||
|
"incrementBy": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"counterName": "test-app-name:test-event-name",
|
||||||
|
"counterType": "click",
|
||||||
|
"incrementBy": 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
|
||||||
expect(storeApplicationUsageMock).toHaveBeenCalledTimes(1);
|
expect(storeApplicationUsageMock).toHaveBeenCalledTimes(1);
|
||||||
expect(storeApplicationUsageMock).toHaveBeenCalledWith(
|
expect(storeApplicationUsageMock).toHaveBeenCalledWith(
|
||||||
|
@ -108,7 +130,7 @@ describe('store_report', () => {
|
||||||
uiCounter: void 0,
|
uiCounter: void 0,
|
||||||
application_usage: void 0,
|
application_usage: void 0,
|
||||||
};
|
};
|
||||||
await storeReport(repository, report);
|
await storeReport(repository, uiCountersUsageCounter, report);
|
||||||
|
|
||||||
expect(repository.bulkCreate).not.toHaveBeenCalled();
|
expect(repository.bulkCreate).not.toHaveBeenCalled();
|
||||||
expect(repository.incrementCounter).not.toHaveBeenCalled();
|
expect(repository.incrementCounter).not.toHaveBeenCalled();
|
||||||
|
|
|
@ -11,9 +11,12 @@ import moment from 'moment';
|
||||||
import { chain, sumBy } from 'lodash';
|
import { chain, sumBy } from 'lodash';
|
||||||
import { ReportSchemaType } from './schema';
|
import { ReportSchemaType } from './schema';
|
||||||
import { storeApplicationUsage } from './store_application_usage';
|
import { storeApplicationUsage } from './store_application_usage';
|
||||||
|
import { UsageCounter } from '../usage_counters';
|
||||||
|
import { serializeUiCounterName } from '../../common/ui_counters';
|
||||||
|
|
||||||
export async function storeReport(
|
export async function storeReport(
|
||||||
internalRepository: ISavedObjectsRepository,
|
internalRepository: ISavedObjectsRepository,
|
||||||
|
uiCountersUsageCounter: UsageCounter,
|
||||||
report: ReportSchemaType
|
report: ReportSchemaType
|
||||||
) {
|
) {
|
||||||
const uiCounters = report.uiCounter ? Object.entries(report.uiCounter) : [];
|
const uiCounters = report.uiCounter ? Object.entries(report.uiCounter) : [];
|
||||||
|
@ -21,7 +24,6 @@ export async function storeReport(
|
||||||
const appUsages = report.application_usage ? Object.values(report.application_usage) : [];
|
const appUsages = report.application_usage ? Object.values(report.application_usage) : [];
|
||||||
|
|
||||||
const momentTimestamp = moment();
|
const momentTimestamp = moment();
|
||||||
const date = momentTimestamp.format('DDMMYYYY');
|
|
||||||
const timestamp = momentTimestamp.toDate();
|
const timestamp = momentTimestamp.toDate();
|
||||||
|
|
||||||
return Promise.allSettled([
|
return Promise.allSettled([
|
||||||
|
@ -55,14 +57,14 @@ export async function storeReport(
|
||||||
})
|
})
|
||||||
.value(),
|
.value(),
|
||||||
// UI Counters
|
// UI Counters
|
||||||
...uiCounters.map(async ([key, metric]) => {
|
...uiCounters.map(async ([, metric]) => {
|
||||||
const { appName, eventName, total, type } = metric;
|
const { appName, eventName, total, type } = metric;
|
||||||
const savedObjectId = `${appName}:${date}:${type}:${eventName}`;
|
const counterName = serializeUiCounterName({ appName, eventName });
|
||||||
return [
|
uiCountersUsageCounter.incrementCounter({
|
||||||
await internalRepository.incrementCounter('ui-counter', savedObjectId, [
|
counterName,
|
||||||
{ fieldName: 'count', incrementBy: total },
|
counterType: type,
|
||||||
]),
|
incrementBy: total,
|
||||||
];
|
});
|
||||||
}),
|
}),
|
||||||
// Application Usage
|
// Application Usage
|
||||||
storeApplicationUsage(internalRepository, appUsages, timestamp),
|
storeApplicationUsage(internalRepository, appUsages, timestamp),
|
||||||
|
|
|
@ -16,14 +16,16 @@ import { Observable } from 'rxjs';
|
||||||
import { CollectorSet } from '../collector';
|
import { CollectorSet } from '../collector';
|
||||||
import { registerUiCountersRoute } from './ui_counters';
|
import { registerUiCountersRoute } from './ui_counters';
|
||||||
import { registerStatsRoute } from './stats';
|
import { registerStatsRoute } from './stats';
|
||||||
|
import type { UsageCounter } from '../usage_counters';
|
||||||
export function setupRoutes({
|
export function setupRoutes({
|
||||||
router,
|
router,
|
||||||
|
uiCountersUsageCounter,
|
||||||
getSavedObjects,
|
getSavedObjects,
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
router: IRouter;
|
router: IRouter;
|
||||||
getSavedObjects: () => ISavedObjectsRepository | undefined;
|
getSavedObjects: () => ISavedObjectsRepository | undefined;
|
||||||
|
uiCountersUsageCounter: UsageCounter;
|
||||||
config: {
|
config: {
|
||||||
allowAnonymous: boolean;
|
allowAnonymous: boolean;
|
||||||
kibanaIndex: string;
|
kibanaIndex: string;
|
||||||
|
@ -39,6 +41,6 @@ export function setupRoutes({
|
||||||
metrics: MetricsServiceSetup;
|
metrics: MetricsServiceSetup;
|
||||||
overallStatus$: Observable<ServiceStatus>;
|
overallStatus$: Observable<ServiceStatus>;
|
||||||
}) {
|
}) {
|
||||||
registerUiCountersRoute(router, getSavedObjects);
|
registerUiCountersRoute(router, getSavedObjects, uiCountersUsageCounter);
|
||||||
registerStatsRoute({ router, ...rest });
|
registerStatsRoute({ router, ...rest });
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,10 +9,12 @@
|
||||||
import { schema } from '@kbn/config-schema';
|
import { schema } from '@kbn/config-schema';
|
||||||
import { IRouter, ISavedObjectsRepository } from 'src/core/server';
|
import { IRouter, ISavedObjectsRepository } from 'src/core/server';
|
||||||
import { storeReport, reportSchema } from '../report';
|
import { storeReport, reportSchema } from '../report';
|
||||||
|
import { UsageCounter } from '../usage_counters';
|
||||||
|
|
||||||
export function registerUiCountersRoute(
|
export function registerUiCountersRoute(
|
||||||
router: IRouter,
|
router: IRouter,
|
||||||
getSavedObjects: () => ISavedObjectsRepository | undefined
|
getSavedObjects: () => ISavedObjectsRepository | undefined,
|
||||||
|
uiCountersUsageCounter: UsageCounter
|
||||||
) {
|
) {
|
||||||
router.post(
|
router.post(
|
||||||
{
|
{
|
||||||
|
@ -30,7 +32,7 @@ export function registerUiCountersRoute(
|
||||||
if (!internalRepository) {
|
if (!internalRepository) {
|
||||||
throw Error(`The saved objects client hasn't been initialised yet`);
|
throw Error(`The saved objects client hasn't been initialised yet`);
|
||||||
}
|
}
|
||||||
await storeReport(internalRepository, report);
|
await storeReport(internalRepository, uiCountersUsageCounter, report);
|
||||||
return res.ok({ body: { status: 'ok' } });
|
return res.ok({ body: { status: 'ok' } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.ok({ body: { status: 'fail' } });
|
return res.ok({ body: { status: 'fail' } });
|
||||||
|
|
|
@ -1,58 +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 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 {
|
|
||||||
elasticsearchServiceMock,
|
|
||||||
httpServerMock,
|
|
||||||
loggingSystemMock,
|
|
||||||
savedObjectsClientMock,
|
|
||||||
} from '../../../../src/core/server/mocks';
|
|
||||||
|
|
||||||
import { CollectorOptions, Collector, UsageCollector } from './collector';
|
|
||||||
import { UsageCollectionSetup, CollectorFetchContext } from './index';
|
|
||||||
|
|
||||||
export type { CollectorOptions };
|
|
||||||
export { Collector };
|
|
||||||
|
|
||||||
const logger = loggingSystemMock.createLogger();
|
|
||||||
|
|
||||||
export const createUsageCollectionSetupMock = () => {
|
|
||||||
const usageCollectionSetupMock: jest.Mocked<UsageCollectionSetup> = {
|
|
||||||
areAllCollectorsReady: jest.fn(),
|
|
||||||
bulkFetch: jest.fn(),
|
|
||||||
bulkFetchUsage: jest.fn(),
|
|
||||||
getCollectorByType: jest.fn(),
|
|
||||||
toApiFieldNames: jest.fn(),
|
|
||||||
toObject: jest.fn(),
|
|
||||||
makeStatsCollector: jest.fn().mockImplementation((cfg) => new Collector(logger, cfg)),
|
|
||||||
makeUsageCollector: jest.fn().mockImplementation((cfg) => new UsageCollector(logger, cfg)),
|
|
||||||
registerCollector: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
usageCollectionSetupMock.areAllCollectorsReady.mockResolvedValue(true);
|
|
||||||
return usageCollectionSetupMock;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createCollectorFetchContextMock(): jest.Mocked<CollectorFetchContext<false>> {
|
|
||||||
const collectorFetchClientsMock: jest.Mocked<CollectorFetchContext<false>> = {
|
|
||||||
esClient: elasticsearchServiceMock.createClusterClient().asInternalUser,
|
|
||||||
soClient: savedObjectsClientMock.create(),
|
|
||||||
};
|
|
||||||
return collectorFetchClientsMock;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCollectorFetchContextWithKibanaMock(): jest.Mocked<
|
|
||||||
CollectorFetchContext<true>
|
|
||||||
> {
|
|
||||||
const collectorFetchClientsMock: jest.Mocked<CollectorFetchContext<true>> = {
|
|
||||||
esClient: elasticsearchServiceMock.createClusterClient().asInternalUser,
|
|
||||||
soClient: savedObjectsClientMock.create(),
|
|
||||||
kibanaRequest: httpServerMock.createKibanaRequest(),
|
|
||||||
};
|
|
||||||
return collectorFetchClientsMock;
|
|
||||||
}
|
|
15
src/plugins/usage_collection/server/usage_counters/index.ts
Normal file
15
src/plugins/usage_collection/server/usage_counters/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type { UsageCountersServiceSetup } from './usage_counters_service';
|
||||||
|
export type { UsageCountersSavedObjectAttributes, UsageCountersSavedObject } from './saved_objects';
|
||||||
|
export type { IncrementCounterParams } from './usage_counter';
|
||||||
|
|
||||||
|
export { UsageCountersService } from './usage_counters_service';
|
||||||
|
export { UsageCounter } from './usage_counter';
|
||||||
|
export { USAGE_COUNTERS_SAVED_OBJECT_TYPE, serializeCounterKey } from './saved_objects';
|
|
@ -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 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 { serializeCounterKey, storeCounter } from './saved_objects';
|
||||||
|
import { savedObjectsRepositoryMock } from '../../../../core/server/mocks';
|
||||||
|
import { CounterMetric } from './usage_counter';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
describe('counterKey', () => {
|
||||||
|
test('#serializeCounterKey returns a serialized string', () => {
|
||||||
|
const result = serializeCounterKey({
|
||||||
|
domainId: 'a',
|
||||||
|
counterName: 'b',
|
||||||
|
counterType: 'c',
|
||||||
|
date: moment('09042021', 'DDMMYYYY'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchInlineSnapshot(`"a:09042021:c:b"`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storeCounter', () => {
|
||||||
|
const internalRepository = savedObjectsRepositoryMock.create();
|
||||||
|
|
||||||
|
const mockNow = 1617954426939;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(moment, 'now').mockReturnValue(mockNow);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores counter in a saved object', async () => {
|
||||||
|
const counterMetric: CounterMetric = {
|
||||||
|
domainId: 'a',
|
||||||
|
counterName: 'b',
|
||||||
|
counterType: 'c',
|
||||||
|
incrementBy: 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
await storeCounter(counterMetric, internalRepository);
|
||||||
|
|
||||||
|
expect(internalRepository.incrementCounter).toBeCalledTimes(1);
|
||||||
|
expect(internalRepository.incrementCounter.mock.calls[0]).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
"usage-counters",
|
||||||
|
"a:09042021:c:b",
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"fieldName": "count",
|
||||||
|
"incrementBy": 13,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Object {
|
||||||
|
"upsertAttributes": Object {
|
||||||
|
"counterName": "b",
|
||||||
|
"counterType": "c",
|
||||||
|
"domainId": "a",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,86 @@
|
||||||
|
/*
|
||||||
|
* 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 {
|
||||||
|
SavedObject,
|
||||||
|
SavedObjectsRepository,
|
||||||
|
SavedObjectAttributes,
|
||||||
|
SavedObjectsServiceSetup,
|
||||||
|
} from 'kibana/server';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { CounterMetric } from './usage_counter';
|
||||||
|
|
||||||
|
export interface UsageCountersSavedObjectAttributes extends SavedObjectAttributes {
|
||||||
|
domainId: string;
|
||||||
|
counterName: string;
|
||||||
|
counterType: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsageCountersSavedObject = SavedObject<UsageCountersSavedObjectAttributes>;
|
||||||
|
|
||||||
|
export const USAGE_COUNTERS_SAVED_OBJECT_TYPE = 'usage-counters';
|
||||||
|
|
||||||
|
export const registerUsageCountersSavedObjectType = (
|
||||||
|
savedObjectsSetup: SavedObjectsServiceSetup
|
||||||
|
) => {
|
||||||
|
savedObjectsSetup.registerType({
|
||||||
|
name: USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
hidden: false,
|
||||||
|
namespaceType: 'agnostic',
|
||||||
|
mappings: {
|
||||||
|
dynamic: false,
|
||||||
|
properties: {
|
||||||
|
domainId: { type: 'keyword' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SerializeCounterParams {
|
||||||
|
domainId: string;
|
||||||
|
counterName: string;
|
||||||
|
counterType: string;
|
||||||
|
date: moment.MomentInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializeCounterKey = ({
|
||||||
|
domainId,
|
||||||
|
counterName,
|
||||||
|
counterType,
|
||||||
|
date,
|
||||||
|
}: SerializeCounterParams) => {
|
||||||
|
const dayDate = moment(date).format('DDMMYYYY');
|
||||||
|
return `${domainId}:${dayDate}:${counterType}:${counterName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeCounter = async (
|
||||||
|
counterMetric: CounterMetric,
|
||||||
|
internalRepository: Pick<SavedObjectsRepository, 'incrementCounter'>
|
||||||
|
) => {
|
||||||
|
const { counterName, counterType, domainId, incrementBy } = counterMetric;
|
||||||
|
const key = serializeCounterKey({
|
||||||
|
date: moment.now(),
|
||||||
|
domainId,
|
||||||
|
counterName,
|
||||||
|
counterType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await internalRepository.incrementCounter(
|
||||||
|
USAGE_COUNTERS_SAVED_OBJECT_TYPE,
|
||||||
|
key,
|
||||||
|
[{ fieldName: 'count', incrementBy }],
|
||||||
|
{
|
||||||
|
upsertAttributes: {
|
||||||
|
domainId,
|
||||||
|
counterName,
|
||||||
|
counterType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* 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 { UsageCounter, CounterMetric } from './usage_counter';
|
||||||
|
import * as Rx from 'rxjs';
|
||||||
|
import * as rxOp from 'rxjs/operators';
|
||||||
|
|
||||||
|
describe('UsageCounter', () => {
|
||||||
|
const domainId = 'test-domain-id';
|
||||||
|
const counter$ = new Rx.Subject<CounterMetric>();
|
||||||
|
const usageCounter = new UsageCounter({ domainId, counter$ });
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
counter$.complete();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#incrementCounter', () => {
|
||||||
|
it('#incrementCounter calls counter$.next', async () => {
|
||||||
|
const result = counter$.pipe(rxOp.take(1), rxOp.toArray()).toPromise();
|
||||||
|
usageCounter.incrementCounter({ counterName: 'test', counterType: 'type', incrementBy: 13 });
|
||||||
|
await expect(result).resolves.toEqual([
|
||||||
|
{ counterName: 'test', counterType: 'type', domainId: 'test-domain-id', incrementBy: 13 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes default configs to counter$', async () => {
|
||||||
|
const result = counter$.pipe(rxOp.take(1), rxOp.toArray()).toPromise();
|
||||||
|
usageCounter.incrementCounter({ counterName: 'test' });
|
||||||
|
await expect(result).resolves.toEqual([
|
||||||
|
{ counterName: 'test', counterType: 'count', domainId: 'test-domain-id', incrementBy: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* 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 * as Rx from 'rxjs';
|
||||||
|
|
||||||
|
export interface CounterMetric {
|
||||||
|
domainId: string;
|
||||||
|
counterName: string;
|
||||||
|
counterType: string;
|
||||||
|
incrementBy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageCounterDeps {
|
||||||
|
domainId: string;
|
||||||
|
counter$: Rx.Subject<CounterMetric>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncrementCounterParams {
|
||||||
|
counterName: string;
|
||||||
|
counterType?: string;
|
||||||
|
incrementBy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsageCounter {
|
||||||
|
private domainId: string;
|
||||||
|
private counter$: Rx.Subject<CounterMetric>;
|
||||||
|
|
||||||
|
constructor({ domainId, counter$ }: UsageCounterDeps) {
|
||||||
|
this.domainId = domainId;
|
||||||
|
this.counter$ = counter$;
|
||||||
|
}
|
||||||
|
|
||||||
|
public incrementCounter = (params: IncrementCounterParams) => {
|
||||||
|
const { counterName, counterType = 'count', incrementBy = 1 } = params;
|
||||||
|
|
||||||
|
this.counter$.next({
|
||||||
|
counterName,
|
||||||
|
domainId: this.domainId,
|
||||||
|
counterType,
|
||||||
|
incrementBy,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* 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 type { PublicMethodsOf } from '@kbn/utility-types';
|
||||||
|
import type { UsageCountersService, UsageCountersServiceSetup } from './usage_counters_service';
|
||||||
|
import type { UsageCounter } from './usage_counter';
|
||||||
|
|
||||||
|
const createSetupContractMock = () => {
|
||||||
|
const setupContract: jest.Mocked<UsageCountersServiceSetup> = {
|
||||||
|
createUsageCounter: jest.fn(),
|
||||||
|
getUsageCounterByType: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setupContract.createUsageCounter.mockReturnValue(({
|
||||||
|
incrementCounter: jest.fn(),
|
||||||
|
} as unknown) as jest.Mocked<UsageCounter>);
|
||||||
|
|
||||||
|
return setupContract;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUsageCountersServiceMock = () => {
|
||||||
|
const mocked: jest.Mocked<PublicMethodsOf<UsageCountersService>> = {
|
||||||
|
setup: jest.fn(),
|
||||||
|
start: jest.fn(),
|
||||||
|
stop: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mocked.setup.mockReturnValue(createSetupContractMock());
|
||||||
|
return mocked;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usageCountersServiceMock = {
|
||||||
|
create: createUsageCountersServiceMock,
|
||||||
|
createSetupContract: createSetupContractMock,
|
||||||
|
};
|
|
@ -0,0 +1,241 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable dot-notation */
|
||||||
|
import { UsageCountersService } from './usage_counters_service';
|
||||||
|
import { loggingSystemMock, coreMock } from '../../../../core/server/mocks';
|
||||||
|
import * as rxOp from 'rxjs/operators';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('UsageCountersService', () => {
|
||||||
|
const retryCount = 1;
|
||||||
|
const bufferDurationMs = 100;
|
||||||
|
const mockNow = 1617954426939;
|
||||||
|
const logger = loggingSystemMock.createLogger();
|
||||||
|
const coreSetup = coreMock.createSetup();
|
||||||
|
const coreStart = coreMock.createStart();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(moment, 'now').mockReturnValue(mockNow);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores data in cache during setup', async () => {
|
||||||
|
const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs });
|
||||||
|
const { createUsageCounter } = usageCountersService.setup(coreSetup);
|
||||||
|
|
||||||
|
const usageCounter = createUsageCounter('test-counter');
|
||||||
|
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterA' });
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterA' });
|
||||||
|
|
||||||
|
const dataInSourcePromise = usageCountersService['source$'].pipe(rxOp.toArray()).toPromise();
|
||||||
|
usageCountersService['flushCache$'].next();
|
||||||
|
usageCountersService['source$'].complete();
|
||||||
|
await expect(dataInSourcePromise).resolves.toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers savedObject type during setup', () => {
|
||||||
|
const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs });
|
||||||
|
usageCountersService.setup(coreSetup);
|
||||||
|
expect(coreSetup.savedObjects.registerType).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes cached data on start', async () => {
|
||||||
|
const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs });
|
||||||
|
|
||||||
|
const mockRepository = coreStart.savedObjects.createInternalRepository();
|
||||||
|
const mockIncrementCounter = jest.fn();
|
||||||
|
mockRepository.incrementCounter = mockIncrementCounter;
|
||||||
|
|
||||||
|
coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository);
|
||||||
|
const { createUsageCounter } = usageCountersService.setup(coreSetup);
|
||||||
|
|
||||||
|
const usageCounter = createUsageCounter('test-counter');
|
||||||
|
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterA' });
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterA' });
|
||||||
|
|
||||||
|
const dataInSourcePromise = usageCountersService['source$'].pipe(rxOp.toArray()).toPromise();
|
||||||
|
usageCountersService.start(coreStart);
|
||||||
|
usageCountersService['source$'].complete();
|
||||||
|
|
||||||
|
await expect(dataInSourcePromise).resolves.toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"counterName": "counterA",
|
||||||
|
"counterType": "count",
|
||||||
|
"domainId": "test-counter",
|
||||||
|
"incrementBy": 1,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"counterName": "counterA",
|
||||||
|
"counterType": "count",
|
||||||
|
"domainId": "test-counter",
|
||||||
|
"incrementBy": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buffers data into savedObject', async () => {
|
||||||
|
const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs });
|
||||||
|
|
||||||
|
const mockRepository = coreStart.savedObjects.createInternalRepository();
|
||||||
|
const mockIncrementCounter = jest.fn().mockResolvedValue('success');
|
||||||
|
mockRepository.incrementCounter = mockIncrementCounter;
|
||||||
|
|
||||||
|
coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository);
|
||||||
|
const { createUsageCounter } = usageCountersService.setup(coreSetup);
|
||||||
|
jest.useFakeTimers('modern');
|
||||||
|
const usageCounter = createUsageCounter('test-counter');
|
||||||
|
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterA' });
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterA' });
|
||||||
|
|
||||||
|
usageCountersService.start(coreStart);
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterA' });
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterB' });
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
expect(mockIncrementCounter).toBeCalledTimes(2);
|
||||||
|
expect(mockIncrementCounter.mock.calls).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Array [
|
||||||
|
"usage-counters",
|
||||||
|
"test-counter:09042021:count:counterA",
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"fieldName": "count",
|
||||||
|
"incrementBy": 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Object {
|
||||||
|
"upsertAttributes": Object {
|
||||||
|
"counterName": "counterA",
|
||||||
|
"counterType": "count",
|
||||||
|
"domainId": "test-counter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Array [
|
||||||
|
"usage-counters",
|
||||||
|
"test-counter:09042021:count:counterB",
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"fieldName": "count",
|
||||||
|
"incrementBy": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Object {
|
||||||
|
"upsertAttributes": Object {
|
||||||
|
"counterName": "counterB",
|
||||||
|
"counterType": "count",
|
||||||
|
"domainId": "test-counter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries errors by `retryCount` times before failing to store', async () => {
|
||||||
|
const usageCountersService = new UsageCountersService({
|
||||||
|
logger,
|
||||||
|
retryCount: 1,
|
||||||
|
bufferDurationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRepository = coreStart.savedObjects.createInternalRepository();
|
||||||
|
const mockError = new Error('failed.');
|
||||||
|
const mockIncrementCounter = jest.fn().mockImplementation((_, key) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'test-counter:09042021:count:counterA':
|
||||||
|
throw mockError;
|
||||||
|
case 'test-counter:09042021:count:counterB':
|
||||||
|
return 'pass';
|
||||||
|
default:
|
||||||
|
throw new Error(`unknown key ${key}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRepository.incrementCounter = mockIncrementCounter;
|
||||||
|
|
||||||
|
coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository);
|
||||||
|
const { createUsageCounter } = usageCountersService.setup(coreSetup);
|
||||||
|
jest.useFakeTimers('modern');
|
||||||
|
const usageCounter = createUsageCounter('test-counter');
|
||||||
|
|
||||||
|
usageCountersService.start(coreStart);
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterA' });
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterB' });
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
|
||||||
|
// wait for retries to kick in on next scheduler call
|
||||||
|
await tick();
|
||||||
|
// number of incrementCounter calls + number of retries
|
||||||
|
expect(mockIncrementCounter).toBeCalledTimes(2 + 1);
|
||||||
|
expect(logger.debug).toHaveBeenNthCalledWith(1, 'Store counters into savedObjects', [
|
||||||
|
mockError,
|
||||||
|
'pass',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buffers counters within `bufferDurationMs` time', async () => {
|
||||||
|
const usageCountersService = new UsageCountersService({
|
||||||
|
logger,
|
||||||
|
retryCount,
|
||||||
|
bufferDurationMs: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRepository = coreStart.savedObjects.createInternalRepository();
|
||||||
|
const mockIncrementCounter = jest.fn().mockImplementation((_data, key, counter) => {
|
||||||
|
expect(counter).toHaveLength(1);
|
||||||
|
return { key, incrementBy: counter[0].incrementBy };
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRepository.incrementCounter = mockIncrementCounter;
|
||||||
|
|
||||||
|
coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository);
|
||||||
|
|
||||||
|
const { createUsageCounter } = usageCountersService.setup(coreSetup);
|
||||||
|
jest.useFakeTimers('modern');
|
||||||
|
const usageCounter = createUsageCounter('test-counter');
|
||||||
|
|
||||||
|
usageCountersService.start(coreStart);
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterA' });
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterA' });
|
||||||
|
jest.advanceTimersByTime(30000);
|
||||||
|
|
||||||
|
usageCounter.incrementCounter({ counterName: 'counterA' });
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
|
||||||
|
// wait for debounce to kick in on next scheduler call
|
||||||
|
await tick();
|
||||||
|
expect(mockIncrementCounter).toBeCalledTimes(2);
|
||||||
|
expect(mockIncrementCounter.mock.results.map(({ value }) => value)).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"incrementBy": 2,
|
||||||
|
"key": "test-counter:09042021:count:counterA",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"incrementBy": 1,
|
||||||
|
"key": "test-counter:09042021:count:counterA",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,185 @@
|
||||||
|
/*
|
||||||
|
* 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 * as Rx from 'rxjs';
|
||||||
|
import * as rxOp from 'rxjs/operators';
|
||||||
|
import {
|
||||||
|
SavedObjectsRepository,
|
||||||
|
SavedObjectsServiceSetup,
|
||||||
|
SavedObjectsServiceStart,
|
||||||
|
} from 'src/core/server';
|
||||||
|
import type { Logger } from 'src/core/server';
|
||||||
|
|
||||||
|
import moment from 'moment';
|
||||||
|
import { CounterMetric, UsageCounter } from './usage_counter';
|
||||||
|
import {
|
||||||
|
registerUsageCountersSavedObjectType,
|
||||||
|
storeCounter,
|
||||||
|
serializeCounterKey,
|
||||||
|
} from './saved_objects';
|
||||||
|
|
||||||
|
export interface UsageCountersServiceDeps {
|
||||||
|
logger: Logger;
|
||||||
|
retryCount: number;
|
||||||
|
bufferDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageCountersServiceSetup {
|
||||||
|
createUsageCounter: (type: string) => UsageCounter;
|
||||||
|
getUsageCounterByType: (type: string) => UsageCounter | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* internal */
|
||||||
|
export interface UsageCountersServiceSetupDeps {
|
||||||
|
savedObjects: SavedObjectsServiceSetup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* internal */
|
||||||
|
export interface UsageCountersServiceStartDeps {
|
||||||
|
savedObjects: SavedObjectsServiceStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsageCountersService {
|
||||||
|
private readonly stop$ = new Rx.Subject();
|
||||||
|
private readonly retryCount: number;
|
||||||
|
private readonly bufferDurationMs: number;
|
||||||
|
|
||||||
|
private readonly counterSets = new Map<string, UsageCounter>();
|
||||||
|
private readonly source$ = new Rx.Subject<CounterMetric>();
|
||||||
|
private readonly counter$ = this.source$.pipe(rxOp.multicast(new Rx.Subject()), rxOp.refCount());
|
||||||
|
private readonly flushCache$ = new Rx.Subject<CounterMetric>();
|
||||||
|
|
||||||
|
private readonly stopCaching$ = new Rx.Subject();
|
||||||
|
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
|
constructor({ logger, retryCount, bufferDurationMs }: UsageCountersServiceDeps) {
|
||||||
|
this.logger = logger;
|
||||||
|
this.retryCount = retryCount;
|
||||||
|
this.bufferDurationMs = bufferDurationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setup = (core: UsageCountersServiceSetupDeps): UsageCountersServiceSetup => {
|
||||||
|
const cache$ = new Rx.ReplaySubject<CounterMetric>();
|
||||||
|
const storingCache$ = new Rx.BehaviorSubject<boolean>(false);
|
||||||
|
// flush cache data from cache -> source
|
||||||
|
this.flushCache$
|
||||||
|
.pipe(
|
||||||
|
rxOp.exhaustMap(() => cache$),
|
||||||
|
rxOp.takeUntil(this.stop$)
|
||||||
|
)
|
||||||
|
.subscribe((data) => {
|
||||||
|
storingCache$.next(true);
|
||||||
|
this.source$.next(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// store data into cache when not paused
|
||||||
|
storingCache$
|
||||||
|
.pipe(
|
||||||
|
rxOp.distinctUntilChanged(),
|
||||||
|
rxOp.switchMap((isStoring) => (isStoring ? Rx.EMPTY : this.source$)),
|
||||||
|
rxOp.takeUntil(Rx.merge(this.stopCaching$, this.stop$))
|
||||||
|
)
|
||||||
|
.subscribe((data) => {
|
||||||
|
cache$.next(data);
|
||||||
|
storingCache$.next(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerUsageCountersSavedObjectType(core.savedObjects);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createUsageCounter: this.createUsageCounter,
|
||||||
|
getUsageCounterByType: this.getUsageCounterByType,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
public start = ({ savedObjects }: UsageCountersServiceStartDeps): void => {
|
||||||
|
this.stopCaching$.next();
|
||||||
|
const internalRepository = savedObjects.createInternalRepository();
|
||||||
|
this.counter$
|
||||||
|
.pipe(
|
||||||
|
/* buffer source events every ${bufferDurationMs} */
|
||||||
|
rxOp.bufferTime(this.bufferDurationMs),
|
||||||
|
/**
|
||||||
|
* bufferTime will trigger every ${bufferDurationMs}
|
||||||
|
* regardless if source emitted anything or not.
|
||||||
|
* using filter will stop cut the pipe short
|
||||||
|
*/
|
||||||
|
rxOp.filter((counters) => Array.isArray(counters) && counters.length > 0),
|
||||||
|
rxOp.map((counters) => Object.values(this.mergeCounters(counters))),
|
||||||
|
rxOp.takeUntil(this.stop$),
|
||||||
|
rxOp.concatMap((counters) => this.storeDate$(counters, internalRepository))
|
||||||
|
)
|
||||||
|
.subscribe((results) => {
|
||||||
|
this.logger.debug('Store counters into savedObjects', results);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.flushCache$.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
public stop = () => {
|
||||||
|
this.stop$.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
private storeDate$(
|
||||||
|
counters: CounterMetric[],
|
||||||
|
internalRepository: Pick<SavedObjectsRepository, 'incrementCounter'>
|
||||||
|
) {
|
||||||
|
return Rx.forkJoin(
|
||||||
|
counters.map((counter) =>
|
||||||
|
Rx.defer(() => storeCounter(counter, internalRepository)).pipe(
|
||||||
|
rxOp.retry(this.retryCount),
|
||||||
|
rxOp.catchError((error) => {
|
||||||
|
this.logger.warn(error);
|
||||||
|
return Rx.of(error);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createUsageCounter = (type: string): UsageCounter => {
|
||||||
|
if (this.counterSets.get(type)) {
|
||||||
|
throw new Error(`Usage counter set "${type}" already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const counterSet = new UsageCounter({
|
||||||
|
domainId: type,
|
||||||
|
counter$: this.source$,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.counterSets.set(type, counterSet);
|
||||||
|
|
||||||
|
return counterSet;
|
||||||
|
};
|
||||||
|
|
||||||
|
private getUsageCounterByType = (type: string): UsageCounter | undefined => {
|
||||||
|
return this.counterSets.get(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
private mergeCounters = (counters: CounterMetric[]): Record<string, CounterMetric> => {
|
||||||
|
const date = moment.now();
|
||||||
|
return counters.reduce((acc, counter) => {
|
||||||
|
const { counterName, domainId, counterType } = counter;
|
||||||
|
const key = serializeCounterKey({ domainId, counterName, counterType, date });
|
||||||
|
const existingCounter = acc[key];
|
||||||
|
if (!existingCounter) {
|
||||||
|
acc[key] = counter;
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[key]: {
|
||||||
|
...existingCounter,
|
||||||
|
...counter,
|
||||||
|
incrementBy: existingCounter.incrementBy + counter.incrementBy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, {} as Record<string, CounterMetric>);
|
||||||
|
};
|
||||||
|
}
|
|
@ -10,8 +10,10 @@ jest.mock('./get_stats', () => ({
|
||||||
getStats: jest.fn().mockResolvedValue({ somestat: 1 }),
|
getStats: jest.fn().mockResolvedValue({ somestat: 1 }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock';
|
import {
|
||||||
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
|
createUsageCollectionSetupMock,
|
||||||
|
createCollectorFetchContextMock,
|
||||||
|
} from 'src/plugins/usage_collection/server/mocks';
|
||||||
|
|
||||||
import { registerVisTypeTableUsageCollector } from './register_usage_collector';
|
import { registerVisTypeTableUsageCollector } from './register_usage_collector';
|
||||||
import { getStats } from './get_stats';
|
import { getStats } from './get_stats';
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { mockStats, mockGetStats } from './get_usage_collector.mock';
|
import { mockStats, mockGetStats } from './get_usage_collector.mock';
|
||||||
import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock';
|
import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/mocks';
|
||||||
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
|
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
|
||||||
import { registerTimeseriesUsageCollector } from './register_timeseries_collector';
|
import { registerTimeseriesUsageCollector } from './register_timeseries_collector';
|
||||||
import { ConfigObservable } from '../types';
|
import { ConfigObservable } from '../types';
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { mockStats, mockGetStats } from './get_usage_collector.mock';
|
import { mockStats, mockGetStats } from './get_usage_collector.mock';
|
||||||
import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock';
|
import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/mocks';
|
||||||
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
|
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
|
||||||
import { HomeServerPluginSetup } from '../../../home/server';
|
import { HomeServerPluginSetup } from '../../../home/server';
|
||||||
import { registerVegaUsageCollector } from './register_vega_collector';
|
import { registerVegaUsageCollector } from './register_vega_collector';
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { mockStats, mockGetStats } from './get_usage_collector.mock';
|
import { mockStats, mockGetStats } from './get_usage_collector.mock';
|
||||||
import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock';
|
import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/mocks';
|
||||||
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
|
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
|
||||||
|
|
||||||
import { registerVisualizationsCollector } from './register_visualizations_collector';
|
import { registerVisualizationsCollector } from './register_visualizations_collector';
|
||||||
|
|
|
@ -8,6 +8,14 @@
|
||||||
|
|
||||||
export const basicUiCounters = {
|
export const basicUiCounters = {
|
||||||
dailyEvents: [
|
dailyEvents: [
|
||||||
|
{
|
||||||
|
appName: 'myApp',
|
||||||
|
eventName: 'some_app_event',
|
||||||
|
lastUpdatedAt: '2021-11-20T11:43:00.961Z',
|
||||||
|
fromTimestamp: '2021-11-20T00:00:00Z',
|
||||||
|
counterType: 'count',
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
appName: 'myApp',
|
appName: 'myApp',
|
||||||
eventName: 'my_event_885082425109579',
|
eventName: 'my_event_885082425109579',
|
||||||
|
|
|
@ -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 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const basicUsageCounters = {
|
||||||
|
dailyEvents: [
|
||||||
|
{
|
||||||
|
domainId: 'anotherDomainId',
|
||||||
|
counterName: 'some_event_name',
|
||||||
|
counterType: 'count',
|
||||||
|
lastUpdatedAt: '2021-11-20T11:43:00.961Z',
|
||||||
|
fromTimestamp: '2021-11-20T00:00:00Z',
|
||||||
|
total: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domainId: 'anotherDomainId',
|
||||||
|
counterName: 'some_event_name',
|
||||||
|
counterType: 'count',
|
||||||
|
lastUpdatedAt: '2021-04-09T11:43:00.961Z',
|
||||||
|
fromTimestamp: '2021-04-09T00:00:00Z',
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domainId: 'anotherDomainId2',
|
||||||
|
counterName: 'some_event_name',
|
||||||
|
counterType: 'count',
|
||||||
|
lastUpdatedAt: '2021-04-20T08:18:03.030Z',
|
||||||
|
fromTimestamp: '2021-04-20T00:00:00Z',
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
|
@ -9,6 +9,7 @@
|
||||||
import expect from '@kbn/expect';
|
import expect from '@kbn/expect';
|
||||||
import supertestAsPromised from 'supertest-as-promised';
|
import supertestAsPromised from 'supertest-as-promised';
|
||||||
import { basicUiCounters } from './__fixtures__/ui_counters';
|
import { basicUiCounters } from './__fixtures__/ui_counters';
|
||||||
|
import { basicUsageCounters } from './__fixtures__/usage_counters';
|
||||||
import type { FtrProviderContext } from '../../ftr_provider_context';
|
import type { FtrProviderContext } from '../../ftr_provider_context';
|
||||||
import type { SavedObject } from '../../../../src/core/server';
|
import type { SavedObject } from '../../../../src/core/server';
|
||||||
import ossRootTelemetrySchema from '../../../../src/plugins/telemetry/schema/oss_root.json';
|
import ossRootTelemetrySchema from '../../../../src/plugins/telemetry/schema/oss_root.json';
|
||||||
|
@ -153,6 +154,20 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Usage Counters telemetry', () => {
|
||||||
|
before('Add UI Counters saved objects', () =>
|
||||||
|
esArchiver.load('saved_objects/usage_counters')
|
||||||
|
);
|
||||||
|
after('cleanup saved objects changes', () =>
|
||||||
|
esArchiver.unload('saved_objects/usage_counters')
|
||||||
|
);
|
||||||
|
|
||||||
|
it('returns usage counters aggregated by day', async () => {
|
||||||
|
const stats = await retrieveTelemetry(supertest);
|
||||||
|
expect(stats.stack_stats.kibana.plugins.usage_counters).to.eql(basicUsageCounters);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('application usage limits', () => {
|
describe('application usage limits', () => {
|
||||||
function createSavedObject(viewId?: string) {
|
function createSavedObject(viewId?: string) {
|
||||||
return supertest
|
return supertest
|
||||||
|
|
|
@ -7,11 +7,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import expect from '@kbn/expect';
|
import expect from '@kbn/expect';
|
||||||
import { ReportManager, METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
|
import { ReportManager, METRIC_TYPE, UiCounterMetricType, Report } from '@kbn/analytics';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||||
import { SavedObject } from '../../../../src/core/server';
|
import { UsageCountersSavedObject } from '../../../../src/plugins/usage_collection/server';
|
||||||
import { UICounterSavedObjectAttributes } from '../../../../src/plugins/kibana_usage_collection/server/collectors/ui_counters/ui_counter_saved_object_type';
|
|
||||||
|
|
||||||
export default function ({ getService }: FtrProviderContext) {
|
export default function ({ getService }: FtrProviderContext) {
|
||||||
const supertest = getService('supertest');
|
const supertest = getService('supertest');
|
||||||
|
@ -24,10 +23,22 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
count,
|
count,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sendReport = async (report: Report) => {
|
||||||
|
await supertest
|
||||||
|
.post('/api/ui_counters/_report')
|
||||||
|
.set('kbn-xsrf', 'kibana')
|
||||||
|
.set('content-type', 'application/json')
|
||||||
|
.send({ report })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// wait for SO to index data into ES
|
||||||
|
await new Promise((res) => setTimeout(res, 5 * 1000));
|
||||||
|
};
|
||||||
|
|
||||||
const getCounterById = (
|
const getCounterById = (
|
||||||
savedObjects: Array<SavedObject<UICounterSavedObjectAttributes>>,
|
savedObjects: UsageCountersSavedObject[],
|
||||||
targetId: string
|
targetId: string
|
||||||
): SavedObject<UICounterSavedObjectAttributes> => {
|
): UsageCountersSavedObject => {
|
||||||
const savedObject = savedObjects.find(({ id }: { id: string }) => id === targetId);
|
const savedObject = savedObjects.find(({ id }: { id: string }) => id === targetId);
|
||||||
if (!savedObject) {
|
if (!savedObject) {
|
||||||
throw new Error(`Unable to find savedObject id ${targetId}`);
|
throw new Error(`Unable to find savedObject id ${targetId}`);
|
||||||
|
@ -40,30 +51,25 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
const dayDate = moment().format('DDMMYYYY');
|
const dayDate = moment().format('DDMMYYYY');
|
||||||
before(async () => await esArchiver.emptyKibanaIndex());
|
before(async () => await esArchiver.emptyKibanaIndex());
|
||||||
|
|
||||||
it('stores ui counter events in savedObjects', async () => {
|
it('stores ui counter events in usage counters savedObjects', async () => {
|
||||||
const reportManager = new ReportManager();
|
const reportManager = new ReportManager();
|
||||||
|
|
||||||
const { report } = reportManager.assignReports([
|
const { report } = reportManager.assignReports([
|
||||||
createUiCounterEvent('my_event', METRIC_TYPE.COUNT),
|
createUiCounterEvent('my_event', METRIC_TYPE.COUNT),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await supertest
|
await sendReport(report);
|
||||||
.post('/api/ui_counters/_report')
|
|
||||||
.set('kbn-xsrf', 'kibana')
|
|
||||||
.set('content-type', 'application/json')
|
|
||||||
.send({ report })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
body: { saved_objects: savedObjects },
|
body: { saved_objects: savedObjects },
|
||||||
} = await supertest
|
} = await supertest
|
||||||
.get('/api/saved_objects/_find?type=ui-counter')
|
.get('/api/saved_objects/_find?type=usage-counters')
|
||||||
.set('kbn-xsrf', 'kibana')
|
.set('kbn-xsrf', 'kibana')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const countTypeEvent = getCounterById(
|
const countTypeEvent = getCounterById(
|
||||||
savedObjects,
|
savedObjects,
|
||||||
`myApp:${dayDate}:${METRIC_TYPE.COUNT}:my_event`
|
`uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:my_event`
|
||||||
);
|
);
|
||||||
expect(countTypeEvent.attributes.count).to.eql(1);
|
expect(countTypeEvent.attributes.count).to.eql(1);
|
||||||
});
|
});
|
||||||
|
@ -78,35 +84,31 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
createUiCounterEvent(`${uniqueEventName}_2`, METRIC_TYPE.COUNT),
|
createUiCounterEvent(`${uniqueEventName}_2`, METRIC_TYPE.COUNT),
|
||||||
createUiCounterEvent(uniqueEventName, METRIC_TYPE.CLICK, 2),
|
createUiCounterEvent(uniqueEventName, METRIC_TYPE.CLICK, 2),
|
||||||
]);
|
]);
|
||||||
await supertest
|
|
||||||
.post('/api/ui_counters/_report')
|
await sendReport(report);
|
||||||
.set('kbn-xsrf', 'kibana')
|
|
||||||
.set('content-type', 'application/json')
|
|
||||||
.send({ report })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
body: { saved_objects: savedObjects },
|
body: { saved_objects: savedObjects },
|
||||||
} = await supertest
|
} = await supertest
|
||||||
.get('/api/saved_objects/_find?type=ui-counter&fields=count')
|
.get('/api/saved_objects/_find?type=usage-counters&fields=count')
|
||||||
.set('kbn-xsrf', 'kibana')
|
.set('kbn-xsrf', 'kibana')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const countTypeEvent = getCounterById(
|
const countTypeEvent = getCounterById(
|
||||||
savedObjects,
|
savedObjects,
|
||||||
`myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}`
|
`uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}`
|
||||||
);
|
);
|
||||||
expect(countTypeEvent.attributes.count).to.eql(1);
|
expect(countTypeEvent.attributes.count).to.eql(1);
|
||||||
|
|
||||||
const clickTypeEvent = getCounterById(
|
const clickTypeEvent = getCounterById(
|
||||||
savedObjects,
|
savedObjects,
|
||||||
`myApp:${dayDate}:${METRIC_TYPE.CLICK}:${uniqueEventName}`
|
`uiCounter:${dayDate}:${METRIC_TYPE.CLICK}:myApp:${uniqueEventName}`
|
||||||
);
|
);
|
||||||
expect(clickTypeEvent.attributes.count).to.eql(2);
|
expect(clickTypeEvent.attributes.count).to.eql(2);
|
||||||
|
|
||||||
const secondEvent = getCounterById(
|
const secondEvent = getCounterById(
|
||||||
savedObjects,
|
savedObjects,
|
||||||
`myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}_2`
|
`uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}_2`
|
||||||
);
|
);
|
||||||
expect(secondEvent.attributes.count).to.eql(1);
|
expect(secondEvent.attributes.count).to.eql(1);
|
||||||
});
|
});
|
||||||
|
|
|
@ -31,6 +31,8 @@ export default async function ({ readConfigFile }) {
|
||||||
'--server.xsrf.disableProtection=true',
|
'--server.xsrf.disableProtection=true',
|
||||||
'--server.compression.referrerWhitelist=["some-host.com"]',
|
'--server.compression.referrerWhitelist=["some-host.com"]',
|
||||||
`--savedObjects.maxImportExportSize=10001`,
|
`--savedObjects.maxImportExportSize=10001`,
|
||||||
|
// for testing set buffer duration to 0 to immediately flush counters into saved objects.
|
||||||
|
'--usageCollection.usageCounters.bufferDuration=0',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,111 @@
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "ui-counter:myApp:30112020:loaded:my_event_885082425109579",
|
||||||
|
"source": {
|
||||||
|
"ui-counter": {
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"type": "ui-counter",
|
||||||
|
"updated_at": "2020-11-30T11:43:00.961Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "ui-counter:myApp:30112020:count:my_event_885082425109579_2",
|
||||||
|
"source": {
|
||||||
|
"ui-counter": {
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"type": "ui-counter",
|
||||||
|
"updated_at": "2020-11-30T11:43:00.961Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "ui-counter:myApp:30112020:count:my_event_885082425109579_2",
|
||||||
|
"source": {
|
||||||
|
"ui-counter": {
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"type": "ui-counter",
|
||||||
|
"updated_at": "2020-10-28T11:43:00.961Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "ui-counter:myApp:30112020:click:my_event_885082425109579",
|
||||||
|
"source": {
|
||||||
|
"ui-counter": {
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
"type": "ui-counter",
|
||||||
|
"updated_at": "2020-11-30T11:43:00.961Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "ui-counter:myApp:30112020:click:my_event_885082425109579",
|
||||||
|
"source": {
|
||||||
|
"ui-counter": {
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
"type": "ui-counter",
|
||||||
|
"updated_at": "2020-11-30T11:43:00.961Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "uiCounter:09042021:count:myApp:some_app_event",
|
||||||
|
"source": {
|
||||||
|
"usage-counters": {
|
||||||
|
"count": 2,
|
||||||
|
"domainId": "uiCounter",
|
||||||
|
"counterName": "myApp:some_app_event",
|
||||||
|
"counterType": "count"
|
||||||
|
},
|
||||||
|
"type": "usage-counters",
|
||||||
|
"updated_at": "2021-11-20T11:43:00.961Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "anotherDomainId:09042021:count:some_event_name",
|
||||||
|
"source": {
|
||||||
|
"usage-counters": {
|
||||||
|
"count": 2,
|
||||||
|
"domainId": "anotherDomainId",
|
||||||
|
"counterName": "some_event_name",
|
||||||
|
"counterType": "count"
|
||||||
|
},
|
||||||
|
"type": "usage-counters",
|
||||||
|
"updated_at": "2021-11-20T11:43:00.961Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Binary file not shown.
|
@ -35,6 +35,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"usage-counters": {
|
||||||
|
"dynamic": false,
|
||||||
|
"properties": {
|
||||||
|
"domainId": {
|
||||||
|
"type": "keyword",
|
||||||
|
"ignore_above": 256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"description": {
|
"description": {
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "uiCounter:20112020:count:myApp:some_app_event",
|
||||||
|
"source": {
|
||||||
|
"usage-counters": {
|
||||||
|
"count": 2,
|
||||||
|
"domainId": "uiCounter",
|
||||||
|
"counterName": "myApp:some_app_event",
|
||||||
|
"counterType": "count"
|
||||||
|
},
|
||||||
|
"type": "usage-counters",
|
||||||
|
"updated_at": "2021-11-20T11:43:00.961Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "anotherDomainId:20112020:count:some_event_name",
|
||||||
|
"source": {
|
||||||
|
"usage-counters": {
|
||||||
|
"count": 3,
|
||||||
|
"domainId": "anotherDomainId",
|
||||||
|
"counterName": "some_event_name",
|
||||||
|
"counterType": "count"
|
||||||
|
},
|
||||||
|
"type": "usage-counters",
|
||||||
|
"updated_at": "2021-11-20T11:43:00.961Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "anotherDomainId:09042021:count:some_event_name",
|
||||||
|
"source": {
|
||||||
|
"usage-counters": {
|
||||||
|
"count": 2,
|
||||||
|
"domainId": "anotherDomainId",
|
||||||
|
"counterName": "some_event_name",
|
||||||
|
"counterType": "count"
|
||||||
|
},
|
||||||
|
"type": "usage-counters",
|
||||||
|
"updated_at": "2021-04-09T11:43:00.961Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "anotherDomainId2:09042021:count:some_event_name",
|
||||||
|
"source": {
|
||||||
|
"usage-counters": {
|
||||||
|
"count": 1,
|
||||||
|
"domainId": "anotherDomainId2",
|
||||||
|
"counterName": "some_event_name",
|
||||||
|
"counterType": "count"
|
||||||
|
},
|
||||||
|
"type": "usage-counters",
|
||||||
|
"updated_at": "2021-04-20T08:18:03.030Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"id": "anotherDomainId3:09042021:custom_type:zero_count",
|
||||||
|
"source": {
|
||||||
|
"usage-counters": {
|
||||||
|
"count": 0,
|
||||||
|
"domainId": "anotherDomainId3",
|
||||||
|
"counterName": "zero_count",
|
||||||
|
"counterType": "custom_type"
|
||||||
|
},
|
||||||
|
"type": "usage-counters",
|
||||||
|
"updated_at": "2021-04-20T08:18:03.030Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,276 @@
|
||||||
|
{
|
||||||
|
"type": "index",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"settings": {
|
||||||
|
"index": {
|
||||||
|
"number_of_shards": "1",
|
||||||
|
"number_of_replicas": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mappings": {
|
||||||
|
"dynamic": "strict",
|
||||||
|
"properties": {
|
||||||
|
"config": {
|
||||||
|
"dynamic": "true",
|
||||||
|
"properties": {
|
||||||
|
"buildNum": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"defaultIndex": {
|
||||||
|
"type": "text",
|
||||||
|
"fields": {
|
||||||
|
"keyword": {
|
||||||
|
"type": "keyword",
|
||||||
|
"ignore_above": 256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usage-counters": {
|
||||||
|
"dynamic": false,
|
||||||
|
"properties": {
|
||||||
|
"domainId": {
|
||||||
|
"type": "keyword",
|
||||||
|
"ignore_above": 256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"hits": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"kibanaSavedObjectMeta": {
|
||||||
|
"properties": {
|
||||||
|
"searchSourceJSON": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"optionsJSON": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"panelsJSON": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"refreshInterval": {
|
||||||
|
"properties": {
|
||||||
|
"display": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"pause": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"section": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeFrom": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"timeRestore": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"timeTo": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"uiStateJSON": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"index-pattern": {
|
||||||
|
"properties": {
|
||||||
|
"fieldFormatMap": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"intervalName": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"notExpandable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"sourceFilters": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"timeFieldName": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"properties": {
|
||||||
|
"columns": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"hits": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"kibanaSavedObjectMeta": {
|
||||||
|
"properties": {
|
||||||
|
"searchSourceJSON": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "keyword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timelion-sheet": {
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"hits": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"kibanaSavedObjectMeta": {
|
||||||
|
"properties": {
|
||||||
|
"searchSourceJSON": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timelion_chart_height": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"timelion_columns": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"timelion_interval": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"timelion_other_interval": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"timelion_rows": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"timelion_sheet": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"namespace": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"references": {
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "keyword"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "nested"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"properties": {
|
||||||
|
"accessCount": {
|
||||||
|
"type": "long"
|
||||||
|
},
|
||||||
|
"accessDate": {
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
"createDate": {
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "text",
|
||||||
|
"fields": {
|
||||||
|
"keyword": {
|
||||||
|
"type": "keyword",
|
||||||
|
"ignore_above": 2048
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visualization": {
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"kibanaSavedObjectMeta": {
|
||||||
|
"properties": {
|
||||||
|
"searchSourceJSON": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"savedSearchId": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"uiStateJSON": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"visState": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,6 +21,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
testFiles: [
|
testFiles: [
|
||||||
|
require.resolve('./test_suites/usage_collection'),
|
||||||
require.resolve('./test_suites/core'),
|
require.resolve('./test_suites/core'),
|
||||||
require.resolve('./test_suites/custom_visualizations'),
|
require.resolve('./test_suites/custom_visualizations'),
|
||||||
require.resolve('./test_suites/panel_actions'),
|
require.resolve('./test_suites/panel_actions'),
|
||||||
|
@ -59,6 +60,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
||||||
'--corePluginDeprecations.oldProperty=hello',
|
'--corePluginDeprecations.oldProperty=hello',
|
||||||
'--corePluginDeprecations.secret=100',
|
'--corePluginDeprecations.secret=100',
|
||||||
'--corePluginDeprecations.noLongerUsed=still_using',
|
'--corePluginDeprecations.noLongerUsed=still_using',
|
||||||
|
// for testing set buffer duration to 0 to immediately flush counters into saved objects.
|
||||||
|
'--usageCollection.usageCounters.bufferDuration=0',
|
||||||
...plugins.map(
|
...plugins.map(
|
||||||
(pluginDir) => `--plugin-path=${path.resolve(__dirname, 'plugins', pluginDir)}`
|
(pluginDir) => `--plugin-path=${path.resolve(__dirname, 'plugins', pluginDir)}`
|
||||||
),
|
),
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"id": "usageCollectionTestPlugin",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"kibanaVersion": "kibana",
|
||||||
|
"configPath": ["usageCollectionTestPlugin"],
|
||||||
|
"requiredPlugins": ["usageCollection"],
|
||||||
|
"server": true,
|
||||||
|
"ui": false
|
||||||
|
}
|
14
test/plugin_functional/plugins/usage_collection/package.json
Normal file
14
test/plugin_functional/plugins/usage_collection/package.json
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "usage_collection_test_plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "target/test/plugin_functional/plugins/usage_collection",
|
||||||
|
"kibana": {
|
||||||
|
"version": "kibana",
|
||||||
|
"templateVersion": "1.0.0"
|
||||||
|
},
|
||||||
|
"license": "SSPL-1.0 OR Elastic License 2.0",
|
||||||
|
"scripts": {
|
||||||
|
"kbn": "node ../../../../scripts/kbn.js",
|
||||||
|
"build": "rm -rf './target' && ../../../../node_modules/.bin/tsc"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
/*
|
||||||
|
* 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 { UsageCollectionTestPlugin } from './plugin';
|
||||||
|
export const plugin = () => new UsageCollectionTestPlugin();
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* 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 type { Plugin, CoreSetup } from 'kibana/server';
|
||||||
|
import {
|
||||||
|
UsageCollectionSetup,
|
||||||
|
UsageCounter,
|
||||||
|
} from '../../../../../src/plugins/usage_collection/server';
|
||||||
|
import { registerRoutes } from './routes';
|
||||||
|
|
||||||
|
export interface TestPluginDepsSetup {
|
||||||
|
usageCollection: UsageCollectionSetup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsageCollectionTestPlugin implements Plugin {
|
||||||
|
private usageCounter?: UsageCounter;
|
||||||
|
|
||||||
|
public setup(core: CoreSetup, { usageCollection }: TestPluginDepsSetup) {
|
||||||
|
const usageCounter = usageCollection.createUsageCounter('usageCollectionTestPlugin');
|
||||||
|
|
||||||
|
registerRoutes(core.http, usageCounter);
|
||||||
|
usageCounter.incrementCounter({
|
||||||
|
counterName: 'duringSetup',
|
||||||
|
incrementBy: 10,
|
||||||
|
});
|
||||||
|
usageCounter.incrementCounter({ counterName: 'duringSetup' });
|
||||||
|
this.usageCounter = usageCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public start() {
|
||||||
|
if (!this.usageCounter) {
|
||||||
|
throw new Error('this.usageCounter is expected to be defined during setup.');
|
||||||
|
}
|
||||||
|
this.usageCounter.incrementCounter({ counterName: 'duringStart' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop() {}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* 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 type { HttpServiceSetup } from 'kibana/server';
|
||||||
|
import { UsageCounter } from '../../../../../src/plugins/usage_collection/server';
|
||||||
|
|
||||||
|
export function registerRoutes(http: HttpServiceSetup, usageCounter: UsageCounter) {
|
||||||
|
const router = http.createRouter();
|
||||||
|
router.get(
|
||||||
|
{
|
||||||
|
path: '/api/usage_collection_test_plugin',
|
||||||
|
validate: false,
|
||||||
|
},
|
||||||
|
async (context, req, res) => {
|
||||||
|
usageCounter.incrementCounter({ counterName: 'routeAccessed' });
|
||||||
|
return res.ok();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"extends": "../../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./target",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"index.ts",
|
||||||
|
"public/**/*.ts",
|
||||||
|
"public/**/*.tsx",
|
||||||
|
"server/**/*.ts",
|
||||||
|
"../../../../typings/**/*",
|
||||||
|
],
|
||||||
|
"exclude": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../../../../src/core/tsconfig.json" }
|
||||||
|
]
|
||||||
|
}
|
15
test/plugin_functional/test_suites/usage_collection/index.ts
Normal file
15
test/plugin_functional/test_suites/usage_collection/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* 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 { PluginFunctionalProviderContext } from '../../services';
|
||||||
|
|
||||||
|
export default function ({ loadTestFile }: PluginFunctionalProviderContext) {
|
||||||
|
describe('usage collection', function () {
|
||||||
|
loadTestFile(require.resolve('./usage_counters'));
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
/*
|
||||||
|
* 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 expect from '@kbn/expect';
|
||||||
|
import { PluginFunctionalProviderContext } from '../../services';
|
||||||
|
import {
|
||||||
|
UsageCountersSavedObject,
|
||||||
|
serializeCounterKey,
|
||||||
|
} from '../../../../src/plugins/usage_collection/server/usage_counters';
|
||||||
|
|
||||||
|
export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) {
|
||||||
|
const supertest = getService('supertest');
|
||||||
|
|
||||||
|
async function getSavedObjectCounters() {
|
||||||
|
// wait until ES indexes the counter SavedObject;
|
||||||
|
await new Promise((res) => setTimeout(res, 7 * 1000));
|
||||||
|
|
||||||
|
return await supertest
|
||||||
|
.get('/api/saved_objects/_find?type=usage-counters')
|
||||||
|
.set('kbn-xsrf', 'true')
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }) => {
|
||||||
|
expect(body.total).to.above(1);
|
||||||
|
return (body.saved_objects as UsageCountersSavedObject[]).reduce((acc, savedObj) => {
|
||||||
|
const { count, counterName, domainId } = savedObj.attributes;
|
||||||
|
if (domainId === 'usageCollectionTestPlugin') {
|
||||||
|
acc[counterName] = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Usage Counters service', () => {
|
||||||
|
before(async () => {
|
||||||
|
const key = serializeCounterKey({
|
||||||
|
counterName: 'routeAccessed',
|
||||||
|
counterType: 'count',
|
||||||
|
domainId: 'usageCollectionTestPlugin',
|
||||||
|
date: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await supertest.delete(`/api/saved_objects/usage-counters/${key}`).set('kbn-xsrf', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores usage counters sent during start and setup', async () => {
|
||||||
|
const { duringSetup, duringStart, routeAccessed } = await getSavedObjectCounters();
|
||||||
|
|
||||||
|
expect(duringSetup).to.be(11);
|
||||||
|
expect(duringStart).to.be(1);
|
||||||
|
expect(routeAccessed).to.be(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores usage counters triggered by runtime activities', async () => {
|
||||||
|
await supertest.get('/api/usage_collection_test_plugin').set('kbn-xsrf', 'true').expect(200);
|
||||||
|
|
||||||
|
const { routeAccessed } = await getSavedObjectCounters();
|
||||||
|
expect(routeAccessed).to.be(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue