Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Telemetry] Separate the license retrieval from the stats in the usage collectors #57332

Merged
merged 13 commits into from
Feb 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions src/legacy/core_plugins/telemetry/server/collection_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import { encryptTelemetry } from './collectors';
import { CallCluster } from '../../elasticsearch';
import { UsageCollectionSetup } from '../../../../plugins/usage_collection/server';
import { ESLicense } from './telemetry_collection/get_local_license';

export type EncryptedStatsGetterConfig = { unencrypted: false } & {
server: any;
Expand All @@ -45,22 +46,38 @@ export interface StatsCollectionConfig {
end: string | number;
}

export interface BasicStatsPayload {
timestamp: string;
cluster_uuid: string;
cluster_name: string;
version: string;
cluster_stats: object;
collection?: string;
stack_stats: object;
}

export type StatsGetterConfig = UnencryptedStatsGetterConfig | EncryptedStatsGetterConfig;
export type ClusterDetailsGetter = (config: StatsCollectionConfig) => Promise<ClusterDetails[]>;
export type StatsGetter = (
export type StatsGetter<T extends BasicStatsPayload = BasicStatsPayload> = (
clustersDetails: ClusterDetails[],
config: StatsCollectionConfig
) => Promise<T[]>;
export type LicenseGetter = (
clustersDetails: ClusterDetails[],
config: StatsCollectionConfig
) => Promise<any[]>;
) => Promise<{ [clusterUuid: string]: ESLicense | undefined }>;

interface CollectionConfig {
interface CollectionConfig<T extends BasicStatsPayload> {
title: string;
priority: number;
esCluster: string;
statsGetter: StatsGetter;
statsGetter: StatsGetter<T>;
clusterDetailsGetter: ClusterDetailsGetter;
licenseGetter: LicenseGetter;
}
interface Collection {
statsGetter: StatsGetter;
licenseGetter: LicenseGetter;
clusterDetailsGetter: ClusterDetailsGetter;
esCluster: string;
title: string;
Expand All @@ -70,8 +87,15 @@ export class TelemetryCollectionManager {
private usageGetterMethodPriority = -1;
private collections: Collection[] = [];

public setCollection = (collectionConfig: CollectionConfig) => {
const { title, priority, esCluster, statsGetter, clusterDetailsGetter } = collectionConfig;
public setCollection = <T extends BasicStatsPayload>(collectionConfig: CollectionConfig<T>) => {
const {
title,
priority,
esCluster,
statsGetter,
clusterDetailsGetter,
licenseGetter,
} = collectionConfig;

if (typeof priority !== 'number') {
throw new Error('priority must be set.');
Expand All @@ -88,10 +112,14 @@ export class TelemetryCollectionManager {
throw Error('esCluster name must be set for the getCluster method.');
}
if (!clusterDetailsGetter) {
throw Error('Cluser UUIds method is not set.');
throw Error('Cluster UUIds method is not set.');
}
if (!licenseGetter) {
throw Error('License getter method not set.');
}

this.collections.unshift({
licenseGetter,
statsGetter,
clusterDetailsGetter,
esCluster,
Expand Down Expand Up @@ -141,7 +169,19 @@ export class TelemetryCollectionManager {
return;
}

return await collection.statsGetter(clustersDetails, statsCollectionConfig);
const [stats, licenses] = await Promise.all([
collection.statsGetter(clustersDetails, statsCollectionConfig),
collection.licenseGetter(clustersDetails, statsCollectionConfig),
]);

return stats.map(stat => {
const license = licenses[stat.cluster_uuid];
return {
...(license ? { license } : {}),
...stat,
collectionSource: collection.title,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

};
});
};

public getOptInStats = async (optInStatus: boolean, config: StatsGetterConfig) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,32 @@
* under the License.
*/

import { CallCluster } from 'src/legacy/core_plugins/elasticsearch';

// This can be removed when the ES client improves the types
export interface ESClusterInfo {
cluster_uuid: string;
cluster_name: string;
version: {
number: string;
build_flavor: string;
build_type: string;
build_hash: string;
build_date: string;
build_snapshot?: boolean;
lucene_version: string;
minimum_wire_compatibility_version: string;
minimum_index_compatibility_version: string;
};
}

/**
* Get the cluster info from the connected cluster.
*
* This is the equivalent to GET /
*
* @param {function} callCluster The callWithInternalUser handler (exposed for testing)
* @return {Promise} The response from Elasticsearch.
*/
export function getClusterInfo(callCluster) {
return callCluster('info');
export function getClusterInfo(callCluster: CallCluster) {
return callCluster<ESClusterInfo>('info');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* 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 { CallCluster } from 'src/legacy/core_plugins/elasticsearch';
import { LicenseGetter } from '../collection_manager';

// From https://www.elastic.co/guide/en/elasticsearch/reference/current/get-license.html
export interface ESLicense {
status: string;
uid: string;
type: string;
issue_date: string;
issue_date_in_millis: number;
expiry_date: string;
expirty_date_in_millis: number;
max_nodes: number;
issued_to: string;
issuer: string;
start_date_in_millis: number;
}
let cachedLicense: ESLicense | undefined;

function fetchLicense(callCluster: CallCluster, local: boolean) {
return callCluster<{ license: ESLicense }>('transport.request', {
method: 'GET',
path: '/_license',
query: {
local,
// For versions >= 7.6 and < 8.0, this flag is needed otherwise 'platinum' is returned for 'enterprise' license.
accept_enterprise: 'true',
},
});
}

/**
* Get the cluster's license from the connected node.
*
* This is the equivalent of GET /_license?local=true .
*
* Like any X-Pack related API, X-Pack must installed for this to work.
*/
async function getLicenseFromLocalOrMaster(callCluster: CallCluster) {
// Fetching the local license is cheaper than getting it from the master and good enough
const { license } = await fetchLicense(callCluster, true).catch(async err => {
if (cachedLicense) {
try {
// Fallback to the master node's license info
const response = await fetchLicense(callCluster, false);
return response;
} catch (masterError) {
if (masterError.statusCode === 404) {
// If the master node does not have a license, we can assume there is no license
cachedLicense = undefined;
} else {
// Any other errors from the master node, throw and do not send any telemetry
throw err;
}
}
}
return { license: void 0 };
});

if (license) {
cachedLicense = license;
}
return license;
}

export const getLocalLicense: LicenseGetter = async (clustersDetails, { callCluster }) => {
const license = await getLicenseFromLocalOrMaster(callCluster);

// It should be called only with 1 cluster element in the clustersDetails array, but doing reduce just in case.
return clustersDetails.reduce((acc, { clusterUuid }) => ({ ...acc, [clusterUuid]: license }), {});
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,8 @@
* under the License.
*/

import { get, omit } from 'lodash';
// @ts-ignore
import { getClusterInfo } from './get_cluster_info';
import { getClusterInfo, ESClusterInfo } from './get_cluster_info';
import { getClusterStats } from './get_cluster_stats';
// @ts-ignore
import { getKibana, handleKibanaStats, KibanaUsageStats } from './get_kibana';
import { StatsGetter } from '../collection_manager';

Expand All @@ -33,35 +30,32 @@ import { StatsGetter } from '../collection_manager';
* @param {Object} clusterInfo Cluster info (GET /)
* @param {Object} clusterStats Cluster stats (GET /_cluster/stats)
* @param {Object} kibana The Kibana Usage stats
* @return {Object} A combined object containing the different responses.
*/
export function handleLocalStats(
server: any,
clusterInfo: any,
clusterStats: any,
{ cluster_name, cluster_uuid, version }: ESClusterInfo,
{ _nodes, cluster_name: clusterName, ...clusterStats }: any,
kibana: KibanaUsageStats
) {
return {
timestamp: new Date().toISOString(),
cluster_uuid: get(clusterInfo, 'cluster_uuid'),
cluster_name: get(clusterInfo, 'cluster_name'),
version: get(clusterInfo, 'version.number'),
cluster_stats: omit(clusterStats, '_nodes', 'cluster_name'),
cluster_uuid,
cluster_name,
version: version.number,
cluster_stats: clusterStats,
collection: 'local',
stack_stats: {
kibana: handleKibanaStats(server, kibana),
},
};
}

export type TelemetryLocalStats = ReturnType<typeof handleLocalStats>;

/**
* Get statistics for all products joined by Elasticsearch cluster.
*
* @param {Object} server The Kibana server instance used to call ES as the internal user
* @param {function} callCluster The callWithInternalUser handler (exposed for testing)
* @return {Promise} The object containing the current Elasticsearch cluster's telemetry.
*/
export const getLocalStats: StatsGetter = async (clustersDetails, config) => {
export const getLocalStats: StatsGetter<TelemetryLocalStats> = async (clustersDetails, config) => {
const { server, callCluster, usageCollection } = config;

return await Promise.all(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
* under the License.
*/

// @ts-ignore
export { getLocalStats } from './get_local_stats';
export { getClusterUuids } from './get_cluster_stats';
export { registerCollection } from './register_collection';
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import { telemetryCollectionManager } from '../collection_manager';
import { getLocalStats } from './get_local_stats';
import { getClusterUuids } from './get_cluster_stats';
import { getLocalLicense } from './get_local_license';

export function registerCollection() {
telemetryCollectionManager.setCollection({
Expand All @@ -47,5 +48,6 @@ export function registerCollection() {
priority: 0,
statsGetter: getLocalStats,
clusterDetailsGetter: getClusterUuids,
licenseGetter: getLocalLicense,
});
}
10 changes: 10 additions & 0 deletions src/plugins/telemetry/public/services/telemetry_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,18 @@ const mockSubtract = jest.fn().mockImplementation(() => {
};
});

const mockClone = jest.fn().mockImplementation(() => {
return {
clone: mockClone,
subtract: mockSubtract,
toISOString: jest.fn(),
};
});

jest.mock('moment', () => {
return jest.fn().mockImplementation(() => {
return {
clone: mockClone,
subtract: mockSubtract,
toISOString: jest.fn(),
};
Expand All @@ -43,6 +52,7 @@ describe('TelemetryService', () => {
expect(telemetryService['http'].post).toBeCalledWith('/api/telemetry/v2/clusters/_stats', {
body: JSON.stringify({ unencrypted: false, timeRange: {} }),
});
expect(mockClone).toBeCalled();
expect(mockSubtract).toBeCalledWith(20, 'minutes');
});
});
Expand Down
5 changes: 4 additions & 1 deletion src/plugins/telemetry/public/services/telemetry_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ export class TelemetryService {
body: JSON.stringify({
unencrypted,
timeRange: {
min: now.subtract(20, 'minutes').toISOString(),
min: now
.clone() // Need to clone it to avoid mutation (and max being the same value)
.subtract(20, 'minutes')
.toISOString(),
max: now.toISOString(),
},
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import sinon from 'sinon';
import { addStackStats, getAllStats, handleAllStats } from './get_all_stats';
import { getStackStats, getAllStats, handleAllStats } from './get_all_stats';
import { ESClusterStats } from './get_es_stats';
import { KibanaStats } from './get_kibana_stats';
import { ClustersHighLevelStats } from './get_high_level_stats';
Expand Down Expand Up @@ -223,7 +223,8 @@ describe('get_all_stats', () => {
beats: {},
});

expect(clusters).toStrictEqual(expectedClusters);
const [a, b, c] = expectedClusters;
expect(clusters).toStrictEqual([a, b, { ...c, stack_stats: {} }]);
});

it('handles no clusters response', () => {
Expand All @@ -233,9 +234,8 @@ describe('get_all_stats', () => {
});
});

describe('addStackStats', () => {
describe('getStackStats', () => {
it('searches for clusters', () => {
const cluster = { cluster_uuid: 'a' };
const stats = {
a: {
count: 2,
Expand All @@ -250,9 +250,7 @@ describe('get_all_stats', () => {
},
};

addStackStats(cluster as ESClusterStats, stats, 'xyz');

expect((cluster as any).stack_stats.xyz).toStrictEqual(stats.a);
expect(getStackStats('a', stats, 'xyz')).toStrictEqual({ xyz: stats.a });
});
});
});
Loading