Skip to content

Commit

Permalink
fix: Instrument based on source map paths
Browse files Browse the repository at this point in the history
  • Loading branch information
zermelo-wisen committed Aug 5, 2024
1 parent 780a572 commit e059fa0
Show file tree
Hide file tree
Showing 15 changed files with 1,018 additions and 40 deletions.
Binary file not shown.
Binary file not shown.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"test/next",
"test/typescript-esm",
"test/prisma",
"test/libraryCalls"
"test/libraryCalls",
"test/sourceMapPath"
]
}
94 changes: 65 additions & 29 deletions src/hooks/instrument.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "node:assert";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { debuglog } from "node:util";

import { ancestor as walk } from "acorn-walk";
Expand All @@ -23,6 +23,7 @@ import { FunctionInfo, SourceLocation, createFunctionInfo, createMethodInfo } fr
import findLast from "../util/findLast";
import { isId } from "../util/isId";
import CommentLabelExtractor from "./util/CommentLabelExtractor";
import { CustomSourceMapConsumer, shouldMatchOriginalSourcePaths } from "../transform";

const debug = debuglog("appmap:instrument");

Expand All @@ -36,37 +37,64 @@ function addTransformedFunctionInfo(fi: FunctionInfo): number {

export function transform(
program: ESTree.Program,
sourceMap?: SourceMapConsumer,
sourceMap?: CustomSourceMapConsumer,
comments?: ESTree.Comment[],
): ESTree.Program {
transformedFunctionInfos.splice(0);
const source = program.loc?.source;
const pkg = source ? config().packages.match(source) : undefined;

const locate = makeLocator(sourceMap);
const commentLabelExtractor = comments
? new CommentLabelExtractor(comments, sourceMap)
: undefined;

const getFunctionLabels = (name: string, line: number, klass?: string) => {
const commentLabels = commentLabelExtractor?.labelsFor(line);
const configLabels = config.getFunctionLabels(pkg, name, klass);
const getFunctionLabels = (name: string, location: SourceLocation, klass?: string) => {
const commentLabels = commentLabelExtractor?.labelsFor(location.lineno);
const pkg = config().packages.match(location.path);
const configLabels = config().getFunctionLabels(pkg, name, klass);
if (commentLabels && configLabels)
return [...commentLabels, ...configLabels.filter((l) => !commentLabels.includes(l))];
return commentLabels ?? configLabels;
};

const originalSourceShouldBeInstrumented = new Map<string, boolean>();
const shouldSkipFunction = (
location: SourceLocation | undefined,
name: string,
qname?: string,
) => {
if (!location) return true; // don't instrument generated code

// A function may reside in a bundled file. If we have a source map
// we check if the original source of the function should be instrumented.
if (sourceMap?.originalSources.length) {
if (!originalSourceShouldBeInstrumented.has(location.path)) {
const url = pathToFileURL(location.path);
const originalSource = sourceMap.originalSources.find((s) => s.href == url.href);
originalSourceShouldBeInstrumented.set(
location.path,
originalSource != undefined && shouldInstrument(originalSource),
);
}
if (!originalSourceShouldBeInstrumented.get(location.path)) return true;
}

// check if the function is explicitly excluded in config
const pkg = config().packages.match(location.path);
if (pkg?.exclude?.includes(name)) return true;
if (qname && pkg?.exclude?.includes(qname)) return true;
};

walk(program, {
FunctionDeclaration(fun: ESTree.FunctionDeclaration) {
if (!hasIdentifier(fun)) return;
if (pkg?.exclude?.includes(fun.id.name)) return;

const location = locate(fun);
if (!location) return; // don't instrument generated code
if (shouldSkipFunction(location, fun.id.name)) return;
assert(location);

fun.body = wrapFunction(
fun,
createFunctionInfo(fun, location, getFunctionLabels(fun.id.name, location.lineno)),
createFunctionInfo(fun, location, getFunctionLabels(fun.id.name, location)),
false,
);
},
Expand All @@ -81,25 +109,14 @@ export function transform(
const { name } = method.key;
const qname = [klass.id.name, name].join(".");

// Not sure why eslint complains here, ?? is the wrong operator
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (pkg?.exclude?.includes(name) || pkg?.exclude?.includes(qname)) {
debug(`Excluding ${qname}`);
return;
}

const location = locate(method);
if (!location) return; // don't instrument generated code
if (shouldSkipFunction(location, name, qname)) return;
assert(location);

debug(`Instrumenting ${qname}`);
method.value.body = wrapFunction(
{ ...method.value },
createMethodInfo(
method,
klass,
location,
getFunctionLabels(name, location.lineno, klass.id.name),
),
createMethodInfo(method, klass, location, getFunctionLabels(name, location, klass.id.name)),
method.kind === "constructor",
);
},
Expand All @@ -116,7 +133,10 @@ export function transform(
if (!(declaration.type === "VariableDeclaration" && declaration.kind === "const")) return;
const { id } = declarator;
if (!isId(id)) return;
if (pkg?.exclude?.includes(id.name)) return;

if (shouldSkipFunction(location, id.name)) return;
assert(location);

assert(declarator.init === fun);

debug(`Instrumenting ${id.name}`);
Expand All @@ -125,23 +145,27 @@ export function transform(
createFunctionInfo(
{ ...fun, id, generator: false },
location,
getFunctionLabels(id.name, location.lineno),
getFunctionLabels(id.name, location),
),
);
break;
}
// instrument CommonJS exports
case "AssignmentExpression": {
const id = exportName(declarator.left);
if (!id || pkg?.exclude?.includes(id.name)) return;

if (!id) return;

if (shouldSkipFunction(location, id.name)) return;
assert(location);

debug(`Instrumenting ${id.name}`);
declarator.right = wrapLambda(
fun,
createFunctionInfo(
{ ...fun, id, generator: false },
location,
getFunctionLabels(id.name, location.lineno),
getFunctionLabels(id.name, location),
),
);
break;
Expand Down Expand Up @@ -265,14 +289,26 @@ function wrapFunction(
return wrapped;
}

export function shouldInstrument(url: URL): boolean {
function shouldInstrument_(url: URL) {
if (url.protocol !== "file:") return false;
if (url.pathname.endsWith(".json")) return false;

const filePath = fileURLToPath(url);
return !!config().packages.match(filePath);
}

// If there is a source map, check whether there is at least one original source
// that needs to be instrumented. If so, we should send the "bundled" URL to
// instrument.transform(), but within it, we should check each function to determine
// if its original source URL needs to be instrumented.
export function shouldInstrument(url: URL, sourceMap?: CustomSourceMapConsumer): boolean {
if (sourceMap?.originalSources.length && shouldMatchOriginalSourcePaths(url))
return sourceMap.originalSources.some((s) => shouldInstrument_(s));

const result = shouldInstrument_(url);
return result;
}

function hasIdentifier(
fun: ESTree.FunctionDeclaration,
): fun is ESTree.FunctionDeclaration & { id: ESTree.Identifier } {
Expand Down
61 changes: 51 additions & 10 deletions src/transform.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert";
import { readFileSync, writeFileSync } from "node:fs";
import { URL, fileURLToPath } from "node:url";
import { URL, fileURLToPath, pathToFileURL } from "node:url";
import { debuglog } from "node:util";
import { isNativeError } from "node:util/types";

Expand All @@ -20,23 +20,49 @@ const treeDebug = debuglog("appmap-tree");
const sourceDebug = debuglog("appmap-source");

export interface Hook {
shouldInstrument(url: URL): boolean;
shouldInstrument(url: URL, sourcemap?: CustomSourceMapConsumer): boolean;
transform(
program: ESTree.Program,
sourcemap?: SourceMapConsumer,
sourcemap?: CustomSourceMapConsumer,
comments?: ESTree.Comment[],
): ESTree.Program;
shouldIgnore?(url: URL): boolean;
}

const defaultHooks: Hook[] = [next, vitest, mocha, jest, instrument];

export function findHook(url: URL, hooks = defaultHooks) {
return hooks.find((h) => h.shouldInstrument(url));
export function findHook(url: URL, sourceMap?: CustomSourceMapConsumer, hooks = defaultHooks) {
return hooks.find((h) => h.shouldInstrument(url, sourceMap));
}

export function shouldMatchOriginalSourcePaths(url: URL) {
// We're not interested in source maps of libraries. For instance, in a Next.js project,
// if we were to consult the source map for instrumentation of a file like
// "...node_modules/next/dist/compiled/next-server/pages.runtime.dev.js", we would get
// URLs like "file:///...webpack:/next/dist/compiled/cookie/index.js" that do not include
// the node_modules part, resulting in unintended instrumentation, since node_modules
// folder is typically excluded from instrumentation.
return !["/node_modules/", "/.next/"].some((x) => url.pathname.includes(x));
}

export default function transform(code: string, url: URL, hooks = defaultHooks): string {
const hook = findHook(url, hooks);
let sourceMap: CustomSourceMapConsumer | undefined;
let sourceMapInitialized = false;
const getSourceMap_ = () => {
if (!sourceMapInitialized) {
try {
sourceMap = getSourceMap(url, code);
} catch (e) {
warn("Error getting source map for ", url, e);
}
sourceMapInitialized = true;
}
return sourceMap;
};

if (shouldMatchOriginalSourcePaths(url)) getSourceMap_();

const hook = findHook(url, sourceMap, hooks);
if (!hook) return code;

if (hooks.some((h) => h.shouldIgnore?.(url))) return code;
Expand All @@ -51,7 +77,7 @@ export default function transform(code: string, url: URL, hooks = defaultHooks):
onComment: comments,
});

const xformed = hook.transform(tree, getSourceMap(url, code), comments);
const xformed = hook.transform(tree, getSourceMap_(), comments);
if (treeDebug.enabled) dumpTree(xformed, url);
const src = generate(xformed);
if (sourceDebug.enabled) {
Expand All @@ -75,7 +101,15 @@ function dumpTree(xformed: ESTree.Program, url: URL) {
treeDebug("wrote transformed tree to %s", path);
}

function getSourceMap(fileUrl: URL, code: string): SourceMapConsumer | undefined {
export class CustomSourceMapConsumer extends SourceMapConsumer {
public originalSources: URL[];
constructor(rawSourceMap: RawSourceMap) {
super(rawSourceMap);
this.originalSources = rawSourceMap.sources.map((s) => pathToFileURL(s));
}
}

function getSourceMap(fileUrl: URL, code: string): CustomSourceMapConsumer | undefined {
const sourceMappingUrl = code.match(/\/\/# sourceMappingURL=(.*)/)?.[1];
if (!sourceMappingUrl) return;

Expand All @@ -96,11 +130,18 @@ function getSourceMap(fileUrl: URL, code: string): SourceMapConsumer | undefined
}

sourceMap.sources = sourceMap.sources.map((source) => {
const url = new URL((sourceMap.sourceRoot ?? "") + source, fileUrl);
const rootedSource = (sourceMap.sourceRoot ?? "") + source;
// On Windows, we get incorrect result with URL if the original path contains spaces.
// source: 'C:/Users/John Doe/projects/appmap-node/test/typescript/index.ts'
// => result: 'C:/Users/John%20Doe/projects/appmap-node/test/typescript/index.ts'
// This check prevents it.
if (rootedSource.match(/^[a-zA-Z]:[/\\]/)) return rootedSource;

const url = new URL(rootedSource, fileUrl);
return url.protocol === "file:" ? fileURLToPath(url) : url.toString();
});

return new SourceMapConsumer(sourceMap);
return new CustomSourceMapConsumer(sourceMap);
}

function parseDataUrl(fileUrl: URL) {
Expand Down
71 changes: 71 additions & 0 deletions test/__snapshots__/sourceMapPath.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`mapping a bundled script 1`] = `
{
"classMap": [
{
"children": [
{
"children": [
{
"location": "original.mjs:3",
"name": "foo",
"static": true,
"type": "function",
},
],
"name": "original",
"type": "class",
},
],
"name": "appmap-node",
"type": "package",
},
],
"events": [
{
"defined_class": "original",
"event": "call",
"id": 1,
"lineno": 3,
"method_id": "foo",
"parameters": [
{
"class": "String",
"name": "msg",
"value": "'hello'",
},
],
"path": "original.mjs",
"static": true,
"thread_id": 0,
},
{
"elapsed": 31.337,
"event": "return",
"id": 2,
"parent_id": 1,
"thread_id": 0,
},
],
"metadata": {
"app": "appmap-node",
"client": {
"name": "appmap-node",
"url": "https://github.com/getappmap/appmap-node",
"version": "test node-appmap version",
},
"language": {
"engine": "Node.js",
"name": "javascript",
"version": "test node version",
},
"name": "test process recording",
"recorder": {
"name": "process",
"type": "process",
},
},
"version": "1.12",
}
`;
6 changes: 6 additions & 0 deletions test/sourceMapPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { integrationTest, readAppmap, runAppmapNode } from "./helpers";

integrationTest("mapping a bundled script", () => {
expect(runAppmapNode("./built/index.js").status).toBe(0);
expect(readAppmap()).toMatchSnapshot();
});
5 changes: 5 additions & 0 deletions test/sourceMapPath/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
To recreate index.js & index.js.map:

```
$ yarn esbuild original.mjs --bundle --platform=node --sourcemap --outfile=built/index.js
```
Loading

0 comments on commit e059fa0

Please sign in to comment.