elastic-apm-node
Version:
The official Elastic APM agent for Node.js
702 lines (660 loc) • 24 kB
JavaScript
/*
* Copyright Elasticsearch B.V. and other contributors where applicable.
* Licensed under the BSD 2-Clause License; you may not use this file except in
* compliance with the BSD 2-Clause License.
*/
;
const { URL } = require('url');
const { WildcardMatcher } = require('../wildcard-matcher');
const {
TRACE_CONTINUATION_STRATEGY_CONTINUE,
TRACE_CONTINUATION_STRATEGY_RESTART,
TRACE_CONTINUATION_STRATEGY_RESTART_EXTERNAL,
CONTEXT_MANAGER_ASYNCHOOKS,
CONTEXT_MANAGER_ASYNCLOCALSTORAGE,
} = require('../constants');
/**
* Normalizes the key/value pairs properties of the config options object
* KeyValuePairs config vars are either an object or a comma-separated string
* of key=value pairs (whitespace around the "key=value" strings is trimmed):
* {'foo': 'bar', 'eggs': 'spam'} => [['foo', 'bar'], ['eggs', 'spam']]
* foo=bar, eggs=spam => [['foo', 'bar'], ['eggs', 'spam']]
*
* @param {Record<String, unknown>} opts the configuration options to normalize
* @param {String[]} fields the list of fields to normalize as key/value pair
* @param {Record<String, unknown>} defaults the configuration defaults
* @param {import('../logging.js').Logger} logger
*/
function normalizeKeyValuePairs(opts, fields, defaults, logger) {
for (const key of fields) {
if (key in opts) {
if (typeof opts[key] === 'object' && !Array.isArray(opts[key])) {
opts[key] = Object.entries(opts[key]);
continue;
}
if (!Array.isArray(opts[key]) && typeof opts[key] === 'string') {
opts[key] = opts[key].split(',').map((v) => v.trim());
}
if (Array.isArray(opts[key])) {
// Note: Currently this assumes no '=' in the value. Also this does not
// trim whitespace.
opts[key] = opts[key].map((value) =>
typeof value === 'string' ? value.split('=') : value,
);
}
}
}
}
/**
* Normalizes the number properties of the config options object
*
* @param {Record<String, unknown>} opts the configuration options to normalize
* @param {String[]} fields the list of fields to normalize as number
* @param {Record<String, unknown>} defaults the configuration defaults
* @param {import('../logging.js').Logger} logger
*/
function normalizeNumbers(opts, fields, defaults, logger) {
for (const key of fields) {
if (key in opts) opts[key] = Number(opts[key]);
}
}
/**
* Normalizes the number properties of the config options object
*
* @param {Record<String, unknown>} opts the configuration options to normalize
* @param {String[]} fields the list of fields to normalize as number
* @param {Record<String, unknown>} defaults the configuration defaults
* @param {import('../logging.js').Logger} logger
*/
function normalizeInfinity(opts, fields, defaults, logger) {
for (const key of fields) {
if (opts[key] === -1) opts[key] = Infinity;
}
}
/**
* Translates a string byte size, e.g. '10kb', into an integer number of bytes.
*
* @param {String} input
* @param {string} key - the config option key
* @param {import('../logging.js').Logger} logger
* @returns {Number|undefined}
*/
function bytes(input, key, logger) {
const matches = input.match(/^(\d+)(b|kb|mb|gb)$/i);
if (!matches) {
logger.warn(
'units missing in size value "%s" for "%s" config option. Use one of b|kb|mb|gb',
input,
key,
);
return Number(input);
}
const suffix = matches[2].toLowerCase();
let value = Number(matches[1]);
if (!suffix || suffix === 'b') {
return value;
}
value *= 1024;
if (suffix === 'kb') {
return value;
}
value *= 1024;
if (suffix === 'mb') {
return value;
}
value *= 1024;
if (suffix === 'gb') {
return value;
}
}
/**
* Normalizes the byte properties of the config options object
*
* @param {Record<String, unknown>} opts the configuration options to normalize
* @param {String[]} fields the list of fields to normalize as bytes
* @param {Record<String, unknown>} defaults the configuration defaults
* @param {import('../logging.js').Logger} logger
*/
function normalizeBytes(opts, fields, defaults, logger) {
for (const key of fields) {
if (key in opts) {
opts[key] = bytes(String(opts[key]), key, logger);
if (key === 'errorMessageMaxLength') {
logger.warn(
'config option "errorMessageMaxLength" is deprecated. Use "longFieldMaxLength"',
);
}
}
}
}
/**
* Convert a given duration config option into a number of seconds.
* If the given duration is invalid, this returns `null`.
* Units are *case-sensitive*.
*
* Examples:
* secondsFromDuration('30s', 's', ['ms', 's', 'm'], false) // => 30
* secondsFromDuration('-1s', 's', ['ms', 's', 'm'], false) // => null
* secondsFromDuration('-1ms', 's', ['ms', 's', 'm'], true) // => -0.001
* secondsFromDuration(500, 'ms', ['us', 'ms', 's', 'm'], false) // => 0.5
*
* @param {String|Number} duration - Typically a string of the form `<num><unit>`,
* for example `30s`, `-1ms`, `2m`. The `defaultUnit` is used if a unit is
* not part of the string, or if duration is a number. If given as a string,
* decimal ('1.5s') and exponential-notation ('1e-3s') values are not allowed.
* @param {String} defaultUnit
* @param {Array<String>} allowedUnits - An array of the allowed unit strings. This
* array may include any number of `us`, `ms`, `s`, and `m`.
* @param {Boolean} allowNegative - Whether a negative number is allowed.
* @param {string} key - the config option key
* @param {Logger} logger
* @returns {number|null}
*/
function secondsFromDuration(
duration,
defaultUnit,
allowedUnits,
allowNegative,
key,
logger,
) {
let val;
let unit;
if (typeof duration === 'string') {
let match;
if (allowNegative) {
match = /^(-?\d+)(\w+)?$/.exec(duration);
} else {
match = /^(\d+)(\w+)?$/.exec(duration);
}
if (!match) {
return null;
}
val = Number(match[1]);
if (isNaN(val) || !Number.isFinite(val)) {
return null;
}
unit = match[2];
if (!unit) {
logger.warn(
'units missing in duration value "%s" for "%s" config option: using default units "%s"',
duration,
key,
defaultUnit,
);
unit = defaultUnit;
}
if (!allowedUnits.includes(unit)) {
return null;
}
} else if (typeof duration === 'number') {
if (isNaN(duration)) {
return null;
} else if (duration < 0 && !allowNegative) {
return null;
}
val = duration;
unit = defaultUnit;
} else {
return null;
}
// Scale to seconds.
switch (unit) {
case 'us':
val /= 1e6;
break;
case 'ms':
val /= 1e3;
break;
case 's':
break;
case 'm':
val *= 60;
break;
default:
throw new Error(`unknown unit "${unit}" from "${duration}"`);
}
return val;
}
/**
* Normalizes the duration properties of the config options object
*
* @param {Record<String, unknown>} opts the configuration options to normalize
* @param {Array<Object>} fields the list of fields to normalize as duration (with name, defaultUnit, allowedUnits, allowNegative)
* @param {Record<String, unknown>} defaults the configuration defaults
* @param {import('../logging.js').Logger} logger
*/
function normalizeDurationOptions(opts, fields, defaults, logger) {
for (const optSpec of fields) {
const key = optSpec.name;
if (key in opts) {
const val = secondsFromDuration(
opts[key],
optSpec.defaultUnit,
optSpec.allowedUnits,
optSpec.allowNegative,
key,
logger,
);
if (val === null) {
if (key in defaults) {
const def = defaults[key];
logger.warn(
'invalid duration value "%s" for "%s" config option: using default "%s"',
opts[key],
key,
def,
);
opts[key] = secondsFromDuration(
def,
optSpec.defaultUnit,
optSpec.allowedUnits,
optSpec.allowNegative,
key,
logger,
);
} else {
logger.warn(
'invalid duration value "%s" for "%s" config option: ignoring this option',
opts[key],
key,
);
delete opts[key];
}
} else {
opts[key] = val;
}
}
}
}
/**
* Normalizes the array properties of the config options object
* Array config vars are either already an array of strings, or a
* comma-separated string (whitespace is trimmed):
* 'foo, bar' => ['foo', 'bar']
*
* @param {Record<String, unknown>} opts the configuration options to normalize
* @param {String[]} fields the list of fields to normalize as arrays
* @param {Record<String, unknown>} defaults the configuration defaults
* @param {import('../logging.js').Logger} logger
*/
function normalizeArrays(opts, fields, defaults, logger) {
for (const key of fields) {
if (key in opts && typeof opts[key] === 'string') {
opts[key] = opts[key].split(',').map((v) => v.trim());
}
}
}
/**
* Parses "true"|"false" to boolean if not a boolean already and returns it. Returns undefined otherwise
*
* @param {import('../logging.js').Logger} logger
* @param {String} key
* @param {any} value
* @returns {Boolean|undefined}
*/
function strictBool(logger, key, value) {
if (typeof value === 'boolean') {
return value;
}
// This will return undefined for unknown inputs, resulting in them being skipped.
switch (value) {
case 'false':
return false;
case 'true':
return true;
default: {
logger.warn('unrecognized boolean value "%s" for "%s"', value, key);
}
}
}
/**
* Normalizes the boolean properties of the config options object
* Boolean config vars are either already a boolean, or a string
* representation of the boolean value: `true` or `false`
*
* @param {Record<String, unknown>} opts the configuration options to normalize
* @param {String[]} fields the list of fields to normalize as boolean
* @param {Record<String, unknown>} defaults the configuration defaults
* @param {import('../logging.js').Logger} logger
*/
function normalizeBools(opts, fields, defaults, logger) {
for (const key of fields) {
if (key in opts) opts[key] = strictBool(logger, key, opts[key]);
}
}
/**
* Checks validity of the URL properties of the config options object.
*
* @param {Record<String, unknown>} opts the configuration options to normalize
* @param {String[]} fields the list of fields to normalize as boolean
* @param {Record<String, unknown>} defaults the configuration defaults
* @param {import('../logging.js').Logger} logger
*/
function normalizeUrls(opts, fields, defaults, logger) {
for (const key of fields) {
if (key in opts) {
try {
// eslint-disable-next-line no-unused-vars
const url = new URL(opts[key]);
// TODO: consider making the port explicit in the URL
// sourceValue http://foo.com => normalized http://foo.com:80
// sourceValue https://foo.com => normalized https://foo.com:443
} catch (err) {
logger.warn('Invalid "%s" config value, it must be a valid URL', key);
opts[key] = null;
}
}
}
}
/**
* Normalizes the ignore options and places them in specific properties for string and RegExp values
*
* Ignore config vars are either an array of wildcard expressions or an array of strings and RegExps:
* of key=value pairs (whitespace around the "key=value" strings is trimmed):
* ['*foo', 'bar*'] => [ /^.*foo/, /^bar.*$/ ] (result goes to another property)
* ['foo', /url/pathname$/] => ['foo'] (strings are placed into a specific config option)
* => [/url/pathname$/] (RegExps are placed into a specific config option)
*
* @param {Record<String, unknown>} opts the configuration options to normalize
* @param {String[]} fields the list of fields to normalize as boolean
* @param {Record<String, unknown>} defaults the configuration defaults
* @param {import('../logging.js').Logger} logger
*/
function normalizeIgnoreOptions(opts, fields, defaults, logger) {
// Params are meant to be used in upcoming changes
if (opts.transactionIgnoreUrls) {
opts.transactionIgnoreUrlRegExp = [];
const wildcard = new WildcardMatcher();
for (const ptn of opts.transactionIgnoreUrls) {
const re = wildcard.compile(ptn);
opts.transactionIgnoreUrlRegExp.push(re);
}
}
if (opts.ignoreUrls) {
opts.ignoreUrlStr = [];
opts.ignoreUrlRegExp = [];
for (const ptn of opts.ignoreUrls) {
if (typeof ptn === 'string') {
opts.ignoreUrlStr.push(ptn);
} else {
opts.ignoreUrlRegExp.push(ptn);
}
}
delete opts.ignoreUrls;
}
if (opts.ignoreUserAgents) {
opts.ignoreUserAgentStr = [];
opts.ignoreUserAgentRegExp = [];
for (const ptn of opts.ignoreUserAgents) {
if (typeof ptn === 'string') {
opts.ignoreUserAgentStr.push(ptn);
} else {
opts.ignoreUserAgentRegExp.push(ptn);
}
}
delete opts.ignoreUserAgents;
}
if (opts.ignoreMessageQueues) {
opts.ignoreMessageQueuesRegExp = [];
const wildcard = new WildcardMatcher();
for (const ptn of opts.ignoreMessageQueues) {
const re = wildcard.compile(ptn);
opts.ignoreMessageQueuesRegExp.push(re);
}
}
}
/**
* Normalizes the wildcard matchers of sanitizeFieldNames and thansforms the into RegExps
*
* TODO: we are doing the same to some ignoreOptions
* @param {Record<String, unknown>} opts the configuration options to normalize
*/
function normalizeSanitizeFieldNames(opts) {
if (opts.sanitizeFieldNames) {
opts.sanitizeFieldNamesRegExp = [];
const wildcard = new WildcardMatcher();
for (const ptn of opts.sanitizeFieldNames) {
const re = wildcard.compile(ptn);
opts.sanitizeFieldNamesRegExp.push(re);
}
}
}
// TODO: this is the same as normalizeSanitizeFieldNames
// maybe create a normalizeWildcardOptions???
function normalizeDisableMetrics(opts) {
if (opts.disableMetrics) {
opts.disableMetricsRegExp = []; // This line was not in the original code but raised an exception in the tests
const wildcard = new WildcardMatcher();
for (const ptn of opts.disableMetrics) {
const re = wildcard.compile(ptn);
opts.disableMetricsRegExp.push(re);
}
}
}
// TODO: same as above
function normalizeElasticsearchCaptureBodyUrls(opts) {
if (opts.elasticsearchCaptureBodyUrls) {
opts.elasticsearchCaptureBodyUrlsRegExp = [];
const wildcard = new WildcardMatcher();
for (const ptn of opts.elasticsearchCaptureBodyUrls) {
const re = wildcard.compile(ptn);
opts.elasticsearchCaptureBodyUrlsRegExp.push(re);
}
}
}
/**
* Makes sure the cloudProvider options is valid othherwise it set the default value.
*
* @param {Record<String, unknown>} opts the configuration options to normalize
* @param {String[]} fields the list of fields to normalize as duration
* @param {Record<String, unknown>} defaults the configuration defaults
* @param {import('../logging.js').Logger} logger
*/
function normalizeCloudProvider(opts, fields, defaults, logger) {
if ('cloudProvider' in opts) {
const allowedValues = ['auto', 'gcp', 'azure', 'aws', 'none'];
if (allowedValues.indexOf(opts.cloudProvider) === -1) {
logger.warn(
'Invalid "cloudProvider" config value %s, falling back to default %s',
opts.cloudProvider,
defaults.cloudProvider,
);
opts.cloudProvider = defaults.cloudProvider;
}
}
}
// `customMetricsHistogramBoundaries` must be a sorted array of numbers,
// without duplicates.
function normalizeCustomMetricsHistogramBoundaries(
opts,
fields,
defaults,
logger,
) {
if (!('customMetricsHistogramBoundaries' in opts)) {
return;
}
let val = opts.customMetricsHistogramBoundaries;
if (typeof val === 'string') {
val = val.split(',').map((v) => Number(v.trim()));
}
let errReason = null;
if (!Array.isArray(val)) {
errReason = 'value is not an array';
} else if (val.some((el) => typeof el !== 'number' || isNaN(el))) {
errReason = 'array includes non-numbers';
} else {
for (let i = 0; i < val.length - 1; i++) {
if (val[i] === val[i + 1]) {
errReason = 'array has duplicate values';
break;
} else if (val[i] > val[i + 1]) {
errReason = 'array is not sorted';
break;
}
}
}
if (errReason) {
logger.warn(
'Invalid "customMetricsHistogramBoundaries" config value %j, %s; falling back to default',
opts.customMetricsHistogramBoundaries,
errReason,
);
opts.customMetricsHistogramBoundaries =
defaults.customMetricsHistogramBoundaries;
} else {
opts.customMetricsHistogramBoundaries = val;
}
}
// transactionSampleRate is specified to be:
// - in the range [0,1]
// - rounded to 4 decimal places of precision (e.g. 0.0001, 0.5678, 0.9999)
// - with the special case that a value in the range (0, 0.0001] should be
// rounded to 0.0001 -- to avoid a small value being rounded to zero.
//
// https://github.com/elastic/apm/blob/main/specs/agents/tracing-sampling.md
function normalizeTransactionSampleRate(opts, fields, defaults, logger) {
if ('transactionSampleRate' in opts) {
// The value was already run through `Number(...)` in `normalizeNumbers`.
const rate = opts.transactionSampleRate;
if (isNaN(rate) || rate < 0 || rate > 1) {
opts.transactionSampleRate = defaults.transactionSampleRate;
logger.warn(
'Invalid "transactionSampleRate" config value %s, falling back to default %s',
rate,
opts.transactionSampleRate,
);
} else if (rate > 0 && rate < 0.0001) {
opts.transactionSampleRate = 0.0001;
} else {
opts.transactionSampleRate = Math.round(rate * 10000) / 10000;
}
}
}
const ALLOWED_TRACE_CONTINUATION_STRATEGY = {
[TRACE_CONTINUATION_STRATEGY_CONTINUE]: true,
[TRACE_CONTINUATION_STRATEGY_RESTART]: true,
[TRACE_CONTINUATION_STRATEGY_RESTART_EXTERNAL]: true,
};
function normalizeTraceContinuationStrategy(opts, fields, defaults, logger) {
if (
'traceContinuationStrategy' in opts &&
!(opts.traceContinuationStrategy in ALLOWED_TRACE_CONTINUATION_STRATEGY)
) {
logger.warn(
'Invalid "traceContinuationStrategy" config value %j, falling back to default %j',
opts.traceContinuationStrategy,
defaults.traceContinuationStrategy,
);
opts.traceContinuationStrategy = defaults.traceContinuationStrategy;
}
}
const ALLOWED_CONTEXT_MANAGER = {
[CONTEXT_MANAGER_ASYNCHOOKS]: true,
[CONTEXT_MANAGER_ASYNCLOCALSTORAGE]: true,
};
/**
* Normalize and validate the given values for `contextManager`, and the
* deprecated `asyncHooks` that it replaces.
*
* - `contextManager=asynchooks` means use the "async_hooks.createHook()"
* technique. This works in all supported versions of node, but can have
* significant performance overhead for Promise-heavy apps.
* - `contextManager=asynclocalstorage` means use the "AsyncLocalStorage"
* technique *if supported in the version of node* (>=14.5 || ^12.19.0).
* Otherwise, this will warn and fallback to "asynchooks".
* - No specified option means use the best async technique.
*/
function normalizeContextManager(opts, fields, defaults, logger) {
// Treat the empty string, e.g. `ELASTIC_APM_CONTEXT_MANAGER=`, as if it had
// not been specified.
if (opts.contextManager === '') {
delete opts.contextManager;
}
if (
'contextManager' in opts &&
!(opts.contextManager in ALLOWED_CONTEXT_MANAGER)
) {
logger.warn(
'Invalid "contextManager" config value %j, falling back to default behavior',
opts.contextManager,
);
delete opts.contextManager;
}
}
// Normalize provided values for `spanFramesMinDuration` (deprecated),
// `captureSpanStackTraces` (deprecated) and `spanStackTraceMinDuration` into
// a final value for `spanStackTraceMinDuration` that is used by the agent.
//
// This function expects `normalizeDurationOptions()` and `normalizeBools()`
// to have already been called.
//
// | spanStackTraceMinDuration | captureSpanStackTraces | spanFramesMinDuration | resultant spanStackTraceMinDuration |
// | ------------------------- | ---------------------- | ----------------------- | ----------------------------------- |
// | - | - | - | `-1ms` (no span stacktraces) |
// | `-1ms` (negative value) | (any value is ignored) | (any value is ignored) | `-1ms` (no span stacktraces) |
// | `0ms` (zero value) | (any value is ignored) | (any value is ignored) | `0ms` (stacktraces for all spans) |
// | `5ms` (positive value) | (any value is ignored) | (any value is ignored) | `5ms` |
// | - | `false` | (any value) | `-1ms` (no span stacktraces) |
// | - | `true` | - | `10ms` (backwards compatible value) |
// | - | `true` or unspecified | `0ms` (zero value) | `-1ms` (no span stacktraces) |
// | - | `true` or unspecified | `-1ms` (negative value) | `0ms` (stacktraces for all spans) |
// | - | `true` or unspecified | `5ms` (positive value) | `5ms` |
function normalizeSpanStackTraceMinDuration(opts, fields, defaults, logger) {
const before = {};
if (opts.captureSpanStackTraces !== undefined)
before.captureSpanStackTraces = opts.captureSpanStackTraces;
if (opts.spanFramesMinDuration !== undefined)
before.spanFramesMinDuration = opts.spanFramesMinDuration;
if (opts.spanStackTraceMinDuration !== undefined)
before.spanStackTraceMinDuration = opts.spanStackTraceMinDuration;
if ('spanStackTraceMinDuration' in opts) {
// If the new option was specified, then use it and ignore the old two.
} else if (opts.captureSpanStackTraces === false) {
opts.spanStackTraceMinDuration = -1; // Turn off span stacktraces.
} else if ('spanFramesMinDuration' in opts) {
if (opts.spanFramesMinDuration === 0) {
opts.spanStackTraceMinDuration = -1; // Turn off span stacktraces.
} else if (opts.spanFramesMinDuration < 0) {
opts.spanStackTraceMinDuration = 0; // Stacktraces for all spans.
} else {
opts.spanStackTraceMinDuration = opts.spanFramesMinDuration;
}
} else if (opts.captureSpanStackTraces === true) {
// For backwards compat, use the default `spanFramesMinDuration` value
// from before `spanStackTraceMinDuration` was introduced.
opts.spanStackTraceMinDuration = 10 / 1e3; // 10ms
} else {
// None of the three options was specified.
opts.spanStackTraceMinDuration = -1; // Turn off span stacktraces.
}
delete opts.captureSpanStackTraces;
delete opts.spanFramesMinDuration;
// Log if something potentially interesting happened here.
if (Object.keys(before).length > 0) {
const after = { spanStackTraceMinDuration: opts.spanStackTraceMinDuration };
logger.trace({ before, after }, 'normalizeSpanStackTraceMinDuration');
}
}
module.exports = {
normalizeArrays,
normalizeBools,
normalizeBytes,
normalizeCloudProvider,
normalizeCustomMetricsHistogramBoundaries,
normalizeDisableMetrics,
normalizeDurationOptions,
normalizeElasticsearchCaptureBodyUrls,
normalizeIgnoreOptions,
normalizeInfinity,
normalizeKeyValuePairs,
normalizeNumbers,
normalizeSanitizeFieldNames,
normalizeTransactionSampleRate,
secondsFromDuration,
normalizeTraceContinuationStrategy,
normalizeContextManager,
normalizeSpanStackTraceMinDuration,
normalizeUrls,
};