diff --git a/docs/api/spaces-management/copy_saved_objects.asciidoc b/docs/api/spaces-management/copy_saved_objects.asciidoc index 853cca035a291..1dd9cc9734a52 100644 --- a/docs/api/spaces-management/copy_saved_objects.asciidoc +++ b/docs/api/spaces-management/copy_saved_objects.asciidoc @@ -51,9 +51,17 @@ You can request to overwrite any objects that already exist in the target space (Optional, boolean) When set to `true`, all saved objects related to the specified saved objects will also be copied into the target spaces. The default value is `false`. +`createNewCopies`:: + (Optional, boolean) Creates new copies of saved objects, regenerates each object ID, and resets the origin. When used, potential conflict + errors are avoided. The default value is `true`. ++ +NOTE: This cannot be used with the `overwrite` option. + `overwrite`:: (Optional, boolean) When set to `true`, all conflicts are automatically overidden. When a saved object with a matching `type` and `id` exists in the target space, that version is replaced with the version from the source space. The default value is `false`. ++ +NOTE: This cannot be used with the `createNewCopies` option. [role="child_attributes"] [[spaces-api-copy-saved-objects-response-body]] @@ -128,8 +136,7 @@ $ curl -X POST api/spaces/_copy_saved_objects "id": "my-dashboard" }], "spaces": ["marketing"], - "includeReferences": true, - "createNewcopies": true + "includeReferences": true } ---- // KIBANA @@ -193,7 +200,8 @@ $ curl -X POST api/spaces/_copy_saved_objects "id": "my-dashboard" }], "spaces": ["marketing"], - "includeReferences": true + "includeReferences": true, + "createNewCopies": false } ---- // KIBANA @@ -254,7 +262,8 @@ $ curl -X POST api/spaces/_copy_saved_objects "id": "my-dashboard" }], "spaces": ["marketing", "sales"], - "includeReferences": true + "includeReferences": true, + "createNewCopies": false } ---- // KIBANA @@ -405,7 +414,8 @@ $ curl -X POST api/spaces/_copy_saved_objects "id": "my-dashboard" }], "spaces": ["marketing"], - "includeReferences": true + "includeReferences": true, + "createNewCopies": false } ---- // KIBANA diff --git a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc index 6d799ebb0014e..1a0017fe167ab 100644 --- a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc +++ b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc @@ -45,6 +45,10 @@ Execute the <>, w `includeReferences`:: (Optional, boolean) When set to `true`, all saved objects related to the specified saved objects are copied into the target spaces. The `includeReferences` must be the same values used during the failed <> operation. The default value is `false`. +`createNewCopies`:: + (Optional, boolean) Creates new copies of the saved objects, regenerates each object ID, and resets the origin. When enabled during the + initial copy, also enable when resolving copy errors. The default value is `true`. + `retries`:: (Required, object) The retry operations to attempt, which can specify how to resolve different types of errors. Object keys represent the target space IDs. @@ -148,6 +152,7 @@ $ curl -X POST api/spaces/_resolve_copy_saved_objects_errors "id": "my-dashboard" }], "includeReferences": true, + "createNewCopies": false, "retries": { "sales": [ { @@ -246,6 +251,7 @@ $ curl -X POST api/spaces/_resolve_copy_saved_objects_errors "id": "my-dashboard" }], "includeReferences": true, + "createNewCopies": false, "retries": { "marketing": [ { 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 dc62cacf6741b..f4e35d532f235 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 @@ -9,7 +9,7 @@ Increments all the specified counter fields by one. Creates the document if one Signature: ```typescript -incrementCounter(type: string, id: string, counterFieldNames: string[], options?: SavedObjectsIncrementCounterOptions): Promise; +incrementCounter(type: string, id: string, counterFieldNames: string[], options?: SavedObjectsIncrementCounterOptions): Promise>; ``` ## Parameters @@ -23,7 +23,7 @@ incrementCounter(type: string, id: string, counterFieldNames: string[], options? Returns: -`Promise` +`Promise>` The saved object after the specified fields were incremented diff --git a/src/core/server/core_usage_data/constants.ts b/src/core/server/core_usage_data/constants.ts new file mode 100644 index 0000000000000..0bae7a8cad9d2 --- /dev/null +++ b/src/core/server/core_usage_data/constants.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** @internal */ +export const CORE_USAGE_STATS_TYPE = 'core-usage-stats'; + +/** @internal */ +export const CORE_USAGE_STATS_ID = 'core-usage-stats'; diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts index b1c731e8ba534..9501386318cad 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.mock.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts @@ -20,7 +20,16 @@ import { PublicMethodsOf } from '@kbn/utility-types'; import { BehaviorSubject } from 'rxjs'; import { CoreUsageDataService } from './core_usage_data_service'; -import { CoreUsageData, CoreUsageDataStart } from './types'; +import { coreUsageStatsClientMock } from './core_usage_stats_client.mock'; +import { CoreUsageData, CoreUsageDataSetup, CoreUsageDataStart } from './types'; + +const createSetupContractMock = (usageStatsClient = coreUsageStatsClientMock.create()) => { + const setupContract: jest.Mocked = { + registerType: jest.fn(), + getClient: jest.fn().mockReturnValue(usageStatsClient), + }; + return setupContract; +}; const createStartContractMock = () => { const startContract: jest.Mocked = { @@ -140,7 +149,7 @@ const createStartContractMock = () => { const createMock = () => { const mocked: jest.Mocked> = { - setup: jest.fn(), + setup: jest.fn().mockReturnValue(createSetupContractMock()), start: jest.fn().mockReturnValue(createStartContractMock()), stop: jest.fn(), }; @@ -149,5 +158,6 @@ const createMock = () => { export const coreUsageDataServiceMock = { create: createMock, + createSetupContract: createSetupContractMock, createStartContract: createStartContractMock, }; diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts index da3f771f8f216..d6528ae420dbe 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.test.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts @@ -34,6 +34,9 @@ import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service. import { CoreUsageDataService } from './core_usage_data_service'; import { elasticsearchServiceMock } from '../elasticsearch/elasticsearch_service.mock'; +import { typeRegistryMock } from '../saved_objects/saved_objects_type_registry.mock'; +import { CORE_USAGE_STATS_TYPE } from './constants'; +import { CoreUsageStatsClient } from './core_usage_stats_client'; describe('CoreUsageDataService', () => { const getTestScheduler = () => @@ -63,11 +66,67 @@ describe('CoreUsageDataService', () => { service = new CoreUsageDataService(coreContext); }); + describe('setup', () => { + it('creates internal repository', async () => { + const metrics = metricsServiceMock.createInternalSetupContract(); + const savedObjectsStartPromise = Promise.resolve( + savedObjectsServiceMock.createStartContract() + ); + service.setup({ metrics, savedObjectsStartPromise }); + + const savedObjects = await savedObjectsStartPromise; + expect(savedObjects.createInternalRepository).toHaveBeenCalledTimes(1); + expect(savedObjects.createInternalRepository).toHaveBeenCalledWith([CORE_USAGE_STATS_TYPE]); + }); + + describe('#registerType', () => { + it('registers core usage stats type', async () => { + const metrics = metricsServiceMock.createInternalSetupContract(); + const savedObjectsStartPromise = Promise.resolve( + savedObjectsServiceMock.createStartContract() + ); + const coreUsageData = service.setup({ + metrics, + savedObjectsStartPromise, + }); + const typeRegistry = typeRegistryMock.create(); + + coreUsageData.registerType(typeRegistry); + expect(typeRegistry.registerType).toHaveBeenCalledTimes(1); + expect(typeRegistry.registerType).toHaveBeenCalledWith({ + name: CORE_USAGE_STATS_TYPE, + hidden: true, + namespaceType: 'agnostic', + mappings: expect.anything(), + }); + }); + }); + + describe('#getClient', () => { + it('returns client', async () => { + const metrics = metricsServiceMock.createInternalSetupContract(); + const savedObjectsStartPromise = Promise.resolve( + savedObjectsServiceMock.createStartContract() + ); + const coreUsageData = service.setup({ + metrics, + savedObjectsStartPromise, + }); + + const usageStatsClient = coreUsageData.getClient(); + expect(usageStatsClient).toBeInstanceOf(CoreUsageStatsClient); + }); + }); + }); + describe('start', () => { describe('getCoreUsageData', () => { - it('returns core metrics for default config', () => { + it('returns core metrics for default config', async () => { const metrics = metricsServiceMock.createInternalSetupContract(); - service.setup({ metrics }); + const savedObjectsStartPromise = Promise.resolve( + savedObjectsServiceMock.createStartContract() + ); + service.setup({ metrics, savedObjectsStartPromise }); const elasticsearch = elasticsearchServiceMock.createStart(); elasticsearch.client.asInternalUser.cat.indices.mockResolvedValueOnce({ body: [ @@ -243,8 +302,11 @@ describe('CoreUsageDataService', () => { observables.push(newObservable); return newObservable as Observable; }); + const savedObjectsStartPromise = Promise.resolve( + savedObjectsServiceMock.createStartContract() + ); - service.setup({ metrics }); + service.setup({ metrics, savedObjectsStartPromise }); // Use the stopTimer$ to delay calling stop() until the third frame const stopTimer$ = cold('---a|'); diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index 490c411ecb852..02b4f2ac59133 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -21,20 +21,29 @@ import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { CoreService } from 'src/core/types'; -import { SavedObjectsServiceStart } from 'src/core/server'; +import { Logger, SavedObjectsServiceStart, SavedObjectTypeRegistry } from 'src/core/server'; import { CoreContext } from '../core_context'; import { ElasticsearchConfigType } from '../elasticsearch/elasticsearch_config'; import { HttpConfigType } from '../http'; import { LoggingConfigType } from '../logging'; import { SavedObjectsConfigType } from '../saved_objects/saved_objects_config'; -import { CoreServicesUsageData, CoreUsageData, CoreUsageDataStart } from './types'; +import { + CoreServicesUsageData, + CoreUsageData, + CoreUsageDataStart, + CoreUsageDataSetup, +} from './types'; import { isConfigured } from './is_configured'; import { ElasticsearchServiceStart } from '../elasticsearch'; import { KibanaConfigType } from '../kibana_config'; +import { coreUsageStatsType } from './core_usage_stats'; +import { CORE_USAGE_STATS_TYPE } from './constants'; +import { CoreUsageStatsClient } from './core_usage_stats_client'; import { MetricsServiceSetup, OpsMetrics } from '..'; export interface SetupDeps { metrics: MetricsServiceSetup; + savedObjectsStartPromise: Promise; } export interface StartDeps { @@ -60,7 +69,8 @@ const kibanaOrTaskManagerIndex = (index: string, kibanaConfigIndex: string) => { return index === kibanaConfigIndex ? '.kibana' : '.kibana_task_manager'; }; -export class CoreUsageDataService implements CoreService { +export class CoreUsageDataService implements CoreService { + private logger: Logger; private elasticsearchConfig?: ElasticsearchConfigType; private configService: CoreContext['configService']; private httpConfig?: HttpConfigType; @@ -69,8 +79,10 @@ export class CoreUsageDataService implements CoreService; private opsMetrics?: OpsMetrics; private kibanaConfig?: KibanaConfigType; + private coreUsageStatsClient?: CoreUsageStatsClient; constructor(core: CoreContext) { + this.logger = core.logger.get('core-usage-stats-service'); this.configService = core.configService; this.stop$ = new Subject(); } @@ -130,8 +142,15 @@ export class CoreUsageDataService implements CoreService { this.kibanaConfig = config; }); + + const internalRepositoryPromise = savedObjectsStartPromise.then((savedObjects) => + savedObjects.createInternalRepository([CORE_USAGE_STATS_TYPE]) + ); + + const registerType = (typeRegistry: SavedObjectTypeRegistry) => { + typeRegistry.registerType(coreUsageStatsType); + }; + + const getClient = () => { + const debugLogger = (message: string) => this.logger.debug(message); + + return new CoreUsageStatsClient(debugLogger, internalRepositoryPromise); + }; + + this.coreUsageStatsClient = getClient(); + + return { registerType, getClient } as CoreUsageDataSetup; } start({ savedObjects, elasticsearch }: StartDeps) { diff --git a/src/core/server/core_usage_data/core_usage_stats.ts b/src/core/server/core_usage_data/core_usage_stats.ts new file mode 100644 index 0000000000000..382a544a58960 --- /dev/null +++ b/src/core/server/core_usage_data/core_usage_stats.ts @@ -0,0 +1,32 @@ +/* + * 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 { SavedObjectsType } from '../saved_objects'; +import { CORE_USAGE_STATS_TYPE } from './constants'; + +/** @internal */ +export const coreUsageStatsType: SavedObjectsType = { + name: CORE_USAGE_STATS_TYPE, + hidden: true, + namespaceType: 'agnostic', + mappings: { + dynamic: false, // we aren't querying or aggregating over this data, so we don't need to specify any fields + properties: {}, + }, +}; diff --git a/src/core/server/core_usage_data/core_usage_stats_client.mock.ts b/src/core/server/core_usage_data/core_usage_stats_client.mock.ts new file mode 100644 index 0000000000000..3bfb411c9dd49 --- /dev/null +++ b/src/core/server/core_usage_data/core_usage_stats_client.mock.ts @@ -0,0 +1,32 @@ +/* + * 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 { CoreUsageStatsClient } from '.'; + +const createUsageStatsClientMock = () => + (({ + getUsageStats: jest.fn().mockResolvedValue({}), + incrementSavedObjectsImport: jest.fn().mockResolvedValue(null), + incrementSavedObjectsResolveImportErrors: jest.fn().mockResolvedValue(null), + incrementSavedObjectsExport: jest.fn().mockResolvedValue(null), + } as unknown) as jest.Mocked); + +export const coreUsageStatsClientMock = { + create: createUsageStatsClientMock, +}; diff --git a/src/core/server/core_usage_data/core_usage_stats_client.test.ts b/src/core/server/core_usage_data/core_usage_stats_client.test.ts new file mode 100644 index 0000000000000..e4f47667fce6b --- /dev/null +++ b/src/core/server/core_usage_data/core_usage_stats_client.test.ts @@ -0,0 +1,227 @@ +/* + * 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 { savedObjectsRepositoryMock } from '../mocks'; +import { CORE_USAGE_STATS_TYPE, CORE_USAGE_STATS_ID } from './constants'; +import { + IncrementSavedObjectsImportOptions, + IncrementSavedObjectsResolveImportErrorsOptions, + IncrementSavedObjectsExportOptions, + IMPORT_STATS_PREFIX, + RESOLVE_IMPORT_STATS_PREFIX, + EXPORT_STATS_PREFIX, +} from './core_usage_stats_client'; +import { CoreUsageStatsClient } from '.'; + +describe('CoreUsageStatsClient', () => { + const setup = () => { + const debugLoggerMock = jest.fn(); + const repositoryMock = savedObjectsRepositoryMock.create(); + const usageStatsClient = new CoreUsageStatsClient( + debugLoggerMock, + Promise.resolve(repositoryMock) + ); + return { usageStatsClient, debugLoggerMock, repositoryMock }; + }; + + const firstPartyRequestHeaders = { 'kbn-version': 'a', origin: 'b', referer: 'c' }; // as long as these three header fields are truthy, this will be treated like a first-party request + const incrementOptions = { refresh: false }; + + describe('#getUsageStats', () => { + it('returns empty object when encountering a repository error', async () => { + const { usageStatsClient, repositoryMock } = setup(); + repositoryMock.get.mockRejectedValue(new Error('Oh no!')); + + const result = await usageStatsClient.getUsageStats(); + expect(result).toEqual({}); + }); + + it('returns object attributes when usage stats exist', async () => { + const { usageStatsClient, repositoryMock } = setup(); + const usageStats = { foo: 'bar' }; + repositoryMock.incrementCounter.mockResolvedValue({ + type: CORE_USAGE_STATS_TYPE, + id: CORE_USAGE_STATS_ID, + attributes: usageStats, + references: [], + }); + + const result = await usageStatsClient.getUsageStats(); + expect(result).toEqual(usageStats); + }); + }); + + describe('#incrementSavedObjectsImport', () => { + it('does not throw an error if repository incrementCounter operation fails', async () => { + const { usageStatsClient, repositoryMock } = setup(); + repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!')); + + await expect( + usageStatsClient.incrementSavedObjectsImport({} as IncrementSavedObjectsImportOptions) + ).resolves.toBeUndefined(); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + }); + + it('handles falsy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + await usageStatsClient.incrementSavedObjectsImport({} as IncrementSavedObjectsImportOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${IMPORT_STATS_PREFIX}.total`, + `${IMPORT_STATS_PREFIX}.kibanaRequest.no`, + `${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`, + `${IMPORT_STATS_PREFIX}.overwriteEnabled.no`, + ], + incrementOptions + ); + }); + + it('handles truthy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + await usageStatsClient.incrementSavedObjectsImport({ + headers: firstPartyRequestHeaders, + createNewCopies: true, + overwrite: true, + } as IncrementSavedObjectsImportOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${IMPORT_STATS_PREFIX}.total`, + `${IMPORT_STATS_PREFIX}.kibanaRequest.yes`, + `${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.yes`, + `${IMPORT_STATS_PREFIX}.overwriteEnabled.yes`, + ], + incrementOptions + ); + }); + }); + + describe('#incrementSavedObjectsResolveImportErrors', () => { + it('does not throw an error if repository incrementCounter operation fails', async () => { + const { usageStatsClient, repositoryMock } = setup(); + repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!')); + + await expect( + usageStatsClient.incrementSavedObjectsResolveImportErrors( + {} as IncrementSavedObjectsResolveImportErrorsOptions + ) + ).resolves.toBeUndefined(); + expect(repositoryMock.incrementCounter).toHaveBeenCalled(); + }); + + it('handles falsy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + await usageStatsClient.incrementSavedObjectsResolveImportErrors( + {} as IncrementSavedObjectsResolveImportErrorsOptions + ); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${RESOLVE_IMPORT_STATS_PREFIX}.total`, + `${RESOLVE_IMPORT_STATS_PREFIX}.kibanaRequest.no`, + `${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`, + ], + incrementOptions + ); + }); + + it('handles truthy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + await usageStatsClient.incrementSavedObjectsResolveImportErrors({ + headers: firstPartyRequestHeaders, + createNewCopies: true, + } as IncrementSavedObjectsResolveImportErrorsOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${RESOLVE_IMPORT_STATS_PREFIX}.total`, + `${RESOLVE_IMPORT_STATS_PREFIX}.kibanaRequest.yes`, + `${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.yes`, + ], + incrementOptions + ); + }); + }); + + describe('#incrementSavedObjectsExport', () => { + it('does not throw an error if repository incrementCounter operation fails', async () => { + const { usageStatsClient, repositoryMock } = setup(); + repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!')); + + await expect( + usageStatsClient.incrementSavedObjectsExport({} as IncrementSavedObjectsExportOptions) + ).resolves.toBeUndefined(); + expect(repositoryMock.incrementCounter).toHaveBeenCalled(); + }); + + it('handles falsy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + await usageStatsClient.incrementSavedObjectsExport({ + types: undefined, + supportedTypes: ['foo', 'bar'], + } as IncrementSavedObjectsExportOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${EXPORT_STATS_PREFIX}.total`, + `${EXPORT_STATS_PREFIX}.kibanaRequest.no`, + `${EXPORT_STATS_PREFIX}.allTypesSelected.no`, + ], + incrementOptions + ); + }); + + it('handles truthy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + await usageStatsClient.incrementSavedObjectsExport({ + headers: firstPartyRequestHeaders, + types: ['foo', 'bar'], + supportedTypes: ['foo', 'bar'], + } as IncrementSavedObjectsExportOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${EXPORT_STATS_PREFIX}.total`, + `${EXPORT_STATS_PREFIX}.kibanaRequest.yes`, + `${EXPORT_STATS_PREFIX}.allTypesSelected.yes`, + ], + incrementOptions + ); + }); + }); +}); diff --git a/src/core/server/core_usage_data/core_usage_stats_client.ts b/src/core/server/core_usage_data/core_usage_stats_client.ts new file mode 100644 index 0000000000000..58356832d8b8a --- /dev/null +++ b/src/core/server/core_usage_data/core_usage_stats_client.ts @@ -0,0 +1,154 @@ +/* + * 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 { CORE_USAGE_STATS_TYPE, CORE_USAGE_STATS_ID } from './constants'; +import { CoreUsageStats } from './types'; +import { + Headers, + ISavedObjectsRepository, + SavedObjectsImportOptions, + SavedObjectsResolveImportErrorsOptions, + SavedObjectsExportOptions, +} from '..'; + +interface BaseIncrementOptions { + headers?: Headers; +} +/** @internal */ +export type IncrementSavedObjectsImportOptions = BaseIncrementOptions & + Pick; +/** @internal */ +export type IncrementSavedObjectsResolveImportErrorsOptions = BaseIncrementOptions & + Pick; +/** @internal */ +export type IncrementSavedObjectsExportOptions = BaseIncrementOptions & + Pick & { supportedTypes: string[] }; + +export const IMPORT_STATS_PREFIX = 'apiCalls.savedObjectsImport'; +export const RESOLVE_IMPORT_STATS_PREFIX = 'apiCalls.savedObjectsResolveImportErrors'; +export const EXPORT_STATS_PREFIX = 'apiCalls.savedObjectsExport'; +const ALL_COUNTER_FIELDS = [ + `${IMPORT_STATS_PREFIX}.total`, + `${IMPORT_STATS_PREFIX}.kibanaRequest.yes`, + `${IMPORT_STATS_PREFIX}.kibanaRequest.no`, + `${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.yes`, + `${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`, + `${IMPORT_STATS_PREFIX}.overwriteEnabled.yes`, + `${IMPORT_STATS_PREFIX}.overwriteEnabled.no`, + `${RESOLVE_IMPORT_STATS_PREFIX}.total`, + `${RESOLVE_IMPORT_STATS_PREFIX}.kibanaRequest.yes`, + `${RESOLVE_IMPORT_STATS_PREFIX}.kibanaRequest.no`, + `${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.yes`, + `${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`, + `${EXPORT_STATS_PREFIX}.total`, + `${EXPORT_STATS_PREFIX}.kibanaRequest.yes`, + `${EXPORT_STATS_PREFIX}.kibanaRequest.no`, + `${EXPORT_STATS_PREFIX}.allTypesSelected.yes`, + `${EXPORT_STATS_PREFIX}.allTypesSelected.no`, +]; + +/** @internal */ +export class CoreUsageStatsClient { + constructor( + private readonly debugLogger: (message: string) => void, + private readonly repositoryPromise: Promise + ) {} + + public async getUsageStats() { + this.debugLogger('getUsageStats() called'); + let coreUsageStats: CoreUsageStats = {}; + try { + const repository = await this.repositoryPromise; + const result = await repository.incrementCounter( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + ALL_COUNTER_FIELDS, + { initialize: true } // set all counter fields to 0 if they don't exist + ); + coreUsageStats = result.attributes; + } catch (err) { + // do nothing + } + return coreUsageStats; + } + + public async incrementSavedObjectsImport({ + headers, + createNewCopies, + overwrite, + }: IncrementSavedObjectsImportOptions) { + const isKibanaRequest = getIsKibanaRequest(headers); + const counterFieldNames = [ + 'total', + `kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`, + `createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`, + `overwriteEnabled.${overwrite ? 'yes' : 'no'}`, + ]; + await this.updateUsageStats(counterFieldNames, IMPORT_STATS_PREFIX); + } + + public async incrementSavedObjectsResolveImportErrors({ + headers, + createNewCopies, + }: IncrementSavedObjectsResolveImportErrorsOptions) { + const isKibanaRequest = getIsKibanaRequest(headers); + const counterFieldNames = [ + 'total', + `kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`, + `createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`, + ]; + await this.updateUsageStats(counterFieldNames, RESOLVE_IMPORT_STATS_PREFIX); + } + + public async incrementSavedObjectsExport({ + headers, + types, + supportedTypes, + }: IncrementSavedObjectsExportOptions) { + const isKibanaRequest = getIsKibanaRequest(headers); + const isAllTypesSelected = !!types && supportedTypes.every((x) => types.includes(x)); + const counterFieldNames = [ + 'total', + `kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`, + `allTypesSelected.${isAllTypesSelected ? 'yes' : 'no'}`, + ]; + await this.updateUsageStats(counterFieldNames, EXPORT_STATS_PREFIX); + } + + private async updateUsageStats(counterFieldNames: string[], prefix: string) { + const options = { refresh: false }; + try { + const repository = await this.repositoryPromise; + await repository.incrementCounter( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + counterFieldNames.map((x) => `${prefix}.${x}`), + options + ); + } catch (err) { + // do nothing + } + } +} + +function getIsKibanaRequest(headers?: Headers) { + // The presence of these three request headers gives us a good indication that this is a first-party request from the Kibana client. + // We can't be 100% certain, but this is a reasonable attempt. + return headers && headers['kbn-version'] && headers.origin && headers.referer; +} diff --git a/src/core/server/core_usage_data/index.ts b/src/core/server/core_usage_data/index.ts index b78c126657ef6..95d88f165a976 100644 --- a/src/core/server/core_usage_data/index.ts +++ b/src/core/server/core_usage_data/index.ts @@ -16,16 +16,24 @@ * specific language governing permissions and limitations * under the License. */ -export { CoreUsageDataStart } from './types'; +export { CoreUsageDataSetup, CoreUsageDataStart } from './types'; export { CoreUsageDataService } from './core_usage_data_service'; +export { CoreUsageStatsClient } from './core_usage_stats_client'; // Because of #79265 we need to explicity import, then export these types for // scripts/telemetry_check.js to work as expected import { + CoreUsageStats, CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData, } from './types'; -export { CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData }; +export { + CoreUsageStats, + CoreUsageData, + CoreConfigUsageData, + CoreEnvironmentUsageData, + CoreServicesUsageData, +}; diff --git a/src/core/server/core_usage_data/types.ts b/src/core/server/core_usage_data/types.ts index 258f452cfa6ae..aa41d75e6f2d4 100644 --- a/src/core/server/core_usage_data/types.ts +++ b/src/core/server/core_usage_data/types.ts @@ -17,11 +17,40 @@ * under the License. */ +import { CoreUsageStatsClient } from './core_usage_stats_client'; +import { ISavedObjectTypeRegistry, SavedObjectTypeRegistry } from '..'; + +/** + * @internal + * + * CoreUsageStats are collected over time while Kibana is running. This is related to CoreUsageData, which is a superset of this that also + * includes point-in-time configuration information. + * */ +export interface CoreUsageStats { + 'apiCalls.savedObjectsImport.total'?: number; + 'apiCalls.savedObjectsImport.kibanaRequest.yes'?: number; + 'apiCalls.savedObjectsImport.kibanaRequest.no'?: number; + 'apiCalls.savedObjectsImport.createNewCopiesEnabled.yes'?: number; + 'apiCalls.savedObjectsImport.createNewCopiesEnabled.no'?: number; + 'apiCalls.savedObjectsImport.overwriteEnabled.yes'?: number; + 'apiCalls.savedObjectsImport.overwriteEnabled.no'?: number; + 'apiCalls.savedObjectsResolveImportErrors.total'?: number; + 'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.yes'?: number; + 'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.no'?: number; + 'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.yes'?: number; + 'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.no'?: number; + 'apiCalls.savedObjectsExport.total'?: number; + 'apiCalls.savedObjectsExport.kibanaRequest.yes'?: number; + 'apiCalls.savedObjectsExport.kibanaRequest.no'?: number; + 'apiCalls.savedObjectsExport.allTypesSelected.yes'?: number; + 'apiCalls.savedObjectsExport.allTypesSelected.no'?: number; +} + /** * Type describing Core's usage data payload * @internal */ -export interface CoreUsageData { +export interface CoreUsageData extends CoreUsageStats { config: CoreConfigUsageData; services: CoreServicesUsageData; environment: CoreEnvironmentUsageData; @@ -141,6 +170,14 @@ export interface CoreConfigUsageData { // }; } +/** @internal */ +export interface CoreUsageDataSetup { + registerType( + typeRegistry: ISavedObjectTypeRegistry & Pick + ): void; + getClient(): CoreUsageStatsClient; +} + /** * Internal API for getting Core's usage data payload. * diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 9e654ea1e2303..7ce5c29a7e18b 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -69,13 +69,20 @@ import { I18nServiceSetup } from './i18n'; // Because of #79265 we need to explicity import, then export these types for // scripts/telemetry_check.js to work as expected import { + CoreUsageStats, CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData, } from './core_usage_data'; -export { CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData }; +export { + CoreUsageStats, + CoreUsageData, + CoreConfigUsageData, + CoreEnvironmentUsageData, + CoreServicesUsageData, +}; export { bootstrap } from './bootstrap'; export { Capabilities, CapabilitiesProvider, CapabilitiesSwitcher } from './capabilities'; diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 05a91f4aa4c2c..387280d777eaa 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -22,11 +22,20 @@ import stringify from 'json-stable-stringify'; import { createPromiseFromStreams, createMapStream, createConcatStream } from '@kbn/utils'; import { IRouter } from '../../http'; +import { CoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; import { exportSavedObjectsToStream } from '../export'; import { validateTypes, validateObjects } from './utils'; -export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) => { +interface RouteDependencies { + config: SavedObjectConfig; + coreUsageData: CoreUsageDataSetup; +} + +export const registerExportRoute = ( + router: IRouter, + { config, coreUsageData }: RouteDependencies +) => { const { maxImportExportSize } = config; const referenceSchema = schema.object({ @@ -95,6 +104,12 @@ export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) } } + const { headers } = req; + const usageStatsClient = coreUsageData.getClient(); + usageStatsClient + .incrementSavedObjectsExport({ headers, types, supportedTypes }) + .catch(() => {}); + const exportStream = await exportSavedObjectsToStream({ savedObjectsClient, types, diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 291da5a5f0183..27be710c0a92a 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -21,17 +21,26 @@ import { Readable } from 'stream'; import { extname } from 'path'; import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; +import { CoreUsageDataSetup } from '../../core_usage_data'; import { importSavedObjectsFromStream } from '../import'; import { SavedObjectConfig } from '../saved_objects_config'; import { createSavedObjectsStreamFromNdJson } from './utils'; +interface RouteDependencies { + config: SavedObjectConfig; + coreUsageData: CoreUsageDataSetup; +} + interface FileStream extends Readable { hapi: { filename: string; }; } -export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) => { +export const registerImportRoute = ( + router: IRouter, + { config, coreUsageData }: RouteDependencies +) => { const { maxImportExportSize, maxImportPayloadBytes } = config; router.post( @@ -65,6 +74,13 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) }, router.handleLegacyErrors(async (context, req, res) => { const { overwrite, createNewCopies } = req.query; + + const { headers } = req; + const usageStatsClient = coreUsageData.getClient(); + usageStatsClient + .incrementSavedObjectsImport({ headers, createNewCopies, overwrite }) + .catch(() => {}); + const file = req.body.file as FileStream; const fileExtension = extname(file.hapi.filename).toLowerCase(); if (fileExtension !== '.ndjson') { diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index fd57a9f3059e3..19154b8583654 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -18,6 +18,7 @@ */ import { InternalHttpServiceSetup } from '../../http'; +import { CoreUsageDataSetup } from '../../core_usage_data'; import { Logger } from '../../logging'; import { SavedObjectConfig } from '../saved_objects_config'; import { IKibanaMigrator } from '../migrations'; @@ -37,11 +38,13 @@ import { registerMigrateRoute } from './migrate'; export function registerRoutes({ http, + coreUsageData, logger, config, migratorPromise, }: { http: InternalHttpServiceSetup; + coreUsageData: CoreUsageDataSetup; logger: Logger; config: SavedObjectConfig; migratorPromise: Promise; @@ -57,9 +60,9 @@ export function registerRoutes({ registerBulkCreateRoute(router); registerBulkUpdateRoute(router); registerLogLegacyImportRoute(router, logger); - registerExportRoute(router, config); - registerImportRoute(router, config); - registerResolveImportErrorsRoute(router, config); + registerExportRoute(router, { config, coreUsageData }); + registerImportRoute(router, { config, coreUsageData }); + registerResolveImportErrorsRoute(router, { config, coreUsageData }); const internalRouter = http.createRouter('/internal/saved_objects/'); diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index 07bf320c29496..c37ed2da97681 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -25,6 +25,9 @@ import * as exportMock from '../../export'; import supertest from 'supertest'; import type { UnwrapPromise } from '@kbn/utility-types'; import { createListStream } from '@kbn/utils'; +import { CoreUsageStatsClient } from '../../../core_usage_data'; +import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; +import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { SavedObjectConfig } from '../../saved_objects_config'; import { registerExportRoute } from '../export'; import { setupServer, createExportableType } from '../test_utils'; @@ -36,6 +39,7 @@ const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000, } as SavedObjectConfig; +let coreUsageStatsClient: jest.Mocked; describe('POST /api/saved_objects/_export', () => { let server: SetupServerReturn['server']; @@ -49,7 +53,10 @@ describe('POST /api/saved_objects/_export', () => { ); const router = httpSetup.createRouter('/api/saved_objects/'); - registerExportRoute(router, config); + coreUsageStatsClient = coreUsageStatsClientMock.create(); + coreUsageStatsClient.incrementSavedObjectsExport.mockRejectedValue(new Error('Oh no!')); // this error is intentionally swallowed so the export does not fail + const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); + registerExportRoute(router, { config, coreUsageData }); await server.start(); }); @@ -59,7 +66,7 @@ describe('POST /api/saved_objects/_export', () => { await server.stop(); }); - it('formats successful response', async () => { + it('formats successful response and records usage stats', async () => { const sortedObjects = [ { id: '1', @@ -110,5 +117,10 @@ describe('POST /api/saved_objects/_export', () => { types: ['search'], }) ); + expect(coreUsageStatsClient.incrementSavedObjectsExport).toHaveBeenCalledWith({ + headers: expect.anything(), + types: ['search'], + supportedTypes: ['index-pattern', 'search'], + }); }); }); diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 34cd449f31963..9dfb7f79a925d 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -22,6 +22,9 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerImportRoute } from '../import'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { CoreUsageStatsClient } from '../../../core_usage_data'; +import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; +import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { SavedObjectConfig } from '../../saved_objects_config'; import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectsErrorHelpers } from '../..'; @@ -31,6 +34,7 @@ type SetupServerReturn = UnwrapPromise>; const { v4: uuidv4 } = jest.requireActual('uuid'); const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig; +let coreUsageStatsClient: jest.Mocked; const URL = '/internal/saved_objects/_import'; describe(`POST ${URL}`, () => { @@ -71,7 +75,10 @@ describe(`POST ${URL}`, () => { savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); const router = httpSetup.createRouter('/internal/saved_objects/'); - registerImportRoute(router, config); + coreUsageStatsClient = coreUsageStatsClientMock.create(); + coreUsageStatsClient.incrementSavedObjectsImport.mockRejectedValue(new Error('Oh no!')); // this error is intentionally swallowed so the import does not fail + const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); + registerImportRoute(router, { config, coreUsageData }); await server.start(); }); @@ -80,7 +87,7 @@ describe(`POST ${URL}`, () => { await server.stop(); }); - it('formats successful response', async () => { + it('formats successful response and records usage stats', async () => { const result = await supertest(httpSetup.server.listener) .post(URL) .set('content-Type', 'multipart/form-data; boundary=BOUNDARY') @@ -98,6 +105,11 @@ describe(`POST ${URL}`, () => { expect(result.body).toEqual({ success: true, successCount: 0 }); expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created + expect(coreUsageStatsClient.incrementSavedObjectsImport).toHaveBeenCalledWith({ + headers: expect.anything(), + createNewCopies: false, + overwrite: false, + }); }); it('defaults migrationVersion to empty object', async () => { diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index 0e8fb0e563dbc..46f4d2435bf67 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -22,6 +22,9 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerResolveImportErrorsRoute } from '../resolve_import_errors'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { CoreUsageStatsClient } from '../../../core_usage_data'; +import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; +import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectConfig } from '../../saved_objects_config'; @@ -30,6 +33,7 @@ type SetupServerReturn = UnwrapPromise>; const { v4: uuidv4 } = jest.requireActual('uuid'); const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig; +let coreUsageStatsClient: jest.Mocked; const URL = '/api/saved_objects/_resolve_import_errors'; describe(`POST ${URL}`, () => { @@ -76,7 +80,12 @@ describe(`POST ${URL}`, () => { savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); const router = httpSetup.createRouter('/api/saved_objects/'); - registerResolveImportErrorsRoute(router, config); + coreUsageStatsClient = coreUsageStatsClientMock.create(); + coreUsageStatsClient.incrementSavedObjectsResolveImportErrors.mockRejectedValue( + new Error('Oh no!') // this error is intentionally swallowed so the export does not fail + ); + const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); + registerResolveImportErrorsRoute(router, { config, coreUsageData }); await server.start(); }); @@ -85,7 +94,7 @@ describe(`POST ${URL}`, () => { await server.stop(); }); - it('formats successful response', async () => { + it('formats successful response and records usage stats', async () => { const result = await supertest(httpSetup.server.listener) .post(URL) .set('content-Type', 'multipart/form-data; boundary=BOUNDARY') @@ -107,6 +116,10 @@ describe(`POST ${URL}`, () => { expect(result.body).toEqual({ success: true, successCount: 0 }); expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created + expect(coreUsageStatsClient.incrementSavedObjectsResolveImportErrors).toHaveBeenCalledWith({ + headers: expect.anything(), + createNewCopies: false, + }); }); it('defaults migrationVersion to empty object', async () => { diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 03b4322b27cbc..34c178a975304 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -21,17 +21,26 @@ import { extname } from 'path'; import { Readable } from 'stream'; import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; +import { CoreUsageDataSetup } from '../../core_usage_data'; import { resolveSavedObjectsImportErrors } from '../import'; import { SavedObjectConfig } from '../saved_objects_config'; import { createSavedObjectsStreamFromNdJson } from './utils'; +interface RouteDependencies { + config: SavedObjectConfig; + coreUsageData: CoreUsageDataSetup; +} + interface FileStream extends Readable { hapi: { filename: string; }; } -export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedObjectConfig) => { +export const registerResolveImportErrorsRoute = ( + router: IRouter, + { config, coreUsageData }: RouteDependencies +) => { const { maxImportExportSize, maxImportPayloadBytes } = config; router.post( @@ -72,6 +81,14 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO }, }, router.handleLegacyErrors(async (context, req, res) => { + const { createNewCopies } = req.query; + + const { headers } = req; + const usageStatsClient = coreUsageData.getClient(); + usageStatsClient + .incrementSavedObjectsResolveImportErrors({ headers, createNewCopies }) + .catch(() => {}); + const file = req.body.file as FileStream; const fileExtension = extname(file.hapi.filename).toLowerCase(); if (fileExtension !== '.ndjson') { @@ -93,7 +110,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO readStream, retries: req.body.retries, objectLimit: maxImportExportSize, - createNewCopies: req.query.createNewCopies, + createNewCopies, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index 8e4c73137033d..c90f564ce33d7 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -33,6 +33,7 @@ import { Env } from '../config'; import { configServiceMock } from '../mocks'; import { elasticsearchServiceMock } from '../elasticsearch/elasticsearch_service.mock'; import { elasticsearchClientMock } from '../elasticsearch/client/mocks'; +import { coreUsageDataServiceMock } from '../core_usage_data/core_usage_data_service.mock'; import { httpServiceMock } from '../http/http_service.mock'; import { httpServerMock } from '../http/http_server.mocks'; import { SavedObjectsClientFactoryProvider } from './service/lib'; @@ -64,6 +65,7 @@ describe('SavedObjectsService', () => { return { http: httpServiceMock.createInternalSetupContract(), elasticsearch: elasticsearchMock, + coreUsageData: coreUsageDataServiceMock.createSetupContract(), }; }; diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 5cc59d55a254e..400d3157bd00d 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -27,6 +27,7 @@ import { } from './'; import { KibanaMigrator, IKibanaMigrator } from './migrations'; import { CoreContext } from '../core_context'; +import { CoreUsageDataSetup } from '../core_usage_data'; import { ElasticsearchClient, IClusterClient, @@ -253,6 +254,7 @@ export interface SavedObjectsRepositoryFactory { export interface SavedObjectsSetupDeps { http: InternalHttpServiceSetup; elasticsearch: InternalElasticsearchServiceSetup; + coreUsageData: CoreUsageDataSetup; } interface WrappedClientFactoryWrapper { @@ -288,6 +290,7 @@ export class SavedObjectsService this.logger.debug('Setting up SavedObjects service'); this.setupDeps = setupDeps; + const { http, elasticsearch, coreUsageData } = setupDeps; const savedObjectsConfig = await this.coreContext.configService .atPath('savedObjects') @@ -299,8 +302,11 @@ export class SavedObjectsService .toPromise(); this.config = new SavedObjectConfig(savedObjectsConfig, savedObjectsMigrationConfig); + coreUsageData.registerType(this.typeRegistry); + registerRoutes({ - http: setupDeps.http, + http, + coreUsageData, logger: this.logger, config: this.config, migratorPromise: this.migrator$.pipe(first()).toPromise(), @@ -309,7 +315,7 @@ export class SavedObjectsService return { status$: calculateStatus$( this.migrator$.pipe(switchMap((migrator) => migrator.getStatus$())), - setupDeps.elasticsearch.status$ + elasticsearch.status$ ), setClientFactoryProvider: (provider) => { if (this.started) { diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 2f09ad71de558..f3f4bdfff0e76 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -1562,12 +1562,12 @@ export class SavedObjectsRepository { * @param options - {@link SavedObjectsIncrementCounterOptions} * @returns The saved object after the specified fields were incremented */ - async incrementCounter( + async incrementCounter( type: string, id: string, counterFieldNames: string[], options: SavedObjectsIncrementCounterOptions = {} - ): Promise { + ): Promise> { if (typeof type !== 'string') { throw new Error('"type" argument must be a string'); } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 59f9c4f9ff38c..be654da5660c2 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -521,7 +521,7 @@ export interface CoreStatus { } // @internal -export interface CoreUsageData { +export interface CoreUsageData extends CoreUsageStats { // (undocumented) config: CoreConfigUsageData; // (undocumented) @@ -535,6 +535,44 @@ export interface CoreUsageDataStart { getCoreUsageData(): Promise; } +// @internal +export interface CoreUsageStats { + // (undocumented) + 'apiCalls.savedObjectsExport.allTypesSelected.no'?: number; + // (undocumented) + 'apiCalls.savedObjectsExport.allTypesSelected.yes'?: number; + // (undocumented) + 'apiCalls.savedObjectsExport.kibanaRequest.no'?: number; + // (undocumented) + 'apiCalls.savedObjectsExport.kibanaRequest.yes'?: number; + // (undocumented) + 'apiCalls.savedObjectsExport.total'?: number; + // (undocumented) + 'apiCalls.savedObjectsImport.createNewCopiesEnabled.no'?: number; + // (undocumented) + 'apiCalls.savedObjectsImport.createNewCopiesEnabled.yes'?: number; + // (undocumented) + 'apiCalls.savedObjectsImport.kibanaRequest.no'?: number; + // (undocumented) + 'apiCalls.savedObjectsImport.kibanaRequest.yes'?: number; + // (undocumented) + 'apiCalls.savedObjectsImport.overwriteEnabled.no'?: number; + // (undocumented) + 'apiCalls.savedObjectsImport.overwriteEnabled.yes'?: number; + // (undocumented) + 'apiCalls.savedObjectsImport.total'?: number; + // (undocumented) + 'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.no'?: number; + // (undocumented) + 'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.yes'?: number; + // (undocumented) + 'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.no'?: number; + // (undocumented) + 'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.yes'?: number; + // (undocumented) + 'apiCalls.savedObjectsResolveImportErrors.total'?: number; +} + // @public (undocumented) export interface CountResponse { // (undocumented) @@ -2448,7 +2486,7 @@ export class SavedObjectsRepository { // (undocumented) find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; - incrementCounter(type: string, id: string, counterFieldNames: 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/core/server/server.test.ts b/src/core/server/server.test.ts index f377bfc321735..7cc6d108b4cf4 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -185,6 +185,7 @@ test(`doesn't setup core services if config validation fails`, async () => { expect(mockElasticsearchService.setup).not.toHaveBeenCalled(); expect(mockPluginsService.setup).not.toHaveBeenCalled(); expect(mockLegacyService.setup).not.toHaveBeenCalled(); + expect(mockSavedObjectsService.stop).not.toHaveBeenCalled(); expect(mockUiSettingsService.setup).not.toHaveBeenCalled(); expect(mockRenderingService.setup).not.toHaveBeenCalled(); expect(mockMetricsService.setup).not.toHaveBeenCalled(); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index e253663d8dc8d..0b3249ad58750 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -31,7 +31,7 @@ import { LegacyService, ensureValidConfiguration } from './legacy'; import { Logger, LoggerFactory, LoggingService, ILoggingSystem } from './logging'; import { UiSettingsService } from './ui_settings'; import { PluginsService, config as pluginsConfig } from './plugins'; -import { SavedObjectsService } from './saved_objects'; +import { SavedObjectsService, SavedObjectsServiceStart } from './saved_objects'; import { MetricsService, opsConfig } from './metrics'; import { CapabilitiesService } from './capabilities'; import { EnvironmentService, config as pidConfig } from './environment'; @@ -78,6 +78,9 @@ export class Server { private readonly coreUsageData: CoreUsageDataService; private readonly i18n: I18nService; + private readonly savedObjectsStartPromise: Promise; + private resolveSavedObjectsStartPromise?: (value: SavedObjectsServiceStart) => void; + #pluginsInitialized?: boolean; private coreStart?: InternalCoreStart; private readonly logger: LoggerFactory; @@ -109,6 +112,10 @@ export class Server { this.logging = new LoggingService(core); this.coreUsageData = new CoreUsageDataService(core); this.i18n = new I18nService(core); + + this.savedObjectsStartPromise = new Promise((resolve) => { + this.resolveSavedObjectsStartPromise = resolve; + }); } public async setup() { @@ -155,9 +162,17 @@ export class Server { http: httpSetup, }); + const metricsSetup = await this.metrics.setup({ http: httpSetup }); + + const coreUsageDataSetup = this.coreUsageData.setup({ + metrics: metricsSetup, + savedObjectsStartPromise: this.savedObjectsStartPromise, + }); + const savedObjectsSetup = await this.savedObjects.setup({ http: httpSetup, elasticsearch: elasticsearchServiceSetup, + coreUsageData: coreUsageDataSetup, }); const uiSettingsSetup = await this.uiSettings.setup({ @@ -165,8 +180,6 @@ export class Server { savedObjects: savedObjectsSetup, }); - const metricsSetup = await this.metrics.setup({ http: httpSetup }); - const statusSetup = await this.status.setup({ elasticsearch: elasticsearchServiceSetup, pluginDependencies: pluginTree.asNames, @@ -191,8 +204,6 @@ export class Server { loggingSystem: this.loggingSystem, }); - this.coreUsageData.setup({ metrics: metricsSetup }); - const coreSetup: InternalCoreSetup = { capabilities: capabilitiesSetup, context: contextServiceSetup, @@ -235,6 +246,8 @@ export class Server { elasticsearch: elasticsearchStart, pluginsInitialized: this.#pluginsInitialized, }); + await this.resolveSavedObjectsStartPromise!(savedObjectsStart); + soStartSpan?.end(); const capabilitiesStart = this.capabilities.start(); const uiSettingsStart = await this.uiSettings.start(); diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts index a514f9f899e55..d30a3c5ab6861 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts @@ -115,6 +115,23 @@ export function getCoreUsageCollector( }, }, }, + 'apiCalls.savedObjectsImport.total': { type: 'long' }, + 'apiCalls.savedObjectsImport.kibanaRequest.yes': { type: 'long' }, + 'apiCalls.savedObjectsImport.kibanaRequest.no': { type: 'long' }, + 'apiCalls.savedObjectsImport.createNewCopiesEnabled.yes': { type: 'long' }, + 'apiCalls.savedObjectsImport.createNewCopiesEnabled.no': { type: 'long' }, + 'apiCalls.savedObjectsImport.overwriteEnabled.yes': { type: 'long' }, + 'apiCalls.savedObjectsImport.overwriteEnabled.no': { type: 'long' }, + 'apiCalls.savedObjectsResolveImportErrors.total': { type: 'long' }, + 'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.yes': { type: 'long' }, + 'apiCalls.savedObjectsResolveImportErrors.kibanaRequest.no': { type: 'long' }, + 'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.yes': { type: 'long' }, + 'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.no': { type: 'long' }, + 'apiCalls.savedObjectsExport.total': { type: 'long' }, + 'apiCalls.savedObjectsExport.kibanaRequest.yes': { type: 'long' }, + 'apiCalls.savedObjectsExport.kibanaRequest.no': { type: 'long' }, + 'apiCalls.savedObjectsExport.allTypesSelected.yes': { type: 'long' }, + 'apiCalls.savedObjectsExport.allTypesSelected.no': { type: 'long' }, }, fetch() { return getCoreUsageDataService().getCoreUsageData(); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index 3fbacef99806d..17f15b6aa1c3e 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -570,11 +570,17 @@ exports[`Flyout should render import step 1`] = ` hasChildLabel={true} hasEmptyLabelSpace={false} label={ - + + + + + } labelType="label" > diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx index c19bb5d819158..0ffc162b7ae7a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -758,10 +758,14 @@ export class Flyout extends Component { + + + + + } > onChange({ overwrite: id === overwriteEnabled.id })} - disabled={createNewCopies} + disabled={createNewCopies && !isLegacyFile} data-test-subj={'savedObjectsManagement-importModeControl-overwriteRadioGroup'} /> ); diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index e1078c60caf2e..91039d9ca1c68 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -1516,6 +1516,57 @@ } } } + }, + "apiCalls.savedObjectsImport.total": { + "type": "long" + }, + "apiCalls.savedObjectsImport.kibanaRequest.yes": { + "type": "long" + }, + "apiCalls.savedObjectsImport.kibanaRequest.no": { + "type": "long" + }, + "apiCalls.savedObjectsImport.createNewCopiesEnabled.yes": { + "type": "long" + }, + "apiCalls.savedObjectsImport.createNewCopiesEnabled.no": { + "type": "long" + }, + "apiCalls.savedObjectsImport.overwriteEnabled.yes": { + "type": "long" + }, + "apiCalls.savedObjectsImport.overwriteEnabled.no": { + "type": "long" + }, + "apiCalls.savedObjectsResolveImportErrors.total": { + "type": "long" + }, + "apiCalls.savedObjectsResolveImportErrors.kibanaRequest.yes": { + "type": "long" + }, + "apiCalls.savedObjectsResolveImportErrors.kibanaRequest.no": { + "type": "long" + }, + "apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.yes": { + "type": "long" + }, + "apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.no": { + "type": "long" + }, + "apiCalls.savedObjectsExport.total": { + "type": "long" + }, + "apiCalls.savedObjectsExport.kibanaRequest.yes": { + "type": "long" + }, + "apiCalls.savedObjectsExport.kibanaRequest.no": { + "type": "long" + }, + "apiCalls.savedObjectsExport.allTypesSelected.yes": { + "type": "long" + }, + "apiCalls.savedObjectsExport.allTypesSelected.no": { + "type": "long" } } }, diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx index cf406653990c8..3035959f9a941 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx @@ -10,7 +10,7 @@ import { mountWithIntl } from '@kbn/test/jest'; import { CopyModeControl, CopyModeControlProps } from './copy_mode_control'; describe('CopyModeControl', () => { - const initialValues = { createNewCopies: false, overwrite: true }; // some test cases below make assumptions based on these initial values + const initialValues = { createNewCopies: true, overwrite: true }; // some test cases below make assumptions based on these initial values const updateSelection = jest.fn(); const getOverwriteRadio = (wrapper: ReactWrapper) => @@ -34,21 +34,23 @@ describe('CopyModeControl', () => { const wrapper = mountWithIntl(); expect(updateSelection).not.toHaveBeenCalled(); - const { createNewCopies } = initialValues; + // need to disable `createNewCopies` first + getCreateNewCopiesDisabled(wrapper).simulate('change'); + const createNewCopies = false; getOverwriteDisabled(wrapper).simulate('change'); - expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies, overwrite: false }); + expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies, overwrite: false }); getOverwriteEnabled(wrapper).simulate('change'); - expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies, overwrite: true }); + expect(updateSelection).toHaveBeenNthCalledWith(3, { createNewCopies, overwrite: true }); }); - it('should disable the Overwrite switch when `createNewCopies` is enabled', async () => { + it('should enable the Overwrite switch when `createNewCopies` is disabled', async () => { const wrapper = mountWithIntl(); - expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(false); - getCreateNewCopiesEnabled(wrapper).simulate('change'); expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(true); + getCreateNewCopiesDisabled(wrapper).simulate('change'); + expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(false); }); it('should allow the user to toggle `createNewCopies`', async () => { @@ -57,10 +59,10 @@ describe('CopyModeControl', () => { expect(updateSelection).not.toHaveBeenCalled(); const { overwrite } = initialValues; - getCreateNewCopiesEnabled(wrapper).simulate('change'); - expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies: true, overwrite }); - getCreateNewCopiesDisabled(wrapper).simulate('change'); - expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies: false, overwrite }); + expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies: false, overwrite }); + + getCreateNewCopiesEnabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies: true, overwrite }); }); }); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx index c3e631e335ea7..f060f7e34e230 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx @@ -126,6 +126,15 @@ export const CopyModeControl = ({ initialValues, updateSelection }: CopyModeCont ), }} > + onChange({ createNewCopies: true })} + /> + + + - - - - onChange({ createNewCopies: true })} - /> diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx index ac45db40a3810..96fc3bacd59ba 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx @@ -12,6 +12,7 @@ import { EuiLoadingSpinner, EuiEmptyPrompt } from '@elastic/eui'; import { Space } from '../../../common/model/space'; import { findTestSubject } from '@kbn/test/jest'; import { SelectableSpacesControl } from './selectable_spaces_control'; +import { CopyModeControl } from './copy_mode_control'; import { act } from '@testing-library/react'; import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { spacesManagerMock } from '../../spaces_manager/mocks'; @@ -289,7 +290,7 @@ describe('CopyToSpaceFlyout', () => { [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }], ['space-1', 'space-2'], true, - false, + true, // `createNewCopies` is enabled by default true ); @@ -376,14 +377,25 @@ describe('CopyToSpaceFlyout', () => { spaceSelector.props().onChange(['space-1', 'space-2']); }); - const startButton = findTestSubject(wrapper, 'cts-initiate-button'); + // Change copy mode to check for conflicts + const copyModeControl = wrapper.find(CopyModeControl); + copyModeControl.find('input[id="createNewCopiesDisabled"]').simulate('change'); await act(async () => { + const startButton = findTestSubject(wrapper, 'cts-initiate-button'); startButton.simulate('click'); await nextTick(); wrapper.update(); }); + expect(mockSpacesManager.copySavedObjects).toHaveBeenCalledWith( + [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }], + ['space-1', 'space-2'], + true, + false, // `createNewCopies` is disabled + true + ); + expect(wrapper.find(CopyToSpaceForm)).toHaveLength(0); expect(wrapper.find(ProcessingCopyToSpace)).toHaveLength(1); @@ -429,7 +441,7 @@ describe('CopyToSpaceFlyout', () => { ], }, true, - false + false // `createNewCopies` is disabled ); expect(onClose).toHaveBeenCalledTimes(1); @@ -545,7 +557,7 @@ describe('CopyToSpaceFlyout', () => { ], }, true, - false + true // `createNewCopies` is enabled by default ); expect(onClose).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx index 5253eb18bce75..aeb6aab8c8dad 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx @@ -42,7 +42,7 @@ interface Props { } const INCLUDE_RELATED_DEFAULT = true; -const CREATE_NEW_COPIES_DEFAULT = false; +const CREATE_NEW_COPIES_DEFAULT = true; const OVERWRITE_ALL_DEFAULT = true; export const CopySavedObjectsToSpaceFlyout = (props: Props) => { diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx index 551573feebcdb..9c38b747ba074 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { EuiSpacer, EuiFormRow } from '@elastic/eui'; +import { EuiSpacer, EuiTitle, EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { CopyOptions } from '../types'; import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; @@ -45,14 +45,18 @@ export const CopyToSpaceForm = (props: Props) => { updateSelection={(newValues: CopyMode) => changeCopyMode(newValues)} /> - + + + + + + } fullWidth > diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx index d4e12b31b5b4f..bfd25ba4de0bb 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -72,7 +72,7 @@ export const SelectableSpacesControl = (props: Props) => { className: 'spcCopyToSpace__spacesList', 'data-test-subj': 'cts-form-space-selector', }} - searchable + searchable={options.length > 6} > {(list, search) => { return ( diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index e53cc152442a2..f6d1576b5067f 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -175,7 +175,7 @@ export const SelectableSpacesControl = (props: Props) => { 'data-test-subj': 'sts-form-space-selector', }} height={ROW_HEIGHT * 3.5} - searchable + searchable={options.length > 6} > {(list, search) => { return ( diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx index bc196208ab35c..75e40b85a37dd 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx @@ -38,7 +38,7 @@ export const ShareToSpaceForm = (props: Props) => { title={ } color="warning" diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts index 8e530ddf8ff2e..856899c127fd2 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -91,7 +91,8 @@ export class SpacesManager { objects, spaces, includeReferences, - ...(createNewCopies ? { createNewCopies } : { overwrite }), + createNewCopies, + ...(createNewCopies ? { overwrite: false } : { overwrite }), // ignore the overwrite option if createNewCopies is enabled }), }); } diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index 517fde6ecb41a..cd36ca3c7a6ec 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -20,8 +20,8 @@ import { import { LicensingPluginSetup } from '../../licensing/server'; import { createSpacesTutorialContextFactory } from './lib/spaces_tutorial_context_factory'; import { registerSpacesUsageCollector } from './usage_collection'; -import { SpacesService, SpacesServiceStart } from './spaces_service'; -import { SpacesServiceSetup } from './spaces_service'; +import { SpacesService, SpacesServiceSetup, SpacesServiceStart } from './spaces_service'; +import { UsageStatsService } from './usage_stats'; import { ConfigType } from './config'; import { initSpacesRequestInterceptors } from './lib/request_interceptors'; import { initExternalSpacesApi } from './routes/api/external'; @@ -99,6 +99,10 @@ export class Plugin { return this.spacesServiceStart; }; + const usageStatsServicePromise = new UsageStatsService(this.log).setup({ + getStartServices: core.getStartServices, + }); + const savedObjectsService = new SpacesSavedObjectsService(); savedObjectsService.setup({ core, getSpacesService }); @@ -126,6 +130,7 @@ export class Plugin { getStartServices: core.getStartServices, getImportExportObjectLimit: core.savedObjects.getImportExportObjectLimit, getSpacesService, + usageStatsServicePromise, }); const internalRouter = core.http.createRouter(); @@ -148,6 +153,7 @@ export class Plugin { kibanaIndexConfig$: this.kibanaIndexConfig$, features: plugins.features, licensing: plugins.licensing, + usageStatsServicePromise, }); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index a6e1c11d011a0..cb81476454cd3 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -22,6 +22,8 @@ import { coreMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; +import { usageStatsClientMock } from '../../../usage_stats/usage_stats_client.mock'; +import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock'; import { initCopyToSpacesApi } from './copy_to_space'; import { spacesConfig } from '../../../lib/__fixtures__'; import { ObjectType } from '@kbn/config-schema'; @@ -82,6 +84,11 @@ describe('copy to space', () => { basePath: httpService.basePath, }); + const usageStatsClient = usageStatsClientMock.create(); + const usageStatsServicePromise = Promise.resolve( + usageStatsServiceMock.createSetupContract(usageStatsClient) + ); + const clientServiceStart = clientService.start(coreStart); const spacesServiceStart = service.start({ @@ -95,6 +102,7 @@ describe('copy to space', () => { getImportExportObjectLimit: () => 1000, log, getSpacesService: () => spacesServiceStart, + usageStatsServicePromise, }); const [ @@ -113,6 +121,7 @@ describe('copy to space', () => { routeHandler: resolveRouteHandler, }, savedObjectsRepositoryMock, + usageStatsClient, }; }; @@ -136,6 +145,27 @@ describe('copy to space', () => { }); }); + it(`records usageStats data`, async () => { + const createNewCopies = Symbol(); + const overwrite = Symbol(); + const payload = { spaces: ['a-space'], objects: [], createNewCopies, overwrite }; + + const { copyToSpace, usageStatsClient } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + await copyToSpace.routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(usageStatsClient.incrementCopySavedObjects).toHaveBeenCalledWith({ + headers: request.headers, + createNewCopies, + overwrite, + }); + }); + it(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { const payload = { spaces: ['a-space'], @@ -272,6 +302,25 @@ describe('copy to space', () => { }); }); + it(`records usageStats data`, async () => { + const createNewCopies = Symbol(); + const payload = { retries: {}, objects: [], createNewCopies }; + + const { resolveConflicts, usageStatsClient } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + await resolveConflicts.routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(usageStatsClient.incrementResolveCopySavedObjectsErrors).toHaveBeenCalledWith({ + headers: request.headers, + createNewCopies, + }); + }); + it(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { const payload = { retries: { diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts index 989c513ac00bc..2b1be42f9cbb0 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts @@ -21,7 +21,14 @@ const areObjectsUnique = (objects: SavedObjectIdentifier[]) => _.uniqBy(objects, (o: SavedObjectIdentifier) => `${o.type}:${o.id}`).length === objects.length; export function initCopyToSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, getSpacesService, getImportExportObjectLimit, getStartServices } = deps; + const { + externalRouter, + getSpacesService, + usageStatsServicePromise, + getImportExportObjectLimit, + getStartServices, + } = deps; + const usageStatsClientPromise = usageStatsServicePromise.then(({ getClient }) => getClient()); externalRouter.post( { @@ -63,7 +70,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { ), includeReferences: schema.boolean({ defaultValue: false }), overwrite: schema.boolean({ defaultValue: false }), - createNewCopies: schema.boolean({ defaultValue: false }), + createNewCopies: schema.boolean({ defaultValue: true }), }, { validate: (object) => { @@ -77,12 +84,6 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { }, createLicensedRouteHandler(async (context, request, response) => { const [startServices] = await getStartServices(); - - const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( - startServices.savedObjects, - getImportExportObjectLimit, - request - ); const { spaces: destinationSpaceIds, objects, @@ -90,6 +91,17 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { overwrite, createNewCopies, } = request.body; + + const { headers } = request; + usageStatsClientPromise.then((usageStatsClient) => + usageStatsClient.incrementCopySavedObjects({ headers, createNewCopies, overwrite }) + ); + + const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( + startServices.savedObjects, + getImportExportObjectLimit, + request + ); const sourceSpaceId = getSpacesService().getSpaceId(request); const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { objects, @@ -142,19 +154,24 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { } ), includeReferences: schema.boolean({ defaultValue: false }), - createNewCopies: schema.boolean({ defaultValue: false }), + createNewCopies: schema.boolean({ defaultValue: true }), }), }, }, createLicensedRouteHandler(async (context, request, response) => { const [startServices] = await getStartServices(); + const { objects, includeReferences, retries, createNewCopies } = request.body; + + const { headers } = request; + usageStatsClientPromise.then((usageStatsClient) => + usageStatsClient.incrementResolveCopySavedObjectsErrors({ headers, createNewCopies }) + ); const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( startServices.savedObjects, getImportExportObjectLimit, request ); - const { objects, includeReferences, retries, createNewCopies } = request.body; const sourceSpaceId = getSpacesService().getSpaceId(request); const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts( sourceSpaceId, diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts index c9b5fc96094cb..0dc6f67cc278f 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -27,6 +27,7 @@ import { initDeleteSpacesApi } from './delete'; import { spacesConfig } from '../../../lib/__fixtures__'; import { ObjectType } from '@kbn/config-schema'; import { SpacesClientService } from '../../../spaces_client'; +import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock'; describe('Spaces Public API', () => { const spacesSavedObjects = createSpaces(); @@ -51,6 +52,8 @@ describe('Spaces Public API', () => { basePath: httpService.basePath, }); + const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract()); + const clientServiceStart = clientService.start(coreStart); const spacesServiceStart = service.start({ @@ -64,6 +67,7 @@ describe('Spaces Public API', () => { getImportExportObjectLimit: () => 1000, log, getSpacesService: () => spacesServiceStart, + usageStatsServicePromise, }); const [routeDefinition, routeHandler] = router.delete.mock.calls[0]; diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts index 6fa26a7bcd557..9944655f73b75 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts @@ -21,6 +21,7 @@ import { import { SpacesService } from '../../../spaces_service'; import { spacesConfig } from '../../../lib/__fixtures__'; import { SpacesClientService } from '../../../spaces_client'; +import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock'; describe('GET space', () => { const spacesSavedObjects = createSpaces(); @@ -46,6 +47,8 @@ describe('GET space', () => { basePath: httpService.basePath, }); + const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract()); + const clientServiceStart = clientService.start(coreStart); const spacesServiceStart = service.start({ @@ -59,6 +62,7 @@ describe('GET space', () => { getImportExportObjectLimit: () => 1000, log, getSpacesService: () => spacesServiceStart, + usageStatsServicePromise, }); return { diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts index 5b24a33cb014d..d79596b754fc9 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -22,6 +22,7 @@ import { initGetAllSpacesApi } from './get_all'; import { spacesConfig } from '../../../lib/__fixtures__'; import { ObjectType } from '@kbn/config-schema'; import { SpacesClientService } from '../../../spaces_client'; +import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock'; describe('GET /spaces/space', () => { const spacesSavedObjects = createSpaces(); @@ -47,6 +48,8 @@ describe('GET /spaces/space', () => { basePath: httpService.basePath, }); + const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract()); + const clientServiceStart = clientService.start(coreStart); const spacesServiceStart = service.start({ @@ -60,6 +63,7 @@ describe('GET /spaces/space', () => { getImportExportObjectLimit: () => 1000, log, getSpacesService: () => spacesServiceStart, + usageStatsServicePromise, }); return { diff --git a/x-pack/plugins/spaces/server/routes/api/external/index.ts b/x-pack/plugins/spaces/server/routes/api/external/index.ts index e34f67adc04ac..b828bb457aba5 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/index.ts @@ -10,7 +10,8 @@ import { initGetSpaceApi } from './get'; import { initGetAllSpacesApi } from './get_all'; import { initPostSpacesApi } from './post'; import { initPutSpacesApi } from './put'; -import { SpacesServiceStart } from '../../../spaces_service/spaces_service'; +import { SpacesServiceStart } from '../../../spaces_service'; +import { UsageStatsServiceSetup } from '../../../usage_stats'; import { initCopyToSpacesApi } from './copy_to_space'; import { initShareToSpacesApi } from './share_to_space'; @@ -19,6 +20,7 @@ export interface ExternalRouteDeps { getStartServices: CoreSetup['getStartServices']; getImportExportObjectLimit: () => number; getSpacesService: () => SpacesServiceStart; + usageStatsServicePromise: Promise; log: Logger; } diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts index bd8b4f2119109..30429bb2866ef 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts @@ -22,6 +22,7 @@ import { initPostSpacesApi } from './post'; import { spacesConfig } from '../../../lib/__fixtures__'; import { ObjectType } from '@kbn/config-schema'; import { SpacesClientService } from '../../../spaces_client'; +import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock'; describe('Spaces Public API', () => { const spacesSavedObjects = createSpaces(); @@ -46,6 +47,8 @@ describe('Spaces Public API', () => { basePath: httpService.basePath, }); + const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract()); + const clientServiceStart = clientService.start(coreStart); const spacesServiceStart = service.start({ @@ -59,6 +62,7 @@ describe('Spaces Public API', () => { getImportExportObjectLimit: () => 1000, log, getSpacesService: () => spacesServiceStart, + usageStatsServicePromise, }); const [routeDefinition, routeHandler] = router.post.mock.calls[0]; diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts index d87cfd96e2429..f4aed1efbaa5f 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts @@ -23,6 +23,7 @@ import { initPutSpacesApi } from './put'; import { spacesConfig } from '../../../lib/__fixtures__'; import { ObjectType } from '@kbn/config-schema'; import { SpacesClientService } from '../../../spaces_client'; +import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock'; describe('PUT /api/spaces/space', () => { const spacesSavedObjects = createSpaces(); @@ -47,6 +48,8 @@ describe('PUT /api/spaces/space', () => { basePath: httpService.basePath, }); + const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract()); + const clientServiceStart = clientService.start(coreStart); const spacesServiceStart = service.start({ @@ -60,6 +63,7 @@ describe('PUT /api/spaces/space', () => { getImportExportObjectLimit: () => 1000, log, getSpacesService: () => spacesServiceStart, + usageStatsServicePromise, }); const [routeDefinition, routeHandler] = router.put.mock.calls[0]; diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts index b376e56a87fd8..9a8a619f66146 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts @@ -23,6 +23,7 @@ import { initShareToSpacesApi } from './share_to_space'; import { spacesConfig } from '../../../lib/__fixtures__'; import { ObjectType } from '@kbn/config-schema'; import { SpacesClientService } from '../../../spaces_client'; +import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock'; describe('share to space', () => { const spacesSavedObjects = createSpaces(); @@ -47,6 +48,8 @@ describe('share to space', () => { basePath: httpService.basePath, }); + const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract()); + const clientServiceStart = clientService.start(coreStart); const spacesServiceStart = service.start({ @@ -59,6 +62,7 @@ describe('share to space', () => { getImportExportObjectLimit: () => 1000, log, getSpacesService: () => spacesServiceStart, + usageStatsServicePromise, }); const [ diff --git a/x-pack/plugins/spaces/server/saved_objects/mappings.ts b/x-pack/plugins/spaces/server/saved_objects/mappings.ts index 875a164e25217..7a82e0b667f4a 100644 --- a/x-pack/plugins/spaces/server/saved_objects/mappings.ts +++ b/x-pack/plugins/spaces/server/saved_objects/mappings.ts @@ -38,3 +38,8 @@ export const SpacesSavedObjectMappings = deepFreeze({ }, }, }); + +export const UsageStatsMappings = deepFreeze({ + dynamic: false as false, // we aren't querying or aggregating over this data, so we don't need to specify any fields + properties: {}, +}); diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts index a0b0ab41e9d89..43dccf28c9a8f 100644 --- a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts @@ -5,6 +5,7 @@ */ import { coreMock } from 'src/core/server/mocks'; +import { SPACES_USAGE_STATS_TYPE } from '../usage_stats'; import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; import { SpacesSavedObjectsService } from './saved_objects_service'; @@ -17,51 +18,15 @@ describe('SpacesSavedObjectsService', () => { const service = new SpacesSavedObjectsService(); service.setup({ core, getSpacesService: () => spacesService }); - expect(core.savedObjects.registerType).toHaveBeenCalledTimes(1); - expect(core.savedObjects.registerType.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "hidden": true, - "mappings": Object { - "properties": Object { - "_reserved": Object { - "type": "boolean", - }, - "color": Object { - "type": "keyword", - }, - "description": Object { - "type": "text", - }, - "disabledFeatures": Object { - "type": "keyword", - }, - "imageUrl": Object { - "index": false, - "type": "text", - }, - "initials": Object { - "type": "keyword", - }, - "name": Object { - "fields": Object { - "keyword": Object { - "ignore_above": 2048, - "type": "keyword", - }, - }, - "type": "text", - }, - }, - }, - "migrations": Object { - "6.6.0": [Function], - }, - "name": "space", - "namespaceType": "agnostic", - }, - ] - `); + expect(core.savedObjects.registerType).toHaveBeenCalledTimes(2); + expect(core.savedObjects.registerType).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: 'space' }) + ); + expect(core.savedObjects.registerType).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: SPACES_USAGE_STATS_TYPE }) + ); }); it('registers the client wrapper', () => { diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts index b52f1eda1b6ac..fa3b36ffbbd57 100644 --- a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts @@ -5,10 +5,11 @@ */ import { CoreSetup } from 'src/core/server'; -import { SpacesSavedObjectMappings } from './mappings'; +import { SpacesSavedObjectMappings, UsageStatsMappings } from './mappings'; import { migrateToKibana660 } from './migrations'; import { spacesSavedObjectsClientWrapperFactory } from './saved_objects_client_wrapper_factory'; import { SpacesServiceStart } from '../spaces_service'; +import { SPACES_USAGE_STATS_TYPE } from '../usage_stats'; interface SetupDeps { core: Pick; @@ -27,6 +28,13 @@ export class SpacesSavedObjectsService { }, }); + core.savedObjects.registerType({ + name: SPACES_USAGE_STATS_TYPE, + hidden: true, + namespaceType: 'agnostic', + mappings: UsageStatsMappings, + }); + core.savedObjects.addClientWrapper( Number.MIN_SAFE_INTEGER, 'spaces', diff --git a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts index 1a377d2f801a0..ea8770b7843cf 100644 --- a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts +++ b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getSpacesUsageCollector, UsageStats } from './spaces_usage_collector'; +import { getSpacesUsageCollector, UsageData } from './spaces_usage_collector'; import * as Rx from 'rxjs'; import { PluginsSetup } from '../plugin'; import { KibanaFeature } from '../../../features/server'; import { ILicense, LicensingPluginSetup } from '../../../licensing/server'; +import { UsageStats } from '../usage_stats'; +import { usageStatsClientMock } from '../usage_stats/usage_stats_client.mock'; +import { usageStatsServiceMock } from '../usage_stats/usage_stats_service.mock'; import { pluginInitializerContextConfigMock } from 'src/core/server/mocks'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; @@ -17,6 +20,21 @@ interface SetupOpts { features?: KibanaFeature[]; } +const MOCK_USAGE_STATS: UsageStats = { + 'apiCalls.copySavedObjects.total': 5, + 'apiCalls.copySavedObjects.kibanaRequest.yes': 5, + 'apiCalls.copySavedObjects.kibanaRequest.no': 0, + 'apiCalls.copySavedObjects.createNewCopiesEnabled.yes': 2, + 'apiCalls.copySavedObjects.createNewCopiesEnabled.no': 3, + 'apiCalls.copySavedObjects.overwriteEnabled.yes': 1, + 'apiCalls.copySavedObjects.overwriteEnabled.no': 4, + 'apiCalls.resolveCopySavedObjectsErrors.total': 13, + 'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.yes': 13, + 'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no': 0, + 'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes': 6, + 'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no': 7, +}; + function setup({ license = { isAvailable: true }, features = [{ id: 'feature1' } as KibanaFeature, { id: 'feature2' } as KibanaFeature], @@ -41,12 +59,18 @@ function setup({ getKibanaFeatures: jest.fn().mockReturnValue(features), } as unknown) as PluginsSetup['features']; + const usageStatsClient = usageStatsClientMock.create(); + usageStatsClient.getUsageStats.mockResolvedValue(MOCK_USAGE_STATS); + const usageStatsService = usageStatsServiceMock.createSetupContract(usageStatsClient); + return { licensing, features: featuresSetup, usageCollection: { makeUsageCollector: (options: any) => new MockUsageCollector(options), }, + usageStatsService, + usageStatsClient, }; } @@ -77,26 +101,28 @@ const getMockFetchContext = (mockedCallCluster: jest.Mock) => { describe('error handling', () => { it('handles a 404 when searching for space usage', async () => { - const { features, licensing, usageCollection } = setup({ + const { features, licensing, usageCollection, usageStatsService } = setup({ license: { isAvailable: true, type: 'basic' }, }); const collector = getSpacesUsageCollector(usageCollection as any, { kibanaIndexConfig$: Rx.of({ kibana: { index: '.kibana' } }), features, licensing, + usageStatsServicePromise: Promise.resolve(usageStatsService), }); await collector.fetch(getMockFetchContext(jest.fn().mockRejectedValue({ status: 404 }))); }); it('throws error for a non-404', async () => { - const { features, licensing, usageCollection } = setup({ + const { features, licensing, usageCollection, usageStatsService } = setup({ license: { isAvailable: true, type: 'basic' }, }); const collector = getSpacesUsageCollector(usageCollection as any, { kibanaIndexConfig$: Rx.of({ kibana: { index: '.kibana' } }), features, licensing, + usageStatsServicePromise: Promise.resolve(usageStatsService), }); const statusCodes = [401, 402, 403, 500]; @@ -110,17 +136,19 @@ describe('error handling', () => { }); describe('with a basic license', () => { - let usageStats: UsageStats; + let usageData: UsageData; + const { features, licensing, usageCollection, usageStatsService, usageStatsClient } = setup({ + license: { isAvailable: true, type: 'basic' }, + }); + beforeAll(async () => { - const { features, licensing, usageCollection } = setup({ - license: { isAvailable: true, type: 'basic' }, - }); const collector = getSpacesUsageCollector(usageCollection as any, { kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$, features, licensing, + usageStatsServicePromise: Promise.resolve(usageStatsService), }); - usageStats = await collector.fetch(getMockFetchContext(defaultCallClusterMock)); + usageData = await collector.fetch(getMockFetchContext(defaultCallClusterMock)); expect(defaultCallClusterMock).toHaveBeenCalledWith('search', { body: { @@ -138,87 +166,111 @@ describe('with a basic license', () => { }); test('sets enabled to true', () => { - expect(usageStats.enabled).toBe(true); + expect(usageData.enabled).toBe(true); }); test('sets available to true', () => { - expect(usageStats.available).toBe(true); + expect(usageData.available).toBe(true); }); test('sets the number of spaces', () => { - expect(usageStats.count).toBe(2); + expect(usageData.count).toBe(2); }); test('calculates feature control usage', () => { - expect(usageStats.usesFeatureControls).toBe(true); - expect(usageStats).toHaveProperty('disabledFeatures'); - expect(usageStats.disabledFeatures).toEqual({ + expect(usageData.usesFeatureControls).toBe(true); + expect(usageData).toHaveProperty('disabledFeatures'); + expect(usageData.disabledFeatures).toEqual({ feature1: 1, feature2: 0, }); }); + + test('fetches usageStats data', () => { + expect(usageStatsService.getClient).toHaveBeenCalledTimes(1); + expect(usageStatsClient.getUsageStats).toHaveBeenCalledTimes(1); + expect(usageData).toEqual(expect.objectContaining(MOCK_USAGE_STATS)); + }); }); describe('with no license', () => { - let usageStats: UsageStats; + let usageData: UsageData; + const { features, licensing, usageCollection, usageStatsService, usageStatsClient } = setup({ + license: { isAvailable: false }, + }); + beforeAll(async () => { - const { features, licensing, usageCollection } = setup({ license: { isAvailable: false } }); const collector = getSpacesUsageCollector(usageCollection as any, { kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$, features, licensing, + usageStatsServicePromise: Promise.resolve(usageStatsService), }); - usageStats = await collector.fetch(getMockFetchContext(defaultCallClusterMock)); + usageData = await collector.fetch(getMockFetchContext(defaultCallClusterMock)); }); test('sets enabled to false', () => { - expect(usageStats.enabled).toBe(false); + expect(usageData.enabled).toBe(false); }); test('sets available to false', () => { - expect(usageStats.available).toBe(false); + expect(usageData.available).toBe(false); }); test('does not set the number of spaces', () => { - expect(usageStats.count).toBeUndefined(); + expect(usageData.count).toBeUndefined(); }); test('does not set feature control usage', () => { - expect(usageStats.usesFeatureControls).toBeUndefined(); + expect(usageData.usesFeatureControls).toBeUndefined(); + }); + + test('does not fetch usageStats data', () => { + expect(usageStatsService.getClient).not.toHaveBeenCalled(); + expect(usageStatsClient.getUsageStats).not.toHaveBeenCalled(); + expect(usageData).not.toEqual(expect.objectContaining(MOCK_USAGE_STATS)); }); }); describe('with platinum license', () => { - let usageStats: UsageStats; + let usageData: UsageData; + const { features, licensing, usageCollection, usageStatsService, usageStatsClient } = setup({ + license: { isAvailable: true, type: 'platinum' }, + }); + beforeAll(async () => { - const { features, licensing, usageCollection } = setup({ - license: { isAvailable: true, type: 'platinum' }, - }); const collector = getSpacesUsageCollector(usageCollection as any, { kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$, features, licensing, + usageStatsServicePromise: Promise.resolve(usageStatsService), }); - usageStats = await collector.fetch(getMockFetchContext(defaultCallClusterMock)); + usageData = await collector.fetch(getMockFetchContext(defaultCallClusterMock)); }); test('sets enabled to true', () => { - expect(usageStats.enabled).toBe(true); + expect(usageData.enabled).toBe(true); }); test('sets available to true', () => { - expect(usageStats.available).toBe(true); + expect(usageData.available).toBe(true); }); test('sets the number of spaces', () => { - expect(usageStats.count).toBe(2); + expect(usageData.count).toBe(2); }); test('calculates feature control usage', () => { - expect(usageStats.usesFeatureControls).toBe(true); - expect(usageStats.disabledFeatures).toEqual({ + expect(usageData.usesFeatureControls).toBe(true); + expect(usageData.disabledFeatures).toEqual({ feature1: 1, feature2: 0, }); }); + + test('fetches usageStats data', () => { + expect(usageStatsService.getClient).toHaveBeenCalledTimes(1); + expect(usageStatsClient.getUsageStats).toHaveBeenCalledTimes(1); + expect(usageData).toEqual(expect.objectContaining(MOCK_USAGE_STATS)); + }); }); diff --git a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts index d563a4a9b100d..44388453d0707 100644 --- a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts +++ b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts @@ -9,6 +9,7 @@ import { take } from 'rxjs/operators'; import { CollectorFetchContext, UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { Observable } from 'rxjs'; import { PluginsSetup } from '../plugin'; +import { UsageStats, UsageStatsServiceSetup } from '../usage_stats'; type CallCluster = ( endpoint: string, @@ -33,7 +34,7 @@ interface SpacesAggregationResponse { * @param {string} kibanaIndex * @param {PluginsSetup['features']} features * @param {boolean} spacesAvailable - * @return {UsageStats} + * @return {UsageData} */ async function getSpacesUsage( callCluster: CallCluster, @@ -109,10 +110,22 @@ async function getSpacesUsage( count, usesFeatureControls, disabledFeatures, - } as UsageStats; + } as UsageData; } -export interface UsageStats { +async function getUsageStats( + usageStatsServicePromise: Promise, + spacesAvailable: boolean +) { + if (!spacesAvailable) { + return null; + } + + const usageStatsClient = await usageStatsServicePromise.then(({ getClient }) => getClient()); + return usageStatsClient.getUsageStats(); +} + +export interface UsageData extends UsageStats { available: boolean; enabled: boolean; count?: number; @@ -143,6 +156,7 @@ interface CollectorDeps { kibanaIndexConfig$: Observable<{ kibana: { index: string } }>; features: PluginsSetup['features']; licensing: PluginsSetup['licensing']; + usageStatsServicePromise: Promise; } /* @@ -153,7 +167,7 @@ export function getSpacesUsageCollector( usageCollection: UsageCollectionSetup, deps: CollectorDeps ) { - return usageCollection.makeUsageCollector({ + return usageCollection.makeUsageCollector({ type: 'spaces', isReady: () => true, schema: { @@ -181,20 +195,35 @@ export function getSpacesUsageCollector( available: { type: 'boolean' }, enabled: { type: 'boolean' }, count: { type: 'long' }, + 'apiCalls.copySavedObjects.total': { type: 'long' }, + 'apiCalls.copySavedObjects.kibanaRequest.yes': { type: 'long' }, + 'apiCalls.copySavedObjects.kibanaRequest.no': { type: 'long' }, + 'apiCalls.copySavedObjects.createNewCopiesEnabled.yes': { type: 'long' }, + 'apiCalls.copySavedObjects.createNewCopiesEnabled.no': { type: 'long' }, + 'apiCalls.copySavedObjects.overwriteEnabled.yes': { type: 'long' }, + 'apiCalls.copySavedObjects.overwriteEnabled.no': { type: 'long' }, + 'apiCalls.resolveCopySavedObjectsErrors.total': { type: 'long' }, + 'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.yes': { type: 'long' }, + 'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no': { type: 'long' }, + 'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes': { type: 'long' }, + 'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no': { type: 'long' }, }, fetch: async ({ callCluster }: CollectorFetchContext) => { - const license = await deps.licensing.license$.pipe(take(1)).toPromise(); + const { licensing, kibanaIndexConfig$, features, usageStatsServicePromise } = deps; + const license = await licensing.license$.pipe(take(1)).toPromise(); const available = license.isAvailable; // some form of spaces is available for all valid licenses - const kibanaIndex = (await deps.kibanaIndexConfig$.pipe(take(1)).toPromise()).kibana.index; + const kibanaIndex = (await kibanaIndexConfig$.pipe(take(1)).toPromise()).kibana.index; - const usageStats = await getSpacesUsage(callCluster, kibanaIndex, deps.features, available); + const usageData = await getSpacesUsage(callCluster, kibanaIndex, features, available); + const usageStats = await getUsageStats(usageStatsServicePromise, available); return { available, enabled: available, + ...usageData, ...usageStats, - } as UsageStats; + } as UsageData; }, }); } diff --git a/x-pack/plugins/spaces/server/usage_stats/constants.ts b/x-pack/plugins/spaces/server/usage_stats/constants.ts new file mode 100644 index 0000000000000..60fc98d868e4d --- /dev/null +++ b/x-pack/plugins/spaces/server/usage_stats/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const SPACES_USAGE_STATS_TYPE = 'spaces-usage-stats'; +export const SPACES_USAGE_STATS_ID = 'spaces-usage-stats'; diff --git a/x-pack/plugins/spaces/server/usage_stats/index.ts b/x-pack/plugins/spaces/server/usage_stats/index.ts new file mode 100644 index 0000000000000..f661a39934608 --- /dev/null +++ b/x-pack/plugins/spaces/server/usage_stats/index.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SPACES_USAGE_STATS_TYPE } from './constants'; +export { UsageStatsService, UsageStatsServiceSetup } from './usage_stats_service'; +export { UsageStats } from './types'; diff --git a/x-pack/plugins/spaces/server/usage_stats/types.ts b/x-pack/plugins/spaces/server/usage_stats/types.ts new file mode 100644 index 0000000000000..05733d6bf3a11 --- /dev/null +++ b/x-pack/plugins/spaces/server/usage_stats/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface UsageStats { + 'apiCalls.copySavedObjects.total'?: number; + 'apiCalls.copySavedObjects.kibanaRequest.yes'?: number; + 'apiCalls.copySavedObjects.kibanaRequest.no'?: number; + 'apiCalls.copySavedObjects.createNewCopiesEnabled.yes'?: number; + 'apiCalls.copySavedObjects.createNewCopiesEnabled.no'?: number; + 'apiCalls.copySavedObjects.overwriteEnabled.yes'?: number; + 'apiCalls.copySavedObjects.overwriteEnabled.no'?: number; + 'apiCalls.resolveCopySavedObjectsErrors.total'?: number; + 'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.yes'?: number; + 'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no'?: number; + 'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes'?: number; + 'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no'?: number; +} diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.mock.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.mock.ts new file mode 100644 index 0000000000000..f1b17430a7655 --- /dev/null +++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.mock.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UsageStatsClient } from './usage_stats_client'; + +const createUsageStatsClientMock = () => + (({ + getUsageStats: jest.fn().mockResolvedValue({}), + incrementCopySavedObjects: jest.fn().mockResolvedValue(null), + incrementResolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(null), + } as unknown) as jest.Mocked); + +export const usageStatsClientMock = { + create: createUsageStatsClientMock, +}; diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts new file mode 100644 index 0000000000000..b313c0be32b95 --- /dev/null +++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { savedObjectsRepositoryMock } from 'src/core/server/mocks'; +import { SPACES_USAGE_STATS_TYPE, SPACES_USAGE_STATS_ID } from './constants'; +import { + UsageStatsClient, + IncrementCopySavedObjectsOptions, + IncrementResolveCopySavedObjectsErrorsOptions, + COPY_STATS_PREFIX, + RESOLVE_COPY_STATS_PREFIX, +} from './usage_stats_client'; + +describe('UsageStatsClient', () => { + const setup = () => { + const debugLoggerMock = jest.fn(); + const repositoryMock = savedObjectsRepositoryMock.create(); + const usageStatsClient = new UsageStatsClient(debugLoggerMock, Promise.resolve(repositoryMock)); + return { usageStatsClient, debugLoggerMock, repositoryMock }; + }; + + const firstPartyRequestHeaders = { 'kbn-version': 'a', origin: 'b', referer: 'c' }; // as long as these three header fields are truthy, this will be treated like a first-party request + const incrementOptions = { refresh: false }; + + describe('#getUsageStats', () => { + it('calls repository.incrementCounter and initializes fields', async () => { + const { usageStatsClient, repositoryMock } = setup(); + await usageStatsClient.getUsageStats(); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + SPACES_USAGE_STATS_TYPE, + SPACES_USAGE_STATS_ID, + [ + `${COPY_STATS_PREFIX}.total`, + `${COPY_STATS_PREFIX}.kibanaRequest.yes`, + `${COPY_STATS_PREFIX}.kibanaRequest.no`, + `${COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`, + `${COPY_STATS_PREFIX}.createNewCopiesEnabled.no`, + `${COPY_STATS_PREFIX}.overwriteEnabled.yes`, + `${COPY_STATS_PREFIX}.overwriteEnabled.no`, + `${RESOLVE_COPY_STATS_PREFIX}.total`, + `${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.yes`, + `${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`, + `${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`, + `${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`, + ], + { initialize: true } + ); + }); + + it('returns empty object when encountering a repository error', async () => { + const { usageStatsClient, repositoryMock } = setup(); + repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!')); + + const result = await usageStatsClient.getUsageStats(); + expect(result).toEqual({}); + }); + + it('returns object attributes when usageStats data exists', async () => { + const { usageStatsClient, repositoryMock } = setup(); + const usageStats = { foo: 'bar' }; + repositoryMock.incrementCounter.mockResolvedValue({ + type: SPACES_USAGE_STATS_TYPE, + id: SPACES_USAGE_STATS_ID, + attributes: usageStats, + references: [], + }); + + const result = await usageStatsClient.getUsageStats(); + expect(result).toEqual(usageStats); + }); + }); + + describe('#incrementCopySavedObjects', () => { + it('does not throw an error if repository incrementCounter operation fails', async () => { + const { usageStatsClient, repositoryMock } = setup(); + repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!')); + + await expect( + usageStatsClient.incrementCopySavedObjects({} as IncrementCopySavedObjectsOptions) + ).resolves.toBeUndefined(); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + }); + + it('handles falsy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + await usageStatsClient.incrementCopySavedObjects({} as IncrementCopySavedObjectsOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + SPACES_USAGE_STATS_TYPE, + SPACES_USAGE_STATS_ID, + [ + `${COPY_STATS_PREFIX}.total`, + `${COPY_STATS_PREFIX}.kibanaRequest.no`, + `${COPY_STATS_PREFIX}.createNewCopiesEnabled.no`, + `${COPY_STATS_PREFIX}.overwriteEnabled.no`, + ], + incrementOptions + ); + }); + + it('handles truthy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + await usageStatsClient.incrementCopySavedObjects({ + headers: firstPartyRequestHeaders, + createNewCopies: true, + overwrite: true, + } as IncrementCopySavedObjectsOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + SPACES_USAGE_STATS_TYPE, + SPACES_USAGE_STATS_ID, + [ + `${COPY_STATS_PREFIX}.total`, + `${COPY_STATS_PREFIX}.kibanaRequest.yes`, + `${COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`, + `${COPY_STATS_PREFIX}.overwriteEnabled.yes`, + ], + incrementOptions + ); + }); + }); + + describe('#incrementResolveCopySavedObjectsErrors', () => { + it('does not throw an error if repository create operation fails', async () => { + const { usageStatsClient, repositoryMock } = setup(); + repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!')); + + await expect( + usageStatsClient.incrementResolveCopySavedObjectsErrors( + {} as IncrementResolveCopySavedObjectsErrorsOptions + ) + ).resolves.toBeUndefined(); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + }); + + it('handles falsy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + await usageStatsClient.incrementResolveCopySavedObjectsErrors( + {} as IncrementResolveCopySavedObjectsErrorsOptions + ); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + SPACES_USAGE_STATS_TYPE, + SPACES_USAGE_STATS_ID, + [ + `${RESOLVE_COPY_STATS_PREFIX}.total`, + `${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`, + `${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`, + ], + incrementOptions + ); + }); + + it('handles truthy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + await usageStatsClient.incrementResolveCopySavedObjectsErrors({ + headers: firstPartyRequestHeaders, + createNewCopies: true, + } as IncrementResolveCopySavedObjectsErrorsOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + SPACES_USAGE_STATS_TYPE, + SPACES_USAGE_STATS_ID, + [ + `${RESOLVE_COPY_STATS_PREFIX}.total`, + `${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.yes`, + `${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`, + ], + incrementOptions + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts new file mode 100644 index 0000000000000..4c9d11a11ccca --- /dev/null +++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISavedObjectsRepository, Headers } from 'src/core/server'; +import { SPACES_USAGE_STATS_TYPE, SPACES_USAGE_STATS_ID } from './constants'; +import { CopyOptions, ResolveConflictsOptions } from '../lib/copy_to_spaces/types'; +import { UsageStats } from './types'; + +interface BaseIncrementOptions { + headers?: Headers; +} +export type IncrementCopySavedObjectsOptions = BaseIncrementOptions & + Pick; +export type IncrementResolveCopySavedObjectsErrorsOptions = BaseIncrementOptions & + Pick; + +export const COPY_STATS_PREFIX = 'apiCalls.copySavedObjects'; +export const RESOLVE_COPY_STATS_PREFIX = 'apiCalls.resolveCopySavedObjectsErrors'; +const ALL_COUNTER_FIELDS = [ + `${COPY_STATS_PREFIX}.total`, + `${COPY_STATS_PREFIX}.kibanaRequest.yes`, + `${COPY_STATS_PREFIX}.kibanaRequest.no`, + `${COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`, + `${COPY_STATS_PREFIX}.createNewCopiesEnabled.no`, + `${COPY_STATS_PREFIX}.overwriteEnabled.yes`, + `${COPY_STATS_PREFIX}.overwriteEnabled.no`, + `${RESOLVE_COPY_STATS_PREFIX}.total`, + `${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.yes`, + `${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`, + `${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`, + `${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`, +]; +export class UsageStatsClient { + constructor( + private readonly debugLogger: (message: string) => void, + private readonly repositoryPromise: Promise + ) {} + + public async getUsageStats() { + this.debugLogger('getUsageStats() called'); + let usageStats: UsageStats = {}; + try { + const repository = await this.repositoryPromise; + const result = await repository.incrementCounter( + SPACES_USAGE_STATS_TYPE, + SPACES_USAGE_STATS_ID, + ALL_COUNTER_FIELDS, + { initialize: true } + ); + usageStats = result.attributes; + } catch (err) { + // do nothing + } + return usageStats; + } + + public async incrementCopySavedObjects({ + headers, + createNewCopies, + overwrite, + }: IncrementCopySavedObjectsOptions) { + const isKibanaRequest = getIsKibanaRequest(headers); + const counterFieldNames = [ + 'total', + `kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`, + `createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`, + `overwriteEnabled.${overwrite ? 'yes' : 'no'}`, + ]; + await this.updateUsageStats(counterFieldNames, COPY_STATS_PREFIX); + } + + public async incrementResolveCopySavedObjectsErrors({ + headers, + createNewCopies, + }: IncrementResolveCopySavedObjectsErrorsOptions) { + const isKibanaRequest = getIsKibanaRequest(headers); + const counterFieldNames = [ + 'total', + `kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`, + `createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`, + ]; + await this.updateUsageStats(counterFieldNames, RESOLVE_COPY_STATS_PREFIX); + } + + private async updateUsageStats(counterFieldNames: string[], prefix: string) { + const options = { refresh: false }; + try { + const repository = await this.repositoryPromise; + await repository.incrementCounter( + SPACES_USAGE_STATS_TYPE, + SPACES_USAGE_STATS_ID, + counterFieldNames.map((x) => `${prefix}.${x}`), + options + ); + } catch (err) { + // do nothing + } + } +} + +function getIsKibanaRequest(headers?: Headers) { + // The presence of these three request headers gives us a good indication that this is a first-party request from the Kibana client. + // We can't be 100% certain, but this is a reasonable attempt. + return headers && headers['kbn-version'] && headers.origin && headers.referer; +} diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_service.mock.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_service.mock.ts new file mode 100644 index 0000000000000..337d6144bd99d --- /dev/null +++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_service.mock.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { usageStatsClientMock } from './usage_stats_client.mock'; +import { UsageStatsServiceSetup } from './usage_stats_service'; + +const createSetupContractMock = (usageStatsClient = usageStatsClientMock.create()) => { + const setupContract: jest.Mocked = { + getClient: jest.fn().mockReturnValue(usageStatsClient), + }; + return setupContract; +}; + +export const usageStatsServiceMock = { + createSetupContract: createSetupContractMock, +}; diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_service.test.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_service.test.ts new file mode 100644 index 0000000000000..5695a39414155 --- /dev/null +++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_service.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { coreMock, loggingSystemMock } from 'src/core/server/mocks'; +import { UsageStatsService } from '.'; +import { UsageStatsClient } from './usage_stats_client'; +import { SPACES_USAGE_STATS_TYPE } from './constants'; + +describe('UsageStatsService', () => { + const mockLogger = loggingSystemMock.createLogger(); + + describe('#setup', () => { + const setup = async () => { + const core = coreMock.createSetup(); + const usageStatsService = await new UsageStatsService(mockLogger).setup(core); + return { core, usageStatsService }; + }; + + it('creates internal repository', async () => { + const { core } = await setup(); + + const [{ savedObjects }] = await core.getStartServices(); + expect(savedObjects.createInternalRepository).toHaveBeenCalledTimes(1); + expect(savedObjects.createInternalRepository).toHaveBeenCalledWith([SPACES_USAGE_STATS_TYPE]); + }); + + describe('#getClient', () => { + it('returns client', async () => { + const { usageStatsService } = await setup(); + + const usageStatsClient = usageStatsService.getClient(); + expect(usageStatsClient).toBeInstanceOf(UsageStatsClient); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_service.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_service.ts new file mode 100644 index 0000000000000..e6a01bdddfd69 --- /dev/null +++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_service.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Logger, CoreSetup } from '../../../../../src/core/server'; +import { UsageStatsClient } from './usage_stats_client'; +import { SPACES_USAGE_STATS_TYPE } from './constants'; + +export interface UsageStatsServiceSetup { + getClient(): UsageStatsClient; +} + +interface UsageStatsServiceDeps { + getStartServices: CoreSetup['getStartServices']; +} + +export class UsageStatsService { + constructor(private readonly log: Logger) {} + + public async setup({ getStartServices }: UsageStatsServiceDeps): Promise { + const internalRepositoryPromise = getStartServices().then(([coreStart]) => + coreStart.savedObjects.createInternalRepository([SPACES_USAGE_STATS_TYPE]) + ); + + const getClient = () => { + const debugLogger = (message: string) => this.log.debug(message); + return new UsageStatsClient(debugLogger, internalRepositoryPromise); + }; + + return { getClient }; + } + + public async stop() {} +} diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index e1b5f4cb9c3ae..f4eb00644b4ec 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -3381,6 +3381,42 @@ }, "count": { "type": "long" + }, + "apiCalls.copySavedObjects.total": { + "type": "long" + }, + "apiCalls.copySavedObjects.kibanaRequest.yes": { + "type": "long" + }, + "apiCalls.copySavedObjects.kibanaRequest.no": { + "type": "long" + }, + "apiCalls.copySavedObjects.createNewCopiesEnabled.yes": { + "type": "long" + }, + "apiCalls.copySavedObjects.createNewCopiesEnabled.no": { + "type": "long" + }, + "apiCalls.copySavedObjects.overwriteEnabled.yes": { + "type": "long" + }, + "apiCalls.copySavedObjects.overwriteEnabled.no": { + "type": "long" + }, + "apiCalls.resolveCopySavedObjectsErrors.total": { + "type": "long" + }, + "apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.yes": { + "type": "long" + }, + "apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no": { + "type": "long" + }, + "apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes": { + "type": "long" + }, + "apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no": { + "type": "long" } } }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e6331fe67276b..b28520b9a9d24 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -19343,7 +19343,6 @@ "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存して閉じる", "xpack.spaces.management.shareToSpace.shareWarningBody": "1つのスペースでのみ編集するには、{makeACopyLink}してください。", "xpack.spaces.management.shareToSpace.shareWarningLink": "コピーを作成", - "xpack.spaces.management.shareToSpace.shareWarningTitle": "共有オブジェクトの編集は、すべてのスペースで変更を適用します。", "xpack.spaces.management.shareToSpace.showLessSpacesLink": "縮小表示", "xpack.spaces.management.shareToSpace.showMoreSpacesLink": "他{count}件", "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "利用可能なスペースを読み込み中にエラーが発生", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 824a3786d6e45..b328ca829ed7b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -19362,7 +19362,6 @@ "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存并关闭", "xpack.spaces.management.shareToSpace.shareWarningBody": "要仅在一个工作区中编辑,请改为{makeACopyLink}。", "xpack.spaces.management.shareToSpace.shareWarningLink": "创建副本", - "xpack.spaces.management.shareToSpace.shareWarningTitle": "编辑共享对象会在所有工作区中应用更改", "xpack.spaces.management.shareToSpace.showLessSpacesLink": "显示更少", "xpack.spaces.management.shareToSpace.showMoreSpacesLink": "另外 {count} 个", "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "加载可用工作区时出错", diff --git a/x-pack/test/functional/apps/spaces/copy_saved_objects.ts b/x-pack/test/functional/apps/spaces/copy_saved_objects.ts index 8f29ae6a27c3a..b14424154a04e 100644 --- a/x-pack/test/functional/apps/spaces/copy_saved_objects.ts +++ b/x-pack/test/functional/apps/spaces/copy_saved_objects.ts @@ -52,6 +52,7 @@ export default function spaceSelectorFunctonalTests({ await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject('A Dashboard'); await PageObjects.copySavedObjectsToSpace.setupForm({ + createNewCopies: false, overwrite: true, destinationSpaceId, }); @@ -80,6 +81,7 @@ export default function spaceSelectorFunctonalTests({ await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject('A Dashboard'); await PageObjects.copySavedObjectsToSpace.setupForm({ + createNewCopies: false, overwrite: false, destinationSpaceId, }); @@ -116,12 +118,42 @@ export default function spaceSelectorFunctonalTests({ await PageObjects.copySavedObjectsToSpace.finishCopy(); }); + it('avoids conflicts when createNewCopies is enabled', async () => { + const destinationSpaceId = 'sales'; + + await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject('A Dashboard'); + + await PageObjects.copySavedObjectsToSpace.setupForm({ + createNewCopies: true, + overwrite: false, + destinationSpaceId, + }); + + await PageObjects.copySavedObjectsToSpace.startCopy(); + + // Wait for successful copy + await testSubjects.waitForDeleted(`cts-summary-indicator-loading-${destinationSpaceId}`); + await testSubjects.existOrFail(`cts-summary-indicator-success-${destinationSpaceId}`); + + const summaryCounts = await PageObjects.copySavedObjectsToSpace.getSummaryCounts(); + + expect(summaryCounts).to.eql({ + success: 3, + pending: 0, + skipped: 0, + errors: 0, + }); + + await PageObjects.copySavedObjectsToSpace.finishCopy(); + }); + it('allows a dashboard to be copied to the marketing space, with circular references', async () => { const destinationSpaceId = 'marketing'; await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject('Dashboard Foo'); await PageObjects.copySavedObjectsToSpace.setupForm({ + createNewCopies: false, overwrite: true, destinationSpaceId, }); diff --git a/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts b/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts index 00a364bb7543e..e77c33b69dcdb 100644 --- a/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts +++ b/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts @@ -28,12 +28,24 @@ export function CopySavedObjectsToSpacePageProvider({ }, async setupForm({ + createNewCopies, overwrite, destinationSpaceId, }: { + createNewCopies?: boolean; overwrite?: boolean; destinationSpaceId: string; }) { + if (createNewCopies && overwrite) { + throw new Error('createNewCopies and overwrite options cannot be used together'); + } + if (!createNewCopies) { + const form = await testSubjects.find('copy-to-space-form'); + // a radio button consists of a div tag that contains an input, a div, and a label + // we can't click the input directly, need to click the label + const label = await form.findByCssSelector('label[for="createNewCopiesDisabled"]'); + await label.click(); + } if (!overwrite) { const radio = await testSubjects.find('cts-copyModeControl-overwriteRadioGroup'); // a radio button consists of a div tag that contains an input, a div, and a label diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index 2039134f68bbc..24fa3e642a832 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -607,6 +607,7 @@ export function copyToSpaceTestSuiteFactory( objects: [dashboardObject], spaces: [destination], includeReferences: false, + createNewCopies: false, overwrite: false, }) .expect(tests.noConflictsWithoutReferences.statusCode) @@ -625,6 +626,7 @@ export function copyToSpaceTestSuiteFactory( objects: [dashboardObject], spaces: [destination], includeReferences: true, + createNewCopies: false, overwrite: false, }) .expect(tests.noConflictsWithReferences.statusCode) @@ -643,6 +645,7 @@ export function copyToSpaceTestSuiteFactory( objects: [dashboardObject], spaces: [destination], includeReferences: true, + createNewCopies: false, overwrite: true, }) .expect(tests.withConflictsOverwriting.statusCode) @@ -661,6 +664,7 @@ export function copyToSpaceTestSuiteFactory( objects: [dashboardObject], spaces: [destination], includeReferences: true, + createNewCopies: false, overwrite: false, }) .expect(tests.withConflictsWithoutOverwriting.statusCode) @@ -678,6 +682,7 @@ export function copyToSpaceTestSuiteFactory( objects: [dashboardObject], spaces: [conflictDestination, noConflictDestination], includeReferences: true, + createNewCopies: false, overwrite: true, }) .expect(tests.multipleSpaces.statusCode) @@ -710,6 +715,7 @@ export function copyToSpaceTestSuiteFactory( objects: [dashboardObject], spaces: ['non_existent_space'], includeReferences: false, + createNewCopies: false, overwrite: true, }) .expect(tests.nonExistentSpace.statusCode) @@ -720,6 +726,7 @@ export function copyToSpaceTestSuiteFactory( [false, true].forEach((overwrite) => { const spaces = ['space_2']; const includeReferences = false; + const createNewCopies = false; describe(`multi-namespace types with overwrite=${overwrite}`, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); @@ -730,7 +737,7 @@ export function copyToSpaceTestSuiteFactory( return supertest .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) .auth(user.username, user.password) - .send({ objects, spaces, includeReferences, overwrite }) + .send({ objects, spaces, includeReferences, createNewCopies, overwrite }) .expect(statusCode) .then(response); }); diff --git a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts index 63f5de1976440..1ae7c7acd6655 100644 --- a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -442,6 +442,7 @@ export function resolveCopyToSpaceConflictsSuite( .send({ objects: [dashboardObject], includeReferences: true, + createNewCopies: false, retries: { [destination]: [{ ...visualizationObject, overwrite: false }] }, }) .expect(tests.withReferencesNotOverwriting.statusCode) @@ -457,6 +458,7 @@ export function resolveCopyToSpaceConflictsSuite( .send({ objects: [dashboardObject], includeReferences: true, + createNewCopies: false, retries: { [destination]: [{ ...visualizationObject, overwrite: true }] }, }) .expect(tests.withReferencesOverwriting.statusCode) @@ -472,6 +474,7 @@ export function resolveCopyToSpaceConflictsSuite( .send({ objects: [dashboardObject], includeReferences: false, + createNewCopies: false, retries: { [destination]: [{ ...dashboardObject, overwrite: true }] }, }) .expect(tests.withoutReferencesOverwriting.statusCode) @@ -487,6 +490,7 @@ export function resolveCopyToSpaceConflictsSuite( .send({ objects: [dashboardObject], includeReferences: false, + createNewCopies: false, retries: { [destination]: [{ ...dashboardObject, overwrite: false }] }, }) .expect(tests.withoutReferencesNotOverwriting.statusCode) @@ -502,6 +506,7 @@ export function resolveCopyToSpaceConflictsSuite( .send({ objects: [dashboardObject], includeReferences: false, + createNewCopies: false, retries: { [destination]: [{ ...dashboardObject, overwrite: true }] }, }) .expect(tests.nonExistentSpace.statusCode) @@ -510,6 +515,7 @@ export function resolveCopyToSpaceConflictsSuite( }); const includeReferences = false; + const createNewCopies = false; describe(`multi-namespace types with "overwrite" retry`, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); @@ -520,7 +526,7 @@ export function resolveCopyToSpaceConflictsSuite( return supertestWithoutAuth .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) .auth(user.username, user.password) - .send({ objects, includeReferences, retries }) + .send({ objects, includeReferences, createNewCopies, retries }) .expect(statusCode) .then(response); });