diff --git a/src/AppMap.d.ts b/src/AppMap.d.ts index 5e77d746..c1385d4f 100644 --- a/src/AppMap.d.ts +++ b/src/AppMap.d.ts @@ -166,6 +166,7 @@ namespace AppMap { export interface HttpServerResponseEvent extends ReturnEventBase { http_server_response: HttpResponse; + return_value?: string | Parameter; } export interface HttpClientResponseEvent extends ReturnEventBase { diff --git a/src/Recording.ts b/src/Recording.ts index 59a8e392..5d10b40c 100644 --- a/src/Recording.ts +++ b/src/Recording.ts @@ -159,6 +159,7 @@ export default class Recording { elapsed: number, status: number, headers?: Record, + body?: string, ): AppMap.HttpServerResponseEvent { assert(this.stream); @@ -172,6 +173,7 @@ export default class Recording { thread_id: 0, parent_id: callId, elapsed, + return_value: body, }; this.stream.emit(event); diff --git a/src/hooks/http.ts b/src/hooks/http.ts index e2ae23da..3c3a135b 100644 --- a/src/hooks/http.ts +++ b/src/hooks/http.ts @@ -1,7 +1,7 @@ import assert from "node:assert"; import { readFileSync, rmSync } from "node:fs"; import type http from "node:http"; -import type { ClientRequest, IncomingMessage, ServerResponse } from "node:http"; +import { type ClientRequest, type IncomingMessage, type ServerResponse } from "node:http"; import type https from "node:https"; import { URL } from "node:url"; @@ -155,6 +155,79 @@ function shouldIgnoreRequest(request: http.IncomingMessage) { return false; } +type Chunk = string | Buffer; +const maxResponseBodyCaptureLength = 1000; + +function captureResponseBody(response: http.ServerResponse, chunks: Chunk[]) { + let currentLength = 0; + function captureChunk(chunk: Chunk) { + if (maxResponseBodyCaptureLength <= 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[]) { + console.log("WRITE", args.length); + 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[]) { + console.log("ARGS", args); + console.log("ARGS", args); + + // 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); + + //console.log(args); + //console.log("ARGS", args); + + let result: ReturnType; + if (args.length == 3) + result = originalEnd(args[0], args[1] as BufferEncoding, args[2] as () => void); + else if (args.length == 2) result = originalEnd(args[0], args[1] as () => void); + else if (args.length == 1) result = originalEnd(args[0]); + else result = originalEnd(); + + return result; + }; +} + function handleRequest(request: http.IncomingMessage, response: http.ServerResponse) { if (!(request.method && request.url)) return; @@ -169,9 +242,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); }); } @@ -238,13 +315,22 @@ function handleResponse( startTime: number, timestamp: string | undefined, response: http.ServerResponse, + 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, maxResponseBodyCaptureLength); + recording.httpResponse( requestEvent.id, getTime() - startTime, response.statusCode, normalizeHeaders(response.getHeaders()), + body, ); if (remoteRunning) return; const { request_method, path_info } = requestEvent.http_server_request; diff --git a/test/__snapshots__/httpServer.test.ts.snap b/test/__snapshots__/httpServer.test.ts.snap index 0d2ecd1b..0480c683 100644 --- a/test/__snapshots__/httpServer.test.ts.snap +++ b/test/__snapshots__/httpServer.test.ts.snap @@ -85,6 +85,7 @@ exports[`mapping Express.js requests 1`] = ` }, "id": 4, "parent_id": 1, + "return_value": "Hello World!", "thread_id": 0, }, ], @@ -139,6 +140,17 @@ exports[`mapping Express.js requests 1`] = ` }, "id": 2, "parent_id": 1, + "return_value": " + + + +Error + + +
Cannot GET /nonexistent
+ + +", "thread_id": 0, }, ], @@ -309,6 +321,7 @@ exports[`mapping Express.js requests 1`] = ` }, "id": 4, "parent_id": 1, + "return_value": "{"api":"result","param1":"3","param2":"4"}", "thread_id": 0, }, ], @@ -574,6 +587,7 @@ exports[`mapping Express.js requests 1`] = ` }, "id": 4, "parent_id": 1, + "return_value": "{"key":"value","obj":{"foo":42,"arr":[44]},"arr":[{"foo":43},{"foo":44}],"heterogenous":[42,"str"]}", "thread_id": 0, }, ], @@ -799,6 +813,7 @@ exports[`mapping Express.js requests with remote recording 1`] = ` }, "id": 4, "parent_id": 1, + "return_value": "Hello World!", "thread_id": 0, }, { @@ -829,6 +844,17 @@ exports[`mapping Express.js requests with remote recording 1`] = ` }, "id": 6, "parent_id": 5, + "return_value": " + + + +Error + + +
Cannot GET /nonexistent
+ + +", "thread_id": 0, }, { @@ -923,6 +949,7 @@ exports[`mapping Express.js requests with remote recording 1`] = ` }, "id": 10, "parent_id": 7, + "return_value": "{"api":"result","param1":"3","param2":"4"}", "thread_id": 0, }, { @@ -1073,6 +1100,7 @@ exports[`mapping Express.js requests with remote recording 1`] = ` }, "id": 14, "parent_id": 11, + "return_value": "{"key":"value","obj":{"foo":42,"arr":[44]},"arr":[{"foo":43},{"foo":44}],"heterogenous":[42,"str"]}", "thread_id": 0, }, ], @@ -1125,6 +1153,7 @@ exports[`mapping node:http requests 1`] = ` }, "id": 2, "parent_id": 1, + "return_value": "", "thread_id": 0, }, ], @@ -1173,6 +1202,7 @@ exports[`mapping node:http requests 1`] = ` }, "id": 2, "parent_id": 1, + "return_value": "", "thread_id": 0, }, ], @@ -1233,6 +1263,7 @@ exports[`mapping node:http requests 1`] = ` }, "id": 2, "parent_id": 1, + "return_value": "", "thread_id": 0, }, ], @@ -1283,6 +1314,7 @@ exports[`mapping node:http requests 1`] = ` }, "id": 2, "parent_id": 1, + "return_value": "", "thread_id": 0, }, ], @@ -1335,6 +1367,7 @@ exports[`mapping node:http requests with remote recording 1`] = ` }, "id": 2, "parent_id": 1, + "return_value": "", "thread_id": 0, }, { @@ -1359,6 +1392,7 @@ exports[`mapping node:http requests with remote recording 1`] = ` }, "id": 4, "parent_id": 3, + "return_value": "", "thread_id": 0, }, { @@ -1395,6 +1429,7 @@ exports[`mapping node:http requests with remote recording 1`] = ` }, "id": 6, "parent_id": 5, + "return_value": "", "thread_id": 0, }, { @@ -1421,6 +1456,7 @@ exports[`mapping node:http requests with remote recording 1`] = ` }, "id": 8, "parent_id": 7, + "return_value": "", "thread_id": 0, }, ],