diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.initialize.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.initialize.md
new file mode 100644
index 0000000000000..61091306d0dbc
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.initialize.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsIncrementCounterOptions](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.md) > [initialize](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.initialize.md)
+
+## SavedObjectsIncrementCounterOptions.initialize property
+
+(default=false) If true, sets all the counter fields to 0 if they don't already exist. Existing fields will be left as-is and won't be incremented.
+
+Signature:
+
+```typescript
+initialize?: boolean;
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md
index 6077945ddd376..68e9bb09456cd 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md
@@ -15,6 +15,7 @@ export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOpt
| Property | Type | Description |
| --- | --- | --- |
-| [migrationVersion](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.migrationversion.md) | SavedObjectsMigrationVersion
| |
-| [refresh](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.refresh.md) | MutatingOperationRefreshSetting
| The Elasticsearch Refresh setting for this operation |
+| [initialize](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.initialize.md) | boolean
| (default=false) If true, sets all the counter fields to 0 if they don't already exist. Existing fields will be left as-is and won't be incremented. |
+| [migrationVersion](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.migrationversion.md) | SavedObjectsMigrationVersion
| [SavedObjectsMigrationVersion](./kibana-plugin-core-server.savedobjectsmigrationversion.md) |
+| [refresh](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.refresh.md) | MutatingOperationRefreshSetting
| (default='wait\_for') The Elasticsearch refresh setting for this operation. See [MutatingOperationRefreshSetting](./kibana-plugin-core-server.mutatingoperationrefreshsetting.md) |
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.migrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.migrationversion.md
index 417db99fd5a27..aff80138d61cf 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.migrationversion.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.migrationversion.md
@@ -4,6 +4,8 @@
## SavedObjectsIncrementCounterOptions.migrationVersion property
+[SavedObjectsMigrationVersion](./kibana-plugin-core-server.savedobjectsmigrationversion.md)
+
Signature:
```typescript
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.refresh.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.refresh.md
index 31d957ca30a3e..4f217cc223d46 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.refresh.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.refresh.md
@@ -4,7 +4,7 @@
## SavedObjectsIncrementCounterOptions.refresh property
-The Elasticsearch Refresh setting for this operation
+(default='wait\_for') The Elasticsearch refresh setting for this operation. See [MutatingOperationRefreshSetting](./kibana-plugin-core-server.mutatingoperationrefreshsetting.md)
Signature:
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md
index f3a2ee38cbdbd..dc62cacf6741b 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md
@@ -4,26 +4,53 @@
## SavedObjectsRepository.incrementCounter() method
-Increases a counter field by one. Creates the document if one doesn't exist for the given id.
+Increments all the specified counter fields by one. Creates the document if one doesn't exist for the given id.
Signature:
```typescript
-incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise;
+incrementCounter(type: string, id: string, counterFieldNames: string[], options?: SavedObjectsIncrementCounterOptions): Promise;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
-| type | string
| |
-| id | string
| |
-| counterFieldName | string
| |
-| options | SavedObjectsIncrementCounterOptions
| |
+| type | string
| The type of saved object whose fields should be incremented |
+| id | string
| The id of the document whose fields should be incremented |
+| counterFieldNames | string[]
| An array of field names to increment |
+| options | SavedObjectsIncrementCounterOptions
| [SavedObjectsIncrementCounterOptions](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.md) |
Returns:
`Promise`
-{promise}
+The saved object after the specified fields were incremented
+
+## Remarks
+
+When supplying a field name like `stats.api.counter` the field name will be used as-is to create a document like: `{attributes: {'stats.api.counter': 1}}` It will not create a nested structure like: `{attributes: {stats: {api: {counter: 1}}}}`
+
+When using incrementCounter for collecting usage data, you need to ensure that usage collection happens on a best-effort basis and doesn't negatively affect your plugin or users. See https://github.com/elastic/kibana/blob/master/src/plugins/usage\_collection/README.md\#tracking-interactions-with-incrementcounter)
+
+## Example
+
+
+```ts
+const repository = coreStart.savedObjects.createInternalRepository();
+
+// Initialize all fields to 0
+repository
+ .incrementCounter('dashboard_counter_type', 'counter_id', [
+ 'stats.apiCalls',
+ 'stats.sampleDataInstalled',
+ ], {initialize: true});
+
+// Increment the apiCalls field counter
+repository
+ .incrementCounter('dashboard_counter_type', 'counter_id', [
+ 'stats.apiCalls',
+ ])
+
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md
index 6a56f0bee718b..e0a6b8af5658a 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md
@@ -26,7 +26,7 @@ export declare class SavedObjectsRepository
| [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[addToNamespaces
\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. |
| [find(options)](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | |
| [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object |
-| [incrementCounter(type, id, counterFieldName, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increases a counter field by one. Creates the document if one doesn't exist for the given id. |
+| [incrementCounter(type, id, counterFieldNames, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increments all the specified counter fields by one. Creates the document if one doesn't exist for the given id. |
| [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. |
| [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object |
diff --git a/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts b/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts
new file mode 100644
index 0000000000000..2f64776501df0
--- /dev/null
+++ b/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts
@@ -0,0 +1,152 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { InternalCoreStart } from 'src/core/server/internal_types';
+import * as kbnTestServer from '../../../../../test_helpers/kbn_server';
+import { Root } from '../../../../root';
+
+const { startES } = kbnTestServer.createTestServers({
+ adjustTimeout: (t: number) => jest.setTimeout(t),
+});
+let esServer: kbnTestServer.TestElasticsearchUtils;
+
+describe('SavedObjectsRepository', () => {
+ let root: Root;
+ let start: InternalCoreStart;
+
+ beforeAll(async () => {
+ esServer = await startES();
+ root = kbnTestServer.createRootWithCorePlugins({
+ server: {
+ basePath: '/hello',
+ },
+ });
+
+ const setup = await root.setup();
+ setup.savedObjects.registerType({
+ hidden: false,
+ mappings: {
+ dynamic: false,
+ properties: {},
+ },
+ name: 'test_counter_type',
+ namespaceType: 'single',
+ });
+ start = await root.start();
+ });
+
+ afterAll(async () => {
+ await esServer.stop();
+ await root.shutdown();
+ });
+
+ describe('#incrementCounter', () => {
+ describe('initialize=false', () => {
+ it('creates a new document if none exists and sets all counter fields set to 1', async () => {
+ const now = new Date().getTime();
+ const repository = start.savedObjects.createInternalRepository();
+ await repository.incrementCounter('test_counter_type', 'counter_1', [
+ 'stats.api.count',
+ 'stats.api.count2',
+ 'stats.total',
+ ]);
+ const result = await repository.get('test_counter_type', 'counter_1');
+ expect(result.attributes).toMatchInlineSnapshot(`
+ Object {
+ "stats.api.count": 1,
+ "stats.api.count2": 1,
+ "stats.total": 1,
+ }
+ `);
+ expect(Date.parse(result.updated_at!)).toBeGreaterThanOrEqual(now);
+ });
+ it('increments the specified counters of an existing document', async () => {
+ const repository = start.savedObjects.createInternalRepository();
+ // Create document
+ await repository.incrementCounter('test_counter_type', 'counter_2', [
+ 'stats.api.count',
+ 'stats.api.count2',
+ 'stats.total',
+ ]);
+
+ const now = new Date().getTime();
+ // Increment counters
+ await repository.incrementCounter('test_counter_type', 'counter_2', [
+ 'stats.api.count',
+ 'stats.api.count2',
+ 'stats.total',
+ ]);
+ const result = await repository.get('test_counter_type', 'counter_2');
+ expect(result.attributes).toMatchInlineSnapshot(`
+ Object {
+ "stats.api.count": 2,
+ "stats.api.count2": 2,
+ "stats.total": 2,
+ }
+ `);
+ expect(Date.parse(result.updated_at!)).toBeGreaterThanOrEqual(now);
+ });
+ });
+ describe('initialize=true', () => {
+ it('creates a new document if none exists and sets all counter fields to 0', async () => {
+ const now = new Date().getTime();
+ const repository = start.savedObjects.createInternalRepository();
+ await repository.incrementCounter(
+ 'test_counter_type',
+ 'counter_3',
+ ['stats.api.count', 'stats.api.count2', 'stats.total'],
+ { initialize: true }
+ );
+ const result = await repository.get('test_counter_type', 'counter_3');
+ expect(result.attributes).toMatchInlineSnapshot(`
+ Object {
+ "stats.api.count": 0,
+ "stats.api.count2": 0,
+ "stats.total": 0,
+ }
+ `);
+ expect(Date.parse(result.updated_at!)).toBeGreaterThanOrEqual(now);
+ });
+ it('sets any undefined counter fields to 0 but does not alter existing fields of an existing document', async () => {
+ const repository = start.savedObjects.createInternalRepository();
+ // Create document
+ await repository.incrementCounter('test_counter_type', 'counter_4', [
+ 'stats.existing_field',
+ ]);
+
+ const now = new Date().getTime();
+ // Initialize counters
+ await repository.incrementCounter(
+ 'test_counter_type',
+ 'counter_4',
+ ['stats.existing_field', 'stats.new_field'],
+ { initialize: true }
+ );
+ const result = await repository.get('test_counter_type', 'counter_4');
+ expect(result.attributes).toMatchInlineSnapshot(`
+ Object {
+ "stats.existing_field": 1,
+ "stats.new_field": 0,
+ }
+ `);
+ expect(Date.parse(result.updated_at!)).toBeGreaterThanOrEqual(now);
+ });
+ });
+ });
+});
diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js
index 6f885f17fd82b..8443d1dd07184 100644
--- a/src/core/server/saved_objects/service/lib/repository.test.js
+++ b/src/core/server/saved_objects/service/lib/repository.test.js
@@ -3272,11 +3272,11 @@ describe('SavedObjectsRepository', () => {
describe('#incrementCounter', () => {
const type = 'config';
const id = 'one';
- const field = 'buildNum';
+ const counterFields = ['buildNum', 'apiCallsCount'];
const namespace = 'foo-namespace';
const originId = 'some-origin-id';
- const incrementCounterSuccess = async (type, id, field, options) => {
+ const incrementCounterSuccess = async (type, id, fields, options) => {
const isMultiNamespace = registry.isMultiNamespace(type);
if (isMultiNamespace) {
const response = getMockGetResponse({ type, id }, options?.namespace);
@@ -3295,7 +3295,10 @@ describe('SavedObjectsRepository', () => {
type,
...mockTimestampFields,
[type]: {
- [field]: 8468,
+ ...fields.reduce((acc, field) => {
+ acc[field] = 8468;
+ return acc;
+ }, {}),
defaultIndex: 'logstash-*',
},
},
@@ -3303,25 +3306,25 @@ describe('SavedObjectsRepository', () => {
})
);
- const result = await savedObjectsRepository.incrementCounter(type, id, field, options);
+ const result = await savedObjectsRepository.incrementCounter(type, id, fields, options);
expect(client.get).toHaveBeenCalledTimes(isMultiNamespace ? 1 : 0);
return result;
};
describe('client calls', () => {
it(`should use the ES update action if type is not multi-namespace`, async () => {
- await incrementCounterSuccess(type, id, field, { namespace });
+ await incrementCounterSuccess(type, id, counterFields, { namespace });
expect(client.update).toHaveBeenCalledTimes(1);
});
it(`should use the ES get action then update action if type is multi-namespace, ID is defined, and overwrite=true`, async () => {
- await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, field, { namespace });
+ await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace });
expect(client.get).toHaveBeenCalledTimes(1);
expect(client.update).toHaveBeenCalledTimes(1);
});
it(`defaults to a refresh setting of wait_for`, async () => {
- await incrementCounterSuccess(type, id, field, { namespace });
+ await incrementCounterSuccess(type, id, counterFields, { namespace });
expect(client.update).toHaveBeenCalledWith(
expect.objectContaining({
refresh: 'wait_for',
@@ -3331,7 +3334,7 @@ describe('SavedObjectsRepository', () => {
});
it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => {
- await incrementCounterSuccess(type, id, field, { namespace });
+ await incrementCounterSuccess(type, id, counterFields, { namespace });
expect(client.update).toHaveBeenCalledWith(
expect.objectContaining({
id: `${namespace}:${type}:${id}`,
@@ -3341,7 +3344,7 @@ describe('SavedObjectsRepository', () => {
});
it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => {
- await incrementCounterSuccess(type, id, field);
+ await incrementCounterSuccess(type, id, counterFields);
expect(client.update).toHaveBeenCalledWith(
expect.objectContaining({
id: `${type}:${id}`,
@@ -3351,7 +3354,7 @@ describe('SavedObjectsRepository', () => {
});
it(`normalizes options.namespace from 'default' to undefined`, async () => {
- await incrementCounterSuccess(type, id, field, { namespace: 'default' });
+ await incrementCounterSuccess(type, id, counterFields, { namespace: 'default' });
expect(client.update).toHaveBeenCalledWith(
expect.objectContaining({
id: `${type}:${id}`,
@@ -3361,7 +3364,7 @@ describe('SavedObjectsRepository', () => {
});
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
- await incrementCounterSuccess(NAMESPACE_AGNOSTIC_TYPE, id, field, { namespace });
+ await incrementCounterSuccess(NAMESPACE_AGNOSTIC_TYPE, id, counterFields, { namespace });
expect(client.update).toHaveBeenCalledWith(
expect.objectContaining({
id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`,
@@ -3370,7 +3373,7 @@ describe('SavedObjectsRepository', () => {
);
client.update.mockClear();
- await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, field, { namespace });
+ await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace });
expect(client.update).toHaveBeenCalledWith(
expect.objectContaining({
id: `${MULTI_NAMESPACE_TYPE}:${id}`,
@@ -3389,7 +3392,7 @@ describe('SavedObjectsRepository', () => {
it(`throws when options.namespace is '*'`, async () => {
await expect(
- savedObjectsRepository.incrementCounter(type, id, field, {
+ savedObjectsRepository.incrementCounter(type, id, counterFields, {
namespace: ALL_NAMESPACES_STRING,
})
).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"'));
@@ -3398,7 +3401,7 @@ describe('SavedObjectsRepository', () => {
it(`throws when type is not a string`, async () => {
const test = async (type) => {
await expect(
- savedObjectsRepository.incrementCounter(type, id, field)
+ savedObjectsRepository.incrementCounter(type, id, counterFields)
).rejects.toThrowError(`"type" argument must be a string`);
expect(client.update).not.toHaveBeenCalled();
};
@@ -3413,23 +3416,24 @@ describe('SavedObjectsRepository', () => {
const test = async (field) => {
await expect(
savedObjectsRepository.incrementCounter(type, id, field)
- ).rejects.toThrowError(`"counterFieldName" argument must be a string`);
+ ).rejects.toThrowError(`"counterFieldNames" argument must be an array of strings`);
expect(client.update).not.toHaveBeenCalled();
};
- await test(null);
- await test(42);
- await test(false);
- await test({});
+ await test([null]);
+ await test([42]);
+ await test([false]);
+ await test([{}]);
+ await test([{}, false, 42, null, 'string']);
});
it(`throws when type is invalid`, async () => {
- await expectUnsupportedTypeError('unknownType', id, field);
+ await expectUnsupportedTypeError('unknownType', id, counterFields);
expect(client.update).not.toHaveBeenCalled();
});
it(`throws when type is hidden`, async () => {
- await expectUnsupportedTypeError(HIDDEN_TYPE, id, field);
+ await expectUnsupportedTypeError(HIDDEN_TYPE, id, counterFields);
expect(client.update).not.toHaveBeenCalled();
});
@@ -3439,7 +3443,9 @@ describe('SavedObjectsRepository', () => {
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
);
await expect(
- savedObjectsRepository.incrementCounter(MULTI_NAMESPACE_TYPE, id, field, { namespace })
+ savedObjectsRepository.incrementCounter(MULTI_NAMESPACE_TYPE, id, counterFields, {
+ namespace,
+ })
).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id));
expect(client.get).toHaveBeenCalledTimes(1);
});
@@ -3452,8 +3458,8 @@ describe('SavedObjectsRepository', () => {
it(`migrates a document and serializes the migrated doc`, async () => {
const migrationVersion = mockMigrationVersion;
- await incrementCounterSuccess(type, id, field, { migrationVersion });
- const attributes = { buildNum: 1 }; // this is added by the incrementCounter function
+ await incrementCounterSuccess(type, id, counterFields, { migrationVersion });
+ const attributes = { buildNum: 1, apiCallsCount: 1 }; // this is added by the incrementCounter function
const doc = { type, id, attributes, migrationVersion, ...mockTimestampFields };
expectMigrationArgs(doc);
@@ -3476,6 +3482,7 @@ describe('SavedObjectsRepository', () => {
...mockTimestampFields,
config: {
buildNum: 8468,
+ apiCallsCount: 100,
defaultIndex: 'logstash-*',
},
originId,
@@ -3487,7 +3494,7 @@ describe('SavedObjectsRepository', () => {
const response = await savedObjectsRepository.incrementCounter(
'config',
'6.0.0-alpha1',
- 'buildNum',
+ ['buildNum', 'apiCallsCount'],
{
namespace: 'foo-namespace',
}
@@ -3500,6 +3507,7 @@ describe('SavedObjectsRepository', () => {
version: mockVersion,
attributes: {
buildNum: 8468,
+ apiCallsCount: 100,
defaultIndex: 'logstash-*',
},
originId,
diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts
index d362c02de4915..2f09ad71de558 100644
--- a/src/core/server/saved_objects/service/lib/repository.ts
+++ b/src/core/server/saved_objects/service/lib/repository.ts
@@ -101,8 +101,17 @@ export interface SavedObjectsRepositoryOptions {
* @public
*/
export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions {
+ /**
+ * (default=false) If true, sets all the counter fields to 0 if they don't
+ * already exist. Existing fields will be left as-is and won't be incremented.
+ */
+ initialize?: boolean;
+ /** {@link SavedObjectsMigrationVersion} */
migrationVersion?: SavedObjectsMigrationVersion;
- /** The Elasticsearch Refresh setting for this operation */
+ /**
+ * (default='wait_for') The Elasticsearch refresh setting for this
+ * operation. See {@link MutatingOperationRefreshSetting}
+ */
refresh?: MutatingOperationRefreshSetting;
}
@@ -1515,32 +1524,64 @@ export class SavedObjectsRepository {
}
/**
- * Increases a counter field by one. Creates the document if one doesn't exist for the given id.
+ * Increments all the specified counter fields by one. Creates the document
+ * if one doesn't exist for the given id.
*
- * @param {string} type
- * @param {string} id
- * @param {string} counterFieldName
- * @param {object} [options={}]
- * @property {object} [options.migrationVersion=undefined]
- * @returns {promise}
+ * @remarks
+ * When supplying a field name like `stats.api.counter` the field name will
+ * be used as-is to create a document like:
+ * `{attributes: {'stats.api.counter': 1}}`
+ * It will not create a nested structure like:
+ * `{attributes: {stats: {api: {counter: 1}}}}`
+ *
+ * When using incrementCounter for collecting usage data, you need to ensure
+ * that usage collection happens on a best-effort basis and doesn't
+ * negatively affect your plugin or users. See https://github.com/elastic/kibana/blob/master/src/plugins/usage_collection/README.md#tracking-interactions-with-incrementcounter)
+ *
+ * @example
+ * ```ts
+ * const repository = coreStart.savedObjects.createInternalRepository();
+ *
+ * // Initialize all fields to 0
+ * repository
+ * .incrementCounter('dashboard_counter_type', 'counter_id', [
+ * 'stats.apiCalls',
+ * 'stats.sampleDataInstalled',
+ * ], {initialize: true});
+ *
+ * // Increment the apiCalls field counter
+ * repository
+ * .incrementCounter('dashboard_counter_type', 'counter_id', [
+ * 'stats.apiCalls',
+ * ])
+ * ```
+ *
+ * @param type - The type of saved object whose fields should be incremented
+ * @param id - The id of the document whose fields should be incremented
+ * @param counterFieldNames - An array of field names to increment
+ * @param options - {@link SavedObjectsIncrementCounterOptions}
+ * @returns The saved object after the specified fields were incremented
*/
async incrementCounter(
type: string,
id: string,
- counterFieldName: string,
+ counterFieldNames: string[],
options: SavedObjectsIncrementCounterOptions = {}
): Promise {
if (typeof type !== 'string') {
throw new Error('"type" argument must be a string');
}
- if (typeof counterFieldName !== 'string') {
- throw new Error('"counterFieldName" argument must be a string');
+ const isArrayOfStrings =
+ Array.isArray(counterFieldNames) &&
+ !counterFieldNames.some((field) => typeof field !== 'string');
+ if (!isArrayOfStrings) {
+ throw new Error('"counterFieldNames" argument must be an array of strings');
}
if (!this._allowedTypes.includes(type)) {
throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type);
}
- const { migrationVersion, refresh = DEFAULT_REFRESH_SETTING } = options;
+ const { migrationVersion, refresh = DEFAULT_REFRESH_SETTING, initialize = false } = options;
const namespace = normalizeNamespace(options.namespace);
const time = this._getCurrentTime();
@@ -1558,7 +1599,10 @@ export class SavedObjectsRepository {
type,
...(savedObjectNamespace && { namespace: savedObjectNamespace }),
...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }),
- attributes: { [counterFieldName]: 1 },
+ attributes: counterFieldNames.reduce((acc, counterFieldName) => {
+ acc[counterFieldName] = initialize ? 0 : 1;
+ return acc;
+ }, {} as Record),
migrationVersion,
updated_at: time,
});
@@ -1573,20 +1617,22 @@ export class SavedObjectsRepository {
body: {
script: {
source: `
- if (ctx._source[params.type][params.counterFieldName] == null) {
- ctx._source[params.type][params.counterFieldName] = params.count;
- }
- else {
- ctx._source[params.type][params.counterFieldName] += params.count;
+ for (counterFieldName in params.counterFieldNames) {
+ if (ctx._source[params.type][counterFieldName] == null) {
+ ctx._source[params.type][counterFieldName] = params.count;
+ }
+ else {
+ ctx._source[params.type][counterFieldName] += params.count;
+ }
}
ctx._source.updated_at = params.time;
`,
lang: 'painless',
params: {
- count: 1,
+ count: initialize ? 0 : 1,
time,
type,
- counterFieldName,
+ counterFieldNames,
},
},
upsert: raw._source,
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index a03e5ec9acd27..ad31b1ffc682c 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -2366,6 +2366,7 @@ export interface SavedObjectsImportUnsupportedTypeError {
// @public (undocumented)
export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions {
+ initialize?: boolean;
// (undocumented)
migrationVersion?: SavedObjectsMigrationVersion;
refresh?: MutatingOperationRefreshSetting;
@@ -2444,7 +2445,7 @@ export class SavedObjectsRepository {
// (undocumented)
find(options: SavedObjectsFindOptions): Promise>;
get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>;
- incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise;
+ incrementCounter(type: string, id: string, counterFieldNames: string[], options?: SavedObjectsIncrementCounterOptions): Promise;
removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise;
update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>;
}
diff --git a/src/plugins/data/server/kql_telemetry/route.ts b/src/plugins/data/server/kql_telemetry/route.ts
index efcb3d038bcc6..c93500f360ad0 100644
--- a/src/plugins/data/server/kql_telemetry/route.ts
+++ b/src/plugins/data/server/kql_telemetry/route.ts
@@ -45,7 +45,7 @@ export function registerKqlTelemetryRoute(
const counterName = optIn ? 'optInCount' : 'optOutCount';
try {
- await internalRepository.incrementCounter('kql-telemetry', 'kql-telemetry', counterName);
+ await internalRepository.incrementCounter('kql-telemetry', 'kql-telemetry', [counterName]);
} catch (error) {
logger.warn(`Unable to increment counter: ${error}`);
return response.customError({
diff --git a/src/plugins/home/server/services/sample_data/usage/usage.ts b/src/plugins/home/server/services/sample_data/usage/usage.ts
index ba67906febf1a..6a243b47dee55 100644
--- a/src/plugins/home/server/services/sample_data/usage/usage.ts
+++ b/src/plugins/home/server/services/sample_data/usage/usage.ts
@@ -43,7 +43,7 @@ export function usage(
addInstall: async (dataSet: string) => {
try {
const internalRepository = await internalRepositoryPromise;
- await internalRepository.incrementCounter(SAVED_OBJECT_ID, dataSet, `installCount`);
+ await internalRepository.incrementCounter(SAVED_OBJECT_ID, dataSet, [`installCount`]);
} catch (err) {
handleIncrementError(err);
}
@@ -51,7 +51,7 @@ export function usage(
addUninstall: async (dataSet: string) => {
try {
const internalRepository = await internalRepositoryPromise;
- await internalRepository.incrementCounter(SAVED_OBJECT_ID, dataSet, `unInstallCount`);
+ await internalRepository.incrementCounter(SAVED_OBJECT_ID, dataSet, [`unInstallCount`]);
} catch (err) {
handleIncrementError(err);
}
diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md
index 5e6ed901c7647..33f7993f14233 100644
--- a/src/plugins/usage_collection/README.md
+++ b/src/plugins/usage_collection/README.md
@@ -140,6 +140,98 @@ export function registerMyPluginUsageCollector(
}
```
+## Tracking interactions with incrementCounter
+There are several ways to collect data that can provide insight into how users
+use your plugin or specific features. For tracking user interactions the
+`SavedObjectsRepository` provided by Core provides a useful `incrementCounter`
+method which can be used to increment one or more counter fields in a
+document. Examples of interactions include tracking:
+ - the number of API calls
+ - the number of times users installed and uninstalled the sample datasets
+
+When using `incrementCounter` for collecting usage data, you need to ensure
+that usage collection happens on a best-effort basis and doesn't
+negatively affect your plugin or users (see the example):
+ - Swallow any exceptions thrown from the incrementCounter method and log
+ a message in development.
+ - Don't block your application on the incrementCounter method (e.g.
+ don't use `await`)
+ - Set the `refresh` option to false to prevent unecessary index refreshes
+ which slows down Elasticsearch performance
+
+
+Note: for brevity the following example does not follow Kibana's conventions
+for structuring your plugin code.
+```ts
+// src/plugins/dashboard/server/plugin.ts
+
+import { PluginInitializerContext, Plugin, CoreStart, CoreSetup } from '../../src/core/server';
+
+export class DashboardPlugin implements Plugin {
+ private readonly logger: Logger;
+ private readonly isDevEnvironment: boolean;
+
+ constructor(initializerContext: PluginInitializerContext) {
+ this.logger = initializerContext.logger.get();
+ this.isDevEnvironment = initializerContext.env.cliArgs.dev;
+ }
+ public setup(core) {
+ // Register a saved object type to store our usage counters
+ core.savedObjects.registerType({
+ // Don't expose this saved object type via the saved objects HTTP API
+ hidden: true,
+ mappings: {
+ // Since we're not querying or aggregating over our counter documents
+ // we don't define any fields.
+ dynamic: false,
+ properties: {},
+ },
+ name: 'dashboard_usage_counters',
+ namespaceType: 'single',
+ });
+ }
+ public start(core) {
+ const repository = core.savedObjects.createInternalRepository(['dashboard_usage_counters']);
+ // Initialize all the counter fields to 0 when our plugin starts
+ // NOTE: Usage collection happens on a best-effort basis, so we don't
+ // `await` the promise returned by `incrementCounter` and we swallow any
+ // exceptions in production.
+ repository
+ .incrementCounter('dashboard_usage_counters', 'dashboard_usage_counters', [
+ 'apiCalls',
+ 'settingToggled',
+ ], {refresh: false, initialize: true})
+ .catch((e) => (this.isDevEnvironment ? this.logger.error(e) : e));
+
+ const router = core.http.createRouter();
+
+ router.post(
+ {
+ path: `api/v1/dashboard/counters/{counter}`,
+ validate: {
+ params: schema.object({
+ counter: schema.oneOf([schema.literal('apiCalls'), schema.literal('settingToggled')]),
+ }),
+ },
+ },
+ async (context, request, response) => {
+ request.params.id
+
+ // NOTE: Usage collection happens on a best-effort basis, so we don't
+ // `await` the promise returned by `incrementCounter` and we swallow any
+ // exceptions in production.
+ repository
+ .incrementCounter('dashboard_usage_counters', 'dashboard_usage_counters', [
+ counter
+ ], {refresh: false})
+ .catch((e) => (this.isDevEnvironement ? this.logger.error(e) : e));
+
+ return response.ok();
+ }
+ );
+ }
+}
+
## Schema Field
The `schema` field is a proscribed data model assists with detecting changes in usage collector payloads. To define the collector schema add a schema field that specifies every possible field reported when registering the collector. Whenever the `schema` field is set or changed please run `node scripts/telemetry_check.js --fix` to update the stored schema json files.
@@ -200,7 +292,6 @@ export const myCollector = makeUsageCollector({
},
});
```
-
## Update the telemetry payload and telemetry cluster field mappings
There is a module in the telemetry service that creates the payload of data that gets sent up to the telemetry cluster.
diff --git a/src/plugins/usage_collection/server/report/store_report.test.ts b/src/plugins/usage_collection/server/report/store_report.test.ts
index d8327eb834e12..939c37764ab0e 100644
--- a/src/plugins/usage_collection/server/report/store_report.test.ts
+++ b/src/plugins/usage_collection/server/report/store_report.test.ts
@@ -69,7 +69,7 @@ describe('store_report', () => {
expect(savedObjectClient.incrementCounter).toHaveBeenCalledWith(
'ui-metric',
'test-app-name:test-event-name',
- 'count'
+ ['count']
);
expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith([
{
diff --git a/src/plugins/usage_collection/server/report/store_report.ts b/src/plugins/usage_collection/server/report/store_report.ts
index d9aac23fd1ff0..a54d3d226d736 100644
--- a/src/plugins/usage_collection/server/report/store_report.ts
+++ b/src/plugins/usage_collection/server/report/store_report.ts
@@ -50,7 +50,7 @@ export async function storeReport(
const savedObjectId = `${appName}:${eventName}`;
return {
saved_objects: [
- await internalRepository.incrementCounter('ui-metric', savedObjectId, 'count'),
+ await internalRepository.incrementCounter('ui-metric', savedObjectId, ['count']),
],
};
}),
diff --git a/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts
index 0969174c7143c..46f46eaa3026f 100644
--- a/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts
+++ b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts
@@ -83,7 +83,7 @@ export class ValidationTelemetryService implements Plugin {
expect(incrementCounterMock).toHaveBeenCalledWith(
'app_search_telemetry',
'app_search_telemetry',
- 'ui_clicked.button'
+ ['ui_clicked.button']
);
expect(response).toEqual({ success: true });
});
diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts
index cd8ad72bf8358..deba94fc0bd5e 100644
--- a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts
+++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts
@@ -55,7 +55,7 @@ export async function incrementUICounter({
await internalRepository.incrementCounter(
id,
id,
- `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide
+ [`${uiAction}.${metric}`] // e.g., ui_viewed.setup_guide
);
return { success: true };
diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.test.ts
index 703351c45ba5a..af55cc9968b14 100644
--- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.test.ts
+++ b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.test.ts
@@ -29,17 +29,17 @@ describe('Upgrade Assistant Telemetry SavedObject UIOpen', () => {
expect(internalRepo.incrementCounter).toHaveBeenCalledWith(
UPGRADE_ASSISTANT_TYPE,
UPGRADE_ASSISTANT_DOC_ID,
- `ui_open.overview`
+ [`ui_open.overview`]
);
expect(internalRepo.incrementCounter).toHaveBeenCalledWith(
UPGRADE_ASSISTANT_TYPE,
UPGRADE_ASSISTANT_DOC_ID,
- `ui_open.cluster`
+ [`ui_open.cluster`]
);
expect(internalRepo.incrementCounter).toHaveBeenCalledWith(
UPGRADE_ASSISTANT_TYPE,
UPGRADE_ASSISTANT_DOC_ID,
- `ui_open.indices`
+ [`ui_open.indices`]
);
});
});
diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.ts
index 64e9b0f217555..45cae937fb466 100644
--- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.ts
+++ b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.ts
@@ -23,11 +23,9 @@ async function incrementUIOpenOptionCounter({
}: IncrementUIOpenDependencies) {
const internalRepository = savedObjects.createInternalRepository();
- await internalRepository.incrementCounter(
- UPGRADE_ASSISTANT_TYPE,
- UPGRADE_ASSISTANT_DOC_ID,
- `ui_open.${uiOpenOptionCounter}`
- );
+ await internalRepository.incrementCounter(UPGRADE_ASSISTANT_TYPE, UPGRADE_ASSISTANT_DOC_ID, [
+ `ui_open.${uiOpenOptionCounter}`,
+ ]);
}
type UpsertUIOpenOptionDependencies = UIOpen & { savedObjects: SavedObjectsServiceStart };
diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.test.ts
index 31e4e3f07b5de..c157d8860de12 100644
--- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.test.ts
+++ b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.test.ts
@@ -28,22 +28,22 @@ describe('Upgrade Assistant Telemetry SavedObject UIReindex', () => {
expect(internalRepo.incrementCounter).toHaveBeenCalledWith(
UPGRADE_ASSISTANT_TYPE,
UPGRADE_ASSISTANT_DOC_ID,
- `ui_reindex.close`
+ [`ui_reindex.close`]
);
expect(internalRepo.incrementCounter).toHaveBeenCalledWith(
UPGRADE_ASSISTANT_TYPE,
UPGRADE_ASSISTANT_DOC_ID,
- `ui_reindex.open`
+ [`ui_reindex.open`]
);
expect(internalRepo.incrementCounter).toHaveBeenCalledWith(
UPGRADE_ASSISTANT_TYPE,
UPGRADE_ASSISTANT_DOC_ID,
- `ui_reindex.start`
+ [`ui_reindex.start`]
);
expect(internalRepo.incrementCounter).toHaveBeenCalledWith(
UPGRADE_ASSISTANT_TYPE,
UPGRADE_ASSISTANT_DOC_ID,
- `ui_reindex.stop`
+ [`ui_reindex.stop`]
);
});
});
diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.ts
index 0aaaf63196d67..4c57b586a46cd 100644
--- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.ts
+++ b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.ts
@@ -23,11 +23,9 @@ async function incrementUIReindexOptionCounter({
}: IncrementUIReindexOptionDependencies) {
const internalRepository = savedObjects.createInternalRepository();
- await internalRepository.incrementCounter(
- UPGRADE_ASSISTANT_TYPE,
- UPGRADE_ASSISTANT_DOC_ID,
- `ui_reindex.${uiReindexOptionCounter}`
- );
+ await internalRepository.incrementCounter(UPGRADE_ASSISTANT_TYPE, UPGRADE_ASSISTANT_DOC_ID, [
+ `ui_reindex.${uiReindexOptionCounter}`,
+ ]);
}
type UpsertUIReindexOptionDepencies = UIReindex & { savedObjects: SavedObjectsServiceStart };