Reapply custom element telemetry (#37113) (#39070)

* Revert "Revert "[Canvas] Adds telemetry info for custom elements (#36177)" (#37100)"

This reverts commit 3f4c2c89ec.

* Cast fromExpression unknown type

* PR Feedback
This commit is contained in:
Corey Robertson 2019-06-17 09:32:21 -04:00 committed by GitHub
parent 29fa71c889
commit 5d441123e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 430 additions and 77 deletions

View file

@ -4,25 +4,43 @@
* you may not use this file except in compliance with the Elastic License.
*/
// @ts-ignore missing local definition
export * from './datatable';
// @ts-ignore missing local definition
export * from './autocomplete';
export * from './constants';
export * from './dataurl';
// @ts-ignore missing local definition
export * from './errors';
// @ts-ignore missing local definition
export * from './expression_form_handlers';
// @ts-ignore missing local definition
export * from './fetch';
// @ts-ignore missing local definition
export * from './find_in_object';
export * from './fonts';
// @ts-ignore missing local definition
export * from './get_colors_from_palette';
// @ts-ignore missing local definition
export * from './get_field_type';
// @ts-ignore missing local definition
export * from './get_legend_config';
// @ts-ignore missing local definition
export * from './handlebars';
export * from './hex_to_rgb';
// @ts-ignore missing local definition
export * from './httpurl';
// @ts-ignore missing local definition
export * from './latest_change';
// @ts-ignore missing local definition
export * from './missing_asset';
// @ts-ignore missing local definition
export * from './palettes';
// @ts-ignore missing local definition
export * from './pivot_object_array';
// @ts-ignore missing local definition
export * from './resolve_dataurl';
// @ts-ignore missing local definition
export * from './unquote_string';
// @ts-ignore missing local definition
export * from './url';

View file

@ -0,0 +1,84 @@
/*
* 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 '@kbn/expect';
import { summarizeCustomElements, CustomElementDocument } from '../custom_element_collector';
function mockCustomElement(...nodeExpressions: string[]): CustomElementDocument {
return {
content: JSON.stringify({
selectedNodes: nodeExpressions.map(expression => ({
expression,
})),
}),
};
}
describe('custom_element_collector.handleResponse', () => {
describe('invalid responses', () => {
it('returns nothing if no valid hits', () => {
expect(summarizeCustomElements([])).to.eql({});
});
it('returns nothing if no valid elements', () => {
const customElements = [
{
content: 'invalid json',
},
];
expect(summarizeCustomElements(customElements)).to.eql({});
});
});
it('counts total custom elements', () => {
const elements = [mockCustomElement(''), mockCustomElement('')];
const data = summarizeCustomElements(elements);
expect(data.custom_elements).to.not.be(null);
if (data.custom_elements) {
expect(data.custom_elements.count).to.equal(elements.length);
}
});
it('reports all the functions used in custom elements', () => {
const functions1 = ['a', 'b', 'c'];
const functions2 = ['c', 'd', 'e', 'f'];
const expectedFunctions = Array.from(new Set([...functions1, ...functions2]));
const elements = [mockCustomElement(functions1.join('|')), mockCustomElement(...functions2)];
const data = summarizeCustomElements(elements);
expect(data.custom_elements).to.not.be(null);
if (data.custom_elements) {
expect(data.custom_elements.functions_in_use).to.eql(expectedFunctions);
}
});
it('reports minimum, maximum, and avg elements in a custom element', () => {
const functionsMin = ['a', 'b', 'c'];
const functionsMax = ['d', 'e', 'f', 'g', 'h'];
const functionsOther = ['i', 'j', 'k', 'l'];
const avgFunctions = (functionsMin.length + functionsMax.length + functionsOther.length) / 3;
const elements = [
mockCustomElement(...functionsMin),
mockCustomElement(...functionsMax),
mockCustomElement(...functionsOther),
];
const result = summarizeCustomElements(elements);
expect(result.custom_elements).to.not.be(null);
if (result.custom_elements) {
expect(result.custom_elements.elements.max).to.equal(functionsMax.length);
expect(result.custom_elements.elements.min).to.equal(functionsMin.length);
expect(result.custom_elements.elements.avg).to.equal(avgFunctions);
}
});
});

View file

@ -5,22 +5,13 @@
*/
import expect from '@kbn/expect';
import { handleResponse } from '../collector';
import { summarizeWorkpads } from '../workpad_collector';
// @ts-ignore Missing local definition
import { workpads } from '../../../__tests__/fixtures/workpads';
const getMockResponse = (mocks = workpads) => ({
hits: {
hits: mocks.map(workpad => ({
_source: {
'canvas-workpad': workpad,
},
})),
},
});
describe('usage collector handle es response data', () => {
it('should summarize workpads, pages, and elements', () => {
const usage = handleResponse(getMockResponse());
const usage = summarizeWorkpads(workpads);
expect(usage).to.eql({
workpads: {
total: 6, // num workpad documents in .kibana index
@ -63,7 +54,7 @@ describe('usage collector handle es response data', () => {
});
it('should collect correctly if an expression has null as an argument (possible sub-expression)', () => {
const mockEsResponse = getMockResponse([
const mockWorkpads = [
{
name: 'Tweet Data Workpad 1',
id: 'workpad-ae00567f-5510-4d68-b07f-6b1661948e03',
@ -83,8 +74,8 @@ describe('usage collector handle es response data', () => {
'@created': '2018-07-25T22:56:31.460Z',
assets: {},
},
]);
const usage = handleResponse(mockEsResponse);
];
const usage = summarizeWorkpads(mockWorkpads);
expect(usage).to.eql({
workpads: { total: 1 },
pages: { total: 1, per_workpad: { avg: 1, min: 1, max: 1 } },
@ -94,7 +85,7 @@ describe('usage collector handle es response data', () => {
});
it('should fail gracefully if workpad has 0 pages (corrupted workpad)', () => {
const mockEsResponseCorrupted = getMockResponse([
const mockWorkpadsCorrupted = [
{
name: 'Tweet Data Workpad 2',
id: 'workpad-ae00567f-5510-4d68-b07f-6b1661948e03',
@ -106,8 +97,8 @@ describe('usage collector handle es response data', () => {
'@created': '2018-07-25T22:56:31.460Z',
assets: {},
},
]);
const usage = handleResponse(mockEsResponseCorrupted);
];
const usage = summarizeWorkpads(mockWorkpadsCorrupted);
expect(usage).to.eql({
workpads: { total: 1 },
pages: { total: 0, per_workpad: { avg: 0, min: 0, max: 0 } },
@ -117,7 +108,7 @@ describe('usage collector handle es response data', () => {
});
it('should fail gracefully in general', () => {
const usage = handleResponse({ hits: { total: 0 } });
expect(usage).to.eql(undefined);
const usage = summarizeWorkpads([]);
expect(usage).to.eql({});
});
});

View file

@ -0,0 +1,54 @@
/*
* 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 { CallCluster } from 'src/legacy/core_plugins/elasticsearch';
import { Legacy } from 'kibana';
// @ts-ignore missing local declaration
import { CANVAS_USAGE_TYPE } from '../../common/lib/constants';
import { workpadCollector } from './workpad_collector';
import { customElementCollector } from './custom_element_collector';
/**
Function for collecting information about canvas usage
*/
export type TelemetryCollector = (
/** The server instance */
server: Legacy.Server,
/** Function for calling elasticsearch */
callCluster: CallCluster
) => Record<string, any>;
const collectors: TelemetryCollector[] = [workpadCollector, customElementCollector];
/*
Register the canvas usage collector function
This will call all of the defined collectors and combine the individual results into a single object
to be returned to the caller.
A usage collector function returns an object derived from current data in the ES Cluster.
*/
export function registerCanvasUsageCollector(server: Legacy.Server) {
const canvasCollector = server.usage.collectorSet.makeUsageCollector({
type: CANVAS_USAGE_TYPE,
isReady: () => true,
fetch: async (callCluster: CallCluster) => {
const collectorResults = await Promise.all(
collectors.map(collector => collector(server, callCluster))
);
return collectorResults.reduce(
(reduction, usage) => {
return { ...reduction, ...usage };
},
{}
);
},
});
server.usage.collectorSet.register(canvasCollector);
}

View file

@ -0,0 +1,37 @@
/*
* 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.
*/
/*
* @param ast: an ast that includes functions to track
* @param cb: callback to do something with a function that has been found
*/
export interface AST {
type: string;
chain: Array<{
function: string;
arguments: {
[s: string]: AST[];
};
}>;
}
export function collectFns(ast: AST, cb: (functionName: string) => void) {
if (ast.type === 'expression') {
ast.chain.forEach(({ function: cFunction, arguments: cArguments }) => {
cb(cFunction);
// recurse the arguments and update the set along the way
Object.keys(cArguments).forEach(argName => {
cArguments[argName].forEach(subAst => {
if (subAst != null) {
collectFns(subAst, cb);
}
});
});
});
}
}

View file

@ -0,0 +1,133 @@
/*
* 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 { SearchParams } from 'elasticsearch';
import { get } from 'lodash';
import { fromExpression } from '@kbn/interpreter/common';
import { AST, collectFns } from './collector_helpers';
import { TelemetryCollector } from './collector';
const CUSTOM_ELEMENT_TYPE = 'canvas-element';
interface CustomElementSearch {
[CUSTOM_ELEMENT_TYPE]: CustomElementDocument;
}
export interface CustomElementDocument {
content: string;
}
interface CustomElementTelemetry {
custom_elements?: {
count: number;
elements: {
min: number;
max: number;
avg: number;
};
functions_in_use: string[];
};
}
export interface CustomElement {
selectedNodes: Array<{
expression: string;
}>;
}
function isCustomElement(maybeCustomElement: any): maybeCustomElement is CustomElement {
return (
maybeCustomElement !== null &&
Array.isArray(maybeCustomElement.selectedNodes) &&
maybeCustomElement.selectedNodes.every(
(node: any) => node.expression && typeof node.expression === 'string'
)
);
}
function parseJsonOrNull(maybeJson: string) {
try {
return JSON.parse(maybeJson);
} catch (e) {
return null;
}
}
/**
Calculate statistics about a collection of CustomElement Documents
@param customElements - Array of CustomElement documents
@returns Statistics about how Custom Elements are being used
*/
export function summarizeCustomElements(
customElements: CustomElementDocument[]
): CustomElementTelemetry {
const functionSet = new Set<string>();
const parsedContents: CustomElement[] = customElements
.map(element => element.content)
.map(parseJsonOrNull)
.filter(isCustomElement);
if (parsedContents.length === 0) {
return {};
}
const elements = {
min: Infinity,
max: -Infinity,
avg: 0,
};
let totalElements = 0;
parsedContents.map(contents => {
contents.selectedNodes.map(node => {
const ast: AST = fromExpression(node.expression) as AST; // TODO: Remove once fromExpression is properly typed
collectFns(ast, (cFunction: string) => {
functionSet.add(cFunction);
});
});
elements.min = Math.min(elements.min, contents.selectedNodes.length);
elements.max = Math.max(elements.max, contents.selectedNodes.length);
totalElements += contents.selectedNodes.length;
});
elements.avg = totalElements / parsedContents.length;
return {
custom_elements: {
elements,
count: customElements.length,
functions_in_use: Array.from(functionSet),
},
};
}
const customElementCollector: TelemetryCollector = async function customElementCollector(
server,
callCluster
) {
const index = server.config().get<string>('kibana.index');
const customElementParams: SearchParams = {
size: 10000,
index,
ignoreUnavailable: true,
filterPath: [`hits.hits._source.${CUSTOM_ELEMENT_TYPE}.content`],
body: { query: { bool: { filter: { term: { type: CUSTOM_ELEMENT_TYPE } } } } },
};
const esResponse = await callCluster<CustomElementSearch>('search', customElementParams);
if (get(esResponse, 'hits.hits.length') > 0) {
const customElements = esResponse.hits.hits.map(hit => hit._source[CUSTOM_ELEMENT_TYPE]);
return summarizeCustomElements(customElements);
}
return {};
};
export { customElementCollector };

View file

@ -4,67 +4,108 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SearchParams } from 'elasticsearch';
import { sum as arraySum, min as arrayMin, max as arrayMax, get } from 'lodash';
import { fromExpression } from '@kbn/interpreter/common';
import { CANVAS_USAGE_TYPE, CANVAS_TYPE } from '../../common/lib/constants';
import { CANVAS_TYPE } from '../../common/lib/constants';
import { AST, collectFns } from './collector_helpers';
import { TelemetryCollector } from './collector';
/*
* @param ast: an ast that includes functions to track
* @param cb: callback to do something with a function that has been found
*/
const collectFns = (ast, cb) => {
if (ast.type === 'expression') {
ast.chain.forEach(({ function: cFunction, arguments: cArguments }) => {
cb(cFunction);
interface Element {
expression: string;
}
// recurse the argumetns and update the set along the way
Object.keys(cArguments).forEach(argName => {
cArguments[argName].forEach(subAst => {
if (subAst != null) {
collectFns(subAst, cb);
}
});
});
});
}
};
interface Page {
elements: Element[];
}
export function handleResponse({ hits }) {
const workpadDocs = get(hits, 'hits', null);
if (workpadDocs == null) {
return;
}
interface Workpad {
pages: Page[];
[s: string]: any; // Only concerned with the pages here, but allow workpads to have any values
}
interface WorkpadSearch {
[CANVAS_TYPE]: Workpad;
}
interface WorkpadTelemetry {
workpads?: {
total: number;
};
pages?: {
total: number;
per_workpad: {
avg: number;
min: number;
max: number;
};
};
elements?: {
total: number;
per_page: {
avg: number;
min: number;
max: number;
};
};
functions?: {
total: number;
in_use: string[];
per_element: {
avg: number;
min: number;
max: number;
};
};
}
/**
Gather statistic about the given workpads
@param workpadDocs a collection of workpad documents
@returns Workpad Telemetry Data
*/
export function summarizeWorkpads(workpadDocs: Workpad[]): WorkpadTelemetry {
const functionSet = new Set();
// make a summary of info about each workpad
const workpadsInfo = workpadDocs.map(hit => {
const workpad = hit._source[CANVAS_TYPE];
if (workpadDocs.length === 0) {
return {};
}
let pages;
// make a summary of info about each workpad
const workpadsInfo = workpadDocs.map(workpad => {
let pages = { count: 0 };
try {
pages = { count: workpad.pages.length };
} catch (err) {
// eslint-disable-next-line
console.warn(err, workpad);
}
const elementCounts = workpad.pages.reduce(
const elementCounts = workpad.pages.reduce<number[]>(
(accum, page) => accum.concat(page.elements.length),
[]
);
const functionCounts = workpad.pages.reduce((accum, page) => {
const functionCounts = workpad.pages.reduce<number[]>((accum, page) => {
return page.elements.map(element => {
const ast = fromExpression(element.expression);
const ast: AST = fromExpression(element.expression) as AST; // TODO: Remove once fromExpression is properly typed
collectFns(ast, cFunction => {
functionSet.add(cFunction);
});
return ast.chain.length; // get the number of parts in the expression
});
}, []);
return { pages, elementCounts, functionCounts };
});
// combine together info from across the workpads
const combinedWorkpadsInfo = workpadsInfo.reduce(
const combinedWorkpadsInfo = workpadsInfo.reduce<{
pageMin: number;
pageMax: number;
pageCounts: number[];
elementCounts: number[];
functionCounts: number[];
}>(
(accum, pageInfo) => {
const { pages, elementCounts, functionCounts } = pageInfo;
@ -132,29 +173,24 @@ export function handleResponse({ hits }) {
};
}
export function registerCanvasUsageCollector(server) {
const index = server.config().get('kibana.index');
const collector = server.usage.collectorSet.makeUsageCollector({
type: CANVAS_USAGE_TYPE,
isReady: () => true,
fetch: async callCluster => {
const searchParams = {
size: 10000, // elasticsearch index.max_result_window default value
index,
ignoreUnavailable: true,
filterPath: [
'hits.hits._source.canvas-workpad',
'-hits.hits._source.canvas-workpad.assets',
],
body: { query: { bool: { filter: { term: { type: CANVAS_TYPE } } } } },
};
const workpadCollector: TelemetryCollector = async function(server, callCluster) {
const index = server.config().get<string>('kibana.index');
const searchParams: SearchParams = {
size: 10000, // elasticsearch index.max_result_window default value
index,
ignoreUnavailable: true,
filterPath: ['hits.hits._source.canvas-workpad', '-hits.hits._source.canvas-workpad.assets'],
body: { query: { bool: { filter: { term: { type: CANVAS_TYPE } } } } },
};
const esResponse = await callCluster('search', searchParams);
if (get(esResponse, 'hits.hits.length') > 0) {
return handleResponse(esResponse);
}
},
});
const esResponse = await callCluster<WorkpadSearch>('search', searchParams);
server.usage.collectorSet.register(collector);
}
if (get(esResponse, 'hits.hits.length') > 0) {
const workpads = esResponse.hits.hits.map(hit => hit._source[CANVAS_TYPE]);
return summarizeWorkpads(workpads);
}
return {};
};
export { workpadCollector };