elastic-apm-node
Version:
The official Elastic APM agent for Node.js
523 lines (473 loc) • 15.4 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.
*/
;
// Handling for translating `<Error instance>.stack` to the
// `{error.exception,span}.stacktrace` object passed to APM server.
// https://github.com/elastic/apm-server/blob/master/docs/spec/v2/error.json#L633
//
// A lot of this derived from https://github.com/watson/stackman but was
// moved internal to allow breaking compat for perf work.
var fsPromises = require('fs/promises');
var path = require('path');
var { promisify } = require('util');
// avoid loading error-callsites until needed to avoid
// Error.prepareStackTrace side-effects
// https://github.com/elastic/apm-agent-nodejs/issues/2833
let errorCallsites;
function initStackTraceCollection() {
errorCallsites = require('error-callsites');
}
const errorStackParser = require('error-stack-parser');
const loadSourceMap = require('./load-source-map');
const { LRUCache } = require('lru-cache');
const fileCache = new LRUCache({
max: 500, // fileCacheMax
fetchMethod: async (file, _staleValue, { signal }) => {
// Note: We *could* pass `signal` to fsPromies.readFile options.
const data = await fsPromises.readFile(file, { encoding: 'utf8' });
if (signal.aborted) {
return;
}
return data.split(/\r?\n/);
},
});
const sourceMapCache = new LRUCache({
max: 500, // sourceMapCacheMax
fetchMethod: async (filename, _staleValue, { signal }) => {
if (!filename) {
return null;
}
const sourcemap = await promisify(loadSourceMap)(filename);
if (signal.aborted) {
return;
}
// Translate sourcemap===undefined to null, because lru-cache
// treats `undefined` as a cache miss. Without this there is no
// caching for files that have no sourcemap (the common case).
return sourcemap || null;
},
dispose: function (sourcemap, _filename, _reason) {
if (sourcemap) {
sourcemap.destroy();
}
},
});
const frameCache = new LRUCache({ max: 1000 });
const frameCacheStats = {
hits: 0,
misses: 0,
};
let lastCwd = process.cwd();
// ---- internal support functions
// process.cwd() can throw EMFILE if hitting the `ulimit -Hn`. A process's
// cwd should not change regularly, so falling back to the previous one is fine.
// At worst the stacktrace frame.filename values will be relative to an old
// directory.
function getCwd(log) {
let cwd;
try {
cwd = process.cwd();
} catch (ex) {
log.trace(ex, 'error getting cwd: fallback back to %s', lastCwd);
return lastCwd;
}
lastCwd = cwd;
return cwd;
}
// If gathering a stacktrace from the structured CallSites fails, this is
// used as a fallback: parsing the `err.stack` *string*.
function stackTraceFromErrStackString(log, err) {
const stacktrace = [];
// graphqljs adds the `originalError` property which represents the original
// error thrown within the resolver
err = err.originalError || err;
if (err.stack === null) {
return [];
}
// frames is an array of StackFrame (https://github.com/stacktracejs/stackframe).
let frames = null;
try {
frames = errorStackParser.parse(err);
} catch (parseErr) {
log.debug('could not parse err.stack string: %s', parseErr);
}
if (frames) {
const cwd = getCwd(log);
for (var i = 0; i < frames.length; i++) {
const frame = frames[i];
const filename = frame.getFileName() || '';
stacktrace.push({
filename: getRelativeFileName(filename, cwd),
function: frame.getFunctionName(),
lineno: frame.getLineNumber(),
library_frame: !isStackFrameApp(frame),
abs_path: filename,
});
}
}
return stacktrace;
}
const NODE_MODULES_PATH_SEG = 'node_modules' + path.sep;
// Return true iff the given StackFrame
// (https://github.com/stacktracejs/stackframe) is an application frame.
function isStackFrameApp(stackframe) {
if (isStackFrameNode(stackframe)) {
return false;
} else {
const fileName = stackframe.getFileName();
if (!fileName) {
return true;
} else if (fileName.indexOf(NODE_MODULES_PATH_SEG) === -1) {
return true;
} else {
return false;
}
}
}
// Return true iff the given StackFrame
// (https://github.com/stacktracejs/stackframe) is a Node frame.
function isStackFrameNode(stackframe) {
if (stackframe.isNative) {
return true;
} else {
const fileName = stackframe.getFileName();
if (!fileName) {
return true;
} else {
return !path.isAbsolute(fileName) && fileName[0] !== '.';
}
}
}
function isCallSiteApp(callsite) {
if (isCallSiteNode(callsite)) {
return false;
} else {
const fileName = callsite.getFileName();
if (!fileName) {
return true;
} else if (fileName.indexOf(NODE_MODULES_PATH_SEG) === -1) {
return true;
} else {
return false;
}
}
}
function isCallSiteNode(callsite) {
if (callsite.isNative()) {
return true;
} else {
const fileName = callsite.getFileName();
if (!fileName) {
return true;
} else {
return !path.isAbsolute(fileName) && fileName[0] !== '.';
}
}
}
function isValidCallsites(callsites) {
return (
Array.isArray(callsites) &&
callsites.length > 0 &&
typeof callsites[0] === 'object' &&
typeof callsites[0].getFileName === 'function'
);
}
// From stackman getTypeNameSafely().
function getCallSiteTypeNameSafely(callsite) {
try {
return callsite.getTypeName();
} catch (e) {
// This seems to happen sometimes when using 'use strict',
// stemming from `getTypeName`.
// [TypeError: Cannot read property 'constructor' of undefined]
return null;
}
}
// From stackman getFunctionNameSanitized().
function getCallSiteFunctionNameSanitized(callsite) {
var fnName = callsite.getFunctionName();
if (fnName) return fnName;
var typeName = getCallSiteTypeNameSafely(callsite);
if (typeName)
return typeName + '.' + (callsite.getMethodName() || '<anonymous>');
return '<anonymous>';
}
function addSourceContextToFrame(frame, lines, lineNum, n) {
var index = lineNum - 1; // lines 1-based -> index 0-based
var nBefore = Math.ceil((n - 1) / 2);
var nAfter = Math.floor((n - 1) / 2);
frame.pre_context = lines.slice(Math.max(0, index - nBefore), index);
frame.context_line = lines[index];
frame.post_context = lines.slice(index + 1, index + 1 + nAfter);
}
// Get the path of `filename` relative to `relTo` -- which should be a directory
// path *without* a trailing path separator.
function getRelativeFileName(filename, relTo) {
if (filename.startsWith(relTo + path.sep)) {
return filename.slice(relTo.length + 1);
} else {
return filename;
}
}
async function getSourceMapConsumer(callsite) {
if (isCallSiteNode(callsite)) {
return null;
} else {
var filename = callsite.getFileName();
if (!filename) {
return null;
} else {
return sourceMapCache.fetch(filename);
}
}
}
// Put together an APM stacktrace frame object:
// {
// "filename": "...",
// "lineno": 65,
// "function": "...",
// "library_frame": <bool>,
// "abs_path": "...",
// "pre_context": [ ... ],
// "context_line": "...",
// "post_context": [ ... ],
// },
// from a v8 CallSite object
// (https://v8.dev/docs/stack-trace-api#customizing-stack-traces).
//
// This asynchronously returns the call frame object. Getting source context
// is best-effort, so the returned Promise never rejects.
async function frameFromCallSite(
log,
callsite,
cwd,
sourceLinesAppFrames,
sourceLinesLibraryFrames,
) {
// getFileName can return null, e.g. with a `at Generator.next (<anonymous>)` frame.
const filename = callsite.getFileName() || '';
const lineno = callsite.getLineNumber();
const colno = callsite.getColumnNumber();
// Caching
const cacheKey = [
filename,
lineno,
colno,
sourceLinesAppFrames,
sourceLinesLibraryFrames,
].join(':');
const cachedFrame = frameCache.get(cacheKey);
if (cachedFrame !== undefined) {
frameCacheStats.hits++;
// Guard against later JSON serialization mistakenly changing duplicate
// frames in a stacktrace (a totally legal thing) into '[Circular]' as a
// guard against serializing an object with circular references.
const clonedFrame = Object.assign({}, cachedFrame);
if (clonedFrame.pre_context) {
clonedFrame.pre_context = clonedFrame.pre_context.slice();
}
if (clonedFrame.post_context) {
clonedFrame.post_context = clonedFrame.post_context.slice();
}
return clonedFrame;
}
function cacheIt(frame) {
frameCacheStats.misses++;
frameCache.set(cacheKey, frame);
}
let mappedFilename = null;
let absMappedFilename = null;
let mappedLineno = null;
// If the file has a sourcemap, we use that for: filename, lineno, source
// context.
let sourceMapConsumer = null;
try {
sourceMapConsumer = await getSourceMapConsumer(callsite);
} catch (sourceMapErr) {
log.debug(
{ filename, err: sourceMapErr },
'could not process file source map',
);
}
if (sourceMapConsumer) {
let pos;
try {
pos = sourceMapConsumer.originalPositionFor({
line: lineno,
column: colno,
});
} catch (posErr) {
log.debug(
{ filename, line: lineno, err: posErr },
'could not get position from sourcemap',
);
pos = {
source: null,
line: null,
column: null,
name: null,
};
}
if (pos.source !== null) {
mappedFilename = pos.source;
absMappedFilename = path.resolve(path.dirname(filename), mappedFilename);
}
if (pos.line !== null) {
mappedLineno = pos.line;
}
// TODO: Is `pos.name` relevant for `frame.function` if minifying?
}
const isApp = isCallSiteApp(callsite);
const frame = {
filename: getRelativeFileName(absMappedFilename || filename, cwd),
lineno: mappedLineno || lineno,
function: getCallSiteFunctionNameSanitized(callsite),
library_frame: !isApp,
};
if (!Number.isFinite(frame.lineno)) {
// An early comment in stackman suggested this is "sometimes not" an int.
frame.lineno = 0;
}
if (filename) {
frame.abs_path = absMappedFilename || filename;
}
// Finish early if we do not need to collect source lines of context.
var linesOfContext = isApp ? sourceLinesAppFrames : sourceLinesLibraryFrames;
if (linesOfContext === 0 || !filename || isCallSiteNode(callsite)) {
cacheIt(frame);
return frame;
}
// Attempt to use "sourcesContent" in a sourcemap, if available.
if (sourceMapConsumer && mappedFilename && mappedLineno) {
// To use `sourceMapConsumer.sourceContentFor` we need the filename as
// it is in the "sources" field of the source map. `mappedFilename`,
// from `sourceMapConsume.originalPositionFor` above, was made relative
// to "sourceRoot" -- sourceFilename undoes that.
const sourceFilename = sourceMapConsumer.sourceRoot
? path.relative(sourceMapConsumer.sourceRoot, mappedFilename)
: mappedFilename;
var source = sourceMapConsumer.sourceContentFor(sourceFilename, true);
log.trace(
{
sourceRoot: sourceMapConsumer.sourceRoot,
mappedFilename,
sourceFilename,
haveSourceContent: !!source,
},
'sourcemap sourceContent lookup',
);
if (source) {
addSourceContextToFrame(
frame,
source.split(/\r?\n/g),
mappedLineno,
linesOfContext,
);
cacheIt(frame);
return frame;
}
}
// If the file looks like it minimized (as we didn't have a source-map in
// the processing above), then skip adding source context because it
// is mostly useless and the typically 500-char lines result in over-large
// APM error objects.
if (filename.endsWith('.min.js')) {
cacheIt(frame);
return frame;
}
// Otherwise load the file from disk, if available.
let lines;
try {
lines = await fileCache.fetch(frame.abs_path);
} catch (fileErr) {
log.debug(
{ filename: frame.abs_path, err: fileErr },
'could not read file for source context',
);
}
if (lines) {
addSourceContextToFrame(frame, lines, frame.lineno, linesOfContext);
}
cacheIt(frame);
return frame;
}
// ---- exports
// Gather an APM `stacktrace` object from the given `err`.
// This stacktrace object is used for `error.exception.stacktrace`,
// `error.log.stacktrace`, or `span.stacktrace`.
//
// This is a best effort, so it never fails. It will log.debug any parsing
// failures.
//
// @param {Logger} log
// @param {Error|Object} err - Typically an Error instance, but can also be a
// plain object on which `Error.captureStackTrace(...)` has been called.
// @param {Integer} sourceLinesAppFrames - The number of source lines of
// context to include for frames in application code.
// @param {Integer} sourceLinesLibraryFrames - The number of source lines of
// context to include for frames in library code (i.e. under node_modules/)
// @param {Function} filterCallSite - Optional. A function to filter the
// CallSites to include in the stacktrace.
// @param {Function} cb - `cb(null, stacktrace)`
function gatherStackTrace(
log,
err,
sourceLinesAppFrames,
sourceLinesLibraryFrames,
filterCallSite,
cb,
) {
// errorCallsites returns an array of v8 CallSite objects.
// https://v8.dev/docs/stack-trace-api#customizing-stack-traces
let callsites = errorCallsites ? errorCallsites(err) : null;
if (!isValidCallsites(callsites)) {
// When can this happen? Another competing Error.prepareStackTrace breaking
// error-callsites? Also initStackTraceCollection not having been called.
log.debug('could not get valid callsites from error "%s"', err);
// Fallback to parsing the err.stack string.
setImmediate(cb, null, stackTraceFromErrStackString(log, err));
return;
}
if (filterCallSite) {
callsites = callsites.filter(filterCallSite);
if (callsites.length === 0) {
// Note: I'm not sure using the fallback here is the intended behaviour,
// because it defeats the purpose of a `filterCallSite` that filters out
// all callsites. In an earlier implementation this fallback handling was
// lumped in with the `!isValidCallsites(...)` case. `filterCallSite` is
// only used for *span* stacktraces (off by default), so the priority here
// is low.
setImmediate(cb, null, stackTraceFromErrStackString(log, err));
return;
}
}
const cwd = getCwd(log);
const promises = callsites.map((callsite) =>
frameFromCallSite(
log,
callsite,
cwd,
sourceLinesAppFrames,
sourceLinesLibraryFrames,
),
);
Promise.all(promises)
.then((stacktrace) => cb(null, stacktrace))
.catch((frameErr) => {
log.debug(
{ err, frameErr },
'error getting a stack frame from one or more callsites',
);
cb(null, stackTraceFromErrStackString(log, err)); // fallback
});
}
module.exports = {
gatherStackTrace,
frameCacheStats,
initStackTraceCollection,
// Exported for testing only.
stackTraceFromErrStackString,
};