[Telemetry] Add Application Usage Schema (#75283) (#75970)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Alejandro Fernández Haro 2020-08-27 08:37:25 +01:00 committed by GitHub
parent a2d6f1ad6c
commit 02b6ceaff5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1645 additions and 83 deletions

View file

@ -7,7 +7,6 @@
"src/plugins/testbed/",
"src/plugins/kibana_utils/",
"src/plugins/kibana_usage_collection/server/collectors/kibana/kibana_usage_collector.ts",
"src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts",
"src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts",
"src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts",
"src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts"

View file

@ -5,6 +5,22 @@
"flat": {
"type": "keyword"
},
"my_index_signature_prop": {
"properties": {
"avg": {
"type": "number"
},
"count": {
"type": "number"
},
"max": {
"type": "number"
},
"min": {
"type": "number"
}
}
},
"my_str": {
"type": "text"
},

View file

@ -0,0 +1,54 @@
/*
* 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 { SyntaxKind } from 'typescript';
import { ParsedUsageCollection } from '../ts_parser';
export const parsedIndexedInterfaceWithNoMatchingSchema: ParsedUsageCollection = [
'src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts',
{
collectorName: 'indexed_interface_with_not_matching_schema',
schema: {
value: {
something: {
count_1: {
type: 'number',
},
},
},
},
fetch: {
typeName: 'Usage',
typeDescriptor: {
'': {
'@@INDEX@@': {
count_1: {
kind: SyntaxKind.NumberKeyword,
type: 'NumberKeyword',
},
count_2: {
kind: SyntaxKind.NumberKeyword,
type: 'NumberKeyword',
},
},
},
},
},
},
];

View file

@ -32,6 +32,20 @@ export const parsedWorkingCollector: ParsedUsageCollection = [
my_str: {
type: 'text',
},
my_index_signature_prop: {
avg: {
type: 'number',
},
count: {
type: 'number',
},
max: {
type: 'number',
},
min: {
type: 'number',
},
},
my_objects: {
total: {
type: 'number',
@ -60,6 +74,14 @@ export const parsedWorkingCollector: ParsedUsageCollection = [
kind: SyntaxKind.StringKeyword,
type: 'StringKeyword',
},
my_index_signature_prop: {
'': {
'@@INDEX@@': {
kind: SyntaxKind.NumberKeyword,
type: 'NumberKeyword',
},
},
},
my_objects: {
total: {
kind: SyntaxKind.NumberKeyword,

View file

@ -90,6 +90,38 @@ Array [
},
},
],
Array [
"src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts",
Object {
"collectorName": "indexed_interface_with_not_matching_schema",
"fetch": Object {
"typeDescriptor": Object {
"": Object {
"@@INDEX@@": Object {
"count_1": Object {
"kind": 140,
"type": "NumberKeyword",
},
"count_2": Object {
"kind": 140,
"type": "NumberKeyword",
},
},
},
},
"typeName": "Usage",
},
"schema": Object {
"value": Object {
"something": Object {
"count_1": Object {
"type": "long",
},
},
},
},
},
],
Array [
"src/fixtures/telemetry_collectors/nested_collector.ts",
Object {
@ -132,6 +164,14 @@ Array [
"type": "BooleanKeyword",
},
},
"my_index_signature_prop": Object {
"": Object {
"@@INDEX@@": Object {
"kind": 140,
"type": "NumberKeyword",
},
},
},
"my_objects": Object {
"total": Object {
"kind": 140,
@ -166,6 +206,20 @@ Array [
"type": "boolean",
},
},
"my_index_signature_prop": Object {
"avg": Object {
"type": "number",
},
"count": Object {
"type": "number",
},
"max": Object {
"type": "number",
},
"min": Object {
"type": "number",
},
},
"my_objects": Object {
"total": Object {
"type": "number",

View file

@ -20,6 +20,7 @@
import { cloneDeep } from 'lodash';
import * as ts from 'typescript';
import { parsedWorkingCollector } from './__fixture__/parsed_working_collector';
import { parsedIndexedInterfaceWithNoMatchingSchema } from './__fixture__/parsed_indexed_interface_with_not_matching_schema';
import { checkCompatibleTypeDescriptor, checkMatchingMapping } from './check_collector_integrity';
import * as path from 'path';
import { readFile } from 'fs';
@ -82,6 +83,20 @@ describe('checkCompatibleTypeDescriptor', () => {
expect(incompatibles).toHaveLength(0);
});
it('returns diff on indexed interface with no matching schema', () => {
const incompatibles = checkCompatibleTypeDescriptor([
parsedIndexedInterfaceWithNoMatchingSchema,
]);
expect(incompatibles).toHaveLength(1);
const { diff, message } = incompatibles[0];
// eslint-disable-next-line @typescript-eslint/naming-convention
expect(diff).toEqual({ '.@@INDEX@@.count_2.kind': 'number' });
expect(message).toHaveLength(1);
expect(message).toEqual([
'incompatible Type key (Usage..@@INDEX@@.count_2): expected (undefined) got ("number").',
]);
});
describe('Interface Change', () => {
it('returns diff on incompatible type descriptor with mapping', () => {
const malformedParsedCollector = cloneDeep(parsedWorkingCollector);

View file

@ -34,7 +34,7 @@ describe('extractCollectors', () => {
const programPaths = await getProgramPaths(configs[0]);
const results = [...extractCollectors(programPaths, tsConfig)];
expect(results).toHaveLength(6);
expect(results).toHaveLength(7);
expect(results).toMatchSnapshot();
});
});

View file

@ -84,6 +84,11 @@ export function getDescriptor(node: ts.Node, program: ts.Program): Descriptor |
}, {} as any);
}
// If it's defined as signature { [key: string]: OtherInterface }
if (ts.isIndexSignatureDeclaration(node) && node.type) {
return { '@@INDEX@@': getDescriptor(node.type, program) };
}
if (ts.SyntaxKind.FirstNode === node.kind) {
return getDescriptor((node as any).right, program);
}

View file

@ -98,6 +98,14 @@ export function getVariableValue(node: ts.Node): string | Record<string, any> {
return serializeObject(node);
}
if (ts.isIdentifier(node)) {
const declaration = getIdentifierDeclaration(node);
if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
return getVariableValue(declaration.initializer);
}
// TODO: If this is another imported value from another file, we'll need to go fetch it like in getPropertyValue
}
throw Error(`Unsuppored Node: cannot get value of node (${node.getText()}) of kind ${node.kind}`);
}
@ -112,10 +120,11 @@ export function serializeObject(node: ts.Node) {
if (typeof propertyName === 'undefined') {
throw new Error(`Unable to get property name ${property.getText()}`);
}
const cleanPropertyName = propertyName.replace(/["']/g, '');
if (ts.isPropertyAssignment(property)) {
value[propertyName] = getVariableValue(property.initializer);
value[cleanPropertyName] = getVariableValue(property.initializer);
} else {
value[propertyName] = getVariableValue(property);
value[cleanPropertyName] = getVariableValue(property);
}
}
@ -222,9 +231,29 @@ export const flattenKeys = (obj: any, keyPath: any[] = []): any => {
};
export function difference(actual: any, expected: any) {
function changes(obj: any, base: any) {
function changes(obj: { [key: string]: any }, base: { [key: string]: any }) {
return transform(obj, function (result, value, key) {
if (key && !isEqual(value, base[key])) {
if (key && /@@INDEX@@/.test(`${key}`)) {
// The type definition is an Index Signature, fuzzy searching for similar keys
const regexp = new RegExp(`${key}`.replace(/@@INDEX@@/g, '(.+)?'));
const keysInBase = Object.keys(base)
.map((k) => {
const match = k.match(regexp);
return match && match[0];
})
.filter((s): s is string => !!s);
if (keysInBase.length === 0) {
// Mark this key as wrong because we couldn't find any matching keys
result[key] = value;
}
keysInBase.forEach((k) => {
if (!isEqual(value, base[k])) {
result[k] = isObject(value) && isObject(base[k]) ? changes(value, base[k]) : value;
}
});
} else if (key && !isEqual(value, base[key])) {
result[key] = isObject(value) && isObject(base[key]) ? changes(value, base[key]) : value;
}
});

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 { CollectorSet } from '../../plugins/usage_collection/server/collector';
import { loggerMock } from '../../core/server/logging/logger.mock';
const { makeUsageCollector } = new CollectorSet({
logger: loggerMock.create(),
maximumWaitTimeForAllCollectorsInS: 0,
});
interface Usage {
[key: string]: {
count_1?: number;
count_2?: number;
};
}
export const myCollector = makeUsageCollector<Usage>({
type: 'indexed_interface_with_not_matching_schema',
isReady: () => true,
fetch() {
if (Math.random()) {
return { something: { count_1: 1 } };
}
return { something: { count_2: 2 } };
},
schema: {
something: {
count_1: { type: 'long' }, // Intentionally missing count_2
},
},
});

View file

@ -35,6 +35,9 @@ interface Usage {
my_objects: MyObject;
my_array?: MyObject[];
my_str_array?: string[];
my_index_signature_prop?: {
[key: string]: number;
};
}
const SOME_NUMBER: number = 123;
@ -93,5 +96,11 @@ export const myCollector = makeUsageCollector<Usage>({
type: { type: 'boolean' },
},
my_str_array: { type: 'keyword' },
my_index_signature_prop: {
count: { type: 'number' },
avg: { type: 'number' },
max: { type: 'number' },
min: { type: 'number' },
},
},
});

View file

@ -0,0 +1,99 @@
/*
* 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 { MakeSchemaFrom } from 'src/plugins/usage_collection/server';
import { ApplicationUsageTelemetryReport } from './telemetry_application_usage_collector';
const commonSchema: MakeSchemaFrom<ApplicationUsageTelemetryReport[string]> = {
clicks_total: {
type: 'long',
},
clicks_7_days: {
type: 'long',
},
clicks_30_days: {
type: 'long',
},
clicks_90_days: {
type: 'long',
},
minutes_on_screen_total: {
type: 'float',
},
minutes_on_screen_7_days: {
type: 'float',
},
minutes_on_screen_30_days: {
type: 'float',
},
minutes_on_screen_90_days: {
type: 'float',
},
};
// These keys obtained by searching for `/application\w*\.register\(/` and checking the value of the attr `id`.
// TODO: Find a way to update these keys automatically.
export const applicationUsageSchema = {
// OSS
dashboards: commonSchema,
dev_tools: commonSchema,
discover: commonSchema,
home: commonSchema,
kibana: commonSchema, // It's a forward app so we'll likely never report it
management: commonSchema,
short_url_redirect: commonSchema, // It's a forward app so we'll likely never report it
timelion: commonSchema,
visualize: commonSchema,
// X-Pack
apm: commonSchema,
csm: commonSchema,
canvas: commonSchema,
dashboard_mode: commonSchema, // It's a forward app so we'll likely never report it
appSearch: commonSchema,
workplaceSearch: commonSchema,
graph: commonSchema,
logs: commonSchema,
metrics: commonSchema,
infra: commonSchema, // It's a forward app so we'll likely never report it
ingestManager: commonSchema,
lens: commonSchema,
maps: commonSchema,
ml: commonSchema,
monitoring: commonSchema,
'observability-overview': commonSchema,
security_account: commonSchema,
security_access_agreement: commonSchema,
security_capture_url: commonSchema, // It's a forward app so we'll likely never report it
security_logged_out: commonSchema,
security_login: commonSchema,
security_logout: commonSchema,
security_overwritten_session: commonSchema,
securitySolution: commonSchema, // It's a forward app so we'll likely never report it
'securitySolution:overview': commonSchema,
'securitySolution:detections': commonSchema,
'securitySolution:hosts': commonSchema,
'securitySolution:network': commonSchema,
'securitySolution:timelines': commonSchema,
'securitySolution:case': commonSchema,
'securitySolution:administration': commonSchema,
siem: commonSchema,
space_selector: commonSchema,
uptime: commonSchema,
};

View file

@ -26,6 +26,7 @@ import {
ApplicationUsageTransactional,
registerMappings,
} from './saved_objects_types';
import { applicationUsageSchema } from './schema';
/**
* Roll indices every 24h
@ -40,7 +41,7 @@ export const ROLL_INDICES_START = 5 * 60 * 1000;
export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals';
export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional';
interface ApplicationUsageTelemetryReport {
export interface ApplicationUsageTelemetryReport {
[appId: string]: {
clicks_total: number;
clicks_7_days: number;
@ -60,93 +61,96 @@ export function registerApplicationUsageCollector(
) {
registerMappings(registerType);
const collector = usageCollection.makeUsageCollector({
type: 'application_usage',
isReady: () => typeof getSavedObjectsClient() !== 'undefined',
fetch: async () => {
const savedObjectsClient = getSavedObjectsClient();
if (typeof savedObjectsClient === 'undefined') {
return;
}
const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([
findAll<ApplicationUsageTotal>(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }),
findAll<ApplicationUsageTransactional>(savedObjectsClient, {
type: SAVED_OBJECTS_TRANSACTIONAL_TYPE,
}),
]);
const collector = usageCollection.makeUsageCollector<ApplicationUsageTelemetryReport | undefined>(
{
type: 'application_usage',
isReady: () => typeof getSavedObjectsClient() !== 'undefined',
schema: applicationUsageSchema,
fetch: async () => {
const savedObjectsClient = getSavedObjectsClient();
if (typeof savedObjectsClient === 'undefined') {
return;
}
const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([
findAll<ApplicationUsageTotal>(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }),
findAll<ApplicationUsageTransactional>(savedObjectsClient, {
type: SAVED_OBJECTS_TRANSACTIONAL_TYPE,
}),
]);
const applicationUsageFromTotals = rawApplicationUsageTotals.reduce(
(acc, { attributes: { appId, minutesOnScreen, numberOfClicks } }) => {
const existing = acc[appId] || { clicks_total: 0, minutes_on_screen_total: 0 };
return {
...acc,
[appId]: {
clicks_total: numberOfClicks + existing.clicks_total,
const applicationUsageFromTotals = rawApplicationUsageTotals.reduce(
(acc, { attributes: { appId, minutesOnScreen, numberOfClicks } }) => {
const existing = acc[appId] || { clicks_total: 0, minutes_on_screen_total: 0 };
return {
...acc,
[appId]: {
clicks_total: numberOfClicks + existing.clicks_total,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: minutesOnScreen + existing.minutes_on_screen_total,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
},
};
},
{} as ApplicationUsageTelemetryReport
);
const nowMinus7 = moment().subtract(7, 'days');
const nowMinus30 = moment().subtract(30, 'days');
const nowMinus90 = moment().subtract(90, 'days');
const applicationUsage = rawApplicationUsageTransactional.reduce(
(acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => {
const existing = acc[appId] || {
clicks_total: 0,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: minutesOnScreen + existing.minutes_on_screen_total,
minutes_on_screen_total: 0,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
},
};
},
{} as ApplicationUsageTelemetryReport
);
const nowMinus7 = moment().subtract(7, 'days');
const nowMinus30 = moment().subtract(30, 'days');
const nowMinus90 = moment().subtract(90, 'days');
};
const applicationUsage = rawApplicationUsageTransactional.reduce(
(acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => {
const existing = acc[appId] || {
clicks_total: 0,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: 0,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
};
const timeOfEntry = moment(timestamp as string);
const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7);
const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30);
const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90);
const timeOfEntry = moment(timestamp as string);
const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7);
const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30);
const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90);
const last7Days = {
clicks_7_days: existing.clicks_7_days + numberOfClicks,
minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen,
};
const last30Days = {
clicks_30_days: existing.clicks_30_days + numberOfClicks,
minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen,
};
const last90Days = {
clicks_90_days: existing.clicks_90_days + numberOfClicks,
minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen,
};
const last7Days = {
clicks_7_days: existing.clicks_7_days + numberOfClicks,
minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen,
};
const last30Days = {
clicks_30_days: existing.clicks_30_days + numberOfClicks,
minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen,
};
const last90Days = {
clicks_90_days: existing.clicks_90_days + numberOfClicks,
minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen,
};
return {
...acc,
[appId]: {
...existing,
clicks_total: existing.clicks_total + numberOfClicks,
minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen,
...(isInLast7Days ? last7Days : {}),
...(isInLast30Days ? last30Days : {}),
...(isInLast90Days ? last90Days : {}),
},
};
},
applicationUsageFromTotals
);
return {
...acc,
[appId]: {
...existing,
clicks_total: existing.clicks_total + numberOfClicks,
minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen,
...(isInLast7Days ? last7Days : {}),
...(isInLast30Days ? last30Days : {}),
...(isInLast90Days ? last90Days : {}),
},
};
},
applicationUsageFromTotals
);
return applicationUsage;
},
});
return applicationUsage;
},
}
);
usageCollection.registerCollector(collector);

File diff suppressed because it is too large Load diff