Add telemetry for KQL (#23547) (#24340)

This PR adds two usage stats to our telemetry for KQL:

* How many times people click the opt in/out toggle in the query bar UI
* Which language Kibana admins have set as the global default in advanced settings
This commit is contained in:
Matt Bargar 2018-10-23 12:31:26 -04:00 committed by GitHub
parent 71e4600973
commit d10f21ddea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 870 additions and 2 deletions

View file

@ -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));

View file

@ -167,5 +167,15 @@
"type": "keyword"
}
}
},
"kql-telemetry": {
"properties": {
"optInCount": {
"type": "long"
},
"optOutCount": {
"type": "long"
}
}
}
}

View file

@ -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,
};
}

View file

@ -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');
});
});
});

View file

@ -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';

View file

@ -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);
}

View file

@ -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');
});
});

View file

@ -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 });
},
});
}

View file

@ -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();

View file

@ -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));

View file

@ -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 });
});

View file

@ -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'));

View file

@ -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'));
});
}

View file

@ -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),
]);
});
});
}

View file

@ -7,5 +7,6 @@
export default function ({ loadTestFile }) {
describe('kibana', () => {
loadTestFile(require.resolve('./stats'));
loadTestFile(require.resolve('./kql_telemetry'));
});
}

View file

@ -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'));
});
}

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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);
});
});
});
});
}