Skip to content

Commit c0a2b5f

Browse files
authored
Fix JSON.stringify circular reference error in logging infrastructure (#607)
Resolves circular structure errors when serializing request/response bodies for logging. Other changes: * Added test coverage for logging infrastructure * Fixed flaky stream test * Calculate body size directly from strings/buffers instead of stringifying * Use util.inspect instead of JSON.stringify for body serialization Fixes #606
1 parent b2ac27b commit c0a2b5f

File tree

14 files changed

+573
-59
lines changed

14 files changed

+573
-59
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixed
66

77
- Logging in or out in one VS Code window now properly updates the authentication status in all other open windows.
8+
- Fix an issue with JSON stringification errors occurring when logging circular objects.
89

910
### Added
1011

src/api/coderApi.ts

Lines changed: 78 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 { 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,59 @@ 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 get the size (measure what's sent over the wire)
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+
// Get the size before transforming the response (measure what's sent over the wire)
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+
return sizeOf(data);
305+
}
306+
231307
function getLogLevel(): HttpClientLogLevel {
232308
const logLevelStr = vscode.workspace
233309
.getConfiguration()

src/core/cliManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ export class CliManager {
267267
if (Number.isNaN(contentLength)) {
268268
this.output.warn(
269269
"Got invalid or missing content length",
270-
rawContentLength,
270+
rawContentLength ?? "",
271271
);
272272
} else {
273273
this.output.info("Got content length", prettyBytes(contentLength));

src/logging/formatters.ts

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import prettyBytes from "pretty-bytes";
22

3-
import type { InternalAxiosRequestConfig } from "axios";
3+
import { safeStringify } from "./utils";
4+
5+
import type { AxiosRequestConfig } from "axios";
46

57
const SENSITIVE_HEADERS = ["Coder-Session-Token", "Proxy-Authorization"];
68

@@ -18,35 +20,14 @@ export function formatTime(ms: number): string {
1820
}
1921

2022
export function formatMethod(method: string | undefined): string {
21-
return (method ?? "GET").toUpperCase();
23+
return method?.toUpperCase() || "GET";
2224
}
2325

24-
/**
25-
* Formats content-length for display. Returns the header value if available,
26-
* otherwise estimates size by serializing the data body (prefixed with ~).
27-
*/
28-
export function formatContentLength(
29-
headers: Record<string, unknown>,
30-
data: unknown,
31-
): string {
32-
const len = headers["content-length"];
33-
if (len && typeof len === "string") {
34-
const bytes = parseInt(len, 10);
35-
return isNaN(bytes) ? "(?b)" : `(${prettyBytes(bytes)})`;
36-
}
37-
38-
// Estimate from data if no header
39-
if (data !== undefined && data !== null) {
40-
const estimated = Buffer.byteLength(JSON.stringify(data), "utf8");
41-
return `(~${prettyBytes(estimated)})`;
42-
}
43-
44-
return `(${prettyBytes(0)})`;
26+
export function formatSize(size: number | undefined): string {
27+
return size === undefined ? "(? B)" : `(${prettyBytes(size)})`;
4528
}
4629

47-
export function formatUri(
48-
config: InternalAxiosRequestConfig | undefined,
49-
): string {
30+
export function formatUri(config: AxiosRequestConfig | undefined): string {
5031
return config?.url || "<no url>";
5132
}
5233

@@ -66,7 +47,7 @@ export function formatHeaders(headers: Record<string, unknown>): string {
6647

6748
export function formatBody(body: unknown): string {
6849
if (body) {
69-
return JSON.stringify(body);
50+
return safeStringify(body) ?? "<invalid body>";
7051
} else {
7152
return "<no body>";
7253
}

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: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,37 @@
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+
13+
/**
14+
* Returns the byte size of the data if it can be determined from the data's intrinsic properties,
15+
* otherwise returns undefined (e.g., for plain objects and arrays that would require serialization).
16+
*/
817
export function sizeOf(data: unknown): number | undefined {
918
if (data === null || data === undefined) {
1019
return 0;
1120
}
12-
if (typeof data === "string") {
13-
return Buffer.byteLength(data);
21+
if (typeof data === "boolean") {
22+
return 4;
23+
}
24+
if (typeof data === "number") {
25+
return 8;
1426
}
15-
if (Buffer.isBuffer(data)) {
16-
return data.length;
27+
if (typeof data === "string" || typeof data === "bigint") {
28+
return Buffer.byteLength(data.toString());
1729
}
18-
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
30+
if (
31+
Buffer.isBuffer(data) ||
32+
data instanceof ArrayBuffer ||
33+
ArrayBuffer.isView(data)
34+
) {
1935
return data.byteLength;
2036
}
2137
if (
@@ -28,6 +44,19 @@ export function sizeOf(data: unknown): number | undefined {
2844
return undefined;
2945
}
3046

31-
export function createRequestId(): string {
32-
return crypto.randomUUID().replace(/-/g, "");
47+
export function safeStringify(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+
}
3362
}

src/logging/wsLogger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,6 @@ export class WsLogger {
7777

7878
private formatBytes(): string {
7979
const bytes = prettyBytes(this.byteCount);
80-
return this.unknownByteCount ? `>=${bytes}` : bytes;
80+
return this.unknownByteCount ? `>= ${bytes}` : bytes;
8181
}
8282
}

test/mocks/testHelpers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { vi } from "vitest";
22
import * as vscode from "vscode";
33

4+
import { type Logger } from "@/logging/logger";
5+
46
/**
57
* Mock configuration provider that integrates with the vscode workspace configuration mock.
68
* Use this to set configuration values that will be returned by vscode.workspace.getConfiguration().
@@ -286,3 +288,13 @@ export class InMemorySecretStorage implements vscode.SecretStorage {
286288
this.listeners.forEach((listener) => listener(event));
287289
}
288290
}
291+
292+
export function createMockLogger(): Logger {
293+
return {
294+
trace: vi.fn(),
295+
debug: vi.fn(),
296+
info: vi.fn(),
297+
warn: vi.fn(),
298+
error: vi.fn(),
299+
};
300+
}

0 commit comments

Comments
 (0)