You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

492 lines
15 KiB
JavaScript

/*
* Copyright DataStax, Inc.
*
* Licensed 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.
*/
'use strict';
const os = require('os');
const path = require('path');
const fs = require('fs');
const utils = require('./utils');
const promiseUtils = require('./promise-utils');
const types = require('./types');
const requests = require('./requests');
const { ExecutionOptions } = require('./execution-options');
const packageInfo = require('../package.json');
const VersionNumber = require('./types/version-number');
const { NoAuthProvider } = require('./auth');
let kerberosModule;
try {
// eslint-disable-next-line
kerberosModule = require('kerberos');
}
catch (err) {
// Kerberos is an optional dependency
}
const minDse6Version = new VersionNumber(6, 0, 5);
const minDse51Version = new VersionNumber(5, 1, 13);
const dse600Version = new VersionNumber(6, 0, 0);
const rpc = "CALL InsightsRpc.reportInsight(?)";
const maxStatusErrorLogs = 5;
/**
* Contains methods and functionality to send events to DSE Insights.
*/
class InsightsClient {
/**
* Creates a new instance of the {@link InsightsClient} using the driver {@link Client}.
* @param {Client} client
* @param {Object} [options]
* @param {Number} [options.statusEventDelay]
* @param {Function} [options.errorCallback]
*/
constructor(client, options) {
this._client = client;
this._sessionId = types.Uuid.random().toString();
this._enabled = false;
this._closed = false;
this._firstTimeout = null;
this._recurrentTimeout = null;
this._statusErrorLogs = 0;
options = options || {};
this._statusEventDelay = options.statusEventDelay || 300000;
this._errorCallback = options.errorCallback || utils.noop;
}
/**
* Initializes the insights client in the background by sending the startup event and scheduling status events at
* regular intervals.
* @returns {undefined}
*/
init() {
this._enabled = this._client.options.monitorReporting.enabled && this._dseSupportsInsights();
if (!this._enabled) {
return;
}
promiseUtils.toBackground(this._init());
}
async _init() {
try {
await this._sendStartupEvent();
if (this._closed) {
// The client was shutdown
return;
}
// Send the status event the first time with a delay containing some random portion
// Initial delay should be statusEventDelay - (0 to 10%)
const firstDelay = Math.floor(this._statusEventDelay - 0.1 * this._statusEventDelay * Math.random());
// Schedule the first timer
this._firstTimeout = setTimeout(() => {
// Send the first status event, the promise will never be rejected
this._sendStatusEvent();
// The following status events are sent at regular intervals
this._recurrentTimeout = setInterval(() => this._sendStatusEvent(), this._statusEventDelay);
}, firstDelay);
} catch (err) {
if (this._closed) {
// Sending failed because the Client was shutdown
return;
}
// We shouldn't try to recover
this._client.log('verbose', `Insights startup message could not be sent (${err})`, err);
this._errorCallback(err);
}
}
/**
* Sends the startup event.
* @returns {Promise}
* @private
*/
async _sendStartupEvent() {
const message = await this._getStartupMessage();
const request = new requests.QueryRequest(rpc, [message], ExecutionOptions.empty());
await this._client.controlConnection.query(request, false);
}
/**
* Sends the status event.
* @returns {Promise} A promise that is never rejected.
* @private
*/
async _sendStatusEvent() {
const request = new requests.QueryRequest(rpc, [ this._getStatusEvent() ], ExecutionOptions.empty());
try {
await this._client.controlConnection.query(request, false);
} catch (err) {
if (this._closed) {
// Sending failed because the Client was shutdown
return;
}
if (this._statusErrorLogs < maxStatusErrorLogs) {
this._client.log('warning', `Insights status message could not be sent (${err})`, err);
this._statusErrorLogs++;
}
this._errorCallback(err);
}
}
/**
* Validates the minimum server version for all nodes in the cluster.
* @private
*/
_dseSupportsInsights() {
if (this._client.hosts.length === 0) {
return false;
}
return this._client.hosts.values().reduce((acc, host) => {
if (!acc) {
return acc;
}
const versionArr = host.getDseVersion();
if (versionArr.length === 0) {
return false;
}
const version = new VersionNumber(...versionArr);
return version.compare(minDse6Version) >= 0 ||
(version.compare(dse600Version) < 0 && version.compare(minDse51Version) >= 0);
}, true);
}
/**
* @returns {Promise<String>} Returns a json string with the startup message.
* @private
*/
async _getStartupMessage() {
const cc = this._client.controlConnection;
const options = this._client.options;
const appInfo = await this._getAppInfo(options);
const message = {
metadata: {
name: 'driver.startup',
insightMappingId: 'v1',
insightType: 'EVENT',
timestamp: Date.now(),
tags: { language: 'nodejs' }
},
data: {
driverName: packageInfo.description,
driverVersion: packageInfo.version,
clientId: options.id,
sessionId: this._sessionId,
applicationName: appInfo.applicationName,
applicationVersion: appInfo.applicationVersion,
applicationNameWasGenerated: appInfo.applicationNameWasGenerated,
contactPoints: mapToObject(cc.getResolvedContactPoints()),
dataCenters: this._getDataCenters(),
initialControlConnection: cc.host ? cc.host.address : undefined,
protocolVersion: cc.protocolVersion,
localAddress: cc.getLocalAddress(),
hostName: os.hostname(),
executionProfiles: getExecutionProfiles(this._client),
poolSizeByHostDistance: {
local: options.pooling.coreConnectionsPerHost[types.distance.local],
remote: options.pooling.coreConnectionsPerHost[types.distance.remote]
},
heartbeatInterval: options.pooling.heartBeatInterval,
compression: 'NONE',
reconnectionPolicy: getPolicyInfo(options.policies.reconnection),
ssl: {
enabled: !!options.sslOptions,
certValidation: options.sslOptions ? !!options.sslOptions.rejectUnauthorized : undefined
},
authProvider: {
type: !(options.authProvider instanceof NoAuthProvider) ? getConstructor(options.authProvider) : undefined,
},
otherOptions: {
coalescingThreshold: options.socketOptions.coalescingThreshold,
},
platformInfo: {
os: {
name: os.platform(),
version: os.release(),
arch: os.arch()
},
cpus: {
length: os.cpus().length,
model: os.cpus()[0].model
},
runtime: {
node: process.versions['node'],
v8: process.versions['v8'],
uv: process.versions['uv'],
openssl: process.versions['openssl'],
kerberos: kerberosModule ? kerberosModule.version : undefined
}
},
configAntiPatterns: this._getConfigAntiPatterns(),
periodicStatusInterval: Math.floor(this._statusEventDelay / 1000)
}
};
return JSON.stringify(message);
}
_getConfigAntiPatterns() {
const options = this._client.options;
const result = {};
if (options.sslOptions && !options.sslOptions.rejectUnauthorized) {
result.sslWithoutCertValidation =
'Client-to-node encryption is enabled but server certificate validation is disabled';
}
return result;
}
/**
* Gets an array of data centers the driver connects to.
* Whether the driver connects to a certain host is determined by the host distance (local and remote hosts)
* and the pooling options (whether connection length for remote hosts is greater than 0).
* @returns {Array}
* @private
*/
_getDataCenters() {
const remoteConnectionsLength = this._client.options.pooling.coreConnectionsPerHost[types.distance.remote];
const dataCenters = new Set();
this._client.hosts.values().forEach(h => {
const distance = this._client.profileManager.getDistance(h);
if (distance === types.distance.local || (distance === types.distance.remote && remoteConnectionsLength > 0)) {
dataCenters.add(h.datacenter);
}
});
return Array.from(dataCenters);
}
/**
* Tries to obtain the application name and version from
* @param {DseClientOptions} options
* @returns {Promise}
* @private
*/
async _getAppInfo(options) {
if (typeof options.applicationName === 'string') {
return Promise.resolve({
applicationName: options.applicationName,
applicationVersion: options.applicationVersion,
applicationNameWasGenerated: false
});
}
let readPromise = Promise.resolve();
if (require.main && require.main.filename) {
const packageInfoPath = path.dirname(require.main.filename);
readPromise = this._readPackageInfoFile(packageInfoPath);
}
const text = await readPromise;
let applicationName = 'Default Node.js Application';
let applicationVersion;
if (text) {
try {
const packageInfo = JSON.parse(text);
if (packageInfo.name) {
applicationName = packageInfo.name;
applicationVersion = packageInfo.version;
}
}
catch (err) {
// The package.json file could not be parsed
// Use the default name
}
}
return {
applicationName,
applicationVersion,
applicationNameWasGenerated: true
};
}
/**
* @private
* @returns {Promise<string>} A Promise that will never be rejected
*/
_readPackageInfoFile(packageInfoPath) {
return new Promise(resolve => {
fs.readFile(path.join(packageInfoPath, 'package.json'), 'utf8', (err, data) => {
// Swallow error
resolve(data);
});
});
}
/**
* @returns {String} Returns a json string with the startup message.
* @private
*/
_getStatusEvent() {
const cc = this._client.controlConnection;
const options = this._client.options;
const state = this._client.getState();
const connectedNodes = {};
state.getConnectedHosts().forEach(h => {
connectedNodes[h.address] = {
connections: state.getOpenConnections(h),
inFlightQueries: state.getInFlightQueries(h)
};
});
const message = {
metadata: {
name: 'driver.status',
insightMappingId: 'v1',
insightType: 'EVENT',
timestamp: Date.now(),
tags: { language: 'nodejs' }
},
data: {
clientId: options.id,
sessionId: this._sessionId,
controlConnection: cc.host ? cc.host.address : undefined,
connectedNodes
}
};
return JSON.stringify(message);
}
/**
* Cleans any timer used internally and sets the client as closed.
*/
shutdown() {
if (!this._enabled) {
return;
}
this._closed = true;
if (this._firstTimeout !== null) {
clearTimeout(this._firstTimeout);
}
if (this._recurrentTimeout !== null) {
clearInterval(this._recurrentTimeout);
}
}
}
module.exports = InsightsClient;
function mapToObject(map) {
const result = {};
map.forEach((value, key) => result[key] = value);
return result;
}
function getPolicyInfo(policy) {
if (!policy) {
return undefined;
}
const options = policy.getOptions && policy.getOptions();
return {
type: policy.constructor.name,
options: (options instanceof Map) ? mapToObject(options) : utils.emptyObject
};
}
function getConsistencyString(c) {
if (typeof c !== 'number') {
return undefined;
}
return types.consistencyToString[c];
}
function getConstructor(instance) {
return instance ? instance.constructor.name : undefined;
}
function getExecutionProfiles(client) {
const executionProfiles = {};
const defaultProfile = client.profileManager.getDefault();
setExecutionProfileProperties(client, executionProfiles, defaultProfile, defaultProfile);
client.profileManager.getAll()
.filter(p => p !== defaultProfile)
.forEach(profile => setExecutionProfileProperties(client, executionProfiles, profile, defaultProfile));
return executionProfiles;
}
function setExecutionProfileProperties(client, parent, profile, defaultProfile) {
const output = parent[profile.name] = {};
setExecutionProfileItem(output, profile, defaultProfile, 'readTimeout');
setExecutionProfileItem(output, profile, defaultProfile, 'loadBalancing', getPolicyInfo);
setExecutionProfileItem(output, profile, defaultProfile, 'retry', getPolicyInfo);
setExecutionProfileItem(output, profile, defaultProfile, 'consistency', getConsistencyString);
setExecutionProfileItem(output, profile, defaultProfile, 'serialConsistency', getConsistencyString);
if (profile === defaultProfile) {
// Speculative execution policy is included in the profiles as some drivers support
// different spec exec policy per profile, in this case is fixed for all profiles
output.speculativeExecution = getPolicyInfo(client.options.policies.speculativeExecution);
}
if (profile.graphOptions) {
output.graphOptions = {};
const defaultGraphOptions = defaultProfile.graphOptions || utils.emptyObject;
setExecutionProfileItem(output.graphOptions, profile.graphOptions, defaultGraphOptions, 'language');
setExecutionProfileItem(output.graphOptions, profile.graphOptions, defaultGraphOptions, 'name');
setExecutionProfileItem(output.graphOptions, profile.graphOptions, defaultGraphOptions, 'readConsistency',
getConsistencyString);
setExecutionProfileItem(output.graphOptions, profile.graphOptions, defaultGraphOptions, 'source');
setExecutionProfileItem(output.graphOptions, profile.graphOptions, defaultGraphOptions, 'writeConsistency',
getConsistencyString);
if (Object.keys(output.graphOptions).length === 0) {
// Properties that are undefined will not be included in the JSON
output.graphOptions = undefined;
}
}
}
function setExecutionProfileItem(output, profile, defaultProfile, prop, valueGetter) {
const value = profile[prop];
valueGetter = valueGetter || (x => x);
if ((profile === defaultProfile && value !== undefined) || value !== defaultProfile[prop]) {
output[prop] = valueGetter(value);
}
}