Skip to content

Commit 36e0b7c

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 ad5cd8d commit 36e0b7c

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;
@@ -30,6 +36,9 @@ export class Config {
3036
// If it's 0 then no async tracking.
3137
public readonly asyncTrackingTimeout: number;
3238

39+
public maxRecordedCalls: number;
40+
public maxRecordedCallsPerFunction: number;
41+
3342
private readonly document?: Document;
3443
private migrationPending = false;
3544

@@ -84,6 +93,16 @@ export class Config {
8493
getNonNegativeIntegerEnvVarValue(kAsyncTrackingTimeoutEnvar) ??
8594
config?.async_tracking_timeout ??
8695
asyncTrackingTimeoutDefault;
96+
97+
this.maxRecordedCalls =
98+
getNonNegativeIntegerEnvVarValue(kMaxRecordedCallsEnvar) ??
99+
config?.max_recorded_calls ??
100+
maxRecordedCallsDefault;
101+
102+
this.maxRecordedCallsPerFunction =
103+
getNonNegativeIntegerEnvVarValue(kMaxRecordedCallsPerFunctionEnvar) ??
104+
config?.max_recorded_calls_per_function ??
105+
maxRecordedCallsPerFunctionDefault;
87106
}
88107

89108
private absoluteAppmapDir?: string;
@@ -170,6 +189,8 @@ interface ConfigFile {
170189
response_body_max_length?: number;
171190
language?: string;
172191
async_tracking_timeout?: number;
192+
max_recorded_calls?: number;
193+
max_recorded_calls_per_function?: number;
173194
}
174195

175196
// Maintaining the YAML document is important to preserve existing comments and formatting
@@ -211,6 +232,14 @@ function readConfigFile(document: Document): ConfigFile {
211232
const value = parseInt(String(config.async_tracking_timeout));
212233
result.async_tracking_timeout = value >= 0 ? value : undefined;
213234
}
235+
if ("max_recorded_calls" in config) {
236+
const value = parseInt(String(config.max_recorded_calls));
237+
result.max_recorded_calls = value >= 0 ? value : undefined;
238+
}
239+
if ("max_recorded_calls_per_function" in config) {
240+
const value = parseInt(String(config.max_recorded_calls_per_function));
241+
result.max_recorded_calls_per_function = value >= 0 ? value : undefined;
242+
}
214243

215244
return result;
216245
}

src/recorder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ export function record<This, Return>(
5959
funInfo: FunctionInfo,
6060
isLibrary = false,
6161
): Return {
62-
const recordings = getActiveRecordings();
62+
const recordings = getActiveRecordings().filter((r) => !r.willExceedFunctionCallLimits(funInfo));
63+
6364
let pkg;
6465
if (
6566
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
@@ -126,6 +126,18 @@ integrationTest("mapping a script with tangled async functions", () => {
126126
expect(readAppmap()).toMatchSnapshot();
127127
});
128128

129+
integrationTest.only("mapping a script using function call limits", () => {
130+
const options = {
131+
env: {
132+
...process.env,
133+
APPMAP_MAX_RECORDED_CALLS: "5",
134+
APPMAP_MAX_RECORDED_CALLS_PER_FUNCTION: "2",
135+
},
136+
};
137+
expect(runAppmapNodeWithOptions(options, "callLimits.js").status).toBe(0);
138+
expect(readAppmap()).toMatchSnapshot();
139+
});
140+
129141
const asyncTimeoutCases = new Map<string, string[]>([
130142
// No async tracking
131143
["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)