UNPKG

elastic-apm-node

Version:

The official Elastic APM agent for Node.js

622 lines (553 loc) 18.4 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 { executionAsyncId } = require('async_hooks'); const { URL } = require('url'); var util = require('util'); var Value = require('async-value-promise'); const constants = require('../constants'); var GenericSpan = require('./generic-span'); var { SpanIds } = require('./ids'); const { gatherStackTrace } = require('../stacktraces'); const TEST = process.env.ELASTIC_APM_TEST; module.exports = Span; util.inherits(Span, GenericSpan); // new Span(transaction) // new Span(transaction, name?, opts?) // new Span(transaction, name?, type?, opts?) // new Span(transaction, name?, type?, subtype?, opts?) // new Span(transaction, name?, type?, subtype?, action?, opts?) function Span(transaction, ...args) { const opts = typeof args[args.length - 1] === 'object' ? args.pop() || {} : {}; const [name, ...tsaArgs] = args; // "tsa" === Type, Subtype, Action if (opts.timer) { // Before 4.x this option could be passed in. It was never publicly documented. delete opts.timer; } if (!opts.childOf) { const defaultChildOf = transaction._agent._instrumentation.currSpan() || transaction; opts.childOf = defaultChildOf; opts.timer = defaultChildOf._timer; } else if (opts.childOf._timer) { opts.timer = opts.childOf._timer; } this._exitSpan = !!opts.exitSpan; this.discardable = this._exitSpan; delete opts.exitSpan; this.type = null; this.subtype = null; this.action = null; this.setType(...tsaArgs); GenericSpan.call(this, transaction._agent, opts); this._db = null; this._http = null; this._destination = null; this._serviceTarget = null; this._excludeServiceTarget = false; this._message = null; this._stackObj = null; this._capturedStackTrace = null; this.sync = true; this._startXid = executionAsyncId(); this.transaction = transaction; this.name = name || 'unnamed'; if (this._agent._conf.spanStackTraceMinDuration >= 0) { this._recordStackTrace(); } this._agent.logger.debug('start span %o', { span: this.id, parent: this.parentId, trace: this.traceId, name: this.name, type: this.type, subtype: this.subtype, action: this.action, }); } Object.defineProperty(Span.prototype, 'ids', { get() { return this._ids === null ? (this._ids = new SpanIds(this)) : this._ids; }, }); Span.prototype.setType = function (type = null, subtype = null, action = null) { this.type = type || constants.DEFAULT_SPAN_TYPE; this.subtype = subtype; this.action = action; }; /* * A string representation of the span to help with internal debugging. This * is not a promised interface. */ Span.prototype.toString = function () { return `Span(${this.id}, '${this.name}'${this.ended ? ', ended' : ''})`; }; Span.prototype.customStackTrace = function (stackObj) { this._agent.logger.debug('applying custom stack trace to span %o', { span: this.id, parent: this.parentId, trace: this.traceId, }); this._recordStackTrace(stackObj); }; Span.prototype.end = function (endTime) { if (this.ended) { this._agent.logger.debug( 'tried to call span.end() on already ended span %o', { span: this.id, parent: this.parentId, trace: this.traceId, name: this.name, type: this.type, subtype: this.subtype, action: this.action, }, ); return; } this._timer.end(endTime); this._endTimestamp = this._timer.endTimestamp; this._duration = this._timer.duration; if (executionAsyncId() !== this._startXid) { this.sync = false; } this._setOutcomeFromSpanEnd(); this._inferServiceTargetAndDestinationService(); this.ended = true; this._agent.logger.debug('ended span %o', { span: this.id, parent: this.parentId, trace: this.traceId, name: this.name, type: this.type, subtype: this.subtype, action: this.action, }); if ( this._capturedStackTrace !== null && this._agent._conf.spanStackTraceMinDuration >= 0 && this._duration / 1000 >= this._agent._conf.spanStackTraceMinDuration ) { // NOTE: This uses a promise-like thing and not a *real* promise // because passing error stacks into a promise context makes it // uncollectable by the garbage collector. this._stackObj = new Value(); var self = this; gatherStackTrace( this._agent.logger, this._capturedStackTrace, this._agent._conf.sourceLinesSpanAppFrames, this._agent._conf.sourceLinesSpanLibraryFrames, TEST ? null : filterCallSite, function (_err, stacktrace) { // _err from gatherStackTrace is always null. self._stackObj.resolve(stacktrace); }, ); } this._agent._instrumentation.addEndedSpan(this); this.transaction._captureBreakdown(this); }; Span.prototype._inferServiceTargetAndDestinationService = function () { // `context.service.target.*` must be set for exit spans. There is a public // `span.setServiceTarget(...)` for users to manually set this, but typically // it is inferred from other span fields here. // https://github.com/elastic/apm/blob/main/specs/agents/tracing-spans-service-target.md#field-values if (this._excludeServiceTarget || !this._exitSpan) { this._serviceTarget = null; } else { if (!this._serviceTarget) { this._serviceTarget = {}; } if (!('type' in this._serviceTarget)) { this._serviceTarget.type = this.subtype || this.type || constants.DEFAULT_SPAN_TYPE; } if (!('name' in this._serviceTarget)) { if (this._db) { if (this._db.instance) { this._serviceTarget.name = this._db.instance; } } else if (this._message) { if (this._message.queue && this._message.queue.name) { this._serviceTarget.name = this._message.queue.name; } } else if (this._http && this._http.url) { try { const defaultPorts = { 'https:': '443', 'http:': '80' }; const url = new URL(this._http.url); if (!url.port && defaultPorts[url.protocol]) { this._serviceTarget.name = `${url.host}:${ defaultPorts[url.protocol] }`; } else { this._serviceTarget.name = url.host; } } catch (invalidUrlErr) { this._agent.logger.debug( 'cannot set "service.target.name": %s (ignoring)', invalidUrlErr, ); } } } // `destination.service.*` is deprecated, but still required for older // APM servers. if (!this._destination) { this._destination = {}; } if (!this._destination.service) { this._destination.service = {}; } // - `destination.service.{type,name}` could be skipped if the upstream APM server is known to be >=7.14. this._destination.service.type = ''; this._destination.service.name = ''; // - Infer the now deprecated `context.destination.service.resource` value. // https://github.com/elastic/apm/blob/main/specs/agents/tracing-spans-destination.md#destination-resource if (!this._destination.service.resource) { if (!this._serviceTarget.name) { // If we only have `.type`, then use that. this._destination.service.resource = this._serviceTarget.type; } else if (!this._serviceTarget.type) { // If we only have `.name`, then use that. this._destination.service.resource = this._serviceTarget.name; } else if (this.type === 'external') { // Typically the "resource" value would now be "$type/$name", e.g. // "mysql/customers". However, we want a special case for some spans (to // have the same value as historically?) where we do NOT use the // "$type/" prefix. One example is HTTP spans. Another is gRPC spans // and, I infer from otel_bridge.feature, any OTel "rpc.system"-usage // spans as well // (https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/rpc/). // Options to infer this from other span data: // - Use the presence of "http" context, but without "db" and "message" // context. This is a little brittle, and requires more complete OTel // bridge compatibility mapping of OTel attributes than is currently // being done. // } else if (!this._db && !this._message && this._http && this._http.url) { // - Use `span.subtype`: "http", "grpc", ... add others if/when they are // used. // - Use `span.type === "external"`. This, at least currently corresponds. // Let's use this one. this._destination.service.resource = this._serviceTarget.name; } else { this._destination.service.resource = `${this._serviceTarget.type}/${this._serviceTarget.name}`; } } } }; Span.prototype.setDbContext = function (context) { if (!context) return; this._db = Object.assign(this._db || {}, context); }; Span.prototype.setHttpContext = function (context) { if (!context) return; this._http = Object.assign(this._http || {}, context); }; /** * This is deprecated and will be dropped in a future version. This was always * an internal method, but possibly used by enterprising users of manual * instrumentation. * * @deprecated Users should use the public `setServiceTarget()`. * Internal APM agent code should use `_setDestinationContext()`. */ Span.prototype.setDestinationContext = function (destCtx) { process.emitWarning( '<span>.setDestinationContext() was never a public API and will be removed, use <span>.setServiceTarget().', 'DeprecationWarning', 'ELASTIC_APM_SET_DESTINATION_CONTEXT', ); if (destCtx.service && destCtx.service.resource) { this.setServiceTarget('', destCtx.service.resource); } const destCtxWithoutService = Object.assign({}, destCtx); delete destCtxWithoutService.service; this._setDestinationContext(destCtxWithoutService); }; /** * The internal method for setting "destination" context. * * "destination.service.resource" should only ever be included for special * cases. It is typically inferred from other fields via a general algorithm. */ Span.prototype._setDestinationContext = function (destCtx) { this._destination = Object.assign(this._destination || {}, destCtx); }; /** * Manually set the `service.target.type` and `service.target.name` fields that * are used for service maps and the identification of downstream services. The * values are only used for "exit" spans -- spans representing outgoing * communication, marked with `exitSpan: true` at span creation. * * If false-y values (e.g. `null`) are given for both `type` and `name`, then * `service.target` will explicitly be excluded from this span. This may impact * Service Maps and other Kibana APM app reporting for this service. * * If this method is not called, values are inferred from other span fields per * https://github.com/elastic/apm/blob/main/specs/agents/tracing-spans-service-target.md#field-values * * `service.target.*` fields are ignored for APM Server before v8.3. * * @param {string | null} type - service target type, usually same value as * `span.subtype` * @param {string | null} name - service target name: value depends on type, * for databases it's usually the database name */ Span.prototype.setServiceTarget = function (type, name) { if (!type && !name) { this._excludeServiceTarget = true; this._serviceTarget = null; return; } if (typeof type === 'string') { this._excludeServiceTarget = false; if (this._serviceTarget === null) { this._serviceTarget = { type }; } else { this._serviceTarget.type = type; } } else if (type != null) { this._agent.logger.warn( '"type" argument to Span#setServiceTarget must be of type "string", got type "%s": ignoring', typeof type, ); } if (typeof name === 'string') { this._excludeServiceTarget = false; if (this._serviceTarget === null) { this._serviceTarget = { name }; } else { this._serviceTarget.name = name; } } else if (name != null) { this._agent.logger.warn( '"name" argument to Span#setServiceTarget must be of type "string", got type "%s": ignoring', typeof name, ); } }; Span.prototype.setMessageContext = function (context) { this._message = Object.assign(this._message || {}, context); }; Span.prototype.setOutcome = function (outcome) { if (!this._isValidOutcome(outcome)) { this._agent.logger.trace( 'Unknown outcome [%s] seen in Span.setOutcome, ignoring', outcome, ); return; } if (this.ended) { this._agent.logger.debug( 'tried to call Span.setOutcome() on already ended span %o', { span: this.id, parent: this.parentId, trace: this.traceId, name: this.name, type: this.type, subtype: this.subtype, action: this.action, }, ); return; } this._freezeOutcome(); this._setOutcome(outcome); }; Span.prototype._setOutcomeFromErrorCapture = function (outcome) { if (this._isOutcomeFrozen) { return; } this._setOutcome(outcome); }; Span.prototype._setOutcomeFromHttpStatusCode = function (statusCode) { if (this._isOutcomeFrozen) { return; } /** * The statusCode could be undefined for example if, * the request is aborted before socket, in that case * we keep the default 'unknown' value. */ if (typeof statusCode !== 'undefined') { if (statusCode >= 400) { this._setOutcome(constants.OUTCOME_FAILURE); } else { this._setOutcome(constants.OUTCOME_SUCCESS); } } this._freezeOutcome(); }; Span.prototype._setOutcomeFromSpanEnd = function () { if (this.outcome === constants.OUTCOME_UNKNOWN && !this._isOutcomeFrozen) { this._setOutcome(constants.OUTCOME_SUCCESS); } }; /** * Central setting for outcome * * Enables "when outcome does X, Y should also happen" behaviors */ Span.prototype._setOutcome = function (outcome) { this.outcome = outcome; if (outcome !== constants.OUTCOME_SUCCESS) { this.discardable = false; } }; Span.prototype._recordStackTrace = function (obj) { if (!obj) { obj = {}; Error.captureStackTrace(obj, Span); } this._capturedStackTrace = obj; }; Span.prototype._encode = function (cb) { var self = this; if (!this.ended) { return cb(new Error('cannot encode un-ended span')); } const payload = { id: self.id, transaction_id: self.transaction.id, parent_id: self.parentId, trace_id: self.traceId, name: self.name, type: self.type || constants.DEFAULT_SPAN_TYPE, subtype: self.subtype, action: self.action, timestamp: self.timestamp, duration: self._duration, context: undefined, stacktrace: undefined, sync: self.sync, outcome: self.outcome, }; // if a valid sample rate is set (truthy or zero), set the property const sampleRate = self.sampleRate; if (sampleRate !== null) { payload.sample_rate = sampleRate; } let haveContext = false; const context = {}; if (self._serviceTarget) { context.service = { target: self._serviceTarget }; haveContext = true; } if (self._destination) { context.destination = self._destination; haveContext = true; } if (self._db) { context.db = self._db; haveContext = true; } if (self._message) { context.message = self._message; haveContext = true; } if (self._http) { context.http = self._http; haveContext = true; } if (self._labels) { context.tags = self._labels; haveContext = true; } if (haveContext) { payload.context = context; } if (self.isComposite()) { payload.composite = self._compression.encode(); payload.timestamp = self._compression.timestamp; payload.duration = self._compression.duration; } this._serializeOTel(payload); if (this._links.length > 0) { payload.links = this._links; } if (this._stackObj) { this._stackObj.then( (value) => done(null, value), (error) => done(error), ); } else { process.nextTick(done); } function done(err, frames) { if (err) { self._agent.logger.debug('could not capture stack trace for span %o', { span: self.id, parent: self.parentId, trace: self.traceId, name: self.name, type: self.type, subtype: self.subtype, action: self.action, err: err.message, }); } else if (frames) { payload.stacktrace = frames; } // Reduce this span's memory usage by dropping references once they're // no longer needed. We also keep fields required to support // `interface Span`. // Span fields: self._db = null; self._http = null; self._message = null; self._capturedStackTrace = null; // GenericSpan fields: // - Cannot drop `this._context` because it is used for traceparent and ids. self._timer = null; self._labels = null; cb(null, payload); } }; Span.prototype.isCompressionEligible = function () { if (!this.getParentSpan()) { return false; } if ( this.outcome !== constants.OUTCOME_UNKNOWN && this.outcome !== constants.OUTCOME_SUCCESS ) { return false; } if (!this._exitSpan) { return false; } if (this._hasPropagatedTraceContext) { return false; } return true; }; Span.prototype.tryToCompress = function (spanToCompress) { return this._compression.tryToCompress(this, spanToCompress); }; Span.prototype.isRecorded = function () { return this._context.isRecorded(); }; Span.prototype.setRecorded = function (value) { return this._context.setRecorded(value); }; Span.prototype.propagateTraceContextHeaders = function (carrier, setter) { this.discardable = false; return GenericSpan.prototype.propagateTraceContextHeaders.call( this, carrier, setter, ); }; function filterCallSite(callsite) { var filename = callsite.getFileName(); return filename ? filename.indexOf('/node_modules/elastic-apm-node/') === -1 : true; }