Skip to content

Commit 598ac9d

Browse files
committed
Reduce computation by size estimating on raw JSON requests
1 parent 8c56ebe commit 598ac9d

File tree

7 files changed

+167
-97
lines changed

7 files changed

+167
-97
lines changed

src/api/coderApi.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { type AxiosInstance } from "axios";
1+
import {
2+
type AxiosResponseHeaders,
3+
type AxiosInstance,
4+
type AxiosHeaders,
5+
type AxiosResponseTransformer,
6+
} from "axios";
27
import { Api } from "coder/site/src/api/api";
38
import {
49
type GetInboxNotificationResponse,
@@ -23,6 +28,7 @@ import {
2328
type RequestConfigWithMeta,
2429
HttpClientLogLevel,
2530
} from "../logging/types";
31+
import { serializeValue, sizeOf } from "../logging/utils";
2632
import { WsLogger } from "../logging/wsLogger";
2733
import {
2834
OneWayWebSocket,
@@ -207,7 +213,24 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) {
207213
(config) => {
208214
const configWithMeta = config as RequestConfigWithMeta;
209215
configWithMeta.metadata = createRequestMeta();
210-
logRequest(logger, configWithMeta, getLogLevel());
216+
217+
config.transformRequest = [
218+
...wrapRequestTransform(
219+
config.transformRequest || client.defaults.transformRequest || [],
220+
configWithMeta,
221+
),
222+
(data) => {
223+
// Log after setting the raw request size
224+
logRequest(logger, configWithMeta, getLogLevel());
225+
return data;
226+
},
227+
];
228+
229+
config.transformResponse = wrapResponseTransform(
230+
config.transformResponse || client.defaults.transformResponse || [],
231+
configWithMeta,
232+
);
233+
211234
return config;
212235
},
213236
(error: unknown) => {
@@ -228,6 +251,66 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) {
228251
);
229252
}
230253

254+
function wrapRequestTransform(
255+
transformer: AxiosResponseTransformer | AxiosResponseTransformer[],
256+
config: RequestConfigWithMeta,
257+
): AxiosResponseTransformer[] {
258+
return [
259+
(data: unknown, headers: AxiosHeaders) => {
260+
const transformerArray = Array.isArray(transformer)
261+
? transformer
262+
: [transformer];
263+
264+
// Transform the request first then estimate the size
265+
const result = transformerArray.reduce(
266+
(d, fn) => fn.call(config, d, headers),
267+
data,
268+
);
269+
270+
config.rawRequestSize = getSize(config.headers, result);
271+
272+
return result;
273+
},
274+
];
275+
}
276+
277+
function wrapResponseTransform(
278+
transformer: AxiosResponseTransformer | AxiosResponseTransformer[],
279+
config: RequestConfigWithMeta,
280+
): AxiosResponseTransformer[] {
281+
return [
282+
(data: unknown, headers: AxiosResponseHeaders, status?: number) => {
283+
// estimate the size before transforming the response
284+
config.rawResponseSize = getSize(headers, data);
285+
286+
const transformerArray = Array.isArray(transformer)
287+
? transformer
288+
: [transformer];
289+
290+
return transformerArray.reduce(
291+
(d, fn) => fn.call(config, d, headers, status),
292+
data,
293+
);
294+
},
295+
];
296+
}
297+
298+
function getSize(headers: AxiosHeaders, data: unknown): number | undefined {
299+
const contentLength = headers["content-length"];
300+
if (contentLength !== undefined) {
301+
return parseInt(contentLength, 10);
302+
}
303+
304+
const size = sizeOf(data);
305+
if (size !== undefined) {
306+
return size;
307+
}
308+
309+
// Fallback
310+
const stringified = serializeValue(data);
311+
return stringified === null ? undefined : Buffer.byteLength(stringified);
312+
}
313+
231314
function getLogLevel(): HttpClientLogLevel {
232315
const logLevelStr = vscode.workspace
233316
.getConfiguration()

src/logging/formatters.ts

Lines changed: 5 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import util from "node:util";
21
import prettyBytes from "pretty-bytes";
32

4-
import { sizeOf } from "./utils";
3+
import { serializeValue } from "./utils";
54

65
import type { AxiosRequestConfig } from "axios";
76

@@ -21,38 +20,11 @@ export function formatTime(ms: number): string {
2120
}
2221

2322
export function formatMethod(method: string | undefined): string {
24-
return (method ? method : "GET").toUpperCase();
23+
return method?.toUpperCase() || "GET";
2524
}
2625

27-
/**
28-
* Formats content-length for display. Returns the header value if available,
29-
* otherwise estimates size by serializing the data body (prefixed with ~).
30-
*/
31-
export function formatContentLength(
32-
headers: Record<string, unknown>,
33-
data: unknown,
34-
): string {
35-
const len = headers["content-length"];
36-
if (len && typeof len === "string") {
37-
const bytes = parseInt(len, 10);
38-
return isNaN(bytes) ? "(? B)" : `(${prettyBytes(bytes)})`;
39-
}
40-
41-
// Estimate from data if no header
42-
const size = sizeOf(data);
43-
if (size !== undefined) {
44-
return `(${prettyBytes(size)})`;
45-
}
46-
47-
if (typeof data === "object") {
48-
const stringified = safeStringify(data);
49-
if (stringified !== null) {
50-
const bytes = Buffer.byteLength(stringified, "utf8");
51-
return `(~${prettyBytes(bytes)})`;
52-
}
53-
}
54-
55-
return "(? B)";
26+
export function formatSize(size: number | undefined): string {
27+
return size === undefined ? "(? B)" : `(${prettyBytes(size)})`;
5628
}
5729

5830
export function formatUri(config: AxiosRequestConfig | undefined): string {
@@ -75,25 +47,8 @@ export function formatHeaders(headers: Record<string, unknown>): string {
7547

7648
export function formatBody(body: unknown): string {
7749
if (body) {
78-
return safeStringify(body) ?? "<invalid body>";
50+
return serializeValue(body) ?? "<invalid body>";
7951
} else {
8052
return "<no body>";
8153
}
8254
}
83-
84-
function safeStringify(data: unknown): string | null {
85-
try {
86-
return util.inspect(data, {
87-
showHidden: false,
88-
depth: Infinity,
89-
maxArrayLength: Infinity,
90-
maxStringLength: Infinity,
91-
breakLength: Infinity,
92-
compact: true,
93-
getters: false, // avoid side-effects
94-
});
95-
} catch {
96-
// Should rarely happen but just in case
97-
return null;
98-
}
99-
}

src/logging/httpLogger.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { getErrorDetail } from "../error";
55

66
import {
77
formatBody,
8-
formatContentLength,
98
formatHeaders,
109
formatMethod,
10+
formatSize,
1111
formatTime,
1212
formatUri,
1313
} from "./formatters";
@@ -42,11 +42,10 @@ export function logRequest(
4242
return;
4343
}
4444

45-
const { requestId, method, url } = parseConfig(config);
46-
const len = formatContentLength(config.headers, config.data);
45+
const { requestId, method, url, requestSize } = parseConfig(config);
4746

4847
const msg = [
49-
`→ ${shortId(requestId)} ${method} ${url} ${len}`,
48+
`→ ${shortId(requestId)} ${method} ${url} ${requestSize}`,
5049
...buildExtraLogs(config.headers, config.data, logLevel),
5150
];
5251
logger.trace(msg.join("\n"));
@@ -64,11 +63,12 @@ export function logResponse(
6463
return;
6564
}
6665

67-
const { requestId, method, url, time } = parseConfig(response.config);
68-
const len = formatContentLength(response.headers, response.data);
66+
const { requestId, method, url, time, responseSize } = parseConfig(
67+
response.config,
68+
);
6969

7070
const msg = [
71-
`← ${shortId(requestId)} ${response.status} ${method} ${url} ${len} ${time}`,
71+
`← ${shortId(requestId)} ${response.status} ${method} ${url} ${responseSize} ${time}`,
7272
...buildExtraLogs(response.headers, response.data, logLevel),
7373
];
7474
logger.trace(msg.join("\n"));
@@ -150,12 +150,16 @@ function parseConfig(config: RequestConfigWithMeta | undefined): {
150150
method: string;
151151
url: string;
152152
time: string;
153+
requestSize: string;
154+
responseSize: string;
153155
} {
154156
const meta = config?.metadata;
155157
return {
156158
requestId: meta?.requestId || "unknown",
157159
method: formatMethod(config?.method),
158160
url: formatUri(config),
159161
time: meta ? formatTime(Date.now() - meta.startedAt) : "?ms",
162+
requestSize: formatSize(config?.rawRequestSize),
163+
responseSize: formatSize(config?.rawResponseSize),
160164
};
161165
}

src/logging/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ export interface RequestMeta {
1414

1515
export type RequestConfigWithMeta = InternalAxiosRequestConfig & {
1616
metadata?: RequestMeta;
17+
rawRequestSize?: number;
18+
rawResponseSize?: number;
1719
};

src/logging/utils.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { Buffer } from "node:buffer";
22
import crypto from "node:crypto";
3+
import util from "node:util";
34

45
export function shortId(id: string): string {
56
return id.slice(0, 8);
67
}
78

9+
export function createRequestId(): string {
10+
return crypto.randomUUID().replace(/-/g, "");
11+
}
12+
813
/**
914
* Returns the byte size of the data if it can be determined from the data's intrinsic properties,
1015
* otherwise returns undefined (e.g., for plain objects and arrays that would require serialization).
@@ -13,7 +18,10 @@ export function sizeOf(data: unknown): number | undefined {
1318
if (data === null || data === undefined) {
1419
return 0;
1520
}
16-
if (typeof data === "number" || typeof data === "boolean") {
21+
if (typeof data === "boolean") {
22+
return 4;
23+
}
24+
if (typeof data === "number") {
1725
return 8;
1826
}
1927
if (typeof data === "string" || typeof data === "bigint") {
@@ -36,6 +44,19 @@ export function sizeOf(data: unknown): number | undefined {
3644
return undefined;
3745
}
3846

39-
export function createRequestId(): string {
40-
return crypto.randomUUID().replace(/-/g, "");
47+
export function serializeValue(data: unknown): string | null {
48+
try {
49+
return util.inspect(data, {
50+
showHidden: false,
51+
depth: Infinity,
52+
maxArrayLength: Infinity,
53+
maxStringLength: Infinity,
54+
breakLength: Infinity,
55+
compact: true,
56+
getters: false, // avoid side-effects
57+
});
58+
} catch {
59+
// Should rarely happen but just in case
60+
return null;
61+
}
4162
}

test/unit/logging/formatters.test.ts

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
22

33
import {
44
formatBody,
5-
formatContentLength,
65
formatHeaders,
76
formatMethod,
7+
formatSize,
88
formatTime,
99
formatUri,
1010
} from "@/logging/formatters";
@@ -34,40 +34,14 @@ describe("Logging formatters", () => {
3434
});
3535
});
3636

37-
describe("formatContentLength", () => {
38-
it("uses content-length header when available", () => {
39-
const result = formatContentLength({ "content-length": "1024" }, null);
40-
expect(result).toContain("1.02 kB");
37+
describe("formatSize", () => {
38+
it("formats byte sizes using pretty-bytes", () => {
39+
expect(formatSize(1024)).toContain("1.02 kB");
40+
expect(formatSize(0)).toBe("(0 B)");
4141
});
4242

43-
it("handles invalid content-length header", () => {
44-
const result = formatContentLength({ "content-length": "invalid" }, null);
45-
expect(result).toContain("?");
46-
});
47-
48-
it.each([
49-
["hello", 5],
50-
[Buffer.from("test"), 4],
51-
[123, 8],
52-
[false, 8],
53-
[BigInt(1234), 4],
54-
[null, 0],
55-
[undefined, 0],
56-
])("calculates size for %s", (data: unknown, bytes: number) => {
57-
const result = formatContentLength({}, data);
58-
expect(result).toContain(`${bytes} B`);
59-
});
60-
61-
it("estimates size for objects", () => {
62-
const result = formatContentLength({}, { foo: "bar" });
63-
expect(result).toMatch(/~\d+/);
64-
});
65-
66-
it("handles circular references safely", () => {
67-
const circular: Record<string, unknown> = { a: 1 };
68-
circular.self = circular;
69-
const result = formatContentLength({}, circular);
70-
expect(result).toMatch(/~\d+/);
43+
it("returns placeholder for undefined", () => {
44+
expect(formatSize(undefined)).toBe("(? B)");
7145
});
7246
});
7347

0 commit comments

Comments
 (0)