diff --git a/src/Recording.ts b/src/Recording.ts index 40078c5..33911f8 100644 --- a/src/Recording.ts +++ b/src/Recording.ts @@ -19,6 +19,9 @@ import { shouldRecord } from "./recorderControl"; import { warn } from "./message"; export default class Recording { + public callCountPerFunction = new Map(); + public totalCallCount = 0; + constructor(type: AppMap.RecorderType, recorder: string, ...names: string[]) { const dirs = [recorder, ...names].map(quotePathSegment); const name = dirs.pop()!; // it must have at least one element @@ -40,6 +43,10 @@ export default class Recording { public metadata: AppMap.Metadata; functionCall(funInfo: FunctionInfo, thisArg: unknown, args: unknown[]): AppMap.FunctionCallEvent { + const count = this.callCountPerFunction.get(funInfo) ?? 0; + this.callCountPerFunction.set(funInfo, count + 1); + this.totalCallCount++; + this.functionsSeen.add(funInfo); const event = makeCallEvent(this.nextId++, funInfo, thisArg, args); this.emit(event); diff --git a/src/config.ts b/src/config.ts index b561d4d..60a114f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,12 @@ const kResponseBodyMaxLengthEnvar = "APPMAP_RESPONSE_BODY_MAX_LENGTH"; const asyncTrackingTimeoutDefault = 3000; const kAsyncTrackingTimeoutEnvar = "APPMAP_ASYNC_TRACKING_TIMEOUT"; +const maxRecordedCallsDefault = 10_000_000; +const kMaxRecordedCallsEnvar = "APPMAP_MAX_RECORDED_CALLS"; + +const maxRecordedCallsPerFunctionDefault = 100_000; +const kMaxRecordedCallsPerFunctionEnvar = "APPMAP_MAX_RECORDED_CALLS_PER_FUNCTION"; + export class Config { public readonly relativeAppmapDir: string; public readonly appName: string; @@ -30,6 +36,9 @@ export class Config { // If it's 0 then no async tracking. public readonly asyncTrackingTimeout: number; + public maxRecordedCalls: number; + public maxRecordedCallsPerFunction: number; + private readonly document?: Document; private migrationPending = false; @@ -84,6 +93,16 @@ export class Config { getNonNegativeIntegerEnvVarValue(kAsyncTrackingTimeoutEnvar) ?? config?.async_tracking_timeout ?? asyncTrackingTimeoutDefault; + + this.maxRecordedCalls = + getNonNegativeIntegerEnvVarValue(kMaxRecordedCallsEnvar) ?? + config?.max_recorded_calls ?? + maxRecordedCallsDefault; + + this.maxRecordedCallsPerFunction = + getNonNegativeIntegerEnvVarValue(kMaxRecordedCallsPerFunctionEnvar) ?? + config?.max_recorded_calls_per_function ?? + maxRecordedCallsPerFunctionDefault; } private absoluteAppmapDir?: string; @@ -170,6 +189,8 @@ interface ConfigFile { response_body_max_length?: number; language?: string; async_tracking_timeout?: number; + max_recorded_calls?: number; + max_recorded_calls_per_function?: number; } // Maintaining the YAML document is important to preserve existing comments and formatting @@ -211,6 +232,14 @@ function readConfigFile(document: Document): ConfigFile { const value = parseInt(String(config.async_tracking_timeout)); result.async_tracking_timeout = value >= 0 ? value : undefined; } + if ("max_recorded_calls" in config) { + const value = parseInt(String(config.max_recorded_calls)); + result.max_recorded_calls = value >= 0 ? value : undefined; + } + if ("max_recorded_calls_per_function" in config) { + const value = parseInt(String(config.max_recorded_calls_per_function)); + result.max_recorded_calls_per_function = value >= 0 ? value : undefined; + } return result; } diff --git a/src/recorder.ts b/src/recorder.ts index 3c2c513..b17929c 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -59,7 +59,16 @@ export function record( funInfo: FunctionInfo, isLibrary = false, ): Return { - const recordings = getActiveRecordings(); + let recordings = getActiveRecordings(); + + // Skip recordings reaching max call limits + if (recordings.length > 0 && config().maxRecordedCalls > 0) + recordings = recordings.filter((r) => r.totalCallCount < config().maxRecordedCalls); + if (recordings.length > 0 && config().maxRecordedCallsPerFunction > 0) + recordings = recordings.filter( + (r) => (r.callCountPerFunction.get(funInfo) ?? 0) < config().maxRecordedCallsPerFunction, + ); + let pkg; if ( recordings.length == 0 || diff --git a/test/__snapshots__/simple.test.ts.snap b/test/__snapshots__/simple.test.ts.snap index 144f617..761955e 100644 --- a/test/__snapshots__/simple.test.ts.snap +++ b/test/__snapshots__/simple.test.ts.snap @@ -764,6 +764,154 @@ exports[`mapping a script using async tracking timeout 3000 1`] = ` } `; +exports[`mapping a script using function call limits 1`] = ` +{ + "classMap": [ + { + "children": [ + { + "children": [ + { + "location": "callLimits.js:1", + "name": "a", + "static": true, + "type": "function", + }, + { + "location": "callLimits.js:5", + "name": "b", + "static": true, + "type": "function", + }, + { + "location": "callLimits.js:9", + "name": "c", + "static": true, + "type": "function", + }, + ], + "name": "callLimits", + "type": "class", + }, + ], + "name": "simple", + "type": "package", + }, + ], + "events": [ + { + "defined_class": "callLimits", + "event": "call", + "id": 1, + "lineno": 1, + "method_id": "a", + "parameters": [], + "path": "callLimits.js", + "static": true, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 2, + "parent_id": 1, + "thread_id": 0, + }, + { + "defined_class": "callLimits", + "event": "call", + "id": 3, + "lineno": 1, + "method_id": "a", + "parameters": [], + "path": "callLimits.js", + "static": true, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 4, + "parent_id": 3, + "thread_id": 0, + }, + { + "defined_class": "callLimits", + "event": "call", + "id": 5, + "lineno": 5, + "method_id": "b", + "parameters": [], + "path": "callLimits.js", + "static": true, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 6, + "parent_id": 5, + "thread_id": 0, + }, + { + "defined_class": "callLimits", + "event": "call", + "id": 7, + "lineno": 5, + "method_id": "b", + "parameters": [], + "path": "callLimits.js", + "static": true, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 8, + "parent_id": 7, + "thread_id": 0, + }, + { + "defined_class": "callLimits", + "event": "call", + "id": 9, + "lineno": 9, + "method_id": "c", + "parameters": [], + "path": "callLimits.js", + "static": true, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 10, + "parent_id": 9, + "thread_id": 0, + }, + ], + "metadata": { + "app": "simple", + "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", +} +`; + exports[`mapping a script with import attributes/assertions 1`] = ` { "classMap": [ diff --git a/test/simple.test.ts b/test/simple.test.ts index 5c2bff1..9047228 100644 --- a/test/simple.test.ts +++ b/test/simple.test.ts @@ -126,6 +126,18 @@ integrationTest("mapping a script with tangled async functions", () => { expect(readAppmap()).toMatchSnapshot(); }); +integrationTest.only("mapping a script using function call limits", () => { + const options = { + env: { + ...process.env, + APPMAP_MAX_RECORDED_CALLS: "5", + APPMAP_MAX_RECORDED_CALLS_PER_FUNCTION: "2", + }, + }; + expect(runAppmapNodeWithOptions(options, "callLimits.js").status).toBe(0); + expect(readAppmap()).toMatchSnapshot(); +}); + const asyncTimeoutCases = new Map([ // No async tracking ["0", ["1 task", "2 process", "return 2", "return 1", "5 getMessage", "return 5"]], diff --git a/test/simple/callLimits.js b/test/simple/callLimits.js new file mode 100644 index 0000000..81343b3 --- /dev/null +++ b/test/simple/callLimits.js @@ -0,0 +1,23 @@ +function a() { + console.log("a"); +} + +function b() { + console.log("b"); +} + +function c() { + console.log("c"); +} + +a(); +a(); +a(); + +b(); +b(); +b(); + +c(); +c(); +c();