[Telemetry] Migrate ui_metric plugin to NP under usageCollecti… (#51972)

* move cloud dir to plugins from legacy

* create ui_metrics in NP

* migrate first plugin

* ui_metric plugin uses npStart

* sinin mock

* karma mocks

* type check fix

* rename old configs

* fix mocks and use configs

* use  fo debug

* ui_metric deprecation configs

* remove commented out code

* remove unused type import

* mock ui_metric in client_integration

* jest.mock ui/new_platform

* fix all failing tests

* platform team code review fixes

* reset interval back to default

* apm cypress config use usageCollection

* revert kibana.yml change

* remove license type from NP def

* undo revert of NP type

* code review fixes

* report schema in a separate dir
This commit is contained in:
Ahmad Bamieh 2019-12-13 14:41:51 -05:00 committed by GitHub
parent 0dac43516e
commit 3658220048
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 566 additions and 336 deletions

View file

@ -20,3 +20,4 @@
export { ReportHTTP, Reporter, ReporterConfig } from './reporter';
export { UiStatsMetricType, METRIC_TYPE } from './metrics';
export { Report, ReportManager } from './report';
export { Storage } from './storage';

View file

@ -23,23 +23,25 @@ const REPORT_VERSION = 1;
export interface Report {
reportVersion: typeof REPORT_VERSION;
uiStatsMetrics: {
[key: string]: {
uiStatsMetrics?: Record<
string,
{
key: string;
appName: string;
eventName: string;
type: UiStatsMetricType;
stats: Stats;
};
};
userAgent?: {
[key: string]: {
}
>;
userAgent?: Record<
string,
{
userAgent: string;
key: string;
type: METRIC_TYPE.USER_AGENT;
appName: string;
};
};
}
>;
}
export class ReportManager {
@ -49,14 +51,15 @@ export class ReportManager {
this.report = report || ReportManager.createReport();
}
static createReport(): Report {
return { reportVersion: REPORT_VERSION, uiStatsMetrics: {} };
return { reportVersion: REPORT_VERSION };
}
public clearReport() {
this.report = ReportManager.createReport();
}
public isReportEmpty(): boolean {
const noUiStats = Object.keys(this.report.uiStatsMetrics).length === 0;
const noUserAgent = !this.report.userAgent || Object.keys(this.report.userAgent).length === 0;
const { uiStatsMetrics, userAgent } = this.report;
const noUiStats = !uiStatsMetrics || Object.keys(uiStatsMetrics).length === 0;
const noUserAgent = !userAgent || Object.keys(userAgent).length === 0;
return noUiStats && noUserAgent;
}
private incrementStats(count: number, stats?: Stats): Stats {
@ -113,14 +116,17 @@ export class ReportManager {
case METRIC_TYPE.LOADED:
case METRIC_TYPE.COUNT: {
const { appName, type, eventName, count } = metric;
const existingStats = (report.uiStatsMetrics[key] || {}).stats;
this.report.uiStatsMetrics[key] = {
key,
appName,
eventName,
type,
stats: this.incrementStats(count, existingStats),
};
if (report.uiStatsMetrics) {
const existingStats = (report.uiStatsMetrics[key] || {}).stats;
this.report.uiStatsMetrics = this.report.uiStatsMetrics || {};
this.report.uiStatsMetrics[key] = {
key,
appName,
eventName,
type,
stats: this.incrementStats(count, existingStats),
};
}
return;
}
default:

View file

@ -19,7 +19,13 @@
import { Report } from './report';
export type Storage = Map<string, any>;
export interface Storage<T = any, S = void> {
get: (key: string) => T | null;
set: (key: string, value: T) => S;
remove: (key: string) => T | null;
clear: () => void;
}
export class ReportStorageManager {
storageKey: string;
private storage?: Storage;

View file

@ -23,6 +23,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { VisType } from '../legacy_imports';
import { TypesStart } from '../../../../visualizations/public/np_ready/public/types';
jest.mock('ui/new_platform');
jest.mock('../legacy_imports', () => ({
State: () => null,
AppState: () => null,

View file

@ -18,7 +18,6 @@
*/
import moment from 'moment';
import { setCanTrackUiMetrics } from 'ui/ui_metric';
// @ts-ignore
import { banners, toastNotifications } from 'ui/notify';
import { npStart } from 'ui/new_platform';
@ -69,8 +68,6 @@ export function TelemetryOptInProvider($injector: any, chrome: any, sendOptInSta
'telemetryNotifyUserAboutOptInDefault'
) as boolean;
setCanTrackUiMetrics(currentOptInStatus);
const provider = {
getBannerId: () => bannerId,
getOptInBannerNoticeId: () => optInBannerNoticeId,
@ -116,7 +113,6 @@ export function TelemetryOptInProvider($injector: any, chrome: any, sendOptInSta
if (!allowChangingOptInStatus) {
return;
}
setCanTrackUiMetrics(enabled);
const $http = $injector.get('$http');
try {

View file

@ -1,78 +0,0 @@
# UI Metric app
## Purpose
The purpose of the UI Metric app is to provide a tool for gathering data on how users interact with
various UIs within Kibana. It's useful for gathering _aggregate_ information, e.g. "How many times
has Button X been clicked" or "How many times has Page Y been viewed".
With some finagling, it's even possible to add more meaning to the info you gather, such as "How many
visualizations were created in less than 5 minutes".
### What it doesn't do
The UI Metric app doesn't gather any metadata around a user interaction, e.g. the user's identity,
the name of a dashboard they've viewed, or the timestamp of the interaction.
## How to use it
To track a user interaction, import the `createUiStatsReporter` helper function from UI Metric app:
```js
import { createUiStatsReporter, METRIC_TYPE } from 'relative/path/to/src/legacy/core_plugins/ui_metric/public';
const trackMetric = createUiStatsReporter(`<AppName>`);
trackMetric(METRIC_TYPE.CLICK, `<EventName>`);
trackMetric('click', `<EventName>`);
```
Metric Types:
- `METRIC_TYPE.CLICK` for tracking clicks `trackMetric(METRIC_TYPE.CLICK, 'my_button_clicked');`
- `METRIC_TYPE.LOADED` for a component load or page load `trackMetric(METRIC_TYPE.LOADED', 'my_component_loaded');`
- `METRIC_TYPE.COUNT` for a tracking a misc count `trackMetric(METRIC_TYPE.COUNT', 'my_counter', <count> });`
Call this function whenever you would like to track a user interaction within your app. The function
accepts two arguments, `metricType` and `eventNames`. These should be underscore-delimited strings.
For example, to track the `my_event` metric in the app `my_app` call `trackUiMetric(METRIC_TYPE.*, 'my_event)`.
That's all you need to do!
To track multiple metrics within a single request, provide an array of events, e.g. `trackMetric(METRIC_TYPE.*, ['my_event1', 'my_event2', 'my_event3'])`.
### Disallowed characters
The colon character (`:`) should not be used in app name or event names. Colons play
a special role in how metrics are stored as saved objects.
### Tracking timed interactions
If you want to track how long it takes a user to do something, you'll need to implement the timing
logic yourself. You'll also need to predefine some buckets into which the UI metric can fall.
For example, if you're timing how long it takes to create a visualization, you may decide to
measure interactions that take less than 1 minute, 1-5 minutes, 5-20 minutes, and longer than 20 minutes.
To track these interactions, you'd use the timed length of the interaction to determine whether to
use a `eventName` of `create_vis_1m`, `create_vis_5m`, `create_vis_20m`, or `create_vis_infinity`.
## How it works
Under the hood, your app and metric type will be stored in a saved object of type `user-metric` and the
ID `ui-metric:my_app:my_metric`. This saved object will have a `count` property which will be incremented
every time the above URI is hit.
These saved objects are automatically consumed by the stats API and surfaced under the
`ui_metric` namespace.
```json
{
"ui_metric": {
"my_app": [
{
"key": "my_metric",
"value": 3
}
]
}
}
```
By storing these metrics and their counts as key-value pairs, we can add more metrics without having
to worry about exceeding the 1000-field soft limit in Elasticsearch.

View file

@ -18,10 +18,7 @@
*/
import { resolve } from 'path';
import JoiNamespace from 'joi';
import { Server } from 'hapi';
import { Legacy } from '../../../../kibana';
import { registerUiMetricRoute } from './server/routes/api/ui_metric';
// eslint-disable-next-line import/no-default-export
export default function(kibana: any) {
@ -29,25 +26,16 @@ export default function(kibana: any) {
id: 'ui_metric',
require: ['kibana', 'elasticsearch'],
publicDir: resolve(__dirname, 'public'),
config(Joi: typeof JoiNamespace) {
return Joi.object({
enabled: Joi.boolean().default(true),
debug: Joi.boolean().default(Joi.ref('$dev')),
}).default();
},
uiExports: {
injectDefaultVars(server: Server) {
const config = server.config();
return {
uiMetricEnabled: config.get('ui_metric.enabled'),
debugUiMetric: config.get('ui_metric.debug'),
};
},
mappings: require('./mappings.json'),
hacks: ['plugins/ui_metric/hacks/ui_metric_init'],
},
init(server: Legacy.Server) {
registerUiMetricRoute(server);
const { getSavedObjectsRepository } = server.savedObjects;
const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin');
const internalRepository = getSavedObjectsRepository(callWithInternalUser);
const { usageCollection } = server.newPlatform.setup.plugins;
usageCollection.registerLegacySavedObjects(internalRepository);
},
});
}

View file

@ -17,5 +17,5 @@
* under the License.
*/
export { createUiStatsReporter, trackUserAgent } from './services/telemetry_analytics';
export { createUiStatsReporter } from './services/telemetry_analytics';
export { METRIC_TYPE, UiStatsMetricType } from '@kbn/analytics';

View file

@ -16,61 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { npSetup } from 'ui/new_platform';
import { Reporter, UiStatsMetricType } from '@kbn/analytics';
// @ts-ignore
import { addSystemApiHeader } from 'ui/system_api';
let telemetryReporter: Reporter;
export const setTelemetryReporter = (aTelemetryReporter: Reporter): void => {
telemetryReporter = aTelemetryReporter;
export const createUiStatsReporter = (appName: string) => {
const { usageCollection } = npSetup.plugins;
return usageCollection.reportUiStats.bind(usageCollection, appName);
};
export const getTelemetryReporter = () => {
return telemetryReporter;
};
export const createUiStatsReporter = (appName: string) => (
type: UiStatsMetricType,
eventNames: string | string[],
count?: number
): void => {
if (telemetryReporter) {
return telemetryReporter.reportUiStats(appName, type, eventNames, count);
}
};
export const trackUserAgent = (appName: string) => {
if (telemetryReporter) {
return telemetryReporter.reportUserAgent(appName);
}
};
interface AnalyicsReporterConfig {
localStorage: any;
debug: boolean;
kfetch: any;
}
export function createAnalyticsReporter(config: AnalyicsReporterConfig) {
const { localStorage, debug, kfetch } = config;
return new Reporter({
debug,
storage: localStorage,
async http(report) {
const response = await kfetch({
method: 'POST',
pathname: '/api/telemetry/report',
body: JSON.stringify(report),
headers: addSystemApiHeader({}),
});
if (response.status !== 'ok') {
throw Error('Unable to store report.');
}
return response;
},
});
}

View file

@ -1,102 +0,0 @@
/*
* 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 { Report } from '@kbn/analytics';
import { Server } from 'hapi';
export async function storeReport(server: any, report: Report) {
const { getSavedObjectsRepository } = server.savedObjects;
const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin');
const internalRepository = getSavedObjectsRepository(callWithInternalUser);
const uiStatsMetrics = report.uiStatsMetrics ? Object.entries(report.uiStatsMetrics) : [];
const userAgents = report.userAgent ? Object.entries(report.userAgent) : [];
return Promise.all([
...userAgents.map(async ([key, metric]) => {
const { userAgent } = metric;
const savedObjectId = `${key}:${userAgent}`;
return await internalRepository.create(
'ui-metric',
{ count: 1 },
{
id: savedObjectId,
overwrite: true,
}
);
}),
...uiStatsMetrics.map(async ([key, metric]) => {
const { appName, eventName } = metric;
const savedObjectId = `${appName}:${eventName}`;
return await internalRepository.incrementCounter('ui-metric', savedObjectId, 'count');
}),
]);
}
export function registerUiMetricRoute(server: Server) {
server.route({
method: 'POST',
path: '/api/telemetry/report',
options: {
validate: {
payload: Joi.object({
reportVersion: Joi.number().optional(),
userAgent: Joi.object()
.pattern(
/.*/,
Joi.object({
key: Joi.string().required(),
type: Joi.string().required(),
appName: Joi.string().required(),
userAgent: Joi.string().required(),
})
)
.allow(null)
.optional(),
uiStatsMetrics: Joi.object()
.pattern(
/.*/,
Joi.object({
key: Joi.string().required(),
type: Joi.string().required(),
appName: Joi.string().required(),
eventName: Joi.string().required(),
stats: Joi.object({
min: Joi.number(),
sum: Joi.number(),
max: Joi.number(),
avg: Joi.number(),
}).allow(null),
})
)
.allow(null),
}),
},
},
handler: async (req: any, h: any) => {
try {
const report = req.payload;
await storeReport(server, report);
return { status: 'ok' };
} catch (error) {
return { status: 'fail' };
}
},
});
}

View file

@ -24,6 +24,7 @@ import { embeddablePluginMock } from '../../../../../plugins/embeddable/public/m
import { expressionsPluginMock } from '../../../../../plugins/expressions/public/mocks';
import { inspectorPluginMock } from '../../../../../plugins/inspector/public/mocks';
import { uiActionsPluginMock } from '../../../../../plugins/ui_actions/public/mocks';
import { usageCollectionPluginMock } from '../../../../../plugins/usage_collection/public/mocks';
/* eslint-enable @kbn/eslint/no-restricted-paths */
export const pluginsMock = {
@ -33,6 +34,7 @@ export const pluginsMock = {
inspector: inspectorPluginMock.createSetupContract(),
expressions: expressionsPluginMock.createSetupContract(),
uiActions: uiActionsPluginMock.createSetupContract(),
usageCollection: usageCollectionPluginMock.createSetupContract(),
}),
createStart: () => ({
data: dataPluginMock.createStartContract(),

View file

@ -19,6 +19,7 @@
import sinon from 'sinon';
import { getFieldFormatsRegistry } from '../../../../test_utils/public/stub_field_formats';
import { METRIC_TYPE } from '@kbn/analytics';
const mockObservable = () => {
return {
@ -50,6 +51,11 @@ export const npSetup = {
uiSettings: mockUiSettings,
},
plugins: {
usageCollection: {
allowTrackUserAgent: sinon.fake(),
reportUiStats: sinon.fake(),
METRIC_TYPE,
},
embeddable: {
registerEmbeddableFactory: sinon.fake(),
},

View file

@ -32,6 +32,7 @@ import { DevToolsSetup, DevToolsStart } from '../../../../plugins/dev_tools/publ
import { KibanaLegacySetup, KibanaLegacyStart } from '../../../../plugins/kibana_legacy/public';
import { HomePublicPluginSetup, HomePublicPluginStart } from '../../../../plugins/home/public';
import { SharePluginSetup, SharePluginStart } from '../../../../plugins/share/public';
import { UsageCollectionSetup } from '../../../../plugins/usage_collection/public';
export interface PluginsSetup {
data: ReturnType<DataPlugin['setup']>;
@ -43,6 +44,7 @@ export interface PluginsSetup {
dev_tools: DevToolsSetup;
kibana_legacy: KibanaLegacySetup;
share: SharePluginSetup;
usageCollection: UsageCollectionSetup;
}
export interface PluginsStart {

View file

@ -137,3 +137,83 @@ There are a few ways you can test that your usage collector is working properly.
Keep it simple, and keep it to a model that Kibana will be able to understand. In short, that means don't rely on nested fields (arrays with objects). Flat arrays, such as arrays of strings are fine.
2. **If I accumulate an event counter in server memory, which my fetch method returns, won't it reset when the Kibana server restarts?**
Yes, but that is not a major concern. A visualization on such info might be a date histogram that gets events-per-second or something, which would be impacted by server restarts, so we'll have to offset the beginning of the time range when we detect that the latest metric is smaller than the earliest metric. That would be a pretty custom visualization, but perhaps future Kibana enhancements will be able to support that.
# UI Metric app
## Purpose
The purpose of the UI Metric app is to provide a tool for gathering data on how users interact with
various UIs within Kibana. It's useful for gathering _aggregate_ information, e.g. "How many times
has Button X been clicked" or "How many times has Page Y been viewed".
With some finagling, it's even possible to add more meaning to the info you gather, such as "How many
visualizations were created in less than 5 minutes".
### What it doesn't do
The UI Metric app doesn't gather any metadata around a user interaction, e.g. the user's identity,
the name of a dashboard they've viewed, or the timestamp of the interaction.
## How to use it
To track a user interaction, import the `createUiStatsReporter` helper function from UI Metric app:
```js
import { createUiStatsReporter, METRIC_TYPE } from 'relative/path/to/src/legacy/core_plugins/ui_metric/public';
const trackMetric = createUiStatsReporter(`<AppName>`);
trackMetric(METRIC_TYPE.CLICK, `<EventName>`);
trackMetric('click', `<EventName>`);
```
Metric Types:
- `METRIC_TYPE.CLICK` for tracking clicks `trackMetric(METRIC_TYPE.CLICK, 'my_button_clicked');`
- `METRIC_TYPE.LOADED` for a component load or page load `trackMetric(METRIC_TYPE.LOADED', 'my_component_loaded');`
- `METRIC_TYPE.COUNT` for a tracking a misc count `trackMetric(METRIC_TYPE.COUNT', 'my_counter', <count> });`
Call this function whenever you would like to track a user interaction within your app. The function
accepts two arguments, `metricType` and `eventNames`. These should be underscore-delimited strings.
For example, to track the `my_event` metric in the app `my_app` call `trackUiMetric(METRIC_TYPE.*, 'my_event)`.
That's all you need to do!
To track multiple metrics within a single request, provide an array of events, e.g. `trackMetric(METRIC_TYPE.*, ['my_event1', 'my_event2', 'my_event3'])`.
### Disallowed characters
The colon character (`:`) should not be used in app name or event names. Colons play
a special role in how metrics are stored as saved objects.
### Tracking timed interactions
If you want to track how long it takes a user to do something, you'll need to implement the timing
logic yourself. You'll also need to predefine some buckets into which the UI metric can fall.
For example, if you're timing how long it takes to create a visualization, you may decide to
measure interactions that take less than 1 minute, 1-5 minutes, 5-20 minutes, and longer than 20 minutes.
To track these interactions, you'd use the timed length of the interaction to determine whether to
use a `eventName` of `create_vis_1m`, `create_vis_5m`, `create_vis_20m`, or `create_vis_infinity`.
## How it works
Under the hood, your app and metric type will be stored in a saved object of type `user-metric` and the
ID `ui-metric:my_app:my_metric`. This saved object will have a `count` property which will be incremented
every time the above URI is hit.
These saved objects are automatically consumed by the stats API and surfaced under the
`ui_metric` namespace.
```json
{
"ui_metric": {
"my_app": [
{
"key": "my_metric",
"value": 3
}
]
}
}
```
By storing these metrics and their counts as key-value pairs, we can add more metrics without having
to worry about exceeding the 1000-field soft limit in Elasticsearch.

View file

@ -18,3 +18,4 @@
*/
export const KIBANA_STATS_TYPE = 'kibana_stats';
export const DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S = 60;

View file

@ -3,5 +3,5 @@
"configPath": ["usageCollection"],
"version": "kibana",
"server": true,
"ui": false
"ui": true
}

View file

@ -0,0 +1,28 @@
/*
* 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 { PluginInitializerContext } from '../../../core/public';
import { UsageCollectionPlugin } from './plugin';
export { METRIC_TYPE } from '@kbn/analytics';
export { UsageCollectionSetup } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new UsageCollectionPlugin(initializerContext);
}

View file

@ -0,0 +1,36 @@
/*
* 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 { UsageCollectionSetup, METRIC_TYPE } from '.';
export type Setup = jest.Mocked<UsageCollectionSetup>;
const createSetupContract = (): Setup => {
const setupContract: Setup = {
allowTrackUserAgent: jest.fn(),
reportUiStats: jest.fn(),
METRIC_TYPE,
};
return setupContract;
};
export const usageCollectionPluginMock = {
createSetupContract,
};

View file

@ -0,0 +1,91 @@
/*
* 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 { Reporter, METRIC_TYPE } from '@kbn/analytics';
import { Storage } from '../../kibana_utils/public';
import { createReporter } from './services';
import {
PluginInitializerContext,
Plugin,
CoreSetup,
CoreStart,
HttpServiceBase,
} from '../../../core/public';
interface PublicConfigType {
uiMetric: {
enabled: boolean;
debug: boolean;
};
}
export interface UsageCollectionSetup {
allowTrackUserAgent: (allow: boolean) => void;
reportUiStats: Reporter['reportUiStats'];
METRIC_TYPE: typeof METRIC_TYPE;
}
export function isUnauthenticated(http: HttpServiceBase) {
const { anonymousPaths } = http;
return anonymousPaths.isAnonymous(window.location.pathname);
}
export class UsageCollectionPlugin implements Plugin<UsageCollectionSetup> {
private trackUserAgent: boolean = true;
private reporter?: Reporter;
private config: PublicConfigType;
constructor(initializerContext: PluginInitializerContext) {
this.config = initializerContext.config.get<PublicConfigType>();
}
public setup({ http }: CoreSetup): UsageCollectionSetup {
const localStorage = new Storage(window.localStorage);
const debug = this.config.uiMetric.debug;
this.reporter = createReporter({
localStorage,
debug,
fetch: http,
});
return {
allowTrackUserAgent: (allow: boolean) => {
this.trackUserAgent = allow;
},
reportUiStats: this.reporter.reportUiStats,
METRIC_TYPE,
};
}
public start({ http }: CoreStart) {
if (!this.reporter) {
return;
}
if (this.config.uiMetric.enabled && !isUnauthenticated(http)) {
this.reporter.start();
}
if (this.trackUserAgent) {
this.reporter.reportUserAgent('kibana');
}
}
public stop() {}
}

View file

@ -17,29 +17,30 @@
* under the License.
*/
// @ts-ignore
import { uiModules } from 'ui/modules';
import chrome from 'ui/chrome';
import { kfetch } from 'ui/kfetch';
import {
createAnalyticsReporter,
setTelemetryReporter,
trackUserAgent,
} from '../services/telemetry_analytics';
import { isUnauthenticated } from '../../../telemetry/public/services';
import { Reporter, Storage } from '@kbn/analytics';
import { HttpServiceBase } from 'kibana/public';
function telemetryInit($injector: any) {
const uiMetricEnabled = chrome.getInjected('uiMetricEnabled');
const debug = chrome.getInjected('debugUiMetric');
if (!uiMetricEnabled || isUnauthenticated()) {
return;
}
const localStorage = $injector.get('localStorage');
const uiReporter = createAnalyticsReporter({ localStorage, debug, kfetch });
setTelemetryReporter(uiReporter);
uiReporter.start();
trackUserAgent('kibana');
interface AnalyicsReporterConfig {
localStorage: Storage;
debug: boolean;
fetch: HttpServiceBase;
}
uiModules.get('kibana').run(telemetryInit);
export function createReporter(config: AnalyicsReporterConfig): Reporter {
const { localStorage, debug, fetch } = config;
return new Reporter({
debug,
storage: localStorage,
async http(report) {
const response = await fetch.post('/api/ui_metric/report', {
body: JSON.stringify({ report }),
});
if (response.status !== 'ok') {
throw Error('Unable to store report.');
}
return response;
},
});
}

View file

@ -17,4 +17,4 @@
* under the License.
*/
export const API_BASE_PATH = '/api/ui_metric';
export { createReporter } from './create_reporter';

View file

@ -17,8 +17,29 @@
* under the License.
*/
import { schema } from '@kbn/config-schema';
import { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from 'kibana/server';
import { DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S } from '../common/constants';
export const ConfigSchema = schema.object({
maximumWaitTimeForAllCollectorsInS: schema.number({ defaultValue: 60 }),
export const configSchema = schema.object({
uiMetric: schema.object({
enabled: schema.boolean({ defaultValue: true }),
debug: schema.boolean({ defaultValue: schema.contextRef('dev') }),
}),
maximumWaitTimeForAllCollectorsInS: schema.number({
defaultValue: DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S,
}),
});
export type ConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<ConfigType> = {
schema: configSchema,
deprecations: ({ renameFromRoot }) => [
renameFromRoot('ui_metric.enabled', 'usageCollection.uiMetric.enabled'),
renameFromRoot('ui_metric.debug', 'usageCollection.uiMetric.debug'),
],
exposeToBrowser: {
uiMetric: true,
},
};

View file

@ -18,10 +18,9 @@
*/
import { PluginInitializerContext } from '../../../../src/core/server';
import { Plugin } from './plugin';
import { ConfigSchema } from './config';
import { UsageCollectionPlugin } from './plugin';
export { UsageCollectionSetup } from './plugin';
export const config = { schema: ConfigSchema };
export { config } from './config';
export const plugin = (initializerContext: PluginInitializerContext) =>
new Plugin(initializerContext);
new UsageCollectionPlugin(initializerContext);

View file

@ -18,22 +18,25 @@
*/
import { first } from 'rxjs/operators';
import { TypeOf } from '@kbn/config-schema';
import { ConfigSchema } from './config';
import { PluginInitializerContext, Logger } from '../../../../src/core/server';
import { ConfigType } from './config';
import { PluginInitializerContext, Logger, CoreSetup } from '../../../../src/core/server';
import { CollectorSet } from './collector';
import { setupRoutes } from './routes';
export type UsageCollectionSetup = CollectorSet;
export type UsageCollectionSetup = CollectorSet & {
registerLegacySavedObjects: (legacySavedObjects: any) => void;
};
export class Plugin {
export class UsageCollectionPlugin {
logger: Logger;
private legacySavedObjects: any;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.logger = this.initializerContext.logger.get();
}
public async setup(): Promise<UsageCollectionSetup> {
public async setup(core: CoreSetup) {
const config = await this.initializerContext.config
.create<TypeOf<typeof ConfigSchema>>()
.create<ConfigType>()
.pipe(first())
.toPromise();
@ -42,7 +45,16 @@ export class Plugin {
maximumWaitTimeForAllCollectorsInS: config.maximumWaitTimeForAllCollectorsInS,
});
return collectorSet;
const router = core.http.createRouter();
const getLegacySavedObjects = () => this.legacySavedObjects;
setupRoutes(router, getLegacySavedObjects);
return {
...collectorSet,
registerLegacySavedObjects: (legacySavedObjects: any) => {
this.legacySavedObjects = legacySavedObjects;
},
};
}
public start() {

View file

@ -17,12 +17,5 @@
* under the License.
*/
let _canTrackUiMetrics = false;
export function setCanTrackUiMetrics(flag: boolean) {
_canTrackUiMetrics = flag;
}
export function getCanTrackUiMetrics(): boolean {
return _canTrackUiMetrics;
}
export { storeReport } from './store_report';
export { reportSchema } from './schema';

View file

@ -0,0 +1,59 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import { METRIC_TYPE } from '@kbn/analytics';
export const reportSchema = schema.object({
reportVersion: schema.maybe(schema.literal(1)),
userAgent: schema.maybe(
schema.recordOf(
schema.string(),
schema.object({
key: schema.string(),
type: schema.string(),
appName: schema.string(),
userAgent: schema.string(),
})
)
),
uiStatsMetrics: schema.maybe(
schema.recordOf(
schema.string(),
schema.object({
key: schema.string(),
type: schema.oneOf([
schema.literal<METRIC_TYPE>(METRIC_TYPE.CLICK),
schema.literal<METRIC_TYPE>(METRIC_TYPE.LOADED),
schema.literal<METRIC_TYPE>(METRIC_TYPE.COUNT),
]),
appName: schema.string(),
eventName: schema.string(),
stats: schema.object({
min: schema.number(),
sum: schema.number(),
max: schema.number(),
avg: schema.number(),
}),
})
)
),
});
export type ReportSchemaType = TypeOf<typeof reportSchema>;

View file

@ -0,0 +1,44 @@
/*
* 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 { ReportSchemaType } from './schema';
export async function storeReport(internalRepository: any, report: ReportSchemaType) {
const uiStatsMetrics = report.uiStatsMetrics ? Object.entries(report.uiStatsMetrics) : [];
const userAgents = report.userAgent ? Object.entries(report.userAgent) : [];
return Promise.all([
...userAgents.map(async ([key, metric]) => {
const { userAgent } = metric;
const savedObjectId = `${key}:${userAgent}`;
return await internalRepository.create(
'ui-metric',
{ count: 1 },
{
id: savedObjectId,
overwrite: true,
}
);
}),
...uiStatsMetrics.map(async ([key, metric]) => {
const { appName, eventName } = metric;
const savedObjectId = `${appName}:${eventName}`;
return await internalRepository.incrementCounter('ui-metric', savedObjectId, 'count');
}),
]);
}

View file

@ -0,0 +1,25 @@
/*
* 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 { IRouter } from '../../../../../src/core/server';
import { registerUiMetricRoute } from './report_metrics';
export function setupRoutes(router: IRouter, getLegacySavedObjects: any) {
registerUiMetricRoute(router, getLegacySavedObjects);
}

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 { schema } from '@kbn/config-schema';
import { IRouter } from '../../../../../src/core/server';
import { storeReport, reportSchema } from '../report';
export function registerUiMetricRoute(router: IRouter, getLegacySavedObjects: () => any) {
router.post(
{
path: '/api/ui_metric/report',
validate: {
body: schema.object({
report: reportSchema,
}),
},
},
async (context, req, res) => {
const { report } = req.body;
try {
const internalRepository = getLegacySavedObjects();
await storeReport(internalRepository, report);
return res.ok({ body: { status: 'ok' } });
} catch (error) {
return res.ok({ body: { status: 'fail' } });
}
}
);
}

View file

@ -49,10 +49,10 @@ export default function ({ getService }) {
}
};
await supertest
.post('/api/telemetry/report')
.post('/api/ui_metric/report')
.set('kbn-xsrf', 'kibana')
.set('content-type', 'application/json')
.send(report)
.send({ report })
.expect(200);
const response = await es.search({ index: '.kibana', q: 'type:ui-metric' });
@ -77,10 +77,10 @@ export default function ({ getService }) {
}
};
await supertest
.post('/api/telemetry/report')
.post('/api/ui_metric/report')
.set('kbn-xsrf', 'kibana')
.set('content-type', 'application/json')
.send(report)
.send({ report })
.expect(200);
const response = await es.search({ index: '.kibana', q: 'type:ui-metric' });

View file

@ -17,6 +17,8 @@ import {
} from '../../../../utils/testHelpers';
import { ApmPluginContextValue } from '../../../../context/ApmPluginContext';
jest.mock('ui/new_platform');
function wrapper({ children }: { children: ReactChild }) {
return (
<MockApmPluginContextWrapper

View file

@ -27,6 +27,7 @@ import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers';
jest.spyOn(history, 'push');
jest.spyOn(history, 'replace');
jest.mock('ui/new_platform');
function setup({
urlParams,
serviceTransactionTypes

View file

@ -10,9 +10,10 @@ import {
withUnconnectedElementsLoadedTelemetry,
WorkpadLoadedMetric,
WorkpadLoadedWithErrorsMetric,
} from '../workpad_telemetry';
import { METRIC_TYPE } from '../../../../lib/ui_metric';
} from './workpad_telemetry';
import { METRIC_TYPE } from '../../../lib/ui_metric';
jest.mock('ui/new_platform');
const trackMetric = jest.fn();
const Component = withUnconnectedElementsLoadedTelemetry(() => <div />, trackMetric);

View file

@ -7,6 +7,7 @@
import { updateFields, updateFormErrors } from './follower_index_form';
jest.mock('ui/new_platform');
jest.mock('ui/indices', () => ({
INDEX_ILLEGAL_CHARACTERS_VISIBLE: [],
}));

View file

@ -8,6 +8,7 @@ import { reducer, initialState } from './api';
import { API_STATUS } from '../../constants';
import { apiRequestStart, apiRequestEnd, setApiError } from '../actions';
jest.mock('ui/new_platform');
jest.mock('../../constants', () => ({
API_STATUS: {
IDLE: 'idle',

View file

@ -33,6 +33,8 @@ import {
maximumDocumentsRequiredMessage,
} from '../../public/store/selectors/lifecycle';
jest.mock('ui/new_platform');
let server;
let store;
const policy = {

View file

@ -17,6 +17,7 @@ import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import { setHttpClient } from '../../public/services/api';
setHttpClient(axios.create({ adapter: axiosXhrAdapter }));
import sinon from 'sinon';
jest.mock('ui/new_platform');
let server = null;
let store = null;

View file

@ -14,6 +14,8 @@ import {
ilmFilterExtension,
ilmSummaryExtension,
} from '../public/extend_index_management';
jest.mock('ui/new_platform');
const indexWithoutLifecyclePolicy = {
health: 'yellow',
status: 'open',

View file

@ -21,6 +21,7 @@ import {
} from '../constants';
import { getUiMetricsForPhases } from './ui_metric';
jest.mock('ui/new_platform');
describe('getUiMetricsForPhases', () => {
test('gets cold phase', () => {

View file

@ -15,7 +15,6 @@ import { init as initHttp } from '../../../public/app/services/http';
import { init as initNotification } from '../../../public/app/services/notification';
import { init as initUiMetric } from '../../../public/app/services/ui_metric';
import { init as initHttpRequests } from './http_requests';
import { createUiStatsReporter } from '../../../../../../../src/legacy/core_plugins/ui_metric/public';
export const setupEnvironment = () => {
chrome.breadcrumbs = {
@ -25,7 +24,7 @@ export const setupEnvironment = () => {
initHttp(axios.create({ adapter: axiosXhrAdapter }), (path) => path);
initBreadcrumb(() => {}, MANAGEMENT_BREADCRUMB);
initNotification(toastNotifications, fatalError);
initUiMetric(createUiStatsReporter);
initUiMetric(() => () => {});
const { server, httpRequestsMockHelpers } = initHttpRequests();

View file

@ -17,6 +17,7 @@ import {
tabToHumanizedMap,
} from '../../components';
jest.mock('ui/new_platform');
jest.mock('../../../services', () => {
const services = require.requireActual('../../../services');
return {

View file

@ -8,6 +8,8 @@ import { registerTestBed } from '../../../../../../../test_utils';
import { rollupJobsStore } from '../../store';
import { JobList } from './job_list';
jest.mock('ui/new_platform');
jest.mock('ui/chrome', () => ({
addBasePath: () => {},
breadcrumbs: { set: () => {} },

View file

@ -11,6 +11,7 @@ import { getJobs, jobCount } from '../../../../../fixtures';
import { rollupJobsStore } from '../../../store';
import { JobTable } from './job_table';
jest.mock('ui/new_platform');
jest.mock('../../../services', () => {
const services = require.requireActual('../../../services');
return {

View file

@ -19,6 +19,7 @@ const testWidth = 640;
const usersViewing = ['elastic'];
const mockUseKibanaCore = useKibanaCore as jest.Mock;
jest.mock('ui/new_platform');
jest.mock('../../../lib/compose/kibana_core');
mockUseKibanaCore.mockImplementation(() => ({
uiSettings: mockUiSettings,

View file

@ -15,6 +15,7 @@ import { HostsTableType } from '../../store/hosts/model';
import { RouteSpyState } from '../../utils/route/types';
import { SiemNavigationProps, SiemNavigationComponentProps } from './types';
jest.mock('ui/new_platform');
jest.mock('./breadcrumbs', () => ({
setBreadcrumbs: jest.fn(),
}));

View file

@ -16,6 +16,8 @@ import { CONSTANTS } from '../../url_state/constants';
import { TabNavigationComponent } from './';
import { TabNavigationProps } from './types';
jest.mock('ui/new_platform');
describe('Tab Navigation', () => {
const pageName = SiemPageName.hosts;
const hostName = 'siem-window';