Skip to content

Commit

Permalink
feat: Capture response body
Browse files Browse the repository at this point in the history
  • Loading branch information
zermelo-wisen committed Feb 26, 2024
1 parent 8fbd330 commit 1d568bb
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 2 deletions.
1 change: 1 addition & 0 deletions src/AppMap.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ namespace AppMap {

export interface HttpServerResponseEvent extends ReturnEventBase {
http_server_response: HttpResponse;
return_value?: Parameter;
}

export interface HttpClientResponseEvent extends ReturnEventBase {
Expand Down
2 changes: 2 additions & 0 deletions src/Recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export default class Recording {
elapsed: number,
status: number,
headers?: Record<string, string>,
returnValue?: AppMap.Parameter,
): AppMap.HttpServerResponseEvent {
assert(this.stream);

Expand All @@ -172,6 +173,7 @@ export default class Recording {
thread_id: 0,
parent_id: callId,
elapsed,
return_value: returnValue,
};
this.stream.emit(event);

Expand Down
9 changes: 9 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import locateFileUp from "./util/findFileUp";
import lazyOpt from "./util/lazyOpt";
import tryOr from "./util/tryOr";

const responseBodyMaxLengthDefault = 10000;
export class Config {
public readonly relativeAppmapDir: string;
public readonly appName: string;
public readonly root: string;
public readonly configPath: string;
public readonly default: boolean;
public readonly packages: PackageMatcher;
public readonly responseBodyMaxLength: number;

constructor(pwd = cwd()) {
const configDir = locateFileUp("appmap.yml", process.env.APPMAP_ROOT ?? pwd);
Expand Down Expand Up @@ -45,6 +47,8 @@ export class Config {
},
],
);

this.responseBodyMaxLength = config?.response_body_max_length ?? responseBodyMaxLengthDefault;
}

private absoluteAppmapDir?: string;
Expand All @@ -69,6 +73,7 @@ interface ConfigFile {
appmap_dir?: string;
name?: string;
packages?: Package[];
response_body_max_length?: number;
}

function readConfigFile(path: string | undefined): ConfigFile | undefined {
Expand All @@ -81,6 +86,10 @@ function readConfigFile(path: string | undefined): ConfigFile | undefined {
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);
if ("response_body_max_length" in config) {
const value = Number.parseInt(String(config.response_body_max_length));
result.response_body_max_length = value >= 0 ? value : undefined;
}

return result;
}
Expand Down
79 changes: 78 additions & 1 deletion src/hooks/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type https from "node:https";
import { URL } from "node:url";

import type AppMap from "../AppMap";
import config from "../config";
import Recording from "../Recording";
import { info, warn } from "../message";
import { parameter } from "../parameter";
Expand Down Expand Up @@ -155,6 +156,69 @@ function shouldIgnoreRequest(request: http.IncomingMessage) {
return false;
}

type Chunk = string | Buffer;

function captureResponseBody(response: http.ServerResponse, chunks: Chunk[]) {
let currentLength = 0;
function captureChunk(chunk: Chunk) {
if (!chunk) return;
if (config.responseBodyMaxLength <= currentLength) return;

chunks.push(chunk);
currentLength += chunk.length;
}

const originalWrite = response.write.bind(response);
const originalEnd = response.end.bind(response);

response.write = function (...args: unknown[]) {
let callback: unknown;
let encoding: unknown;
const chunk = args[0] as Buffer;
// write has these call signatures
// (chunk, callback?)
// (chunk, encoding, callback?)
let argsToConsider = args.length;
// If the last parameter is a function than it's the callback
if (args.length > 0 && typeof args[args.length - 1] === "function") {
callback = args[args.length - 1];
argsToConsider--;
}
if (argsToConsider > 1) encoding = args[1];

captureChunk(chunk);

if (encoding != null)
return originalWrite(
chunk,
encoding as BufferEncoding,
callback as ((error: Error | null | undefined) => void) | undefined,
);

return originalWrite(
chunk,
callback as ((error: Error | null | undefined) => void) | undefined,
);
};

response.end = function (...args: unknown[]) {
// end has these call signatures:
// (cb?); (chunk, cb?); (chunk, encoding, cb?)
// If the last parameter is a function than it's the callback
const argsCountBeforeCallback =
args.length > 0 && typeof args[args.length - 1] === "function"
? args.length - 1
: args.length;
if (argsCountBeforeCallback > 0) captureChunk(args[0] as Chunk);

if (args.length == 3)
return originalEnd(args[0], args[1] as BufferEncoding, args[2] as () => void);
else if (args.length == 2) return originalEnd(args[0], args[1] as () => void);
else if (args.length == 1) return originalEnd(args[0]);
else return originalEnd();
};
}

function handleRequest(request: http.IncomingMessage, response: http.ServerResponse) {
if (!(request.method && request.url)) return;

Expand All @@ -169,9 +233,13 @@ function handleRequest(request: http.IncomingMessage, response: http.ServerRespo
normalizeHeaders(request.headers),
url.searchParams,
);

const bodyChunks: Chunk[] = [];
captureResponseBody(response, bodyChunks);

const startTime = getTime();
response.once("finish", () => {
handleResponse(request, requestEvent, startTime, timestamp, response);
handleResponse(request, requestEvent, startTime, timestamp, response, bodyChunks);
});
}

Expand Down Expand Up @@ -238,13 +306,22 @@ function handleResponse(
startTime: number,
timestamp: string | undefined,
response: http.ServerResponse<http.IncomingMessage>,
chunks: Chunk[],
): void {
if (fixupEvent(request, requestEvent)) recording.fixup(requestEvent);

let body =
chunks.length > 0 && typeof chunks[0] === "string"
? chunks.join("")
: Buffer.concat(chunks as Buffer[]).toString("utf8");
body = body.substring(0, config.responseBodyMaxLength);

recording.httpResponse(
requestEvent.id,
getTime() - startTime,
response.statusCode,
normalizeHeaders(response.getHeaders()),
body ? { class: "[ResponseBody]", value: body } : undefined,
);
if (remoteRunning) return;
const { request_method, path_info } = requestEvent.http_server_request;
Expand Down
52 changes: 52 additions & 0 deletions test/__snapshots__/httpServer.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ exports[`mapping Express.js requests 1`] = `
},
"id": 4,
"parent_id": 1,
"return_value": {
"class": "[ResponseBody]",
"value": "Hello World!",
},
"thread_id": 0,
},
],
Expand Down Expand Up @@ -139,6 +143,20 @@ exports[`mapping Express.js requests 1`] = `
},
"id": 2,
"parent_id": 1,
"return_value": {
"class": "[ResponseBody]",
"value": "<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /nonexistent</pre>
</body>
</html>
",
},
"thread_id": 0,
},
],
Expand Down Expand Up @@ -309,6 +327,10 @@ exports[`mapping Express.js requests 1`] = `
},
"id": 4,
"parent_id": 1,
"return_value": {
"class": "[ResponseBody]",
"value": "{"api":"result","param1":"3","param2":"4"}",
},
"thread_id": 0,
},
],
Expand Down Expand Up @@ -574,6 +596,10 @@ exports[`mapping Express.js requests 1`] = `
},
"id": 4,
"parent_id": 1,
"return_value": {
"class": "[ResponseBody]",
"value": "{"key":"value","obj":{"foo":42,"arr":[44]},"arr":[{"foo":43},{"foo":44}],"heterogenous":[42,"str"]}",
},
"thread_id": 0,
},
],
Expand Down Expand Up @@ -799,6 +825,10 @@ exports[`mapping Express.js requests with remote recording 1`] = `
},
"id": 4,
"parent_id": 1,
"return_value": {
"class": "[ResponseBody]",
"value": "Hello World!",
},
"thread_id": 0,
},
{
Expand Down Expand Up @@ -829,6 +859,20 @@ exports[`mapping Express.js requests with remote recording 1`] = `
},
"id": 6,
"parent_id": 5,
"return_value": {
"class": "[ResponseBody]",
"value": "<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /nonexistent</pre>
</body>
</html>
",
},
"thread_id": 0,
},
{
Expand Down Expand Up @@ -923,6 +967,10 @@ exports[`mapping Express.js requests with remote recording 1`] = `
},
"id": 10,
"parent_id": 7,
"return_value": {
"class": "[ResponseBody]",
"value": "{"api":"result","param1":"3","param2":"4"}",
},
"thread_id": 0,
},
{
Expand Down Expand Up @@ -1073,6 +1121,10 @@ exports[`mapping Express.js requests with remote recording 1`] = `
},
"id": 14,
"parent_id": 11,
"return_value": {
"class": "[ResponseBody]",
"value": "{"key":"value","obj":{"foo":42,"arr":[44]},"arr":[{"foo":43},{"foo":44}],"heterogenous":[42,"str"]}",
},
"thread_id": 0,
},
],
Expand Down
12 changes: 11 additions & 1 deletion test/next.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,17 @@ integrationTest(

app.kill("SIGINT");
await new Promise((r) => app.once("exit", r));
expect(readAppmaps()).toMatchSnapshot();
const appMaps = readAppmaps();
// Delete response body captures because it will be different in every run
Object.values(appMaps).forEach(
(a) =>
a.events?.forEach((e) => {
if ("return_value" in e && e.return_value?.class == "[ResponseBody]")
delete e.return_value;
}),
);

expect(appMaps).toMatchSnapshot();

// We need to kill the next process explicitly on Windows
// because it's spawn-ed with "shell: true" and app is the shell process.
Expand Down

0 comments on commit 1d568bb

Please sign in to comment.