Skip to content

Commit

Permalink
feat: Support for mapping HTTP client requests
Browse files Browse the repository at this point in the history
  • Loading branch information
zermelo-wisen authored and dividedmind committed Nov 9, 2023
1 parent e3473fc commit 55ef2e6
Show file tree
Hide file tree
Showing 7 changed files with 396 additions and 0 deletions.
46 changes: 46 additions & 0 deletions src/Recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,52 @@ export default class Recording {
return event;
}

httpClientRequest(
method: string,
url: string,
headers?: Record<string, string>,
): AppMap.HttpClientRequestEvent {
assert(this.stream);

const event: AppMap.HttpClientRequestEvent = {
event: "call",
http_client_request: compactObject({
request_method: method,
url: url,
headers: headers,
}),
id: this.nextId++,
thread_id: 0,
};
this.stream.emit(event);

return event;
}

httpClientResponse(
callId: number,
elapsed: number,
status: number,
headers?: Record<string, string>,
): AppMap.HttpClientResponseEvent {
assert(this.stream);

const event: AppMap.HttpClientResponseEvent = {
event: "return",
http_client_response: compactObject({
status_code: status,
headers,
}),
id: this.nextId++,
thread_id: 0,
parent_id: callId,
elapsed,
};
this.stream.emit(event);

return event;
}

httpRequest(
method: string,
path: string,
Expand Down
70 changes: 70 additions & 0 deletions src/hooks/http.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import assert from "node:assert";
import { ClientRequest } from "node:http";
import type http from "node:http";
import type https from "node:https";

Expand All @@ -13,13 +15,81 @@ export default function httpHook(mod: HTTP) {
return target.apply(thisArg, argArray).prependListener("request", handleRequest);
},
});

hookClientRequestApi(mod);

return mod;
}

httpHook.applicable = function (id: string) {
return ["http", "https", "node:http", "node:https"].includes(id);
};

function hookClientRequestApi(mod: HTTP) {
const createApplyProxy = (f: HTTP["get" | "request"]) => {
return new Proxy(f, {
apply(target, thisArg, argArray: Parameters<typeof f>) {
const clientRequest = target.apply(thisArg, argArray);
handleClientRequest(clientRequest);
return clientRequest;
},
});
};

mod.request = createApplyProxy(mod.request);
mod.get = createApplyProxy(mod.get);

if ("ClientRequest" in mod) {
mod.ClientRequest = new Proxy(mod.ClientRequest, {
construct(target, argArray, newTarget): object {
const clientRequest = Reflect.construct(target, argArray, newTarget) as ClientRequest;
handleClientRequest(clientRequest);
return clientRequest;
},
});
}
}

const clientRequests = new WeakSet<http.ClientRequest>();

function handleClientRequest(request: http.ClientRequest) {
// for some reason proxy construct/apply is called multiple times for the same request
// so let's make sure we haven't seen this one before
if (clientRequests.has(request)) return;
clientRequests.add(request);

const startTime = getTime();
request.on("finish", () => {
const url = new URL(`${request.protocol}//${request.host}${request.path}`);
// Setting port to the default port for the protocol makes it empty string.
// See: https://nodejs.org/api/url.html#urlport
url.port = request.socket?.remotePort + "";
const clientRequestEvent = recording.httpClientRequest(
request.method,
`${url.protocol}//${url.host}${url.pathname}`,
normalizeHeaders(request.getHeaders()),
);

request.on("response", (response) => {
response.once("end", () => handleClientResponse(clientRequestEvent, startTime, response));
});
});
}

function handleClientResponse(
requestEvent: AppMap.HttpClientRequestEvent,
startTime: number,
response: http.IncomingMessage,
): void {
assert(response.statusCode != undefined);
recording.httpClientResponse(
requestEvent.id,
getTime() - startTime,
response.statusCode,
normalizeHeaders(response.headers),
);
}

const requests = new WeakSet<http.IncomingMessage>();

function handleRequest(request: http.IncomingMessage, response: http.ServerResponse) {
Expand Down
9 changes: 9 additions & 0 deletions src/hooks/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ function wrapWithRecord(

function FunctionDeclaration(fun: ESTree.FunctionDeclaration) {
if (!hasIdentifier(fun)) return;
if (isNotInteresting(fun)) return;
fun.body = wrapWithRecord(fun, createFunctionInfo(fun));
}

Expand Down Expand Up @@ -107,6 +108,14 @@ export function shouldInstrument(url: URL): boolean {
return true;
}

function isNotInteresting(fun: ESTree.FunctionDeclaration) {
// When we import node:http in ts files "_interop_require_default"
// function is injected to the source.
if (fun.id?.name === "_interop_require_default") return true;

return false;
}

function isUnrelated(parentPath: string, targetPath: string) {
const rel = path.relative(parentPath, targetPath);
return rel === targetPath || rel.startsWith("..");
Expand Down
191 changes: 191 additions & 0 deletions test/__snapshots__/httpClient.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`mapping http client requests 1`] = `
{
"classMap": [
{
"children": [
{
"children": [
{
"location": "./index.ts:18",
"name": "makeRequests",
"static": true,
"type": "function",
},
],
"name": "",
"type": "class",
},
],
"name": "index",
"type": "package",
},
],
"eventUpdates": {
"4": {
"elapsed": 31.337,
"event": "return",
"id": 4,
"parent_id": 3,
"return_value": {
"class": "Promise",
"value": "Promise { undefined }",
},
"thread_id": 0,
},
},
"events": [
{
"defined_class": "",
"event": "call",
"id": 1,
"method_id": "_export",
"parameters": [
{
"class": "Object",
"name": "target",
"value": "{}",
},
{
"class": "Object",
"name": "all",
"value": "{
SERVER_PORT: [Function: SERVER_PORT],
TEST_HEADER_VALUE: [Function: TEST_HEADER_VALUE]
}",
},
],
"static": true,
"thread_id": 0,
},
{
"elapsed": 31.337,
"event": "return",
"id": 2,
"parent_id": 1,
"thread_id": 0,
},
{
"defined_class": "",
"event": "call",
"id": 3,
"lineno": 18,
"method_id": "makeRequests",
"parameters": [],
"path": "./index.ts",
"static": true,
"thread_id": 0,
},
{
"elapsed": 31.337,
"event": "return",
"id": 4,
"parent_id": 3,
"return_value": {
"class": "Promise",
"value": "Promise { <pending> }",
},
"thread_id": 0,
},
{
"event": "call",
"http_client_request": {
"headers": {
"host": "localhost:27628",
"test-header": "This test header is added after ClientRequest creation",
},
"request_method": "GET",
"url": "http://localhost:27628/endpoint/one",
},
"id": 5,
"thread_id": 0,
},
{
"elapsed": 31.337,
"event": "return",
"http_client_response": {
"headers": {
"transfer-encoding": "chunked",
},
"status_code": 200,
},
"id": 6,
"parent_id": 5,
"thread_id": 0,
},
{
"event": "call",
"http_client_request": {
"headers": {
"content-type": "application/json",
"host": "localhost:27628",
},
"request_method": "POST",
"url": "http://localhost:27628/endpoint/two",
},
"id": 7,
"thread_id": 0,
},
{
"elapsed": 31.337,
"event": "return",
"http_client_response": {
"headers": {
"content-type": "text/html",
"transfer-encoding": "chunked",
},
"status_code": 404,
},
"id": 8,
"parent_id": 7,
"thread_id": 0,
},
{
"event": "call",
"http_client_request": {
"headers": {
"host": "localhost:27628",
},
"request_method": "GET",
"url": "http://localhost:27628/endpoint/three",
},
"id": 9,
"thread_id": 0,
},
{
"elapsed": 31.337,
"event": "return",
"http_client_response": {
"headers": {
"content-type": "text/html",
"transfer-encoding": "chunked",
},
"status_code": 404,
},
"id": 10,
"parent_id": 9,
"thread_id": 0,
},
],
"metadata": {
"app": "appmap-node",
"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",
}
`;
16 changes: 16 additions & 0 deletions test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ function fixEvent(event: unknown) {
)
// the default of this varies between node versions
delete event.http_server_request.headers.connection;

if (
"http_client_response" in event &&
typeof event.http_client_response === "object" &&
event.http_client_response &&
"headers" in event.http_client_response &&
typeof event.http_client_response.headers === "object" &&
event.http_client_response.headers
) {
if ("date" in event.http_client_response.headers)
delete event.http_client_response.headers.date;
if ("connection" in event.http_client_response.headers)
delete event.http_client_response.headers.connection;
if ("keep-alive" in event.http_client_response.headers)
delete event.http_client_response.headers["keep-alive"];
}
if ("elapsed" in event && typeof event.elapsed === "number") event.elapsed = 31.337;
}

Expand Down
Loading

0 comments on commit 55ef2e6

Please sign in to comment.