UNPKG

elastic-apm-node

Version:

The official Elastic APM agent for Node.js

437 lines (394 loc) 13.3 kB
/* * 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. */ 'use strict'; const fs = require('fs'); const path = require('path'); const truncate = require('unicode-byte-truncate'); const { INTAKE_STRING_MAX_SIZE, REDACTED } = require('../constants'); const logging = require('../logging'); const { NoopApmClient } = require('../apm-client/noop-apm-client'); const { isLambdaExecutionEnvironment } = require('../lambda'); const { isAzureFunctionsEnvironment, } = require('../instrumentation/azure-functions'); const { BOOL_OPTS, NUM_OPTS, DURATION_OPTS, BYTES_OPTS, MINUS_ONE_EQUAL_INFINITY, ARRAY_OPTS, KEY_VALUE_OPTS, URL_OPTS, getDefaultOptions, getEnvironmentOptions, getFileOptions, setStartOptions, getPreambleData, readEnvOptions, } = require('./schema'); const { normalizeUrls, normalizeArrays, normalizeBools, normalizeBytes, normalizeDurationOptions, normalizeIgnoreOptions, normalizeInfinity, normalizeKeyValuePairs, normalizeNumbers, normalizeElasticsearchCaptureBodyUrls, normalizeDisableMetrics, normalizeSanitizeFieldNames, normalizeCloudProvider, normalizeCustomMetricsHistogramBoundaries, normalizeTransactionSampleRate, normalizeTraceContinuationStrategy, normalizeContextManager, normalizeSpanStackTraceMinDuration, } = require('./normalizers'); const EXCLUDE_FIELDS = { logger: true, transport: true, }; const REDACT_FIELDS = { apiKey: true, secretToken: true, serverUrl: true, }; const NICE_REGEXPS_FIELDS = { disableMetricsRegExp: true, ignoreUrlRegExp: true, ignoreUserAgentRegExp: true, transactionIgnoreUrlRegExp: true, sanitizeFieldNamesRegExp: true, ignoreMessageQueuesRegExp: true, }; const confDefaultOptions = getDefaultOptions(); const confEnvOpts = getEnvironmentOptions(); let confFileOpts = getFileOptions(); // Configure a logger for the agent. // // This is separate from `createConfig` to allow the agent to have an early // logger before `agent.start()` is called. function configLogger(opts = {}) { const logLevel = confEnvOpts.logLevel || opts.logLevel || (confFileOpts && confFileOpts.logLevel) || confDefaultOptions.logLevel; // `ELASTIC_APM_LOGGER=false` is provided as a mechanism to *disable* a // custom logger for troubleshooting because a wrapped custom logger does // not get structured log data. // https://www.elastic.co/guide/en/apm/agent/nodejs/current/troubleshooting.html#debug-mode let customLogger = null; if (process.env.ELASTIC_APM_LOGGER !== 'false') { customLogger = opts.logger || (confFileOpts && confFileOpts.logger); } return logging.createLogger(logLevel, customLogger); } // Create an initial configuration from DEFAULTS. This is used as a stand-in // for Agent configuration until `agent.start(...)` is called. function initialConfig(logger) { const cfg = Object.assign({}, confDefaultOptions); normalize(cfg, logger); cfg.transport = new NoopApmClient(); return cfg; } function createConfig(opts, logger) { return new Config(opts, logger); } class Config { constructor(opts, logger) { // TODO: remove this call once config and logging-preamble tests are refactored readEnvOptions(); const isLambda = isLambdaExecutionEnvironment(); const envOptions = getEnvironmentOptions(); setStartOptions(opts); Object.assign( this, confDefaultOptions, // default options getFileOptions(), // options read from config file opts, // options passed in to agent.start() envOptions, // options read from environment variables ); // The logger is used later in this function, so create/update it first. // Unless a new custom `logger` was provided, we use the one created earlier // in `configLogger()`. const customLogger = process.env.ELASTIC_APM_LOGGER === 'false' ? null : this.logger; if (!customLogger && logger) { logging.setLogLevel(logger, this.logLevel); this.logger = logger; } else { this.logger = logging.createLogger(this.logLevel, customLogger); } // Fallback and validation handling for `serviceName` and `serviceVersion`. if (this.serviceName) { // A value here means an explicit value was given. Error out if invalid. try { validateServiceName(this.serviceName); } catch (err) { this.logger.error( 'serviceName "%s" is invalid: %s', this.serviceName, err.message, ); this.serviceName = null; } } else { if (isLambda) { this.serviceName = process.env.AWS_LAMBDA_FUNCTION_NAME; } else if (isAzureFunctionsEnvironment && process.env.WEBSITE_SITE_NAME) { this.serviceName = process.env.WEBSITE_SITE_NAME; } if (this.serviceName) { try { validateServiceName(this.serviceName); } catch (err) { this.logger.warn( '"%s" is not a valid serviceName: %s', this.serviceName, err.message, ); this.serviceName = null; } } if (!this.serviceName) { // Zero-conf support: use package.json#name, else // `unknown-${service.agent.name}-service`. try { this.serviceName = serviceNameFromPackageJson(); } catch (err) { this.logger.warn(err.message); } if (!this.serviceName) { this.serviceName = 'unknown-nodejs-service'; } } } if (this.serviceVersion) { // pass } else if (isLambda) { this.serviceVersion = process.env.AWS_LAMBDA_FUNCTION_VERSION; } else if (isAzureFunctionsEnvironment && process.env.WEBSITE_SITE_NAME) { // Leave this empty. There isn't a meaningful service version field // in Azure Functions envvars, and falling back to package.json ends up // finding the version of the "azure-functions-core-tools" package. } else { // Zero-conf support: use package.json#version, if possible. try { this.serviceVersion = serviceVersionFromPackageJson(); } catch (err) { // pass } } normalize(this, this.logger); if (isLambda || isAzureFunctionsEnvironment) { // Override some config in AWS Lambda or Azure Functions environments. this.metricsInterval = 0; this.cloudProvider = 'none'; this.centralConfig = false; } if (this.metricsInterval === 0) { this.breakdownMetrics = false; } this.loggingPreambleData = getPreambleData(this); } // Return a reasonably loggable object for this Config instance. // Exclude undefined fields and complex objects like `logger`. toJSON() { const loggable = {}; for (const k in this) { if (EXCLUDE_FIELDS[k] || this[k] === undefined) { // pass } else if (REDACT_FIELDS[k]) { loggable[k] = REDACTED; } else if (NICE_REGEXPS_FIELDS[k] && Array.isArray(this[k])) { // JSON.stringify() on a RegExp is "{}", which isn't very helpful. loggable[k] = this[k].map((r) => r instanceof RegExp ? r.toString() : r, ); } else { loggable[k] = this[k]; } } return loggable; } } function validateServiceName(s) { if (typeof s !== 'string') { throw new Error('not a string'); } else if (!/^[a-zA-Z0-9 _-]+$/.test(s)) { throw new Error( 'contains invalid characters (allowed: a-z, A-Z, 0-9, _, -, <space>)', ); } } // findPkgInfo() looks up from the script dir (or cwd) for a "package.json" file // from which to load the name and version. It returns: // { // startDir: "<full path to starting dir>", // path: "/the/full/path/to/package.json", // may be null // data: { // name: "<the package name>", // may be missing // version: "<the package version>" // may be missing // } // } let pkgInfoCache; function findPkgInfo() { if (pkgInfoCache === undefined) { // Determine a good starting dir from which to look for a "package.json". let startDir = require.main && require.main.filename && path.dirname(require.main.filename); if (!startDir && process.argv[1]) { // 'require.main' is undefined if the agent is preloaded with `node // --require elastic-apm-node/... script.js`. startDir = path.dirname(process.argv[1]); } if (!startDir) { startDir = process.cwd(); } pkgInfoCache = { startDir, path: null, data: {}, }; // Look up from the starting dir for a "package.json". const { root } = path.parse(startDir); let dir = startDir; while (true) { const pj = path.resolve(dir, 'package.json'); if (fs.existsSync(pj)) { pkgInfoCache.path = pj; break; } if (dir === root) { break; } dir = path.dirname(dir); } // Attempt to load "name" and "version" from the package.json. if (pkgInfoCache.path) { try { const data = JSON.parse(fs.readFileSync(pkgInfoCache.path)); if (data.name) { // For backward compatibility, maintain the trimming done by // https://github.com/npm/normalize-package-data#what-normalization-currently-entails pkgInfoCache.data.name = data.name.trim(); } if (data.version) { pkgInfoCache.data.version = data.version; } } catch (_err) { // Silently leave data empty. } } } return pkgInfoCache; } function serviceNameFromPackageJson() { const pkg = findPkgInfo(); if (!pkg.path) { throw new Error( `could not infer serviceName: could not find package.json up from ${pkg.startDir}`, ); } if (!pkg.data.name) { throw new Error( `could not infer serviceName: "${pkg.path}" does not contain a "name"`, ); } if (typeof pkg.data.name !== 'string') { throw new Error( `could not infer serviceName: "name" in "${pkg.path}" is not a string`, ); } let serviceName = pkg.data.name; // Normalize a namespaced npm package name, '@ns/name', to 'ns-name'. const match = /^@([^/]+)\/([^/]+)$/.exec(serviceName); if (match) { serviceName = match[1] + '-' + match[2]; } // Sanitize, by replacing invalid service name chars with an underscore. const SERVICE_NAME_BAD_CHARS = /[^a-zA-Z0-9 _-]/g; serviceName = serviceName.replace(SERVICE_NAME_BAD_CHARS, '_'); // Disallow some weird sanitized values. For example, it is better to // have the fallback "unknown-{service.agent.name}-service" than "_" or // "____" or " ". const ALL_NON_ALPHANUMERIC = /^[ _-]*$/; if (ALL_NON_ALPHANUMERIC.test(serviceName)) { serviceName = null; } if (!serviceName) { throw new Error( `could not infer serviceName from name="${pkg.data.name}" in "${pkg.path}"`, ); } return serviceName; } function serviceVersionFromPackageJson() { const pkg = findPkgInfo(); if (!pkg.path) { throw new Error( `could not infer serviceVersion: could not find package.json up from ${pkg.startDir}`, ); } if (!pkg.data.version) { throw new Error( `could not infer serviceVersion: "${pkg.path}" does not contain a "version"`, ); } if (typeof pkg.data.version !== 'string') { throw new Error( `could not infer serviceVersion: "version" in "${pkg.path}" is not a string`, ); } return pkg.data.version; } function normalize(opts, logger) { const defaults = confDefaultOptions; normalizeKeyValuePairs(opts, KEY_VALUE_OPTS, defaults, logger); normalizeNumbers(opts, NUM_OPTS, defaults, logger); normalizeInfinity(opts, MINUS_ONE_EQUAL_INFINITY, defaults, logger); normalizeBytes(opts, BYTES_OPTS, defaults, logger); normalizeArrays(opts, ARRAY_OPTS, defaults, logger); normalizeDurationOptions(opts, DURATION_OPTS, defaults, logger); normalizeBools(opts, BOOL_OPTS, defaults, logger); normalizeIgnoreOptions(opts); normalizeElasticsearchCaptureBodyUrls(opts); normalizeDisableMetrics(opts); normalizeSanitizeFieldNames(opts); normalizeContextManager(opts, [], defaults, logger); normalizeCloudProvider(opts, [], defaults, logger); normalizeTransactionSampleRate(opts, [], defaults, logger); normalizeTraceContinuationStrategy(opts, [], defaults, logger); normalizeCustomMetricsHistogramBoundaries(opts, [], defaults, logger); normalizeUrls(opts, URL_OPTS, defaults, logger); // This must be after `normalizeDurationOptions()` and `normalizeBools()` // because it synthesizes the deprecated `spanFramesMinDuration` and // `captureSpanStackTraces` options into `spanStackTraceMinDuration`. normalizeSpanStackTraceMinDuration(opts, [], defaults, logger); truncateOptions(opts); } function truncateOptions(opts) { if (opts.serviceVersion) opts.serviceVersion = truncate( String(opts.serviceVersion), INTAKE_STRING_MAX_SIZE, ); if (opts.hostname) opts.hostname = truncate(String(opts.hostname), INTAKE_STRING_MAX_SIZE); } // Exports. module.exports = { configLogger, initialConfig, createConfig, normalize, };