UNPKG

newrelic

Version:
423 lines (376 loc) 13 kB
/* * Copyright 2020 New Relic Corporation. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ 'use strict' const util = require('util') const url = require('url') const https = require('https') const zlib = require('zlib') const logger = require('../logger').child({ component: 'remote_method' }) const parse = require('./parse-response') const stringify = require('json-stringify-safe') const Sink = require('../util/stream-sink') const agents = require('./http-agents') const isValidLength = require('../util/byte-limit').isValidLength const { DATA_USAGE } = require('../metrics/names') const symbols = require('../symbols') function getMetricName(name) { return `${DATA_USAGE.PREFIX}/${name}/${DATA_USAGE.SUFFIX}` } /* * * CONSTANTS * */ const RUN_ID_NAME = 'run_id' const RAW_METHOD_PATH = '/agent_listener/invoke_raw_method' // see job/collector-master/javadoc/com/nr/servlet/AgentListener.html on NR Jenkins const USER_AGENT_FORMAT = 'NewRelic-NodeAgent/%s (nodejs %s %s-%s)' const ENCODING_HEADER = 'CONTENT-ENCODING' const DEFAULT_ENCODING = 'identity' function RemoteMethod(name, agent, endpoint) { if (!name) { throw new TypeError('Must include name of method to invoke on collector.') } if (!agent) { throw new TypeError('Must include an agent instance.') } this.name = name this._config = agent.config this._agent = agent this._protocolVersion = 17 this.endpoint = endpoint } RemoteMethod.prototype.updateEndpoint = function updateEndpoint(endpoint) { if (!endpoint) { return } this.endpoint = endpoint } RemoteMethod.prototype.serialize = function serialize(payload, callback) { let res try { res = stringify(payload, (key, value) => (typeof value === 'bigint' ? value.toString() : value)) } catch (error) { logger.error(error, 'Unable to serialize payload for method %s.', this.name) return process.nextTick(function onNextTick() { return callback(error) }) } return callback(null, res) } /** * This records the amount of data usage per function call and sends * it to the metrics. * * @param {string} sent The serialized object we sent * @param {object} received The raw object we got back as a response */ RemoteMethod.prototype._reportDataUsage = function reportDataUsage(sent, received) { const payloadByteLength = byteLength(sent) const responseByteLength = received ? byteLength(JSON.stringify(received)) : 0 const measurement = [payloadByteLength, responseByteLength, true] this._agent.metrics.measureBytes(DATA_USAGE.COLLECTOR, ...measurement) this._agent.metrics.measureBytes(getMetricName(this.name), ...measurement) } /** * The primary operation on RemoteMethod objects. If you're calling anything on * RemoteMethod objects aside from invoke (and you're not writing test code), * you're doing it wrong. * * @param {object} payload Serializable payload. * @param {object} [nrHeaders] NR request headers from connect response. * @param {Function} callback What to do next. Gets passed any error. */ RemoteMethod.prototype.invoke = function invoke(payload, nrHeaders, callback) { if (typeof nrHeaders === 'function') { callback = nrHeaders nrHeaders = null } if (!payload) { payload = [] } logger.trace('Invoking remote method %s', this.name) this.serialize( payload, function onSerialize(err, serialized) { if (err) { return callback(err) } this._post( serialized, nrHeaders, function onResponse(error, res) { this._reportDataUsage(serialized, res && res.payload) callback(error, res) }.bind(this) ) }.bind(this) ) } /** * Take a serialized payload and create a response wrapper for it before * invoking the method on the collector. * * @param {string} data Serialized payload. * @param {object} nrHeaders NR request headers from connect response. * @param {Function} callback What to do next. Gets passed any error. */ RemoteMethod.prototype._post = function _post(data, nrHeaders, callback) { const method = this const options = { port: this.endpoint.port, host: this.endpoint.host, compressed: this._shouldCompress(data), path: this._path(), onError: callback, onResponse, nrHeaders } // Check trace enabled first since we're creating an object for this log message. if (logger.traceEnabled()) { logger.trace({ data, compressed: options.compressed }, 'Calling %s on collector API', this.name) } if (options.compressed) { // NOTE: gzip and deflate throw immediately in Node 14+ with an invalid argument try { const useDeflate = this._config.compressed_content_encoding === 'deflate' const compressor = useDeflate ? zlib.deflate : zlib.gzip compressor(data, function onCompress(err, compressed) { if (err) { logger.warn(err, 'Error compressing JSON for delivery. Not sending.') return callback(err) } options.body = compressed makeRequest() }) } catch (err) { logger.warn(err, 'Error compressing JSON for delivery. Not sending.') return callback(err) } } else { options.body = data makeRequest() } function makeRequest() { try { method._safeRequest(options) } catch (err) { logger.warn(err, 'Failed to prepare request to collector method %s!', method.name) callback(err) } } // set up standard response handling function onResponse(response) { response.on('end', function onEnd() { logger.debug('Finished receiving data back from the collector for %s.', method.name) }) response.setEncoding('utf8') response.pipe(new Sink(parse(method.name, response, callback))) } } /** * http.request does its own DNS lookup, and if it fails, will cause * dns.lookup to throw asynchronously instead of passing the error to * the callback (which is obviously awesome). To prevent New Relic from * crashing people's applications, verify that lookup works and bail out * early if not. * * Also, ensure that all the necessary parameters are set before * actually making the request. Useful to put here to simplify test code * that calls _request directly. * * @param {object} options A dictionary of request parameters. */ RemoteMethod.prototype._safeRequest = function _safeRequest(options) { if (!options) { throw new Error('Must include options to make request!') } if (!options.host) { throw new Error('Must include collector hostname!') } if (!options.port) { throw new Error('Must include collector port!') } if (!options.onError) { throw new Error('Must include error handler!') } if (!options.onResponse) { throw new Error('Must include response handler!') } if (!options.body) { throw new Error('Must include body to send to collector!') } if (!options.path) { throw new Error('Must include URL to request!') } const protocol = 'https' const logConfig = this._config.logging const auditLog = this._config.audit_log const maxPayloadSize = this._config.max_payload_size_in_bytes let level = 'trace' if (!isValidLength(options.body, maxPayloadSize)) { this._agent.metrics .getOrCreateMetric(`${DATA_USAGE.PREFIX}/MaxPayloadSizeLimit/${this.name}`) .incrementCallCount() logger.warn( 'The payload size %d being sent to method %s exceeded the maximum size of %d', Buffer.byteLength(options.body, 'utf8'), this.name, maxPayloadSize ) const error = new Error('Maximum payload size exceeded') error.code = 'NR_REMOTE_METHOD_MAX_PAYLOAD_SIZE_EXCEEDED' throw error } // If trace level is not explicitly enabled check to see if the audit log is // enabled. if ( logConfig != null && logConfig.level !== 'trace' && auditLog.enabled && (auditLog.endpoints.length === 0 || auditLog.endpoints.indexOf(this.name) > -1) ) { level = 'info' } // check if trace is enabled or audit log(aka info in this case) before attempting // to get the body length or parse the path with redacted key if (logger.traceEnabled() || level === 'info') { const logBody = Buffer.isBuffer(options.body) ? 'Buffer ' + options.body.length : options.body logger[level]( { body: logBody }, 'Posting to %s://%s:%s%s', protocol, options.host, options.port, this._path({ redactLicenseKey: true }) ) } this._request(options) } /** * Generate the request headers and wire up the request. There are many * parameters used to make a request: * * @param {object} options options object * @param {string} options.host Hostname (or proxy hostname) for collector. * @param {string} options.port Port (or proxy port) for collector. * @param {string} options.path URL path for method being invoked on collector. * @param {string} options.body Serialized payload to be sent to collector. * @param {boolean} options.compressed Whether the payload has been compressed. * @param {object} options.nrHeaders NR request headers passed in connect response. * @param {Function} options.onError Error handler for this request (probably the * original callback given to .send). * @param {Function} options.onResponse Response handler for this request (created by * ._post). */ RemoteMethod.prototype._request = function _request(options) { const requestOptions = { method: this._config.put_for_data_send ? 'PUT' : 'POST', setHost: false, // See below host: options.host, // Set explicitly in the headers port: options.port, path: options.path, headers: this._headers(options), agent: agents.keepAliveAgent(), [symbols.offTheRecord]: true // don't let the http instrumentation record this request } let request const isProxy = !!(this._config.proxy || this._config.proxy_port || this._config.proxy_host) if (isProxy) { // proxy requestOptions.agent = agents.proxyAgent(this._config) request = https.request(requestOptions) } else { if (this._config.certificates && this._config.certificates.length > 0) { requestOptions.ca = this._config.certificates } request = https.request(requestOptions) } request.on('error', options.onError) request.on('response', options.onResponse) request.end(options.body) } /** * See the constants list for the format string (and the URL that explains it). */ RemoteMethod.prototype._userAgent = function _userAgent() { return util.format( USER_AGENT_FORMAT, this._config.version, process.versions.node, process.platform, process.arch ) } /** * Generate a URL the collector understands. * * @param {boolean} redactLicenseKey flag to redact license key in path * @returns {string} The URL path to be POSTed to. */ RemoteMethod.prototype._path = function _path({ redactLicenseKey } = {}) { const query = { marshal_format: 'json', protocol_version: this._protocolVersion, license_key: redactLicenseKey ? 'REDACTED' : this._config.license_key, method: this.name } if (this._config.run_id) { query[RUN_ID_NAME] = this._config.run_id } return url.format({ pathname: RAW_METHOD_PATH, query }) } /** * @param {object} options options object * @param {number} options.body - Data to be sent. * @param {object} options.nrHeaders - NR request headers from the connect response. * @param {boolean} options.compressed - The compression method used, if any. */ RemoteMethod.prototype._headers = function _headers(options) { const agent = this._userAgent() const headers = { // select the virtual host on the server end Host: this.endpoint.host, 'User-Agent': agent, Connection: 'Keep-Alive', 'Content-Length': byteLength(options.body), 'Content-Type': 'application/json' } if (options.compressed) { headers[ENCODING_HEADER] = this._config.compressed_content_encoding } else { headers[ENCODING_HEADER] = DEFAULT_ENCODING } if (options.nrHeaders) { Object.assign(headers, options.nrHeaders) } return headers } /** * FLN pretty much decided on his own recognizance that 64K was a good point * at which to compress a server response. There's only a loose consensus that * the threshold should probably be much higher than this, if only to keep the * load on the collector down. * * FIXME: come up with a better heuristic * * @param {*} data the data that might get compressed */ RemoteMethod.prototype._shouldCompress = function _shouldCompress(data) { return data && byteLength(data) > 65536 } function byteLength(data) { if (!data) { return 0 } if (data instanceof Buffer) { return data.length } return Buffer.byteLength(data, 'utf8') } module.exports = RemoteMethod