Add user_action app for gathering telemetry on user actions (#31543)

* Add user_action app with API endpoint for tracking user actions.
* Track 'Create rollup job' action and publish it in the collector.
* Extract fetchUserActions into a generic helper inside of xpack/server/lib.
* Add trackUserRequest helper.
This commit is contained in:
CJ Cenizal 2019-03-05 16:53:34 -08:00 committed by GitHub
parent a949da2437
commit 554e7be400
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 293 additions and 1 deletions

View file

@ -0,0 +1,35 @@
/*
* 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 { registerUserActionRoute } from './server/routes/api/user_action';
export default function (kibana) {
return new kibana.Plugin({
id: 'user_action',
require: ['kibana', 'elasticsearch'],
uiExports: {
mappings: require('./mappings.json'),
},
init: function (server) {
registerUserActionRoute(server);
}
});
}

View file

@ -0,0 +1,9 @@
{
"user-action": {
"properties": {
"count": {
"type": "integer"
}
}
}
}

View file

@ -0,0 +1,4 @@
{
"name": "user_action",
"version": "kibana"
}

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 Boom from 'boom';
import { Server } from 'hapi';
export const registerUserActionRoute = (server: Server) => {
/*
* Increment a count on an object representing a specific user action.
*/
server.route({
path: '/api/user_action/{appName}/{actionType}',
method: 'POST',
handler: async (request: any) => {
const { appName, actionType } = request.params;
try {
const { getSavedObjectsRepository } = server.savedObjects;
const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin');
const internalRepository = getSavedObjectsRepository(callWithInternalUser);
const savedObjectId = `${appName}:${actionType}`;
// This object is created if it doesn't already exist.
await internalRepository.incrementCounter('user-action', savedObjectId, 'count');
return {};
} catch (error) {
return new Boom('Something went wrong', { statusCode: error.status });
}
},
});
};

View file

@ -32,5 +32,6 @@ export default function ({ loadTestFile }) {
loadTestFile(require.resolve('./suggestions'));
loadTestFile(require.resolve('./status'));
loadTestFile(require.resolve('./stats'));
loadTestFile(require.resolve('./user_action'));
});
}

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('User Action', () => {
loadTestFile(require.resolve('./user_action'));
});
}

View file

@ -0,0 +1,45 @@
/*
* 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 { get } from 'lodash';
export default function ({ getService }) {
const supertest = getService('supertest');
const es = getService('es');
describe('user_action API', () => {
it('increments the count field in the document defined by the {app}/{action_type} path', async () => {
await supertest
.post('/api/user_action/myApp/myAction')
.set('kbn-xsrf', 'kibana')
.expect(200);
return es.search({
index: '.kibana',
q: 'type:user-action',
}).then(response => {
const doc = get(response, 'hits.hits[0]');
expect(get(doc, '_source.user-action.count')).to.be(1);
expect(doc._id).to.be('user-action:myApp:myAction');
});
});
});
}

View file

@ -0,0 +1,7 @@
/*
* 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 { createUserActionUri } from './user_action';

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.
*/
import chrome from 'ui/chrome';
export function createUserActionUri(appName: string, actionType: string): string {
return chrome.addBasePath(`/api/user_action/${appName}/${actionType}`);
}

View file

@ -7,3 +7,10 @@
export const PLUGIN = {
ID: 'rollup'
};
export const UA_APP_NAME = 'rollup-job-wizard';
export const UA_ROLLUP_JOB_CREATE = 'create';
export const USER_ACTIONS = [
UA_ROLLUP_JOB_CREATE,
];

View file

@ -5,7 +5,9 @@
*/
import chrome from 'ui/chrome';
import { UA_ROLLUP_JOB_CREATE } from '../../../common';
import { getHttp } from './http_provider';
import { trackUserRequest } from './track_user_action';
const apiPrefix = chrome.addBasePath('/api/rollup');
@ -31,7 +33,8 @@ export async function deleteJobs(jobIds) {
export async function createJob(job) {
const body = { job };
return await getHttp().put(`${apiPrefix}/create`, body);
const request = getHttp().put(`${apiPrefix}/create`, body);
return await trackUserRequest(request, UA_ROLLUP_JOB_CREATE);
}
export async function validateIndexPattern(indexPattern) {

View file

@ -92,3 +92,7 @@ export {
export {
sortTable,
} from './sort_table';
export {
trackUserAction,
} from './track_user_action';

View file

@ -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.
*/
import { createUserActionUri } from '../../../../../common/user_action';
import { UA_APP_NAME } from '../../../common';
import { getHttp } from './http_provider';
export function trackUserAction(actionType) {
const userActionUri = createUserActionUri(UA_APP_NAME, actionType);
getHttp().post(userActionUri);
}
export function trackUserRequest(request, actionType) {
// Only track successful actions.
request.then(() => trackUserAction(actionType));
return request;
}

View file

@ -5,6 +5,8 @@
*/
import { get } from 'lodash';
import { fetchUserActions } from '../../../../server/lib/user_action';
import { UA_APP_NAME, USER_ACTIONS } from '../../common';
const ROLLUP_USAGE_TYPE = 'rollups';
@ -180,6 +182,8 @@ export function registerRollupUsageCollector(server) {
rollupVisualizationsFromSavedSearches,
} = await fetchRollupVisualizations(kibanaIndex, callCluster, rollupIndexPatternToFlagMap, rollupSavedSearchesToFlagMap);
const userActions = await fetchUserActions(server, UA_APP_NAME, USER_ACTIONS);
return {
index_patterns: {
total: rollupIndexPatterns.length,
@ -193,6 +197,7 @@ export function registerRollupUsageCollector(server) {
total: rollupVisualizationsFromSavedSearches,
},
},
user_actions: userActions,
};
},
});

View file

@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View file

@ -0,0 +1,59 @@
/*
* 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 { Server } from 'hapi';
export interface UserActionRecord {
actionType: string;
count: number;
}
export interface UserActionAndCountKeyValuePair {
key: string;
value: number;
}
// This is a helper method for retrieving user action telemetry data stored via the OSS
// user_action API.
export function fetchUserActions(
server: Server,
appName: string,
actionTypes: string[]
): Promise<UserActionAndCountKeyValuePair[]> {
const { SavedObjectsClient, getSavedObjectsRepository } = server.savedObjects;
const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin');
const internalRepository = getSavedObjectsRepository(callWithInternalUser);
const savedObjectsClient = new SavedObjectsClient(internalRepository);
async function fetchUserAction(actionType: string): Promise<UserActionRecord | undefined> {
try {
const savedObjectId = `${appName}:${actionType}`;
const savedObject = await savedObjectsClient.get('user-action', savedObjectId);
return { actionType, count: savedObject.attributes.count };
} catch (error) {
return undefined;
}
}
return Promise.all(actionTypes.map(fetchUserAction)).then(
(userActions): UserActionAndCountKeyValuePair[] => {
const userActionAndCountKeyValuePairs = userActions.reduce(
(pairs: UserActionAndCountKeyValuePair[], userAction: UserActionRecord | undefined) => {
// User action is undefined if nobody has performed this action on the client yet.
if (userAction !== undefined) {
const { actionType, count } = userAction;
const pair: UserActionAndCountKeyValuePair = { key: actionType, value: count };
pairs.push(pair);
}
return pairs;
},
[]
);
return userActionAndCountKeyValuePairs;
}
);
}

View file

@ -0,0 +1,7 @@
/*
* 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 { fetchUserActions } from './fetch_user_actions';