UNPKG

elastic-apm-node

Version:

The official Elastic APM agent for Node.js

570 lines (503 loc) 16.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'; var util = require('util'); var ObjectIdentityMap = require('object-identity-map'); const constants = require('../constants'); const { DroppedSpansStats } = require('./dropped-spans-stats'); var getPathFromRequest = require('./express-utils').getPathFromRequest; var GenericSpan = require('./generic-span'); var parsers = require('../parsers'); var Span = require('./span'); var symbols = require('../symbols'); const { TRACE_CONTINUATION_STRATEGY_CONTINUE, TRACE_CONTINUATION_STRATEGY_RESTART, TRACE_CONTINUATION_STRATEGY_RESTART_EXTERNAL, } = require('../constants'); var { TransactionIds } = require('./ids'); const TraceState = require('../tracecontext/tracestate'); module.exports = Transaction; util.inherits(Transaction, GenericSpan); // Usage: // new Transaction(agent) // new Transaction(agent, name, opts?) // new Transaction(agent, name, type?, opts?) // // @param {Agent} agent // @param {string} [name] // @param {string} [type] - Defaults to 'custom' when serialized. // @param {Object} [opts] // - opts.childOf - Used to determine the W3C trace-context trace id, parent // id, and sampling information for this new transaction. This currently // accepts a Transaction instance, Span instance, TraceParent instance, or // a traceparent string. (Arguably any but the latter two are non-sensical // for a new transaction.) // - opts.tracestate - A W3C trace-context tracestate string. // - Any other options supported by GenericSpan ... function Transaction(agent, name, ...args) { const opts = typeof args[args.length - 1] === 'object' ? args.pop() || {} : {}; if (opts.timer) { // Before 4.x this option could be passed in. It was never publicly documented. delete opts.timer; } if (opts.tracestate) { opts.tracestate = TraceState.fromStringFormatString(opts.tracestate); } if (opts.childOf) { // Possibly restart the trace, depending on `traceContinuationStrategy`. // Spec: https://github.com/elastic/apm/blob/main/specs/agents/trace-continuation.md let traceContinuationStrategy = agent._conf.traceContinuationStrategy; if ( traceContinuationStrategy === TRACE_CONTINUATION_STRATEGY_RESTART_EXTERNAL ) { traceContinuationStrategy = TRACE_CONTINUATION_STRATEGY_RESTART; if (opts.tracestate && opts.tracestate.toMap().has('es')) { traceContinuationStrategy = TRACE_CONTINUATION_STRATEGY_CONTINUE; } } if (traceContinuationStrategy === TRACE_CONTINUATION_STRATEGY_RESTART) { if (!opts.links || !Array.isArray(opts.links)) { opts.links = []; } opts.links.push({ context: opts.childOf }); delete opts.childOf; // restart the trace delete opts.tracestate; } } this.type = null; this.setType(...args); GenericSpan.call(this, agent, opts); const verb = this.parentId ? 'continue' : 'start'; agent.logger.debug('%s trace %o', verb, { trans: this.id, parent: this.parentId, trace: this.traceId, name: this.name, type: this.type, }); this._defaultName = name || ''; this._customName = ''; this._user = null; this._custom = null; this._result = constants.RESULT_SUCCESS; this._builtSpans = 0; this._droppedSpans = 0; this._breakdownTimings = new ObjectIdentityMap(); this._faas = undefined; this._service = undefined; this._message = undefined; this._cloud = undefined; this._droppedSpansStats = new DroppedSpansStats(); this.outcome = constants.OUTCOME_UNKNOWN; } Object.defineProperty(Transaction.prototype, 'name', { configurable: true, enumerable: true, get() { // Fall back to a somewhat useful name in case no _defaultName is set. // This might happen if res.writeHead wasn't called. return ( this._customName || this._defaultName || (this.req ? this.req.method + ' unknown route (unnamed)' : 'unnamed') ); }, set(name) { if (this.ended) { this._agent.logger.debug( 'tried to set transaction.name on already ended transaction %o', { trans: this.id, parent: this.parentId, trace: this.traceId }, ); return; } this._agent.logger.debug('setting transaction name %o', { trans: this.id, parent: this.parentId, trace: this.traceId, name, }); this._customName = name; }, }); Object.defineProperty(Transaction.prototype, 'result', { configurable: true, enumerable: true, get() { return this._result; }, set(result) { if (this.ended) { this._agent.logger.debug( 'tried to set transaction.result on already ended transaction %o', { trans: this.id, parent: this.parentId, trace: this.traceId }, ); return; } this._agent.logger.debug('setting transaction result %o', { trans: this.id, parent: this.parentId, trace: this.traceId, result, }); this._result = result; }, }); Object.defineProperty(Transaction.prototype, 'ids', { get() { return this._ids === null ? (this._ids = new TransactionIds(this)) : this._ids; }, }); Transaction.prototype.setType = function (type = null) { this.type = type || constants.DEFAULT_SPAN_TYPE; }; /* * A string representation of the transaction to help with internal debugging. * This is not a promised interface. */ Transaction.prototype.toString = function () { return `Transaction(${this.id}, '${this.name}'${ this.ended ? ', ended' : '' })`; }; Transaction.prototype.setUserContext = function (context) { if (!context) return; this._user = Object.assign(this._user || {}, context); }; Transaction.prototype.setServiceContext = function (serviceContext) { if (!serviceContext) return; this._service = Object.assign(this._service || {}, serviceContext); }; Transaction.prototype.setMessageContext = function (messageContext) { if (!messageContext) return; this._message = Object.assign(this._message || {}, messageContext); }; Transaction.prototype.setFaas = function (faasFields) { if (!faasFields) return; this._faas = Object.assign(this._faas || {}, faasFields); }; Transaction.prototype.setCustomContext = function (context) { if (!context) return; this._custom = Object.assign(this._custom || {}, context); }; Transaction.prototype.setCloudContext = function (cloudContext) { if (!cloudContext) return; this._cloud = Object.assign(this._cloud || {}, cloudContext); }; // Create a span on this transaction and make it the current span. Transaction.prototype.startSpan = function (...args) { const span = this.createSpan(...args); if (span) { this._agent._instrumentation.supersedeWithSpanRunContext(span); } return span; }; // Create a span on this transaction. // // This does *not* replace the current run context to make this span the // "current" one. This allows instrumentations to avoid impacting the run // context of the calling code. Compare to `startSpan`. Transaction.prototype.createSpan = function (...args) { if (!this.sampled) { return null; } // Exit spans must not have child spans (unless of the same type and subtype). // https://github.com/elastic/apm/blob/master/specs/agents/tracing-spans.md#child-spans-of-exit-spans const opts = typeof args[args.length - 1] === 'object' ? args.pop() || {} : {}; const [_name, type, subtype] = args; // eslint-disable-line no-unused-vars opts.childOf = opts.childOf || this._agent._instrumentation.currSpan() || this; const childOf = opts.childOf; if ( childOf instanceof Span && childOf._exitSpan && !(childOf.type === type && childOf.subtype === subtype) ) { this._agent.logger.trace( { exitSpanId: childOf.id, newSpanArgs: args }, 'createSpan: drop child span of exit span', ); return null; } const span = new Span(this, ...args, opts); if (this._builtSpans >= this._agent._conf.transactionMaxSpans) { this._droppedSpans++; span.setRecorded(false); } this._builtSpans++; return span; }; // Note that this only returns a complete result when called *during* the call // to `transaction.end()`. Transaction.prototype.toJSON = function () { var payload = { id: this.id, trace_id: this.traceId, parent_id: this.parentId, name: this.name, type: this.type || constants.DEFAULT_SPAN_TYPE, duration: this._duration, timestamp: this.timestamp, result: String(this.result), sampled: this.sampled, context: undefined, span_count: { started: this._builtSpans - this._droppedSpans, }, outcome: this.outcome, faas: this._faas, }; if (this.sampled) { payload.context = { user: Object.assign( {}, this.req && parsers.getUserContextFromRequest(this.req), this._user, ), tags: this._labels || {}, custom: this._custom || {}, service: this._service || {}, cloud: this._cloud || {}, message: this._message || {}, }; // Only include dropped count when spans have been dropped. if (this._droppedSpans > 0) { payload.span_count.dropped = this._droppedSpans; } var conf = this._agent._conf; if (this.req) { payload.context.request = parsers.getContextFromRequest( this.req, conf, 'transactions', ); } if (this.res) { payload.context.response = parsers.getContextFromResponse(this.res, conf); } } // add sample_rate to transaction // https://github.com/elastic/apm/blob/main/specs/agents/tracing-sampling.md // Only set sample_rate on transaction payload if a valid trace state // variable is set. // // "If there is no tracestate or no valid es entry with an s attribute, // then the agent must omit sample_rate from non-root transactions and // their spans." const sampleRate = this.sampleRate; if (sampleRate !== null) { payload.sample_rate = sampleRate; } this._serializeOTel(payload); if (this._links.length > 0) { payload.links = this._links; } if (this._droppedSpansStats.size() > 0) { payload.dropped_spans_stats = this._droppedSpansStats.encode(); } return payload; }; // Note that this only returns a complete result when called *during* the call // to `transaction.end()`. Transaction.prototype._encode = function () { if (!this.ended) { this._agent.logger.error('cannot encode un-ended transaction: %o', { trans: this.id, parent: this.parentId, trace: this.traceId, }); return null; } return this.toJSON(); }; Transaction.prototype.setDefaultName = function (name) { this._agent.logger.debug('setting default transaction name: %s %o', name, { trans: this.id, parent: this.parentId, trace: this.traceId, }); this._defaultName = name; }; Transaction.prototype.setDefaultNameFromRequest = function () { var req = this.req; var path = getPathFromRequest( req, false, this._agent._conf.usePathAsTransactionName, ); if (!path) { this._agent.logger.debug('could not extract route name from request %o', { url: req.url, type: typeof path, null: path === null, // because typeof null === 'object' route: !!req.route, regex: req.route ? !!req.route.regexp : false, mountstack: req[symbols.expressMountStack] ? req[symbols.expressMountStack].length : false, trans: this.id, parent: this.parentId, trace: this.traceId, }); path = 'unknown route'; } this.setDefaultName(req.method + ' ' + path); }; Transaction.prototype.ensureParentId = function () { return this._context.ensureParentId(); }; Transaction.prototype.end = function (result, endTime) { if (this.ended) { this._agent.logger.debug( 'tried to call transaction.end() on already ended transaction %o', { trans: this.id, parent: this.parentId, trace: this.traceId }, ); return; } if (result !== undefined && result !== null) { this.result = result; } if (!this._defaultName && this.req) this.setDefaultNameFromRequest(); this._timer.end(endTime); this._duration = this._timer.duration; this._captureBreakdown(this); this.ended = true; this._agent._instrumentation.addEndedTransaction(this); this._agent.logger.debug( { trans: this.id, name: this.name, parent: this.parentId, trace: this.traceId, type: this.type, result: this.result, duration: this._duration, }, 'ended transaction', ); // Reduce this transaction's memory usage by dropping references except to // fields required to support `interface Transaction`. // Transaction fields: this._customName = this.name; // Short-circuit the `name` getter. this._defaultName = ''; this.req = null; this.res = null; this._user = null; this._custom = null; this._breakdownTimings = null; this._faas = undefined; this._service = undefined; this._message = undefined; this._cloud = undefined; // GenericSpan fields: // - Cannot drop `this._context` because it is used for `traceparent`, `ids`, // and `.sampled` (when capturing breakdown metrics for child spans). this._timer = null; this._labels = null; }; Transaction.prototype.setOutcome = function (outcome) { if (!this._isValidOutcome(outcome)) { this._agent.logger.trace( 'Unknown outcome [%s] seen in Transaction.setOutcome, ignoring', outcome, ); return; } if (this.ended) { this._agent.logger.debug( 'tried to call Transaction.setOutcome() on already ended transaction %o', { trans: this.id, parent: this.parentId, trace: this.traceId }, ); return; } this._freezeOutcome(); this.outcome = outcome; }; Transaction.prototype._setOutcomeFromHttpStatusCode = function (statusCode) { // if an outcome's been set from the API we // honor its value if (this._isOutcomeFrozen) { return; } if (statusCode >= 500) { this.outcome = constants.OUTCOME_FAILURE; } else { this.outcome = constants.OUTCOME_SUCCESS; } }; Transaction.prototype._captureBreakdown = function (span) { if (this.ended) { return; } const agent = this._agent; const metrics = agent._metrics; const conf = agent._conf; // Avoid unneeded breakdown metrics processing if only propagating trace context. if (conf.contextPropagationOnly) { return; } // Record span data if (this.sampled && conf.breakdownMetrics) { captureBreakdown( this, { transaction: transactionBreakdownDetails(this), span: spanBreakdownDetails(span), }, span._timer.selfTime, ); } // Record transaction data if (span instanceof Transaction) { for (const { labels, time, count } of this._breakdownTimings.values()) { const flattenedLabels = flattenBreakdown(labels); metrics.incrementCounter('span.self_time.count', flattenedLabels, count); metrics.incrementCounter('span.self_time.sum.us', flattenedLabels, time); } } }; Transaction.prototype.captureDroppedSpan = function (span) { return this._droppedSpansStats.captureDroppedSpan(span); }; function transactionBreakdownDetails({ name, type } = {}) { return { name, type, }; } function spanBreakdownDetails(span) { if (span instanceof Transaction) { return { type: 'app', }; } const { type, subtype } = span; return { type, subtype, }; } function captureBreakdown(transaction, labels, time) { const build = () => ({ labels, count: 0, time: 0 }); const counter = transaction._breakdownTimings.ensure(labels, build); counter.time += time; counter.count++; } function flattenBreakdown(source, target = {}, prefix = '') { for (const [key, value] of Object.entries(source)) { if (typeof value === 'undefined' || value === null) continue; if (typeof value === 'object') { flattenBreakdown(value, target, `${prefix}${key}::`); } else { target[`${prefix}${key}`] = value; } } return target; }