diff --git a/src/core_plugins/kibana/index.js b/src/core_plugins/kibana/index.js index 82859b388967..9c8ed813cd84 100644 --- a/src/core_plugins/kibana/index.js +++ b/src/core_plugins/kibana/index.js @@ -29,13 +29,14 @@ import { homeApi } from './server/routes/api/home'; import { managementApi } from './server/routes/api/management'; import { scriptsApi } from './server/routes/api/scripts'; import { registerSuggestionsApi } from './server/routes/api/suggestions'; +import { registerKqlTelemetryApi } from './server/routes/api/kql_telemetry'; import { registerFieldFormats } from './server/field_formats/register'; import { registerTutorials } from './server/tutorials/register'; import * as systemApi from './server/lib/system_api'; import handleEsError from './server/lib/handle_es_error'; import mappings from './mappings.json'; import { getUiSettingDefaults } from './ui_setting_defaults'; - +import { makeKQLUsageCollector } from './server/lib/kql_usage_collector'; import { injectVars } from './inject_vars'; const mkdirp = Promise.promisify(mkdirpNode); @@ -119,6 +120,12 @@ export default function (kibana) { }, ], + savedObjectSchemas: { + 'kql-telemetry': { + isNamespaceAgnostic: true, + }, + }, + injectDefaultVars(server, options) { return { kbnIndex: options.index, @@ -156,8 +163,10 @@ export default function (kibana) { homeApi(server); managementApi(server); registerSuggestionsApi(server); + registerKqlTelemetryApi(server); registerFieldFormats(server); registerTutorials(server); + makeKQLUsageCollector(server); server.expose('systemApi', systemApi); server.expose('handleEsError', handleEsError); server.injectUiAppVars('kibana', () => injectVars(server)); diff --git a/src/core_plugins/kibana/mappings.json b/src/core_plugins/kibana/mappings.json index 471cee57da56..2ccdd5cd8a36 100644 --- a/src/core_plugins/kibana/mappings.json +++ b/src/core_plugins/kibana/mappings.json @@ -167,5 +167,15 @@ "type": "keyword" } } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } } } diff --git a/src/core_plugins/kibana/server/lib/kql_usage_collector/fetch.js b/src/core_plugins/kibana/server/lib/kql_usage_collector/fetch.js new file mode 100644 index 000000000000..20008fdb47c4 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/kql_usage_collector/fetch.js @@ -0,0 +1,70 @@ +/* + * 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 { getUiSettingDefaults } from '../../../ui_setting_defaults'; +import { get } from 'lodash'; + +const uiSettingDefaults = getUiSettingDefaults(); +const defaultSearchQueryLanguageSetting = uiSettingDefaults['search:queryLanguage'].value; + +export async function fetch(callCluster) { + const [response, config] = await Promise.all([ + callCluster('get', { + index: '.kibana', + type: 'doc', + id: 'kql-telemetry:kql-telemetry', + ignore: [404], + }), + callCluster('search', { + index: '.kibana', + body: { query: { term: { type: 'config' } } }, + ignore: [404], + }), + ]); + + const queryLanguageConfigValue = get(config, 'hits.hits[0]._source.config.search:queryLanguage'); + + // search:queryLanguage can potentially be in four states in the .kibana index: + // 1. undefined: this means the user has never touched this setting + // 2. null: this means the user has touched the setting, but the current value matches the default + // 3. 'kuery' or 'lucene': this means the user has explicitly selected the given non-default language + // + // It's nice to know if the user has never touched the setting or if they tried kuery then + // went back to the default, so I preserve this info by prefixing the language name with + // 'default-' when the value in .kibana is undefined (case #1). + let defaultLanguage; + if (queryLanguageConfigValue === undefined) { + defaultLanguage = `default-${defaultSearchQueryLanguageSetting}`; + } else if (queryLanguageConfigValue === null) { + defaultLanguage = defaultSearchQueryLanguageSetting; + } else { + defaultLanguage = queryLanguageConfigValue; + } + + const kqlTelemetryDoc = { + optInCount: 0, + optOutCount: 0, + ...get(response, '_source.kql-telemetry', {}), + }; + + return { + ...kqlTelemetryDoc, + defaultQueryLanguage: defaultLanguage, + }; +} diff --git a/src/core_plugins/kibana/server/lib/kql_usage_collector/fetch.test.js b/src/core_plugins/kibana/server/lib/kql_usage_collector/fetch.test.js new file mode 100644 index 000000000000..17903dbbcb34 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/kql_usage_collector/fetch.test.js @@ -0,0 +1,116 @@ +/* + * 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. + */ + +jest.mock('../../../ui_setting_defaults', () => ({ + getUiSettingDefaults: () => ({ 'search:queryLanguage': { value: 'lucene' } }), +})); + +import { fetch } from './fetch'; + +let callCluster; + +function setupMockCallCluster(optCount, language) { + callCluster = jest.fn((method, params) => { + if ('id' in params && params.id === 'kql-telemetry:kql-telemetry') { + if (optCount === null) { + return Promise.resolve({ + _index: '.kibana_1', + _type: 'doc', + _id: 'kql-telemetry:kql-telemetry', + found: false, + }); + } else { + return Promise.resolve({ + _source: { + 'kql-telemetry': { + ...optCount, + }, + type: 'kql-telemetry', + updated_at: '2018-10-05T20:20:56.258Z', + }, + }); + } + } else if ('body' in params && params.body.query.term.type === 'config') { + if (language === 'missingConfigDoc') { + Promise.resolve({ + hits: { + hits: [], + }, + }); + } else { + return Promise.resolve({ + hits: { + hits: [ + { + _source: { + config: { + 'search:queryLanguage': language, + }, + }, + }, + ], + }, + }); + } + } + }); +} + +describe('makeKQLUsageCollector', () => { + describe('fetch method', () => { + it('should return opt in data from the .kibana/kql-telemetry doc', async () => { + setupMockCallCluster({ optInCount: 1 }, 'kuery'); + const fetchResponse = await fetch(callCluster); + expect(fetchResponse.optInCount).toBe(1); + expect(fetchResponse.optOutCount).toBe(0); + }); + + it('should return the default query language set in advanced settings', async () => { + setupMockCallCluster({ optInCount: 1 }, 'kuery'); + const fetchResponse = await fetch(callCluster); + expect(fetchResponse.defaultQueryLanguage).toBe('kuery'); + }); + + // Indicates the user has modified the setting at some point but the value is currently the default + it('should return the kibana default query language if the config value is null', async () => { + setupMockCallCluster({ optInCount: 1 }, null); + const fetchResponse = await fetch(callCluster); + expect(fetchResponse.defaultQueryLanguage).toBe('lucene'); + }); + + it('should indicate when the default language has never been modified by the user', async () => { + setupMockCallCluster({ optInCount: 1 }, undefined); + const fetchResponse = await fetch(callCluster); + expect(fetchResponse.defaultQueryLanguage).toBe('default-lucene'); + }); + + it('should default to 0 opt in counts if the .kibana/kql-telemetry doc does not exist', async () => { + setupMockCallCluster(null, 'kuery'); + const fetchResponse = await fetch(callCluster); + expect(fetchResponse.optInCount).toBe(0); + expect(fetchResponse.optOutCount).toBe(0); + }); + + it('should default to the kibana default language if the config document does not exist', async () => { + setupMockCallCluster(null, 'missingConfigDoc'); + const fetchResponse = await fetch(callCluster); + expect(fetchResponse.defaultQueryLanguage).toBe('default-lucene'); + }); + }); +}); diff --git a/src/core_plugins/kibana/server/lib/kql_usage_collector/index.js b/src/core_plugins/kibana/server/lib/kql_usage_collector/index.js new file mode 100644 index 000000000000..ea2979834be3 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/kql_usage_collector/index.js @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { makeKQLUsageCollector } from './make_kql_usage_collector'; diff --git a/src/core_plugins/kibana/server/lib/kql_usage_collector/make_kql_usage_collector.js b/src/core_plugins/kibana/server/lib/kql_usage_collector/make_kql_usage_collector.js new file mode 100644 index 000000000000..3ceff0fee504 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/kql_usage_collector/make_kql_usage_collector.js @@ -0,0 +1,29 @@ +/* + * 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 { fetch } from './fetch'; + +export function makeKQLUsageCollector(server) { + const kqlUsageCollector = server.usage.collectorSet.makeUsageCollector({ + type: 'kql', + fetch, + }); + + server.usage.collectorSet.register(kqlUsageCollector); +} diff --git a/src/core_plugins/kibana/server/lib/kql_usage_collector/make_kql_usage_collector.test.js b/src/core_plugins/kibana/server/lib/kql_usage_collector/make_kql_usage_collector.test.js new file mode 100644 index 000000000000..7fd19fbcccc6 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/kql_usage_collector/make_kql_usage_collector.test.js @@ -0,0 +1,48 @@ +/* + * 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 { makeKQLUsageCollector } from './make_kql_usage_collector'; + +describe('makeKQLUsageCollector', () => { + + let server; + let makeUsageCollectorStub; + let registerStub; + + beforeEach(() => { + makeUsageCollectorStub = jest.fn(); + registerStub = jest.fn(); + server = { + usage: { + collectorSet: { makeUsageCollector: makeUsageCollectorStub, register: registerStub }, + }, + }; + }); + + it('should call collectorSet.register', () => { + makeKQLUsageCollector(server); + expect(registerStub).toHaveBeenCalledTimes(1); + }); + + it('should call makeUsageCollector with type = kql', () => { + makeKQLUsageCollector(server); + expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('kql'); + }); +}); diff --git a/src/core_plugins/kibana/server/routes/api/kql_telemetry/index.js b/src/core_plugins/kibana/server/routes/api/kql_telemetry/index.js new file mode 100644 index 000000000000..028f3062be9e --- /dev/null +++ b/src/core_plugins/kibana/server/routes/api/kql_telemetry/index.js @@ -0,0 +1,60 @@ +/* + * 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 Joi from 'joi'; +import Boom from 'boom'; + +export function registerKqlTelemetryApi(server) { + server.route({ + path: '/api/kibana/kql_opt_in_telemetry', + method: 'POST', + config: { + validate: { + payload: { + opt_in: Joi.bool().required(), + }, + }, + tags: ['api'], + }, + handler: async function (request, reply) { + const { savedObjects: { getSavedObjectsRepository } } = server; + const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); + const internalRepository = getSavedObjectsRepository(callWithInternalUser); + + const { + payload: { opt_in: optIn }, + } = request; + + const counterName = optIn ? 'optInCount' : 'optOutCount'; + + try { + await internalRepository.incrementCounter( + 'kql-telemetry', + 'kql-telemetry', + counterName, + ); + } + catch (error) { + reply(new Boom('Something went wrong', { statusCode: error.status, data: { success: false } })); + } + + reply({ success: true }); + }, + }); +} diff --git a/src/server/saved_objects/service/lib/repository.js b/src/server/saved_objects/service/lib/repository.js index b01b893fc93b..992386ac4c8c 100644 --- a/src/server/saved_objects/service/lib/repository.js +++ b/src/server/saved_objects/service/lib/repository.js @@ -500,6 +500,82 @@ export class SavedObjectsRepository { }; } + /** + * Increases a counter field by one. Creates the document if one doesn't exist for the given id. + * + * @param {string} type + * @param {string} id + * @param {string} counterFieldName + * @param {object} [options={}] + * @property {object} [options.migrationVersion=undefined] + * @returns {promise} + */ + async incrementCounter(type, id, counterFieldName, options = {}) { + if (typeof type !== 'string') { + throw new Error('"type" argument must be a string'); + } + if (typeof counterFieldName !== 'string') { + throw new Error('"counterFieldName" argument must be a string'); + } + + const { + migrationVersion, + namespace, + } = options; + + const time = this._getCurrentTime(); + + + const migrated = this._migrator.migrateDocument({ + id, + type, + attributes: { [counterFieldName]: 1 }, + migrationVersion, + updated_at: time, + }); + + const raw = this._serializer.savedObjectToRaw(migrated); + + const response = await this._writeToCluster('update', { + id: this._serializer.generateRawId(namespace, type, id), + type: this._type, + index: this._index, + refresh: 'wait_for', + _source: true, + body: { + script: { + source: ` + if (ctx._source[params.type][params.counterFieldName] == null) { + ctx._source[params.type][params.counterFieldName] = params.count; + } + else { + ctx._source[params.type][params.counterFieldName] += params.count; + } + ctx._source.updated_at = params.time; + `, + lang: 'painless', + params: { + count: 1, + time, + type, + counterFieldName, + }, + }, + upsert: raw._source, + }, + }); + + return { + id, + type, + updated_at: time, + version: response._version, + attributes: response.get._source[type], + }; + + + } + async _writeToCluster(method, params) { try { await this._onBeforeWrite(); diff --git a/src/server/saved_objects/service/lib/repository.test.js b/src/server/saved_objects/service/lib/repository.test.js index c0dbcc964d58..af5050ccc031 100644 --- a/src/server/saved_objects/service/lib/repository.test.js +++ b/src/server/saved_objects/service/lib/repository.test.js @@ -1252,6 +1252,219 @@ describe('SavedObjectsRepository', () => { }); }); + describe('#incrementCounter', () => { + beforeEach(() => { + callAdminCluster.callsFake((method, params) => ({ + _type: 'doc', + _id: params.id, + _version: 2, + _index: '.kibana', + get: { + found: true, + _source: { + type: 'config', + ...mockTimestampFields, + config: { + buildNum: 8468, + defaultIndex: 'logstash-*', + }, + }, + }, + })); + }); + + it('formats Elasticsearch response', async () => { + callAdminCluster.callsFake((method, params) => ({ + _type: 'doc', + _id: params.id, + _version: 2, + _index: '.kibana', + get: { + found: true, + _source: { + type: 'config', + ...mockTimestampFields, + config: { + buildNum: 8468, + defaultIndex: 'logstash-*', + }, + }, + }, + })); + + + const response = await savedObjectsRepository.incrementCounter( + 'config', + '6.0.0-alpha1', + 'buildNum', + { + namespace: 'foo-namespace', + } + ); + + expect(response).toEqual({ + type: 'config', + id: '6.0.0-alpha1', + ...mockTimestampFields, + version: 2, + attributes: { + buildNum: 8468, + defaultIndex: 'logstash-*' + } + }); + }); + + it('migrates the doc if an upsert is required', async () => { + migrator.migrateDocument = (doc) => { + doc.attributes.buildNum = 42; + doc.migrationVersion = { foo: '2.3.4' }; + return doc; + }; + + await savedObjectsRepository.incrementCounter( + 'config', + 'doesnotexist', + 'buildNum', + { + namespace: 'foo-namespace', + } + ); + + sinon.assert.calledOnce(callAdminCluster); + expect(callAdminCluster.firstCall.args[1]).toMatchObject({ + body: { + upsert: { + config: { buildNum: 42 }, + migrationVersion: { foo: '2.3.4' }, + type: 'config', + ...mockTimestampFields + } + }, + }); + }); + + it(`prepends namespace to the id but doesn't add namespace to body when providing namespace for namespaced type`, async () => { + await savedObjectsRepository.incrementCounter('config', '6.0.0-alpha1', 'buildNum', { + namespace: 'foo-namespace', + }); + + sinon.assert.calledOnce(callAdminCluster); + + const requestDoc = callAdminCluster.firstCall.args[1]; + expect(requestDoc.id).toBe('foo-namespace:config:6.0.0-alpha1'); + expect(requestDoc.body.script.params.type).toBe('config'); + expect(requestDoc.body.upsert.type).toBe('config'); + expect(requestDoc).toHaveProperty('body.upsert.config'); + + sinon.assert.calledOnce(onBeforeWrite); + }); + + it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { + await savedObjectsRepository.incrementCounter('config', '6.0.0-alpha1', 'buildNum'); + + sinon.assert.calledOnce(callAdminCluster); + + const requestDoc = callAdminCluster.firstCall.args[1]; + expect(requestDoc.id).toBe('config:6.0.0-alpha1'); + expect(requestDoc.body.script.params.type).toBe('config'); + expect(requestDoc.body.upsert.type).toBe('config'); + expect(requestDoc).toHaveProperty('body.upsert.config'); + + sinon.assert.calledOnce(onBeforeWrite); + }); + + it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { + callAdminCluster.callsFake((method, params) => ({ + _type: 'doc', + _id: params.id, + _version: 2, + _index: '.kibana', + get: { + found: true, + _source: { + type: 'globaltype', + ...mockTimestampFields, + globaltype: { + counter: 1, + }, + }, + }, + })); + + await savedObjectsRepository.incrementCounter('globaltype', 'foo', 'counter', { + namespace: 'foo-namespace', + }); + + sinon.assert.calledOnce(callAdminCluster); + + const requestDoc = callAdminCluster.firstCall.args[1]; + expect(requestDoc.id).toBe('globaltype:foo'); + expect(requestDoc.body.script.params.type).toBe('globaltype'); + expect(requestDoc.body.upsert.type).toBe('globaltype'); + expect(requestDoc).toHaveProperty('body.upsert.globaltype'); + + sinon.assert.calledOnce(onBeforeWrite); + }); + + it('should assert that the "type" and "counterFieldName" arguments are strings', () => { + expect.assertions(6); + + expect(savedObjectsRepository.incrementCounter( + null, + '6.0.0-alpha1', + 'buildNum', + { + namespace: 'foo-namespace', + }), + ).rejects.toEqual(new Error('"type" argument must be a string')); + + expect(savedObjectsRepository.incrementCounter( + 42, + '6.0.0-alpha1', + 'buildNum', + { + namespace: 'foo-namespace', + }), + ).rejects.toEqual(new Error('"type" argument must be a string')); + + expect(savedObjectsRepository.incrementCounter( + {}, + '6.0.0-alpha1', + 'buildNum', + { + namespace: 'foo-namespace', + }), + ).rejects.toEqual(new Error('"type" argument must be a string')); + + expect(savedObjectsRepository.incrementCounter( + 'config', + '6.0.0-alpha1', + null, + { + namespace: 'foo-namespace', + }), + ).rejects.toEqual(new Error('"counterFieldName" argument must be a string')); + + expect(savedObjectsRepository.incrementCounter( + 'config', + '6.0.0-alpha1', + 42, + { + namespace: 'foo-namespace', + }), + ).rejects.toEqual(new Error('"counterFieldName" argument must be a string')); + + expect(savedObjectsRepository.incrementCounter( + 'config', + '6.0.0-alpha1', + {}, + { + namespace: 'foo-namespace', + }), + ).rejects.toEqual(new Error('"counterFieldName" argument must be a string')); + }); + }); + describe('onBeforeWrite', () => { it('blocks calls to callCluster of requests', async () => { onBeforeWrite.returns(delay(500)); diff --git a/src/ui/public/query_bar/directive/query_popover.js b/src/ui/public/query_bar/directive/query_popover.js index 6c8cdd69ad57..14be6f41b3c1 100644 --- a/src/ui/public/query_bar/directive/query_popover.js +++ b/src/ui/public/query_bar/directive/query_popover.js @@ -21,6 +21,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { uiModules } from '../../modules'; import { documentationLinks } from '../../documentation_links/documentation_links'; +import { kfetch } from 'ui/kfetch'; import { EuiPopover, EuiButtonEmpty, @@ -62,8 +63,18 @@ module.directive('queryPopover', function (localStorage) { } function onSwitchChange() { + const newLanguage = $scope.language === 'lucene' ? 'kuery' : 'lucene'; + + // Send telemetry info every time the user opts in or out of kuery + // As a result it is important this function only ever gets called in the + // UI component's change handler. + kfetch({ + pathname: '/api/kibana/kql_opt_in_telemetry', + method: 'POST', + body: JSON.stringify({ opt_in: newLanguage === 'kuery' }), + }); + $scope.$evalAsync(() => { - const newLanguage = $scope.language === 'lucene' ? 'kuery' : 'lucene'; localStorage.set('kibana.userQueryLanguage', newLanguage); $scope.onSelectLanguage({ $language: newLanguage }); }); diff --git a/test/api_integration/apis/index.js b/test/api_integration/apis/index.js index 4ec28eca427b..d43199da7a93 100644 --- a/test/api_integration/apis/index.js +++ b/test/api_integration/apis/index.js @@ -23,6 +23,7 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./home')); loadTestFile(require.resolve('./index_patterns')); + loadTestFile(require.resolve('./kql_telemetry')); loadTestFile(require.resolve('./management')); loadTestFile(require.resolve('./saved_objects')); loadTestFile(require.resolve('./scripts')); diff --git a/test/api_integration/apis/kql_telemetry/index.js b/test/api_integration/apis/kql_telemetry/index.js new file mode 100644 index 000000000000..bf72fa90ac00 --- /dev/null +++ b/test/api_integration/apis/kql_telemetry/index.js @@ -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. + */ + +export default function ({ loadTestFile }) { + describe('KQL', () => { + loadTestFile(require.resolve('./kql_telemetry')); + }); +} diff --git a/test/api_integration/apis/kql_telemetry/kql_telemetry.js b/test/api_integration/apis/kql_telemetry/kql_telemetry.js new file mode 100644 index 000000000000..6ed48434064e --- /dev/null +++ b/test/api_integration/apis/kql_telemetry/kql_telemetry.js @@ -0,0 +1,124 @@ +/* + * 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 expect from 'expect.js'; +import Promise from 'bluebird'; +import { get } from 'lodash'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('telemetry API', () => { + before(() => esArchiver.load('saved_objects/basic')); + after(() => esArchiver.unload('saved_objects/basic')); + + it('should increment the opt *in* counter in the .kibana/kql-telemetry document', async () => { + await supertest + .post('/api/kibana/kql_opt_in_telemetry') + .set('content-type', 'application/json') + .send({ opt_in: true }) + .expect(200); + + return es.search({ + index: '.kibana', + q: 'type:kql-telemetry', + }).then(response => { + const kqlTelemetryDoc = get(response, 'hits.hits[0]._source.kql-telemetry'); + expect(kqlTelemetryDoc.optInCount).to.be(1); + }); + }); + + it('should increment the opt *out* counter in the .kibana/kql-telemetry document', async () => { + await supertest + .post('/api/kibana/kql_opt_in_telemetry') + .set('content-type', 'application/json') + .send({ opt_in: false }) + .expect(200); + + return es.search({ + index: '.kibana', + q: 'type:kql-telemetry', + }).then(response => { + const kqlTelemetryDoc = get(response, 'hits.hits[0]._source.kql-telemetry'); + expect(kqlTelemetryDoc.optOutCount).to.be(1); + }); + + }); + + + it('should report success when opt *in* is incremented successfully', () => { + return supertest + .post('/api/kibana/kql_opt_in_telemetry') + .set('content-type', 'application/json') + .send({ opt_in: true }) + .expect('Content-Type', /json/) + .expect(200) + .then(({ body }) => { + expect(body.success).to.be(true); + }); + }); + + it('should report success when opt *out* is incremented successfully', () => { + return supertest + .post('/api/kibana/kql_opt_in_telemetry') + .set('content-type', 'application/json') + .send({ opt_in: false }) + .expect('Content-Type', /json/) + .expect(200) + .then(({ body }) => { + expect(body.success).to.be(true); + }); + }); + + it('should only accept literal boolean values for the opt_in POST body param', function () { + return Promise.all([ + supertest + .post('/api/kibana/kql_opt_in_telemetry') + .set('content-type', 'application/json') + .send({ opt_in: 'notabool' }) + .expect(400), + supertest + .post('/api/kibana/kql_opt_in_telemetry') + .set('content-type', 'application/json') + .send({ opt_in: 0 }) + .expect(400), + supertest + .post('/api/kibana/kql_opt_in_telemetry') + .set('content-type', 'application/json') + .send({ opt_in: null }) + .expect(400), + supertest + .post('/api/kibana/kql_opt_in_telemetry') + .set('content-type', 'application/json') + .send({ opt_in: undefined }) + .expect(400), + supertest + .post('/api/kibana/kql_opt_in_telemetry') + .set('content-type', 'application/json') + .send({}) + .expect(400), + ]); + }); + + }); + +} + diff --git a/x-pack/test/api_integration/apis/kibana/index.js b/x-pack/test/api_integration/apis/kibana/index.js index d84ac9c75154..be0eb513c515 100644 --- a/x-pack/test/api_integration/apis/kibana/index.js +++ b/x-pack/test/api_integration/apis/kibana/index.js @@ -7,5 +7,6 @@ export default function ({ loadTestFile }) { describe('kibana', () => { loadTestFile(require.resolve('./stats')); + loadTestFile(require.resolve('./kql_telemetry')); }); } diff --git a/x-pack/test/api_integration/apis/kibana/kql_telemetry/index.js b/x-pack/test/api_integration/apis/kibana/kql_telemetry/index.js new file mode 100644 index 000000000000..e6735de40dfe --- /dev/null +++ b/x-pack/test/api_integration/apis/kibana/kql_telemetry/index.js @@ -0,0 +1,11 @@ +/* + * 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 default function ({ loadTestFile }) { + describe('KQL', () => { + loadTestFile(require.resolve('./kql_telemetry')); + }); +} diff --git a/x-pack/test/api_integration/apis/kibana/kql_telemetry/kql_telemetry.js b/x-pack/test/api_integration/apis/kibana/kql_telemetry/kql_telemetry.js new file mode 100644 index 000000000000..ab47576cdc23 --- /dev/null +++ b/x-pack/test/api_integration/apis/kibana/kql_telemetry/kql_telemetry.js @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; + +export default function ({ getService }) { + const supertestNoAuth = getService('supertestWithoutAuth'); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('telemetry API', () => { + before(() => esArchiver.load('empty_kibana')); + after(() => esArchiver.unload('empty_kibana')); + + describe('no auth', () => { + it('should return 401', async () => { + return supertestNoAuth + .post('/api/kibana/kql_opt_in_telemetry') + .set('content-type', 'application/json') + .set('kbn-xsrf', 'much access') + .send({ opt_in: true }) + .expect(401); + }); + }); + + describe('with auth', () => { + it('should return 200 for a successful request', async () => { + return supertest + .post('/api/kibana/kql_opt_in_telemetry') + .set('content-type', 'application/json') + .set('kbn-xsrf', 'such token, wow') + .send({ opt_in: true }) + .expect('Content-Type', /json/) + .expect(200) + .then(({ body }) => { + expect(body.success).to.be(true); + }); + }); + + }); + }); +}