Skip to content

Commit

Permalink
feat: Allow specifying instrumented files
Browse files Browse the repository at this point in the history
  • Loading branch information
dividedmind committed Jan 6, 2024
1 parent 762fcd0 commit fad5ae5
Show file tree
Hide file tree
Showing 7 changed files with 137 additions and 22 deletions.
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,23 @@ to your tool invocation:

## Configuration

Currently there is no configurability.
You can create `appmap.yml` config file; if not found, a default one will be created:

```yaml
name: application-name # from package.json by default
appmap_dir: tmp/appmap
packages:
- path: . # paths to instrument, relative to appmap.yml location
exclude:
- node_modules
- .yaml
```
## Limitations
This is an experimetal rewrite of the original appmap-agent-js. It's still in active
development, not ready for production use, and the feature set is currently limited.
- Node 18+ supported.
- Instruments all the files under current directory that aren't node_modules.
- Only captures named `function`s and methods.
- Http server capture works with node:http, express.js and nest.js (with express.js only).
44 changes: 44 additions & 0 deletions src/PackageMatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { resolve } from "node:path";

export default class PackageMatcher extends Array<Package> {
constructor(
private root: string,
packages: Package[],
) {
super(...packages);
this.resolved = new Map(packages.map(({ path }) => [path, resolve(root, path)]));
}

private resolved: Map<string, string>;

private resolve(path: string) {
return this.resolved.get(path) ?? resolve(this.root, path);
}

match(path: string): Package | undefined {
const pkg = this.find((pkg) => path.startsWith(this.resolve(pkg.path)));
return pkg?.exclude?.find((ex) => path.includes(ex)) ? undefined : pkg;
}
}

export interface Package {
path: string;
exclude?: string[];
}

export function parsePackages(packages: unknown): Package[] | undefined {
if (!packages || !Array.isArray(packages)) return;

const result: Package[] = [];

for (const pkg of packages as unknown[]) {
if (typeof pkg === "string") result.push({ path: pkg });
else if (typeof pkg === "object" && pkg !== null && "path" in pkg) {
const entry: Package = { path: String(pkg.path) };
if ("exclude" in pkg) entry.exclude = Array.isArray(pkg.exclude) ? pkg.exclude : [];
result.push(entry);
}
}

if (result.length > 0) return result;
}
64 changes: 54 additions & 10 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,25 @@ import { basename } from "node:path";
import { chdir, cwd } from "node:process";

import tmp from "tmp";
import { PackageJson } from "type-fest";
import YAML from "yaml";

import { PackageJson } from "type-fest";
import PackageMatcher from "../PackageMatcher";
import { Config } from "../config";

tmp.setGracefulCleanup();

describe(Config, () => {
it("respects environment variables", () => {
process.env.APPMAP_ROOT = "/test/app";
expect(new Config()).toMatchObject({
const config = new Config();
expect(config).toMatchObject({
root: "/test/app",
relativeAppmapDir: "tmp/appmap",
appName: "app",
packages: new PackageMatcher("/test/app", [
{ path: ".", exclude: ["node_modules", ".yarn"] },
]),
});
});

Expand All @@ -41,23 +46,62 @@ describe(Config, () => {
});

it("searches for appmap.yml and uses config from it", () => {
writeFileSync("appmap.yml", YAML.stringify({ name: "test-package", appmap_dir: "appmap" }));
writeFileSync(
"appmap.yml",
YAML.stringify({
name: "test-package",
appmap_dir: "appmap",
packages: [{ path: ".", exclude: ["excluded"] }, "../lib"],
}),
);

mkdirSync("subdirectory");
chdir("subdirectory");
expect(new Config()).toMatchObject({
root: dir,
relativeAppmapDir: "appmap",
appName: "test-package",
packages: new PackageMatcher(dir, [{ path: ".", exclude: ["excluded"] }, { path: "../lib" }]),
});
});

it("uses default packages if the field in appmap.yml has unrecognized format", () => {
writeFileSync(
"appmap.yml",
YAML.stringify({
name: "test-package",
appmap_dir: "appmap",
packages: [{ regexp: "foo", enabled: false }],
}),
);

mkdirSync("subdirectory");
chdir("subdirectory");
expect(new Config()).toMatchObject({
root: dir,
relativeAppmapDir: "appmap",
appName: "test-package",
packages: new PackageMatcher(dir, [{ path: ".", exclude: ["node_modules", ".yarn"] }]),
});
});
});

let dir: string;
beforeEach(() => {
chdir((dir = tmp.dirSync().name));
jest.replaceProperty(process, "env", {});
let dir: string;
beforeEach(() => {
chdir((dir = tmp.dirSync().name));
jest.replaceProperty(process, "env", {});
});

const origCwd = cwd();
afterEach(() => chdir(origCwd));
});

const origCwd = cwd();
afterEach(() => chdir(origCwd));
describe(PackageMatcher, () => {
it("matches packages", () => {
const pkg = { path: ".", exclude: ["node_modules", ".yarn"] };
const matcher = new PackageMatcher("/test/app", [pkg]);
expect(matcher.match("/test/app/lib/foo.js")).toEqual(pkg);
expect(matcher.match("/other/app/lib/foo.js")).toBeUndefined();
expect(matcher.match("/test/app/node_modules/lib/foo.js")).toBeUndefined();
expect(matcher.match("/test/app/.yarn/lib/foo.js")).toBeUndefined();
});
});
16 changes: 16 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { cwd } from "node:process";
import { PackageJson } from "type-fest";
import YAML from "yaml";

import PackageMatcher, { Package, parsePackages } from "./PackageMatcher";
import locateFileUp from "./util/findFileUp";
import lazyOpt from "./util/lazyOpt";
import tryOr from "./util/tryOr";
Expand All @@ -16,6 +17,7 @@ export class Config {
public readonly root: string;
public readonly configPath: string;
public readonly default: boolean;
public readonly packages: PackageMatcher;

constructor(pwd = cwd()) {
const configDir = locateFileUp("appmap.yml", process.env.APPMAP_ROOT ?? pwd);
Expand All @@ -33,6 +35,16 @@ export class Config {
this.relativeAppmapDir = config?.appmap_dir ?? join("tmp", "appmap");

this.appName = config?.name ?? targetPackage()?.name ?? basename(root);

this.packages = new PackageMatcher(
root,
config?.packages ?? [
{
path: ".",
exclude: ["node_modules", ".yarn"],
},
],
);
}

private absoluteAppmapDir?: string;
Expand All @@ -48,13 +60,15 @@ export class Config {
return {
name: this.appName,
appmap_dir: this.relativeAppmapDir,
packages: this.packages,
};
}
}

interface ConfigFile {
appmap_dir?: string;
name?: string;
packages?: Package[];
}

function readConfigFile(path: string | undefined): ConfigFile | undefined {
Expand All @@ -66,6 +80,8 @@ function readConfigFile(path: string | undefined): ConfigFile | undefined {
assert(typeof config === "object");
if ("name" in config) result.name = String(config.name);
if ("appmap_dir" in config) result.appmap_dir = String(config.appmap_dir);
if ("packages" in config) result.packages = parsePackages(config.packages);

return result;
}

Expand Down
6 changes: 6 additions & 0 deletions src/hooks/__tests__/instrument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import { full as walk } from "acorn-walk";
import { ESTree, parse } from "meriyah";

import config from "../../config";
import PackageMatcher from "../../PackageMatcher";
import * as registry from "../../registry";
import * as instrument from "../instrument";

describe(instrument.shouldInstrument, () => {
jest.replaceProperty(config, "root", "/test");
jest.replaceProperty(
config,
"packages",
new PackageMatcher("/test", [{ path: ".", exclude: ["node_modules"] }]),
);
test.each([
["node:test", false],
["file:///test/test.json", false],
Expand Down
11 changes: 1 addition & 10 deletions src/hooks/instrument.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import assert from "node:assert";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { ancestor as walk } from "acorn-walk";
Expand Down Expand Up @@ -152,15 +151,7 @@ export function shouldInstrument(url: URL): boolean {
if (url.pathname.endsWith(".json")) return false;

const filePath = fileURLToPath(url);
if (filePath.includes("node_modules") || filePath.includes(".yarn")) return false;
if (isUnrelated(config.root, filePath)) return false;

return true;
}

function isUnrelated(parentPath: string, targetPath: string) {
const rel = path.relative(parentPath, targetPath);
return rel === targetPath || rel.startsWith("..");
return !!config.packages.match(filePath);
}

function hasIdentifier(
Expand Down
5 changes: 5 additions & 0 deletions test/__snapshots__/simple.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
exports[`creating a default config file 1`] = `
"name: test
appmap_dir: tmp/appmap
packages:
- path: .
exclude:
- node_modules
- .yarn
"
`;

Expand Down

0 comments on commit fad5ae5

Please sign in to comment.