Skip to content

Commit 6885987

Browse files
committed
feat: Ability to limit number of recorded function calls
Added the ability to limit the number of recorded function calls, both total and per function. This approach ensures no orphaned calls are recorded, as the check is made at the start of the function call recording. The limits can be configured via environment variables and configuration file, with default values of 10,000,000 total calls and 100,000 calls per function. Introduced a new test case that generates an AppMap with enforced total and per-function call limits.
1 parent 3c2e33d commit 6885987

File tree

6 files changed

+229
-1
lines changed

6 files changed

+229
-1
lines changed

src/Recording.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,22 @@ export default class Recording {
3939
public readonly path;
4040
public metadata: AppMap.Metadata;
4141

42+
private callCountPerFunction = new Map<FunctionInfo, number>();
43+
private totalCallCount = 0;
44+
45+
public willExceedFunctionCallLimits(funInfo: FunctionInfo) {
46+
return (
47+
(config().maxRecordedCalls > 0 && this.totalCallCount >= config().maxRecordedCalls) ||
48+
(config().maxRecordedCallsPerFunction &&
49+
(this.callCountPerFunction.get(funInfo) ?? 0) >= config().maxRecordedCallsPerFunction)
50+
);
51+
}
52+
4253
functionCall(funInfo: FunctionInfo, thisArg: unknown, args: unknown[]): AppMap.FunctionCallEvent {
54+
const count = this.callCountPerFunction.get(funInfo) ?? 0;
55+
this.callCountPerFunction.set(funInfo, count + 1);
56+
this.totalCallCount++;
57+
4358
this.functionsSeen.add(funInfo);
4459
const event = makeCallEvent(this.nextId++, funInfo, thisArg, args);
4560
this.emit(event);

src/config.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ const kResponseBodyMaxLengthEnvar = "APPMAP_RESPONSE_BODY_MAX_LENGTH";
1818
const asyncTrackingTimeoutDefault = 3000;
1919
const kAsyncTrackingTimeoutEnvar = "APPMAP_ASYNC_TRACKING_TIMEOUT";
2020

21+
const maxRecordedCallsDefault = 10_000_000;
22+
const kMaxRecordedCallsEnvar = "APPMAP_MAX_RECORDED_CALLS";
23+
24+
const maxRecordedCallsPerFunctionDefault = 100_000;
25+
const kMaxRecordedCallsPerFunctionEnvar = "APPMAP_MAX_RECORDED_CALLS_PER_FUNCTION";
26+
2127
export class Config {
2228
public readonly relativeAppmapDir: string;
2329
public readonly appName: string;
@@ -36,6 +42,9 @@ export class Config {
3642
// manipulated. This flag allows easy toggling of the check during testing.
3743
public readonly generateGlobalRecordHookCheck: boolean = true;
3844

45+
public readonly maxRecordedCalls: number;
46+
public readonly maxRecordedCallsPerFunction: number;
47+
3948
private readonly document?: Document;
4049
private migrationPending = false;
4150

@@ -90,6 +99,16 @@ export class Config {
9099
getNonNegativeIntegerEnvVarValue(kAsyncTrackingTimeoutEnvar) ??
91100
config?.async_tracking_timeout ??
92101
asyncTrackingTimeoutDefault;
102+
103+
this.maxRecordedCalls =
104+
getNonNegativeIntegerEnvVarValue(kMaxRecordedCallsEnvar) ??
105+
config?.max_recorded_calls ??
106+
maxRecordedCallsDefault;
107+
108+
this.maxRecordedCallsPerFunction =
109+
getNonNegativeIntegerEnvVarValue(kMaxRecordedCallsPerFunctionEnvar) ??
110+
config?.max_recorded_calls_per_function ??
111+
maxRecordedCallsPerFunctionDefault;
93112
}
94113

95114
private absoluteAppmapDir?: string;
@@ -176,6 +195,8 @@ interface ConfigFile {
176195
response_body_max_length?: number;
177196
language?: string;
178197
async_tracking_timeout?: number;
198+
max_recorded_calls?: number;
199+
max_recorded_calls_per_function?: number;
179200
}
180201

181202
// Maintaining the YAML document is important to preserve existing comments and formatting
@@ -217,6 +238,14 @@ function readConfigFile(document: Document): ConfigFile {
217238
const value = parseInt(String(config.async_tracking_timeout));
218239
result.async_tracking_timeout = value >= 0 ? value : undefined;
219240
}
241+
if ("max_recorded_calls" in config) {
242+
const value = parseInt(String(config.max_recorded_calls));
243+
result.max_recorded_calls = value >= 0 ? value : undefined;
244+
}
245+
if ("max_recorded_calls_per_function" in config) {
246+
const value = parseInt(String(config.max_recorded_calls_per_function));
247+
result.max_recorded_calls_per_function = value >= 0 ? value : undefined;
248+
}
220249

221250
return result;
222251
}

src/recorder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ export function record<This, Return>(
6868
funInfo: FunctionInfo,
6969
isLibrary = false,
7070
): Return {
71-
const recordings = getActiveRecordings();
71+
const recordings = getActiveRecordings().filter((r) => !r.willExceedFunctionCallLimits(funInfo));
72+
7273
let pkg;
7374
if (
7475
recordings.length == 0 ||

test/__snapshots__/simple.test.ts.snap

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,154 @@ exports[`mapping a script using async tracking timeout 3000 1`] = `
764764
}
765765
`;
766766

767+
exports[`mapping a script using function call limits 1`] = `
768+
{
769+
"classMap": [
770+
{
771+
"children": [
772+
{
773+
"children": [
774+
{
775+
"location": "callLimits.js:1",
776+
"name": "a",
777+
"static": true,
778+
"type": "function",
779+
},
780+
{
781+
"location": "callLimits.js:5",
782+
"name": "b",
783+
"static": true,
784+
"type": "function",
785+
},
786+
{
787+
"location": "callLimits.js:9",
788+
"name": "c",
789+
"static": true,
790+
"type": "function",
791+
},
792+
],
793+
"name": "callLimits",
794+
"type": "class",
795+
},
796+
],
797+
"name": "simple",
798+
"type": "package",
799+
},
800+
],
801+
"events": [
802+
{
803+
"defined_class": "callLimits",
804+
"event": "call",
805+
"id": 1,
806+
"lineno": 1,
807+
"method_id": "a",
808+
"parameters": [],
809+
"path": "callLimits.js",
810+
"static": true,
811+
"thread_id": 0,
812+
},
813+
{
814+
"elapsed": 31.337,
815+
"event": "return",
816+
"id": 2,
817+
"parent_id": 1,
818+
"thread_id": 0,
819+
},
820+
{
821+
"defined_class": "callLimits",
822+
"event": "call",
823+
"id": 3,
824+
"lineno": 1,
825+
"method_id": "a",
826+
"parameters": [],
827+
"path": "callLimits.js",
828+
"static": true,
829+
"thread_id": 0,
830+
},
831+
{
832+
"elapsed": 31.337,
833+
"event": "return",
834+
"id": 4,
835+
"parent_id": 3,
836+
"thread_id": 0,
837+
},
838+
{
839+
"defined_class": "callLimits",
840+
"event": "call",
841+
"id": 5,
842+
"lineno": 5,
843+
"method_id": "b",
844+
"parameters": [],
845+
"path": "callLimits.js",
846+
"static": true,
847+
"thread_id": 0,
848+
},
849+
{
850+
"elapsed": 31.337,
851+
"event": "return",
852+
"id": 6,
853+
"parent_id": 5,
854+
"thread_id": 0,
855+
},
856+
{
857+
"defined_class": "callLimits",
858+
"event": "call",
859+
"id": 7,
860+
"lineno": 5,
861+
"method_id": "b",
862+
"parameters": [],
863+
"path": "callLimits.js",
864+
"static": true,
865+
"thread_id": 0,
866+
},
867+
{
868+
"elapsed": 31.337,
869+
"event": "return",
870+
"id": 8,
871+
"parent_id": 7,
872+
"thread_id": 0,
873+
},
874+
{
875+
"defined_class": "callLimits",
876+
"event": "call",
877+
"id": 9,
878+
"lineno": 9,
879+
"method_id": "c",
880+
"parameters": [],
881+
"path": "callLimits.js",
882+
"static": true,
883+
"thread_id": 0,
884+
},
885+
{
886+
"elapsed": 31.337,
887+
"event": "return",
888+
"id": 10,
889+
"parent_id": 9,
890+
"thread_id": 0,
891+
},
892+
],
893+
"metadata": {
894+
"app": "simple",
895+
"client": {
896+
"name": "appmap-node",
897+
"url": "https://github.com/getappmap/appmap-node",
898+
"version": "test node-appmap version",
899+
},
900+
"language": {
901+
"engine": "Node.js",
902+
"name": "javascript",
903+
"version": "test node version",
904+
},
905+
"name": "test process recording",
906+
"recorder": {
907+
"name": "process",
908+
"type": "process",
909+
},
910+
},
911+
"version": "1.12",
912+
}
913+
`;
914+
767915
exports[`mapping a script with import attributes/assertions 1`] = `
768916
{
769917
"classMap": [

test/simple.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,18 @@ integrationTest("mapping a script with tangled async functions", () => {
131131
expect(readAppmap()).toMatchSnapshot();
132132
});
133133

134+
integrationTest.only("mapping a script using function call limits", () => {
135+
const options = {
136+
env: {
137+
...process.env,
138+
APPMAP_MAX_RECORDED_CALLS: "5",
139+
APPMAP_MAX_RECORDED_CALLS_PER_FUNCTION: "2",
140+
},
141+
};
142+
expect(runAppmapNodeWithOptions(options, "callLimits.js").status).toBe(0);
143+
expect(readAppmap()).toMatchSnapshot();
144+
});
145+
134146
const asyncTimeoutCases = new Map<string, string[]>([
135147
// No async tracking
136148
["0", ["1 task", "2 process", "return 2", "return 1", "5 getMessage", "return 5"]],

test/simple/callLimits.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
function a() {
2+
console.log("a");
3+
}
4+
5+
function b() {
6+
console.log("b");
7+
}
8+
9+
function c() {
10+
console.log("c");
11+
}
12+
13+
a();
14+
a();
15+
a();
16+
17+
b();
18+
b();
19+
b();
20+
21+
c();
22+
c();
23+
c();

0 commit comments

Comments
 (0)