[Telemetry] Server side fetcher (#50015)

* initial push

* self code review

* ignore node-fetch type

* usageFetcher api

* user agent metric

* telemetry plugin collector

* remove extra unused method

* remove unused import

* type check

* fix collections tests

* pass kfetch as dep

* add ui metrics integration test for user agent

* dont start ui metrics when not authenticated

* user agent count always 1

* fix broken ui-metric integration tests

* try using config.get

* avoid fetching configs if sending

* type unknown -> string

* check if fetcher is causing the issue

* disable ui_metric from functional tests

* enable ui_metric back again

* ignore keyword above 256

* check requesting app first

* clean up after all the debugging :)

* fix tests

* always return 200 for ui metric reporting

* remove boom import

* logout after removing role/user

* undo some changes in tests

* inside try catch

* prevent potential race conditions in priorities with =

* use snake_case for telemetry plugin collection

* usageFetcher -> sendUsageFrom

* more replacements

* remove extra unused route

* config() -> config

* Update src/legacy/core_plugins/telemetry/index.ts

Co-Authored-By: Mike Côté <mikecote@users.noreply.github.com>

* Update src/legacy/core_plugins/ui_metric/server/routes/api/ui_metric.ts

Co-Authored-By: Mike Côté <mikecote@users.noreply.github.com>

* config() -> config

* fix SO update logic given the current changes

* fix opt in check

* triple check

* check for non boolean

* take into account older settings

* import TelemetryOptInProvider

* update test case
This commit is contained in:
Ahmad Bamieh 2019-11-13 09:18:57 +02:00 committed by GitHub
parent 59e0a1cba1
commit 35a5b770d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1240 additions and 508 deletions

View file

@ -17,6 +17,6 @@
"@babel/cli": "7.5.5",
"@kbn/dev-utils": "1.0.0",
"@kbn/babel-preset": "1.0.0",
"typescript": "3.5.1"
"typescript": "3.5.3"
}
}

View file

@ -17,6 +17,6 @@
* under the License.
*/
export { createReporter, ReportHTTP, Reporter, ReporterConfig } from './reporter';
export { ReportHTTP, Reporter, ReporterConfig } from './reporter';
export { UiStatsMetricType, METRIC_TYPE } from './metrics';
export { Report, ReportManager } from './report';

View file

@ -17,21 +17,17 @@
* under the License.
*/
import { UiStatsMetric, UiStatsMetricType } from './ui_stats';
import { UiStatsMetric } from './ui_stats';
import { UserAgentMetric } from './user_agent';
export {
UiStatsMetric,
createUiStatsMetric,
UiStatsMetricReport,
UiStatsMetricType,
} from './ui_stats';
export { UiStatsMetric, createUiStatsMetric, UiStatsMetricType } from './ui_stats';
export { Stats } from './stats';
export { trackUsageAgent } from './user_agent';
export type Metric = UiStatsMetric<UiStatsMetricType>;
export type MetricType = keyof typeof METRIC_TYPE;
export type Metric = UiStatsMetric | UserAgentMetric;
export enum METRIC_TYPE {
COUNT = 'count',
LOADED = 'loaded',
CLICK = 'click',
USER_AGENT = 'user_agent',
}

View file

@ -17,37 +17,33 @@
* under the License.
*/
import { Stats } from './stats';
import { METRIC_TYPE } from './';
export type UiStatsMetricType = METRIC_TYPE.CLICK | METRIC_TYPE.LOADED | METRIC_TYPE.COUNT;
export interface UiStatsMetricConfig<T extends UiStatsMetricType> {
type: T;
export interface UiStatsMetricConfig {
type: UiStatsMetricType;
appName: string;
eventName: string;
count?: number;
}
export interface UiStatsMetric<T extends UiStatsMetricType = UiStatsMetricType> {
type: T;
export interface UiStatsMetric {
type: UiStatsMetricType;
appName: string;
eventName: string;
count: number;
}
export function createUiStatsMetric<T extends UiStatsMetricType>({
export function createUiStatsMetric({
type,
appName,
eventName,
count = 1,
}: UiStatsMetricConfig<T>): UiStatsMetric<T> {
return { type, appName, eventName, count };
}
export interface UiStatsMetricReport {
key: string;
appName: string;
eventName: string;
type: UiStatsMetricType;
stats: Stats;
}: UiStatsMetricConfig): UiStatsMetric {
return {
type,
appName,
eventName,
count,
};
}

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 { METRIC_TYPE } from './';
export interface UserAgentMetric {
type: METRIC_TYPE.USER_AGENT;
appName: string;
userAgent: string;
}
export function trackUsageAgent(appName: string): UserAgentMetric {
const userAgent = (window && window.navigator && window.navigator.userAgent) || '';
return {
type: METRIC_TYPE.USER_AGENT,
appName,
userAgent,
};
}

View file

@ -17,28 +17,47 @@
* under the License.
*/
import { UnreachableCaseError } from './util';
import { Metric, Stats, UiStatsMetricReport, METRIC_TYPE } from './metrics';
import { UnreachableCaseError, wrapArray } from './util';
import { Metric, Stats, UiStatsMetricType, METRIC_TYPE } from './metrics';
const REPORT_VERSION = 1;
export interface Report {
reportVersion: typeof REPORT_VERSION;
uiStatsMetrics: {
[key: string]: UiStatsMetricReport;
[key: string]: {
key: string;
appName: string;
eventName: string;
type: UiStatsMetricType;
stats: Stats;
};
};
userAgent?: {
[key: string]: {
userAgent: string;
key: string;
type: METRIC_TYPE.USER_AGENT;
appName: string;
};
};
}
export class ReportManager {
static REPORT_VERSION = REPORT_VERSION;
public report: Report;
constructor(report?: Report) {
this.report = report || ReportManager.createReport();
}
static createReport() {
return { uiStatsMetrics: {} };
static createReport(): Report {
return { reportVersion: REPORT_VERSION, uiStatsMetrics: {} };
}
public clearReport() {
this.report = ReportManager.createReport();
}
public isReportEmpty(): boolean {
return Object.keys(this.report.uiStatsMetrics).length === 0;
const noUiStats = Object.keys(this.report.uiStatsMetrics).length === 0;
const noUserAgent = !this.report.userAgent || Object.keys(this.report.userAgent).length === 0;
return noUiStats && noUserAgent;
}
private incrementStats(count: number, stats?: Stats): Stats {
const { min = 0, max = 0, sum = 0 } = stats || {};
@ -54,28 +73,46 @@ export class ReportManager {
sum: newSum,
};
}
assignReports(newMetrics: Metric[]) {
newMetrics.forEach(newMetric => this.assignReport(this.report, newMetric));
assignReports(newMetrics: Metric | Metric[]) {
wrapArray(newMetrics).forEach(newMetric => this.assignReport(this.report, newMetric));
}
static createMetricKey(metric: Metric): string {
switch (metric.type) {
case METRIC_TYPE.USER_AGENT: {
const { appName, type } = metric;
return `${appName}-${type}`;
}
case METRIC_TYPE.CLICK:
case METRIC_TYPE.LOADED:
case METRIC_TYPE.COUNT: {
const { appName, type, eventName } = metric;
const { appName, eventName, type } = metric;
return `${appName}-${type}-${eventName}`;
}
default:
throw new UnreachableCaseError(metric.type);
throw new UnreachableCaseError(metric);
}
}
private assignReport(report: Report, metric: Metric) {
const key = ReportManager.createMetricKey(metric);
switch (metric.type) {
case METRIC_TYPE.USER_AGENT: {
const { appName, type, userAgent } = metric;
if (userAgent) {
this.report.userAgent = {
[key]: {
key,
appName,
type,
userAgent: metric.userAgent,
},
};
}
return;
}
case METRIC_TYPE.CLICK:
case METRIC_TYPE.LOADED:
case METRIC_TYPE.COUNT: {
const { appName, type, eventName, count } = metric;
const key = ReportManager.createMetricKey(metric);
const existingStats = (report.uiStatsMetrics[key] || {}).stats;
this.report.uiStatsMetrics[key] = {
key,
@ -87,7 +124,7 @@ export class ReportManager {
return;
}
default:
throw new UnreachableCaseError(metric.type);
throw new UnreachableCaseError(metric);
}
}
}

View file

@ -18,7 +18,7 @@
*/
import { wrapArray } from './util';
import { Metric, UiStatsMetric, createUiStatsMetric } from './metrics';
import { Metric, createUiStatsMetric, trackUsageAgent, UiStatsMetricType } from './metrics';
import { Storage, ReportStorageManager } from './storage';
import { Report, ReportManager } from './report';
@ -40,10 +40,11 @@ export class Reporter {
private reportManager: ReportManager;
private storageManager: ReportStorageManager;
private debug: boolean;
private retryCount = 0;
private readonly maxRetries = 3;
constructor(config: ReporterConfig) {
const { http, storage, debug, checkInterval = 10000, storageKey = 'analytics' } = config;
const { http, storage, debug, checkInterval = 90000, storageKey = 'analytics' } = config;
this.http = http;
this.checkInterval = checkInterval;
this.interval = null;
@ -59,18 +60,19 @@ export class Reporter {
}
private flushReport() {
this.retryCount = 0;
this.reportManager.clearReport();
this.storageManager.store(this.reportManager.report);
}
public start() {
public start = () => {
if (!this.interval) {
this.interval = setTimeout(() => {
this.interval = null;
this.sendReports();
}, this.checkInterval);
}
}
};
private log(message: any) {
if (this.debug) {
@ -79,36 +81,42 @@ export class Reporter {
}
}
public reportUiStats(
public reportUiStats = (
appName: string,
type: UiStatsMetric['type'],
type: UiStatsMetricType,
eventNames: string | string[],
count?: number
) {
) => {
const metrics = wrapArray(eventNames).map(eventName => {
if (this) this.log(`${type} Metric -> (${appName}:${eventName}):`);
this.log(`${type} Metric -> (${appName}:${eventName}):`);
const report = createUiStatsMetric({ type, appName, eventName, count });
this.log(report);
return report;
});
this.saveToReport(metrics);
}
};
public async sendReports() {
public reportUserAgent = (appName: string) => {
this.log(`Reporting user-agent.`);
const report = trackUsageAgent(appName);
this.saveToReport([report]);
};
public sendReports = async () => {
if (!this.reportManager.isReportEmpty()) {
try {
await this.http(this.reportManager.report);
this.flushReport();
} catch (err) {
this.log(`Error Sending Metrics Report ${err}`);
this.retryCount = this.retryCount + 1;
const versionMismatch =
this.reportManager.report.reportVersion !== ReportManager.REPORT_VERSION;
if (versionMismatch || this.retryCount > this.maxRetries) {
this.flushReport();
}
}
}
this.start();
}
}
export function createReporter(reportedConf: ReporterConfig) {
const reporter = new Reporter(reportedConf);
reporter.start();
return reporter;
};
}

View file

@ -181,6 +181,7 @@ kibana_vars=(
xpack.security.secureCookies
xpack.security.sessionTimeout
telemetry.enabled
telemetry.sendUsageFrom
)
longopts=''

View file

@ -59,6 +59,12 @@ export const PRIVACY_STATEMENT_URL = `https://www.elastic.co/legal/privacy-state
*/
export const KIBANA_LOCALIZATION_STATS_TYPE = 'localization';
/**
* The type name used to publish telemetry plugin stats.
* @type {string}
*/
export const TELEMETRY_STATS_TYPE = 'telemetry';
/**
* UI metric usage type
* @type {string}

View file

@ -27,12 +27,13 @@ import { i18n } from '@kbn/i18n';
import mappings from './mappings.json';
import { CONFIG_TELEMETRY, getConfigTelemetryDesc } from './common/constants';
import { getXpackConfigWithDeprecated } from './common/get_xpack_config_with_deprecated';
import { telemetryPlugin, getTelemetryOptIn } from './server';
import { telemetryPlugin, replaceTelemetryInjectedVars, FetcherTask } from './server';
import {
createLocalizationUsageCollector,
createTelemetryUsageCollector,
createUiMetricUsageCollector,
createTelemetryPluginUsageCollector,
} from './server/collectors';
const ENDPOINT_VERSION = 'v2';
@ -46,20 +47,18 @@ const telemetry = (kibana: any) => {
config(Joi: typeof JoiNamespace) {
return Joi.object({
enabled: Joi.boolean().default(true),
allowChangingOptInStatus: Joi.boolean().default(true),
optIn: Joi.when('allowChangingOptInStatus', {
is: false,
then: Joi.valid(true),
then: Joi.valid(true).required(),
otherwise: Joi.boolean()
.allow(null)
.default(null),
}),
allowChangingOptInStatus: Joi.boolean().default(true),
// `config` is used internally and not intended to be set
config: Joi.string().default(Joi.ref('$defaultConfigPath')),
banner: Joi.boolean().default(true),
lastVersionChecked: Joi.string()
.allow('')
.default(''),
url: Joi.when('$dev', {
is: true,
then: Joi.string().default(
@ -69,6 +68,9 @@ const telemetry = (kibana: any) => {
`https://telemetry.elastic.co/xpack/${ENDPOINT_VERSION}/send`
),
}),
sendUsageFrom: Joi.string()
.allow(['server', 'browser'])
.default('browser'),
}).default();
},
uiExports: {
@ -89,30 +91,8 @@ const telemetry = (kibana: any) => {
},
},
async replaceInjectedVars(originalInjectedVars: any, request: any) {
const config = request.server.config();
const optIn = config.get('telemetry.optIn');
const allowChangingOptInStatus = config.get('telemetry.allowChangingOptInStatus');
const currentKibanaVersion = getCurrentKibanaVersion(request.server);
let telemetryOptedIn: boolean | null;
if (typeof optIn === 'boolean' && !allowChangingOptInStatus) {
// When not allowed to change optIn status and an optIn value is set, we'll overwrite with that
telemetryOptedIn = optIn;
} else {
telemetryOptedIn = await getTelemetryOptIn({
request,
currentKibanaVersion,
});
if (telemetryOptedIn === null) {
// In the senario there's no value set in telemetryOptedIn, we'll return optIn value
telemetryOptedIn = optIn;
}
}
return {
...originalInjectedVars,
telemetryOptedIn,
};
const telemetryInjectedVars = await replaceTelemetryInjectedVars(request);
return Object.assign({}, originalInjectedVars, telemetryInjectedVars);
},
injectDefaultVars(server: Server) {
const config = server.config();
@ -124,16 +104,21 @@ const telemetry = (kibana: any) => {
getXpackConfigWithDeprecated(config, 'telemetry.banner'),
telemetryOptedIn: config.get('telemetry.optIn'),
allowChangingOptInStatus: config.get('telemetry.allowChangingOptInStatus'),
telemetrySendUsageFrom: config.get('telemetry.sendUsageFrom'),
};
},
hacks: ['plugins/telemetry/hacks/telemetry_init', 'plugins/telemetry/hacks/telemetry_opt_in'],
mappings,
},
async init(server: Server) {
postInit(server: Server) {
const fetcherTask = new FetcherTask(server);
fetcherTask.start();
},
init(server: Server) {
const initializerContext = {
env: {
packageInfo: {
version: getCurrentKibanaVersion(server),
version: server.config().get('pkg.version'),
},
},
config: {
@ -156,9 +141,10 @@ const telemetry = (kibana: any) => {
log: server.log,
} as any) as CoreSetup;
await telemetryPlugin(initializerContext).setup(coreSetup);
telemetryPlugin(initializerContext).setup(coreSetup);
// register collectors
server.usage.collectorSet.register(createTelemetryPluginUsageCollector(server));
server.usage.collectorSet.register(createLocalizationUsageCollector(server));
server.usage.collectorSet.register(createTelemetryUsageCollector(server));
server.usage.collectorSet.register(createUiMetricUsageCollector(server));
@ -168,7 +154,3 @@ const telemetry = (kibana: any) => {
// eslint-disable-next-line import/no-default-export
export default telemetry;
function getCurrentKibanaVersion(server: Server): string {
return server.config().get('pkg.version');
}

View file

@ -4,7 +4,15 @@
"enabled": {
"type": "boolean"
},
"sendUsageFrom": {
"ignore_above": 256,
"type": "keyword"
},
"lastReported": {
"type": "date"
},
"lastVersionChecked": {
"ignore_above": 256,
"type": "keyword"
}
}

View file

@ -25,13 +25,21 @@ import { isUnauthenticated } from '../services';
import { Telemetry } from './telemetry';
// @ts-ignore
import { fetchTelemetry } from './fetch_telemetry';
// @ts-ignore
import { isOptInHandleOldSettings } from './welcome_banner/handle_old_settings';
import { TelemetryOptInProvider } from '../services';
function telemetryInit($injector: any) {
const $http = $injector.get('$http');
const Private = $injector.get('Private');
const config = $injector.get('config');
const telemetryOptInProvider = Private(TelemetryOptInProvider);
const telemetryEnabled = npStart.core.injectedMetadata.getInjectedVar('telemetryEnabled');
const telemetryOptedIn = isOptInHandleOldSettings(config, telemetryOptInProvider);
const sendUsageFrom = npStart.core.injectedMetadata.getInjectedVar('telemetrySendUsageFrom');
if (telemetryEnabled) {
if (telemetryEnabled && telemetryOptedIn && sendUsageFrom === 'browser') {
// no telemetry for non-logged in users
if (isUnauthenticated()) {
return;

View file

@ -27,8 +27,9 @@ import { CONFIG_TELEMETRY } from '../../../common/constants';
* @param {Object} config The advanced settings config object.
* @return {Boolean} {@code true} if the banner should still be displayed. {@code false} if the banner should not be displayed.
*/
const CONFIG_ALLOW_REPORT = 'xPackMonitoring:allowReport';
export async function handleOldSettings(config, telemetryOptInProvider) {
const CONFIG_ALLOW_REPORT = 'xPackMonitoring:allowReport';
const CONFIG_SHOW_BANNER = 'xPackMonitoring:showBanner';
const oldAllowReportSetting = config.get(CONFIG_ALLOW_REPORT, null);
const oldTelemetrySetting = config.get(CONFIG_TELEMETRY, null);
@ -62,3 +63,24 @@ export async function handleOldSettings(config, telemetryOptInProvider) {
return true;
}
export async function isOptInHandleOldSettings(config, telemetryOptInProvider) {
const currentOptInSettting = telemetryOptInProvider.getOptIn();
if (typeof currentOptInSettting === 'boolean') {
return currentOptInSettting;
}
const oldTelemetrySetting = config.get(CONFIG_TELEMETRY, null);
if (typeof oldTelemetrySetting === 'boolean') {
return oldTelemetrySetting;
}
const oldAllowReportSetting = config.get(CONFIG_ALLOW_REPORT, null);
if (typeof oldAllowReportSetting === 'boolean') {
return oldAllowReportSetting;
}
return null;
}

View file

@ -41,6 +41,9 @@ export function TelemetryOptInProvider($injector: any, chrome: any) {
bannerId = id;
},
setOptIn: async (enabled: boolean) => {
if (!allowChangingOptInStatus) {
return;
}
setCanTrackUiMetrics(enabled);
const $http = $injector.get('$http');

View file

@ -17,20 +17,72 @@
* under the License.
*/
class TelemetryCollectionManager {
private getterMethod?: any;
private collectionTitle?: string;
private getterMethodPriority = 0;
import { encryptTelemetry } from './collectors';
public setStatsGetter = (statsGetter: any, title: string, priority = 0) => {
if (priority >= this.getterMethodPriority) {
export type EncryptedStatsGetterConfig = { unencrypted: false } & {
server: any;
start: any;
end: any;
isDev: boolean;
};
export type UnencryptedStatsGetterConfig = { unencrypted: true } & {
req: any;
start: any;
end: any;
isDev: boolean;
};
export interface StatsCollectionConfig {
callCluster: any;
server: any;
start: any;
end: any;
}
export type StatsGetterConfig = UnencryptedStatsGetterConfig | EncryptedStatsGetterConfig;
export type StatsGetter = (config: StatsGetterConfig) => Promise<any[]>;
export const getStatsCollectionConfig = (
config: StatsGetterConfig,
esClustser: string
): StatsCollectionConfig => {
const { start, end } = config;
const server = config.unencrypted ? config.req.server : config.server;
const { callWithRequest, callWithInternalUser } = server.plugins.elasticsearch.getCluster(
esClustser
);
const callCluster = config.unencrypted
? (...args: any[]) => callWithRequest(config.req, ...args)
: callWithInternalUser;
return { server, callCluster, start, end };
};
export class TelemetryCollectionManager {
private getterMethod?: StatsGetter;
private collectionTitle?: string;
private getterMethodPriority = -1;
public setStatsGetter = (statsGetter: StatsGetter, title: string, priority = 0) => {
if (priority > this.getterMethodPriority) {
this.getterMethod = statsGetter;
this.collectionTitle = title;
this.getterMethodPriority = priority;
}
};
getCollectionTitle = () => {
private getStats = async (config: StatsGetterConfig) => {
if (!this.getterMethod) {
throw Error('Stats getter method not set.');
}
const usageData = await this.getterMethod(config);
if (config.unencrypted) return usageData;
return encryptTelemetry(usageData, config.isDev);
};
public getCollectionTitle = () => {
return this.collectionTitle;
};
@ -39,7 +91,7 @@ class TelemetryCollectionManager {
throw Error('Stats getter method not set.');
}
return {
getStats: this.getterMethod,
getStats: this.getStats,
priority: this.getterMethodPriority,
title: this.collectionTitle,
};

View file

@ -21,3 +21,4 @@ export { encryptTelemetry } from './encryption';
export { createTelemetryUsageCollector } from './usage';
export { createUiMetricUsageCollector } from './ui_metric';
export { createLocalizationUsageCollector } from './localization';
export { createTelemetryPluginUsageCollector } from './telemetry_plugin';

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 { createTelemetryPluginUsageCollector } from './telemetry_plugin_collector';

View file

@ -0,0 +1,75 @@
/*
* 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 { TELEMETRY_STATS_TYPE } from '../../../common/constants';
import { getTelemetrySavedObject, TelemetrySavedObject } from '../../telemetry_repository';
import { getTelemetryOptIn, getTelemetryUsageFetcher } from '../../telemetry_config';
export interface TelemetryUsageStats {
opt_in_status?: boolean | null;
usage_fetcher?: 'browser' | 'server';
last_reported?: number;
}
export function createCollectorFetch(server: any) {
return async function fetchUsageStats(): Promise<TelemetryUsageStats> {
const config = server.config();
const configTelemetrySendUsageFrom = config.get('telemetry.sendUsageFrom');
const allowChangingOptInStatus = config.get('telemetry.allowChangingOptInStatus');
const configTelemetryOptIn = config.get('telemetry.optIn');
const currentKibanaVersion = config.get('pkg.version');
let telemetrySavedObject: TelemetrySavedObject = {};
try {
const { getSavedObjectsRepository } = server.savedObjects;
const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin');
const internalRepository = getSavedObjectsRepository(callWithInternalUser);
telemetrySavedObject = await getTelemetrySavedObject(internalRepository);
} catch (err) {
// no-op
}
return {
opt_in_status: getTelemetryOptIn({
currentKibanaVersion,
telemetrySavedObject,
allowChangingOptInStatus,
configTelemetryOptIn,
}),
last_reported: telemetrySavedObject ? telemetrySavedObject.lastReported : undefined,
usage_fetcher: getTelemetryUsageFetcher({
telemetrySavedObject,
configTelemetrySendUsageFrom,
}),
};
};
}
/*
* @param {Object} server
* @return {Object} kibana usage stats type collection object
*/
export function createTelemetryPluginUsageCollector(server: any) {
const { collectorSet } = server.usage;
return collectorSet.makeUsageCollector({
type: TELEMETRY_STATS_TYPE,
isReady: () => true,
fetch: createCollectorFetch(server),
});
}

View file

@ -0,0 +1,148 @@
/*
* 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 moment from 'moment';
// @ts-ignore
import fetch from 'node-fetch';
import { telemetryCollectionManager } from './collection_manager';
import { getTelemetryOptIn, getTelemetryUsageFetcher } from './telemetry_config';
import { getTelemetrySavedObject, updateTelemetrySavedObject } from './telemetry_repository';
import { REPORT_INTERVAL_MS } from '../common/constants';
import { getXpackConfigWithDeprecated } from '../common/get_xpack_config_with_deprecated';
export class FetcherTask {
private readonly checkDurationMs = 60 * 1000 * 5;
private intervalId?: NodeJS.Timeout;
private lastReported?: number;
private isSending = false;
private server: any;
constructor(server: any) {
this.server = server;
}
private getInternalRepository = () => {
const { getSavedObjectsRepository } = this.server.savedObjects;
const { callWithInternalUser } = this.server.plugins.elasticsearch.getCluster('admin');
const internalRepository = getSavedObjectsRepository(callWithInternalUser);
return internalRepository;
};
private getCurrentConfigs = async () => {
const internalRepository = this.getInternalRepository();
const telemetrySavedObject = await getTelemetrySavedObject(internalRepository);
const config = this.server.config();
const currentKibanaVersion = config.get('pkg.version');
const configTelemetrySendUsageFrom = config.get('telemetry.sendUsageFrom');
const allowChangingOptInStatus = config.get('telemetry.allowChangingOptInStatus');
const configTelemetryOptIn = config.get('telemetry.optIn');
const telemetryUrl = getXpackConfigWithDeprecated(config, 'telemetry.url') as string;
return {
telemetryOptIn: getTelemetryOptIn({
currentKibanaVersion,
telemetrySavedObject,
allowChangingOptInStatus,
configTelemetryOptIn,
}),
telemetrySendUsageFrom: getTelemetryUsageFetcher({
telemetrySavedObject,
configTelemetrySendUsageFrom,
}),
telemetryUrl,
};
};
private updateLastReported = async () => {
const internalRepository = this.getInternalRepository();
this.lastReported = Date.now();
updateTelemetrySavedObject(internalRepository, {
lastReported: this.lastReported,
});
};
private shouldSendReport = ({ telemetryOptIn, telemetrySendUsageFrom }: any) => {
if (telemetryOptIn && telemetrySendUsageFrom === 'server') {
if (!this.lastReported || Date.now() - this.lastReported > REPORT_INTERVAL_MS) {
return true;
}
}
return false;
};
private fetchTelemetry = async () => {
const { getStats, title } = telemetryCollectionManager.getStatsGetter();
this.server.log(['debug', 'telemetry', 'fetcher'], `Fetching usage using ${title} getter.`);
const config = this.server.config();
return await getStats({
unencrypted: false,
server: this.server,
start: moment()
.subtract(20, 'minutes')
.toISOString(),
end: moment().toISOString(),
isDev: config.get('env.dev'),
});
};
private sendTelemetry = async (url: string, cluster: any): Promise<void> => {
this.server.log(['debug', 'telemetry', 'fetcher'], `Sending usage stats.`);
await fetch(url, {
method: 'post',
body: cluster,
});
};
private sendIfDue = async () => {
if (this.isSending) {
return;
}
try {
const telemetryConfig = await this.getCurrentConfigs();
if (!this.shouldSendReport(telemetryConfig)) {
return;
}
// mark that we are working so future requests are ignored until we're done
this.isSending = true;
const clusters = await this.fetchTelemetry();
for (const cluster of clusters) {
await this.sendTelemetry(telemetryConfig.telemetryUrl, cluster);
}
await this.updateLastReported();
} catch (err) {
this.server.log(
['warning', 'telemetry', 'fetcher'],
`Error sending telemetry usage data: ${err}`
);
}
this.isSending = false;
};
public start = () => {
this.intervalId = setInterval(() => this.sendIfDue(), this.checkDurationMs);
};
public stop = () => {
if (this.intervalId) {
clearInterval(this.intervalId);
}
};
}

View file

@ -21,7 +21,8 @@ import { PluginInitializerContext } from 'src/core/server';
import { TelemetryPlugin } from './plugin';
import * as constants from '../common/constants';
export { getTelemetryOptIn } from './get_telemetry_opt_in';
export { FetcherTask } from './fetcher';
export { replaceTelemetryInjectedVars } from './telemetry_config';
export { telemetryCollectionManager } from './collection_manager';
export const telemetryPlugin = (initializerContext: PluginInitializerContext) =>

View file

@ -29,7 +29,7 @@ export class TelemetryPlugin {
this.currentKibanaVersion = initializerContext.env.packageInfo.version;
}
public async setup(core: CoreSetup) {
public setup(core: CoreSetup) {
const currentKibanaVersion = this.currentKibanaVersion;
telemetryCollectionManager.setStatsGetter(getStats, 'local');
registerRoutes({ core, currentKibanaVersion });

View file

@ -18,7 +18,7 @@
*/
import { CoreSetup } from 'src/core/server';
import { registerOptInRoutes } from './opt_in';
import { registerTelemetryConfigRoutes } from './telemetry_config';
import { registerTelemetryDataRoutes } from './telemetry_stats';
interface RegisterRoutesParams {
@ -27,6 +27,6 @@ interface RegisterRoutesParams {
}
export function registerRoutes({ core, currentKibanaVersion }: RegisterRoutesParams) {
registerTelemetryConfigRoutes({ core, currentKibanaVersion });
registerTelemetryDataRoutes(core);
registerOptInRoutes({ core, currentKibanaVersion });
}

View file

@ -20,18 +20,21 @@
import Joi from 'joi';
import { boomify } from 'boom';
import { CoreSetup } from 'src/core/server';
import { getTelemetryAllowChangingOptInStatus } from '../telemetry_config';
import {
TelemetrySavedObjectAttributes,
updateTelemetrySavedObject,
} from '../telemetry_repository';
interface RegisterOptInRoutesParams {
core: CoreSetup;
currentKibanaVersion: string;
}
export interface SavedObjectAttributes {
enabled?: boolean;
lastVersionChecked: string;
}
export function registerOptInRoutes({ core, currentKibanaVersion }: RegisterOptInRoutesParams) {
export function registerTelemetryConfigRoutes({
core,
currentKibanaVersion,
}: RegisterOptInRoutesParams) {
const { server } = core.http as any;
server.route({
@ -45,17 +48,24 @@ export function registerOptInRoutes({ core, currentKibanaVersion }: RegisterOptI
},
},
handler: async (req: any, h: any) => {
const savedObjectsClient = req.getSavedObjectsClient();
const savedObject: SavedObjectAttributes = {
enabled: req.payload.enabled,
lastVersionChecked: currentKibanaVersion,
};
const options = {
id: 'telemetry',
overwrite: true,
};
try {
await savedObjectsClient.create('telemetry', savedObject, options);
const attributes: TelemetrySavedObjectAttributes = {
enabled: req.payload.enabled,
lastVersionChecked: currentKibanaVersion,
};
const config = req.server.config();
const savedObjectsClient = req.getSavedObjectsClient();
const configTelemetryAllowChangingOptInStatus = config.get(
'telemetry.allowChangingOptInStatus'
);
const allowChangingOptInStatus = getTelemetryAllowChangingOptInStatus({
telemetrySavedObject: savedObjectsClient,
configTelemetryAllowChangingOptInStatus,
});
if (!allowChangingOptInStatus) {
return h.response({ error: 'Not allowed to change Opt-in Status.' }).code(400);
}
await updateTelemetrySavedObject(savedObjectsClient, attributes);
} catch (err) {
return boomify(err);
}

View file

@ -20,7 +20,6 @@
import Joi from 'joi';
import { boomify } from 'boom';
import { CoreSetup } from 'src/core/server';
import { encryptTelemetry } from '../collectors';
import { telemetryCollectionManager } from '../collection_manager';
export function registerTelemetryDataRoutes(core: CoreSetup) {
@ -49,12 +48,16 @@ export function registerTelemetryDataRoutes(core: CoreSetup) {
try {
const { getStats, title } = telemetryCollectionManager.getStatsGetter();
server.log(['debug', 'telemetry'], `Using Stats Getter: ${title}`);
server.log(['debug', 'telemetry', 'fetcher'], `Fetching usage using ${title} getter.`);
const usageData = await getStats(req, config, start, end, unencrypted);
if (unencrypted) return usageData;
return encryptTelemetry(usageData, isDev);
return await getStats({
unencrypted,
server,
req,
start,
end,
isDev,
});
} catch (err) {
if (isDev) {
// don't ignore errors when running in dev mode

View file

@ -26,7 +26,6 @@ import { mockGetClusterStats } from './get_cluster_stats';
import { omit } from 'lodash';
import {
getLocalStats,
getLocalStatsWithCaller,
handleLocalStats,
} from '../get_local_stats';
@ -153,7 +152,7 @@ describe('get_local_stats', () => {
});
});
describe('getLocalStatsWithCaller', () => {
describe('getLocalStats', () => {
it('returns expected object without xpack data when X-Pack fails to respond', async () => {
const callClusterUsageFailed = sinon.stub();
@ -162,8 +161,10 @@ describe('get_local_stats', () => {
Promise.resolve(clusterInfo),
Promise.resolve(clusterStats),
);
const result = await getLocalStatsWithCaller(getMockServer(), callClusterUsageFailed);
const result = await getLocalStats({
server: getMockServer(),
callCluster: callClusterUsageFailed,
});
expect(result.cluster_uuid).to.eql(combinedStatsResult.cluster_uuid);
expect(result.cluster_name).to.eql(combinedStatsResult.cluster_name);
expect(result.cluster_stats).to.eql(combinedStatsResult.cluster_stats);
@ -184,51 +185,13 @@ describe('get_local_stats', () => {
Promise.resolve(clusterStats),
);
const result = await getLocalStatsWithCaller(getMockServer(callCluster, kibana), callCluster);
const result = await getLocalStats({
server: getMockServer(callCluster, kibana),
callCluster,
});
expect(result.stack_stats.xpack).to.eql(combinedStatsResult.stack_stats.xpack);
expect(result.stack_stats.kibana).to.eql(combinedStatsResult.stack_stats.kibana);
});
});
describe('getLocalStats', () => {
it('uses callWithInternalUser from data cluster', async () => {
const getCluster = sinon.stub();
const req = { server: getMockServer(getCluster) };
const callWithInternalUser = sinon.stub();
getCluster.withArgs('data').returns({ callWithInternalUser });
mockGetLocalStats(
callWithInternalUser,
Promise.resolve(clusterInfo),
Promise.resolve(clusterStats),
);
const result = await getLocalStats(req, { useInternalUser: true });
expect(result.cluster_uuid).to.eql(combinedStatsResult.cluster_uuid);
expect(result.cluster_name).to.eql(combinedStatsResult.cluster_name);
expect(result.version).to.eql(combinedStatsResult.version);
expect(result.cluster_stats).to.eql(combinedStatsResult.cluster_stats);
});
it('uses callWithRequest from data cluster', async () => {
const getCluster = sinon.stub();
const req = { server: getMockServer(getCluster) };
const callWithRequest = sinon.stub();
getCluster.withArgs('data').returns({ callWithRequest });
mockGetLocalStats(
callWithRequest,
Promise.resolve(clusterInfo),
Promise.resolve(clusterStats),
req
);
const result = await getLocalStats(req, { useInternalUser: false });
expect(result.cluster_uuid).to.eql(combinedStatsResult.cluster_uuid);
expect(result.cluster_name).to.eql(combinedStatsResult.cluster_name);
expect(result.version).to.eql(combinedStatsResult.version);
expect(result.cluster_stats).to.eql(combinedStatsResult.cluster_stats);
});
});
});

View file

@ -51,7 +51,7 @@ export function handleLocalStats(server, clusterInfo, clusterStats, kibana) {
* @param {function} callCluster The callWithInternalUser handler (exposed for testing)
* @return {Promise} The object containing the current Elasticsearch cluster's telemetry.
*/
export async function getLocalStatsWithCaller(server, callCluster) {
export async function getLocalStats({ server, callCluster }) {
const [ clusterInfo, clusterStats, kibana ] = await Promise.all([
getClusterInfo(callCluster), // cluster info
getClusterStats(callCluster), // cluster stats (not to be confused with cluster _state_)
@ -60,19 +60,3 @@ export async function getLocalStatsWithCaller(server, callCluster) {
return handleLocalStats(server, clusterInfo, clusterStats, kibana);
}
/**
* Get statistics for the connected Elasticsearch cluster.
*
* @param {Object} req The incoming request
* @param {Boolean} useRequestUser callWithRequest, otherwise callWithInternalUser
* @return {Promise} The cluster object containing telemetry.
*/
export async function getLocalStats(req, { useInternalUser = false } = {}) {
const { server } = req;
const { callWithRequest, callWithInternalUser } = server.plugins.elasticsearch.getCluster('data');
const callCluster = useInternalUser ? callWithInternalUser : (...args) => callWithRequest(req, ...args);
return await getLocalStatsWithCaller(server, callCluster);
}

View file

@ -19,27 +19,10 @@
// @ts-ignore
import { getLocalStats } from './get_local_stats';
import { StatsGetter, getStatsCollectionConfig } from '../collection_manager';
/**
* Get the telemetry data.
*
* @param {Object} req The incoming request.
* @param {Object} config Kibana config.
* @param {String} start The start time of the request (likely 20m ago).
* @param {String} end The end time of the request.
* @param {Boolean} unencrypted Is the request payload going to be unencrypted.
* @return {Promise} An array of telemetry objects.
*/
export async function getStats(
req: any,
config: any,
start: string,
end: string,
unencrypted: boolean
) {
return [
await getLocalStats(req, {
useInternalUser: !unencrypted,
}),
];
}
export const getStats: StatsGetter = async function(config) {
const { callCluster, server } = getStatsCollectionConfig(config, 'data');
return [await getLocalStats({ callCluster, server })];
};

View file

@ -19,6 +19,4 @@
// @ts-ignore
export { getLocalStats } from './get_local_stats';
// @ts-ignore
export { getStats } from './get_stats';

View file

@ -0,0 +1,39 @@
/*
* 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 { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object';
interface GetTelemetryAllowChangingOptInStatus {
configTelemetryAllowChangingOptInStatus: boolean;
telemetrySavedObject: TelemetrySavedObject;
}
export function getTelemetryAllowChangingOptInStatus({
telemetrySavedObject,
configTelemetryAllowChangingOptInStatus,
}: GetTelemetryAllowChangingOptInStatus) {
if (!telemetrySavedObject) {
return configTelemetryAllowChangingOptInStatus;
}
if (typeof telemetrySavedObject.telemetryAllowChangingOptInStatus === 'undefined') {
return configTelemetryAllowChangingOptInStatus;
}
return telemetrySavedObject.telemetryAllowChangingOptInStatus;
}

View file

@ -18,72 +18,47 @@
*/
import { getTelemetryOptIn } from './get_telemetry_opt_in';
import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object';
describe('get_telemetry_opt_in', () => {
it('returns false when request path is not /app*', async () => {
const params = getCallGetTelemetryOptInParams({
requestPath: '/foo/bar',
});
const result = await callGetTelemetryOptIn(params);
expect(result).toBe(false);
});
it('returns null when saved object not found', async () => {
describe('getTelemetryOptIn', () => {
it('returns null when saved object not found', () => {
const params = getCallGetTelemetryOptInParams({
savedObjectNotFound: true,
});
const result = await callGetTelemetryOptIn(params);
const result = callGetTelemetryOptIn(params);
expect(result).toBe(null);
});
it('returns false when saved object forbidden', async () => {
it('returns false when saved object forbidden', () => {
const params = getCallGetTelemetryOptInParams({
savedObjectForbidden: true,
});
const result = await callGetTelemetryOptIn(params);
const result = callGetTelemetryOptIn(params);
expect(result).toBe(false);
});
it('throws an error on unexpected saved object error', async () => {
const params = getCallGetTelemetryOptInParams({
savedObjectOtherError: true,
});
let threw = false;
try {
await callGetTelemetryOptIn(params);
} catch (err) {
threw = true;
expect(err.message).toBe(SavedObjectOtherErrorMessage);
}
expect(threw).toBe(true);
});
it('returns null if enabled is null or undefined', async () => {
it('returns null if enabled is null or undefined', () => {
for (const enabled of [null, undefined]) {
const params = getCallGetTelemetryOptInParams({
enabled,
});
const result = await callGetTelemetryOptIn(params);
const result = callGetTelemetryOptIn(params);
expect(result).toBe(null);
}
});
it('returns true when enabled is true', async () => {
it('returns true when enabled is true', () => {
const params = getCallGetTelemetryOptInParams({
enabled: true,
});
const result = await callGetTelemetryOptIn(params);
const result = callGetTelemetryOptIn(params);
expect(result).toBe(true);
});
@ -146,24 +121,24 @@ describe('get_telemetry_opt_in', () => {
});
interface CallGetTelemetryOptInParams {
requestPath: string;
savedObjectNotFound: boolean;
savedObjectForbidden: boolean;
savedObjectOtherError: boolean;
enabled: boolean | null | undefined;
lastVersionChecked?: any; // should be a string, but test with non-strings
currentKibanaVersion: string;
result?: boolean | null;
enabled: boolean | null | undefined;
configTelemetryOptIn: boolean | null;
allowChangingOptInStatus: boolean;
}
const DefaultParams = {
requestPath: '/app/something',
savedObjectNotFound: false,
savedObjectForbidden: false,
savedObjectOtherError: false,
enabled: true,
lastVersionChecked: '8.0.0',
currentKibanaVersion: '8.0.0',
configTelemetryOptIn: null,
allowChangingOptInStatus: true,
};
function getCallGetTelemetryOptInParams(
@ -172,43 +147,28 @@ function getCallGetTelemetryOptInParams(
return { ...DefaultParams, ...overrides };
}
async function callGetTelemetryOptIn(params: CallGetTelemetryOptInParams): Promise<boolean | null> {
const { currentKibanaVersion } = params;
const request = getMockRequest(params);
return await getTelemetryOptIn({ request, currentKibanaVersion });
function callGetTelemetryOptIn(params: CallGetTelemetryOptInParams) {
const { currentKibanaVersion, configTelemetryOptIn, allowChangingOptInStatus } = params;
const telemetrySavedObject = getMockTelemetrySavedObject(params);
return getTelemetryOptIn({
currentKibanaVersion,
telemetrySavedObject,
allowChangingOptInStatus,
configTelemetryOptIn,
});
}
function getMockRequest(params: CallGetTelemetryOptInParams): any {
function getMockTelemetrySavedObject(params: CallGetTelemetryOptInParams): TelemetrySavedObject {
const { savedObjectNotFound, savedObjectForbidden } = params;
if (savedObjectForbidden) {
return false;
}
if (savedObjectNotFound) {
return null;
}
return {
path: params.requestPath,
getSavedObjectsClient() {
return getMockSavedObjectsClient(params);
},
};
}
const SavedObjectNotFoundMessage = 'savedObjectNotFound';
const SavedObjectForbiddenMessage = 'savedObjectForbidden';
const SavedObjectOtherErrorMessage = 'savedObjectOtherError';
function getMockSavedObjectsClient(params: CallGetTelemetryOptInParams) {
return {
async get(type: string, id: string) {
if (params.savedObjectNotFound) throw new Error(SavedObjectNotFoundMessage);
if (params.savedObjectForbidden) throw new Error(SavedObjectForbiddenMessage);
if (params.savedObjectOtherError) throw new Error(SavedObjectOtherErrorMessage);
const enabled = params.enabled;
const lastVersionChecked = params.lastVersionChecked;
return { attributes: { enabled, lastVersionChecked } };
},
errors: {
isNotFoundError(error: any) {
return error.message === SavedObjectNotFoundMessage;
},
isForbiddenError(error: any) {
return error.message === SavedObjectForbiddenMessage;
},
},
enabled: params.enabled,
lastVersionChecked: params.lastVersionChecked,
};
}

View file

@ -18,67 +18,51 @@
*/
import semver from 'semver';
import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object';
import { SavedObjectAttributes } from './routes/opt_in';
interface GetTelemetryOptIn {
request: any;
interface GetTelemetryOptInConfig {
telemetrySavedObject: TelemetrySavedObject;
currentKibanaVersion: string;
allowChangingOptInStatus: boolean;
configTelemetryOptIn: boolean | null;
}
// Returns whether telemetry has been opt'ed into or not.
// Returns null not set, meaning Kibana should prompt in the UI.
export async function getTelemetryOptIn({
request,
currentKibanaVersion,
}: GetTelemetryOptIn): Promise<boolean | null> {
const isRequestingApplication = request.path.startsWith('/app');
type GetTelemetryOptIn = (config: GetTelemetryOptInConfig) => null | boolean;
// Prevent interstitial screens (such as the space selector) from prompting for telemetry
if (!isRequestingApplication) {
export const getTelemetryOptIn: GetTelemetryOptIn = ({
telemetrySavedObject,
currentKibanaVersion,
allowChangingOptInStatus,
configTelemetryOptIn,
}) => {
if (typeof configTelemetryOptIn === 'boolean' && !allowChangingOptInStatus) {
return configTelemetryOptIn;
}
if (telemetrySavedObject === false) {
return false;
}
const savedObjectsClient = request.getSavedObjectsClient();
let savedObject;
try {
savedObject = await savedObjectsClient.get('telemetry', 'telemetry');
} catch (error) {
if (savedObjectsClient.errors.isNotFoundError(error)) {
return null;
}
// if we aren't allowed to get the telemetry document, we can assume that we won't
// be able to opt into telemetry either, so we're returning `false` here instead of null
if (savedObjectsClient.errors.isForbiddenError(error)) {
return false;
}
throw error;
if (telemetrySavedObject === null || typeof telemetrySavedObject.enabled !== 'boolean') {
return null;
}
const { attributes }: { attributes: SavedObjectAttributes } = savedObject;
// if enabled is already null, return null
if (attributes.enabled == null) return null;
const enabled = !!attributes.enabled;
const savedOptIn = telemetrySavedObject.enabled;
// if enabled is true, return it
if (enabled === true) return enabled;
if (savedOptIn === true) return savedOptIn;
// Additional check if they've already opted out (enabled: false):
// - if the Kibana version has changed by at least a minor version,
// return null to re-prompt.
const lastKibanaVersion = attributes.lastVersionChecked;
const lastKibanaVersion = telemetrySavedObject.lastVersionChecked;
// if the last kibana version isn't set, or is somehow not a string, return null
if (typeof lastKibanaVersion !== 'string') return null;
// if version hasn't changed, just return enabled value
if (lastKibanaVersion === currentKibanaVersion) return enabled;
if (lastKibanaVersion === currentKibanaVersion) return savedOptIn;
const lastSemver = parseSemver(lastKibanaVersion);
const currentSemver = parseSemver(currentKibanaVersion);
@ -93,8 +77,8 @@ export async function getTelemetryOptIn({
}
// current version X.Y is not greater than last version X.Y, return enabled
return enabled;
}
return savedOptIn;
};
function parseSemver(version: string): semver.SemVer | null {
// semver functions both return nulls AND throw exceptions: "it depends!"

View file

@ -0,0 +1,85 @@
/*
* 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 { getTelemetryUsageFetcher } from './get_telemetry_usage_fetcher';
import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object';
describe('getTelemetryUsageFetcher', () => {
it('returns kibana.yml config when saved object not found', () => {
const params: CallGetTelemetryUsageFetcherParams = {
savedObjectNotFound: true,
configSendUsageFrom: 'browser',
};
const result = callGetTelemetryUsageFetcher(params);
expect(result).toBe('browser');
});
it('returns kibana.yml config when saved object forbidden', () => {
const params: CallGetTelemetryUsageFetcherParams = {
savedObjectForbidden: true,
configSendUsageFrom: 'browser',
};
const result = callGetTelemetryUsageFetcher(params);
expect(result).toBe('browser');
});
it('returns kibana.yml config when saved object sendUsageFrom is undefined', () => {
const params: CallGetTelemetryUsageFetcherParams = {
savedSendUsagefrom: undefined,
configSendUsageFrom: 'server',
};
const result = callGetTelemetryUsageFetcher(params);
expect(result).toBe('server');
});
});
interface CallGetTelemetryUsageFetcherParams {
savedObjectNotFound?: boolean;
savedObjectForbidden?: boolean;
savedSendUsagefrom?: 'browser' | 'server';
configSendUsageFrom: 'browser' | 'server';
}
function callGetTelemetryUsageFetcher(params: CallGetTelemetryUsageFetcherParams) {
const telemetrySavedObject = getMockTelemetrySavedObject(params);
const configTelemetrySendUsageFrom = params.configSendUsageFrom;
return getTelemetryUsageFetcher({ configTelemetrySendUsageFrom, telemetrySavedObject });
}
function getMockTelemetrySavedObject(
params: CallGetTelemetryUsageFetcherParams
): TelemetrySavedObject {
const { savedObjectNotFound, savedObjectForbidden } = params;
if (savedObjectForbidden) {
return false;
}
if (savedObjectNotFound) {
return null;
}
return {
sendUsageFrom: params.savedSendUsagefrom,
};
}

View file

@ -0,0 +1,39 @@
/*
* 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 { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object';
interface GetTelemetryUsageFetcherConfig {
configTelemetrySendUsageFrom: 'browser' | 'server';
telemetrySavedObject: TelemetrySavedObject;
}
export function getTelemetryUsageFetcher({
telemetrySavedObject,
configTelemetrySendUsageFrom,
}: GetTelemetryUsageFetcherConfig) {
if (!telemetrySavedObject) {
return configTelemetrySendUsageFrom;
}
if (typeof telemetrySavedObject.sendUsageFrom === 'undefined') {
return configTelemetrySendUsageFrom;
}
return telemetrySavedObject.sendUsageFrom;
}

View file

@ -0,0 +1,23 @@
/*
* 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 { replaceTelemetryInjectedVars } from './replace_injected_vars';
export { getTelemetryOptIn } from './get_telemetry_opt_in';
export { getTelemetryUsageFetcher } from './get_telemetry_usage_fetcher';
export { getTelemetryAllowChangingOptInStatus } from './get_telemetry_allow_changing_opt_in_status';

View file

@ -0,0 +1,63 @@
/*
* 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 { getTelemetrySavedObject } from '../telemetry_repository';
import { getTelemetryOptIn } from './get_telemetry_opt_in';
import { getTelemetryUsageFetcher } from './get_telemetry_usage_fetcher';
import { getTelemetryAllowChangingOptInStatus } from './get_telemetry_allow_changing_opt_in_status';
export async function replaceTelemetryInjectedVars(request: any) {
const config = request.server.config();
const configTelemetrySendUsageFrom = config.get('telemetry.sendUsageFrom');
const configTelemetryOptIn = config.get('telemetry.optIn');
const configTelemetryAllowChangingOptInStatus = config.get('telemetry.allowChangingOptInStatus');
const isRequestingApplication = request.path.startsWith('/app');
// Prevent interstitial screens (such as the space selector) from prompting for telemetry
if (!isRequestingApplication) {
return {
telemetryOptedIn: false,
};
}
const currentKibanaVersion = config.get('pkg.version');
const savedObjectsClient = request.getSavedObjectsClient();
const telemetrySavedObject = await getTelemetrySavedObject(savedObjectsClient);
const allowChangingOptInStatus = getTelemetryAllowChangingOptInStatus({
configTelemetryAllowChangingOptInStatus,
telemetrySavedObject,
});
const telemetryOptedIn = getTelemetryOptIn({
configTelemetryOptIn,
allowChangingOptInStatus,
telemetrySavedObject,
currentKibanaVersion,
});
const telemetrySendUsageFrom = getTelemetryUsageFetcher({
configTelemetrySendUsageFrom,
telemetrySavedObject,
});
return {
telemetryOptedIn,
telemetrySendUsageFrom,
};
}

View file

@ -0,0 +1,104 @@
/*
* 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 { getTelemetrySavedObject } from './get_telemetry_saved_object';
import { SavedObjectsErrorHelpers } from '../../../../../core/server';
describe('getTelemetrySavedObject', () => {
it('returns null when saved object not found', async () => {
const params = getCallGetTelemetrySavedObjectParams({
savedObjectNotFound: true,
});
const result = await callGetTelemetrySavedObject(params);
expect(result).toBe(null);
});
it('returns false when saved object forbidden', async () => {
const params = getCallGetTelemetrySavedObjectParams({
savedObjectForbidden: true,
});
const result = await callGetTelemetrySavedObject(params);
expect(result).toBe(false);
});
it('throws an error on unexpected saved object error', async () => {
const params = getCallGetTelemetrySavedObjectParams({
savedObjectOtherError: true,
});
let threw = false;
try {
await callGetTelemetrySavedObject(params);
} catch (err) {
threw = true;
expect(err.message).toBe(SavedObjectOtherErrorMessage);
}
expect(threw).toBe(true);
});
});
interface CallGetTelemetrySavedObjectParams {
savedObjectNotFound: boolean;
savedObjectForbidden: boolean;
savedObjectOtherError: boolean;
result?: any;
}
const DefaultParams = {
savedObjectNotFound: false,
savedObjectForbidden: false,
savedObjectOtherError: false,
};
function getCallGetTelemetrySavedObjectParams(
overrides: Partial<CallGetTelemetrySavedObjectParams>
): CallGetTelemetrySavedObjectParams {
return { ...DefaultParams, ...overrides };
}
async function callGetTelemetrySavedObject(params: CallGetTelemetrySavedObjectParams) {
const savedObjectsClient = getMockSavedObjectsClient(params);
return await getTelemetrySavedObject(savedObjectsClient);
}
const SavedObjectForbiddenMessage = 'savedObjectForbidden';
const SavedObjectOtherErrorMessage = 'savedObjectOtherError';
function getMockSavedObjectsClient(params: CallGetTelemetrySavedObjectParams) {
return {
async get(type: string, id: string) {
if (params.savedObjectNotFound) throw SavedObjectsErrorHelpers.createGenericNotFoundError();
if (params.savedObjectForbidden)
throw SavedObjectsErrorHelpers.decorateForbiddenError(
new Error(SavedObjectForbiddenMessage)
);
if (params.savedObjectOtherError)
throw SavedObjectsErrorHelpers.decorateGeneralError(
new Error(SavedObjectOtherErrorMessage)
);
return { attributes: { enabled: null } };
},
};
}

View file

@ -0,0 +1,43 @@
/*
* 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 { TelemetrySavedObjectAttributes } from './';
import { SavedObjectsErrorHelpers } from '../../../../../core/server';
export type TelemetrySavedObject = TelemetrySavedObjectAttributes | null | false;
type GetTelemetrySavedObject = (repository: any) => Promise<TelemetrySavedObject>;
export const getTelemetrySavedObject: GetTelemetrySavedObject = async (repository: any) => {
try {
const { attributes } = await repository.get('telemetry', 'telemetry');
return attributes;
} catch (error) {
if (SavedObjectsErrorHelpers.isNotFoundError(error)) {
return null;
}
// if we aren't allowed to get the telemetry document, we can assume that we won't
// be able to opt into telemetry either, so we're returning `false` here instead of null
if (SavedObjectsErrorHelpers.isForbiddenError(error)) {
return false;
}
throw error;
}
};

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.
*/
export { getTelemetrySavedObject, TelemetrySavedObject } from './get_telemetry_saved_object';
export { updateTelemetrySavedObject } from './update_telemetry_saved_object';
export interface TelemetrySavedObjectAttributes {
enabled?: boolean | null;
lastVersionChecked?: string;
sendUsageFrom?: 'browser' | 'server';
lastReported?: number;
telemetryAllowChangingOptInStatus?: boolean;
}

View file

@ -0,0 +1,38 @@
/*
* 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 { TelemetrySavedObjectAttributes } from './';
import { SavedObjectsErrorHelpers } from '../../../../../core/server';
export async function updateTelemetrySavedObject(
savedObjectsClient: any,
savedObjectAttributes: TelemetrySavedObjectAttributes
) {
try {
return await savedObjectsClient.update('telemetry', 'telemetry', savedObjectAttributes);
} catch (err) {
if (SavedObjectsErrorHelpers.isNotFoundError(err)) {
return await savedObjectsClient.create('telemetry', savedObjectAttributes, {
id: 'telemetry',
overwrite: true,
});
}
throw err;
}
}

View file

@ -39,13 +39,13 @@ export default function(kibana: any) {
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);
},

View file

@ -20,15 +20,26 @@
// @ts-ignore
import { uiModules } from 'ui/modules';
import chrome from 'ui/chrome';
import { createAnalyticsReporter, setTelemetryReporter } from '../services/telemetry_analytics';
import { kfetch } from 'ui/kfetch';
import {
createAnalyticsReporter,
setTelemetryReporter,
trackUserAgent,
} from '../services/telemetry_analytics';
import { isUnauthenticated } from '../../../telemetry/public/services';
function telemetryInit($injector: any) {
const localStorage = $injector.get('localStorage');
const uiMetricEnabled = chrome.getInjected('uiMetricEnabled');
const debug = chrome.getInjected('debugUiMetric');
const $http = $injector.get('$http');
const basePath = chrome.getBasePath();
const uiReporter = createAnalyticsReporter({ localStorage, $http, basePath, debug });
if (!uiMetricEnabled || isUnauthenticated()) {
return;
}
const localStorage = $injector.get('localStorage');
const uiReporter = createAnalyticsReporter({ localStorage, debug, kfetch });
setTelemetryReporter(uiReporter);
uiReporter.start();
trackUserAgent('kibana');
}
uiModules.get('kibana').run(telemetryInit);

View file

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

View file

@ -17,7 +17,9 @@
* under the License.
*/
import { createReporter, Reporter, UiStatsMetricType } from '@kbn/analytics';
import { Reporter, UiStatsMetricType } from '@kbn/analytics';
// @ts-ignore
import { addSystemApiHeader } from 'ui/system_api';
let telemetryReporter: Reporter;
@ -39,28 +41,36 @@ export const createUiStatsReporter = (appName: string) => (
}
};
export const trackUserAgent = (appName: string) => {
if (telemetryReporter) {
return telemetryReporter.reportUserAgent(appName);
}
};
interface AnalyicsReporterConfig {
localStorage: any;
basePath: string;
debug: boolean;
$http: ng.IHttpService;
kfetch: any;
}
export function createAnalyticsReporter(config: AnalyicsReporterConfig) {
const { localStorage, basePath, debug } = config;
const { localStorage, debug, kfetch } = config;
return createReporter({
return new Reporter({
debug,
storage: localStorage,
async http(report) {
const url = `${basePath}/api/telemetry/report`;
await fetch(url, {
const response = await kfetch({
method: 'POST',
headers: {
'kbn-xsrf': 'true',
},
body: JSON.stringify({ report }),
pathname: '/api/telemetry/report',
body: JSON.stringify(report),
headers: addSystemApiHeader({}),
});
if (response.status !== 'ok') {
throw Error('Unable to store report.');
}
return response;
},
});
}

View file

@ -18,7 +18,6 @@
*/
import Joi from 'joi';
import Boom from 'boom';
import { Report } from '@kbn/analytics';
import { Server } from 'hapi';
@ -27,15 +26,27 @@ export async function storeReport(server: any, report: Report) {
const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin');
const internalRepository = getSavedObjectsRepository(callWithInternalUser);
const metricKeys = Object.keys(report.uiStatsMetrics);
return Promise.all(
metricKeys.map(async key => {
const metric = report.uiStatsMetrics[key];
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 internalRepository.incrementCounter('ui-metric', savedObjectId, 'count');
})
);
return await internalRepository.incrementCounter('ui-metric', savedObjectId, 'count');
}),
]);
}
export function registerUiMetricRoute(server: Server) {
@ -45,36 +56,46 @@ export function registerUiMetricRoute(server: Server) {
options: {
validate: {
payload: Joi.object({
report: Joi.object({
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),
}),
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) => {
const { report } = req.payload;
try {
const report = req.payload;
await storeReport(server, report);
return {};
return { status: 'ok' };
} catch (error) {
return new Boom('Something went wrong', { statusCode: error.status });
return { status: 'fail' };
}
},
});

View file

@ -18,48 +18,59 @@
*/
import expect from '@kbn/expect';
import { ReportManager } from '@kbn/analytics';
import { ReportManager, METRIC_TYPE } from '@kbn/analytics';
export default function ({ getService }) {
const supertest = getService('supertest');
const es = getService('es');
const createMetric = (eventName) => ({
key: ReportManager.createMetricKey({ appName: 'myApp', type: 'click', eventName }),
const createStatsMetric = (eventName) => ({
key: ReportManager.createMetricKey({ appName: 'myApp', type: METRIC_TYPE.CLICK, eventName }),
eventName,
appName: 'myApp',
type: 'click',
type: METRIC_TYPE.CLICK,
stats: { sum: 1, avg: 1, min: 1, max: 1 },
});
const createUserAgentMetric = (appName) => ({
key: ReportManager.createMetricKey({ appName, type: METRIC_TYPE.USER_AGENT }),
appName,
type: METRIC_TYPE.USER_AGENT,
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.87 Safari/537.36',
});
describe('ui_metric API', () => {
const uiStatsMetric = createMetric('myEvent');
const report = {
uiStatsMetrics: {
[uiStatsMetric.key]: uiStatsMetric,
}
};
it('increments the count field in the document defined by the {app}/{action_type} path', async () => {
const uiStatsMetric = createStatsMetric('myEvent');
const report = {
uiStatsMetrics: {
[uiStatsMetric.key]: uiStatsMetric,
}
};
await supertest
.post('/api/telemetry/report')
.set('kbn-xsrf', 'kibana')
.set('content-type', 'application/json')
.send({ report })
.send(report)
.expect(200);
return es.search({
index: '.kibana',
q: 'type:user-action',
}).then(response => {
const ids = response.hits.hits.map(({ _id }) => _id);
expect(ids.includes('user-action:myApp:myEvent'));
});
const response = await es.search({ index: '.kibana', q: 'type:ui-metric' });
const ids = response.hits.hits.map(({ _id }) => _id);
expect(ids.includes('ui-metric:myApp:myEvent')).to.eql(true);
});
it('supports multiple events', async () => {
const uiStatsMetric1 = createMetric('myEvent1');
const uiStatsMetric2 = createMetric('myEvent2');
const userAgentMetric = createUserAgentMetric('kibana');
const uiStatsMetric1 = createStatsMetric('myEvent');
const hrTime = process.hrtime();
const nano = hrTime[0] * 1000000000 + hrTime[1];
const uniqueEventName = `myEvent${nano}`;
const uiStatsMetric2 = createStatsMetric(uniqueEventName);
const report = {
userAgent: {
[userAgentMetric.key]: userAgentMetric,
},
uiStatsMetrics: {
[uiStatsMetric1.key]: uiStatsMetric1,
[uiStatsMetric2.key]: uiStatsMetric2,
@ -69,17 +80,14 @@ export default function ({ getService }) {
.post('/api/telemetry/report')
.set('kbn-xsrf', 'kibana')
.set('content-type', 'application/json')
.send({ report })
.send(report)
.expect(200);
return es.search({
index: '.kibana',
q: 'type:user-action',
}).then(response => {
const ids = response.hits.hits.map(({ _id }) => _id);
expect(ids.includes('user-action:myApp:myEvent1'));
expect(ids.includes('user-action:myApp:myEvent2'));
});
const response = await es.search({ index: '.kibana', q: 'type:ui-metric' });
const ids = response.hits.hits.map(({ _id }) => _id);
expect(ids.includes('ui-metric:myApp:myEvent')).to.eql(true);
expect(ids.includes(`ui-metric:myApp:${uniqueEventName}`)).to.eql(true);
expect(ids.includes(`ui-metric:kibana-user_agent:${userAgentMetric.userAgent}`)).to.eql(true);
});
});
}

View file

@ -59,7 +59,6 @@ export default function () {
`--server.maxPayloadBytes=1679958`,
],
},
services
};
}

View file

@ -7,6 +7,7 @@
import { useEffect } from 'react';
import {
createUiStatsReporter,
UiStatsMetricType,
METRIC_TYPE,
} from '../../../../../../src/legacy/core_plugins/ui_metric/public';
@ -36,7 +37,7 @@ function getTrackerForApp(app: string) {
interface TrackOptions {
app: ObservabilityApp;
metricType?: METRIC_TYPE;
metricType?: UiStatsMetricType;
delay?: number; // in ms
}
type EffectDeps = unknown[];
@ -76,7 +77,7 @@ export function useTrackPageview(
interface TrackEventProps {
app: ObservabilityApp;
name: string;
metricType?: METRIC_TYPE;
metricType?: UiStatsMetricType;
}
export function trackEvent({ app, name, metricType = METRIC_TYPE.CLICK }: TrackEventProps) {

View file

@ -12,8 +12,7 @@ describe('get_all_stats', () => {
const size = 123;
const start = 0;
const end = 1;
const callWithRequest = sinon.stub();
const callWithInternalUser = sinon.stub();
const callCluster = sinon.stub();
const server = {
config: sinon.stub().returns({
get: sinon.stub().withArgs('xpack.monitoring.elasticsearch.index_pattern').returns('.monitoring-es-N-*')
@ -21,16 +20,8 @@ describe('get_all_stats', () => {
.withArgs('xpack.monitoring.logstash.index_pattern').returns('.monitoring-logstash-N-*')
.withArgs('xpack.monitoring.max_bucket_size').returns(size)
}),
plugins: {
elasticsearch: {
getCluster: sinon.stub().withArgs('monitoring').returns({
callWithInternalUser,
callWithRequest
})
}
}
};
const req = { server };
const esClusters = [
{ cluster_uuid: 'a' },
{ cluster_uuid: 'b', random_setting_not_removed: false },
@ -188,19 +179,13 @@ describe('get_all_stats', () => {
}
];
callWithRequest.withArgs(req, 'search')
callCluster.withArgs('search')
.onCall(0).returns(Promise.resolve(clusterUuidsResponse))
.onCall(1).returns(Promise.resolve(esStatsResponse))
.onCall(2).returns(Promise.resolve(kibanaStatsResponse))
.onCall(3).returns(Promise.resolve(logstashStatsResponse));
callWithInternalUser.withArgs('search')
.onCall(0).returns(Promise.resolve(clusterUuidsResponse))
.onCall(1).returns(Promise.resolve(esStatsResponse))
.onCall(2).returns(Promise.resolve(kibanaStatsResponse))
.onCall(3).returns(Promise.resolve(logstashStatsResponse));
expect(await getAllStats(req, start, end)).to.eql(allClusters);
expect(await getAllStats({ callCluster, server, start, end })).to.eql(allClusters);
});
it('returns empty clusters', async () => {
@ -208,10 +193,9 @@ describe('get_all_stats', () => {
aggregations: { cluster_uuids: { buckets: [ ] } }
};
callWithRequest.withArgs(req, 'search').returns(Promise.resolve(clusterUuidsResponse));
callWithInternalUser.withArgs('search').returns(Promise.resolve(clusterUuidsResponse));
callCluster.withArgs('search').returns(Promise.resolve(clusterUuidsResponse));
expect(await getAllStats(req, start, end)).to.eql([]);
expect(await getAllStats({ callCluster, server, start, end })).to.eql([]);
});
});

View file

@ -17,23 +17,6 @@ import { getKibanaStats } from './get_kibana_stats';
import { getBeatsStats } from './get_beats_stats';
import { getHighLevelStats } from './get_high_level_stats';
/**
* Get statistics for all products joined by Elasticsearch cluster.
*
* @param {Object} req The incoming request
* @param {Date} start The starting range to request data
* @param {Date} end The ending range to request data
* @return {Promise} The array of clusters joined with the Kibana and Logstash instances.
*/
export function getAllStats(req, start, end, { useInternalUser = false } = {}) {
const server = req.server;
const { callWithRequest, callWithInternalUser } = server.plugins.elasticsearch.getCluster('monitoring');
const callCluster = useInternalUser ? callWithInternalUser : (...args) => callWithRequest(req, ...args);
return getAllStatsWithCaller(server, callCluster, start, end);
}
/**
* Get statistics for all products joined by Elasticsearch cluster.
*
@ -43,7 +26,7 @@ export function getAllStats(req, start, end, { useInternalUser = false } = {}) {
* @param {Date} end The ending range to request data
* @return {Promise} The array of clusters joined with the Kibana and Logstash instances.
*/
function getAllStatsWithCaller(server, callCluster, start, end) {
export function getAllStats({ server, callCluster, start, end } = {}) {
return getClusterUuids(server, callCluster, start, end)
.then(clusterUuids => {
// don't bother doing a further lookup

View file

@ -7,37 +7,24 @@
// @ts-ignore
import { getAllStats } from './get_all_stats';
import { getStatsWithXpack } from '../../../xpack_main/server/telemetry_collection';
import {
StatsGetter,
getStatsCollectionConfig,
} from '../../../../../../src/legacy/core_plugins/telemetry/server/collection_manager';
/**
* Get the telemetry data.
*
* @param {Object} req The incoming request.
* @param {Object} config Kibana config.
* @param {String} start The start time of the request (likely 20m ago).
* @param {String} end The end time of the request.
* @param {Boolean} unencrypted Is the request payload going to be unencrypted.
* @return {Promise} An array of telemetry objects.
*/
export async function getStatsWithMonitoring(
req: any,
config: any,
start: string,
end: string,
unencrypted: boolean
) {
export const getStatsWithMonitoring: StatsGetter = async function(config) {
let response = [];
const useInternalUser = !unencrypted;
try {
// attempt to collect stats from multiple clusters in monitoring data
response = await getAllStats(req, start, end, { useInternalUser });
const { start, end, server, callCluster } = getStatsCollectionConfig(config, 'monitoring');
response = await getAllStats({ server, callCluster, start, end });
} catch (err) {
// no-op
}
if (!Array.isArray(response) || response.length === 0) {
response = await getStatsWithXpack(req, config, start, end, unencrypted);
response = await getStatsWithXpack(config);
}
return response;
}
};

View file

@ -7,36 +7,19 @@
// @ts-ignore
import { getXPack } from './get_xpack';
import { getLocalStats } from '../../../../../../src/legacy/core_plugins/telemetry/server/telemetry_collection';
import {
StatsGetter,
getStatsCollectionConfig,
} from '../../../../../../src/legacy/core_plugins/telemetry/server/collection_manager';
/**
* Get the telemetry data.
*
* @param {Object} req The incoming request.
* @param {Object} config Kibana config.
* @param {String} start The start time of the request (likely 20m ago).
* @param {String} end The end time of the request.
* @param {Boolean} unencrypted Is the request payload going to be unencrypted.
* @return {Promise} An array of telemetry objects.
*/
export async function getStatsWithXpack(
req: any,
config: any,
start: string,
end: string,
unencrypted: boolean
) {
const useInternalUser = !unencrypted;
const { server } = req;
const { callWithRequest, callWithInternalUser } = server.plugins.elasticsearch.getCluster('data');
const callCluster = useInternalUser
? callWithInternalUser
: (...args: any[]) => callWithRequest(req, ...args);
export const getStatsWithXpack: StatsGetter = async function(config) {
const { server, callCluster } = getStatsCollectionConfig(config, 'data');
const localStats = await getLocalStats(req, { useInternalUser });
const localStats = await getLocalStats({ server, callCluster });
const { license, xpack } = await getXPack(callCluster);
localStats.license = license;
localStats.stack_stats.xpack = xpack;
return [localStats];
}
};

View file

@ -47,7 +47,6 @@ export default function({ getPageObjects, getService }: FtrProviderContext) {
});
await PageObjects.security.logout();
await PageObjects.security.login(
'global_advanced_settings_all_user',
'global_advanced_settings_all_user-password',
@ -55,7 +54,6 @@ export default function({ getPageObjects, getService }: FtrProviderContext) {
expectSpaceSelector: false,
}
);
await kibanaServer.uiSettings.replace({});
await PageObjects.settings.navigateTo();
});

View file

@ -27891,7 +27891,7 @@ typescript-fsa@^2.0.0, typescript-fsa@^2.5.0:
resolved "https://registry.yarnpkg.com/typescript-fsa/-/typescript-fsa-2.5.0.tgz#1baec01b5e8f5f34c322679d1327016e9e294faf"
integrity sha1-G67AG16PXzTDImedEycBbp4pT68=
typescript@3.5.1, typescript@3.5.3, typescript@^3.0.1, typescript@^3.0.3, typescript@^3.2.2, typescript@^3.3.3333, typescript@^3.4.5, typescript@~3.3.3333, typescript@~3.5.3:
typescript@3.5.3, typescript@^3.0.1, typescript@^3.0.3, typescript@^3.2.2, typescript@^3.3.3333, typescript@^3.4.5, typescript@~3.3.3333, typescript@~3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.5.3.tgz#c830f657f93f1ea846819e929092f5fe5983e977"
integrity sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g==