UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

177 lines (157 loc) 6.81 kB
'use strict' const { join, dirname } = require('path') const { normalize } = require('source-map/lib/util') const { loadSourceMapSync } = require('./source-maps') const session = require('./session') const log = require('./log') const WINDOWS_DRIVE_LETTER_REGEX = /[a-zA-Z]/ const loadedScripts = [] const scriptUrls = new Map() let reEvaluateProbesTimer = null module.exports = { locationToBreakpoint: new Map(), breakpointToProbes: new Map(), probeToLocation: new Map(), _loadedScripts: loadedScripts, // Only exposed for testing _scriptUrls: scriptUrls, // Only exposed for testing /** * Find the script to inspect based on a partial or absolute path. Handles both Windows and POSIX paths. * * @param {string} path - Partial or absolute path to match against loaded scripts * @returns {Object | null} - Object containing `url`, `scriptId`, `sourceMapURL`, and `source` - or null if no match */ findScriptFromPartialPath (path) { if (!path) return null // This shouldn't happen, but better safe than sorry path = path.toLowerCase() const bestMatch = { url: null, scriptId: null, sourceMapURL: null, source: null } let maxMatchLength = -1 for (const { url, sourceUrl, scriptId, sourceMapURL, source } of loadedScripts) { let i = url.length - 1 let j = path.length - 1 let matchLength = 0 let lastBoundaryPos = -1 let atBoundary = false // Compare characters from the end while (i >= 0 && j >= 0) { const urlChar = url[i].toLowerCase() const pathChar = path[j] // Check if both characters is a path boundary const isBoundary = (urlChar === '/' || urlChar === '\\') && (pathChar === '/' || pathChar === '\\' || (j === 1 && pathChar === ':' && WINDOWS_DRIVE_LETTER_REGEX.test(path[0]))) // If both are boundaries, or if characters match exactly if (isBoundary || urlChar === pathChar) { if (isBoundary) { atBoundary = true lastBoundaryPos = matchLength } else { atBoundary = false } matchLength++ i-- j-- } else { break } } // If we've matched the entire path pattern, ensure it starts at a path boundary if (j === -1) { if (i >= 0) { // If there are more characters in the URL, the next one must be a slash if (url[i] === '/' || url[i] === '\\') { atBoundary = true lastBoundaryPos = matchLength } } else { atBoundary = true lastBoundaryPos = matchLength } } // If we found a valid match and it's better than our previous best if (atBoundary && ( lastBoundaryPos > maxMatchLength || (lastBoundaryPos === maxMatchLength && url.length < bestMatch.url.length) // Prefer shorter paths )) { maxMatchLength = lastBoundaryPos bestMatch.url = sourceUrl || url bestMatch.scriptId = scriptId bestMatch.sourceMapURL = sourceMapURL bestMatch.source = source } } return maxMatchLength === -1 ? null : bestMatch }, getStackFromCallFrames (callFrames) { return callFrames.map((frame) => { // TODO: Possible race condition: If the breakpoint is in the process of being removed, and this is the last // breakpoint, it will also stop the debugging session, which in turn will clear the state, which means clearing // the `scriptUrls` map. That might result in this the `scriptUrls.get` call above returning `undefined`, which // will throw when `startsWith` is called on it. let fileName = scriptUrls.get(frame.location.scriptId) if (fileName.startsWith('file://')) fileName = fileName.slice(7) // TODO: This might not be required return { fileName, function: frame.functionName, lineNumber: frame.location.lineNumber + 1, // Beware! lineNumber is zero-indexed columnNumber: frame.location.columnNumber + 1 // Beware! columnNumber is zero-indexed } }) }, // The maps locationToBreakpoint, breakpointToProbes, and probeToLocation are always updated when breakpoints are // removed. Therefore they do not need to get manually cleared. Only the state internal to this file needs to be // cleared. clearState () { loadedScripts.length = 0 scriptUrls.clear() } } // Known params.url protocols: // - `node:` - Ignored, as we don't want to instrument Node.js internals // - `wasm:` - Ignored, as we don't support instrumenting WebAssembly // - `file:` - Regular on-disk file // Unknown params.url values: // - `structured-stack` - Not sure what this is, but should just be ignored // - `` - Not sure what this is, but should just be ignored session.on('Debugger.scriptParsed', ({ params }) => { scriptUrls.set(params.scriptId, params.url) if (params.url.startsWith('file:')) { if (params.sourceMapURL) { const dir = dirname(new URL(params.url).pathname) let sources try { sources = loadSourceMapSync(dir, params.sourceMapURL).sources } catch (err) { if (typeof params.sourceMapURL === 'string' && params.sourceMapURL.startsWith('data:')) { log.error('[debugger:devtools_client] could not load inline source map for "%s"', params.url, err) } else { log.error('[debugger:devtools_client] could not load source map "%s" from "%s" for "%s"', params.sourceMapURL, dir, params.url, err) } return } for (const source of sources) { // TODO: Take source map `sourceRoot` into account? loadedScripts.push({ ...params, sourceUrl: params.url, url: new URL(join(dir, source), 'file:').href, // The source url provided by V8 unfortunately doesn't always match the source url used internally in the // `source-map` dependency. Both read the same source maps, but the `source-map` dependency iterates over all // the `sources` and normalize them using an internal `normalize` function. If these two strings don't match, // the `source-map` dependency will not be able to find the generated position. Below we use the same // internal `normalize` function, to ensure compatibility. // TODO: Consider swapping out the `source-map` dependency for something better so we don't have to do this. source: normalize(source) }) } } else { loadedScripts.push(params) } if (reEvaluateProbesTimer === null) { reEvaluateProbesTimer = setTimeout(() => { session.emit('scriptLoadingStabilized') }, 500).unref() } else { reEvaluateProbesTimer.refresh() } } })