Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Fixed

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

### Added

Expand Down
87 changes: 85 additions & 2 deletions src/api/coderApi.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type AxiosInstance } from "axios";
import {
type AxiosResponseHeaders,
type AxiosInstance,
type AxiosHeaders,
type AxiosResponseTransformer,
} from "axios";
import { Api } from "coder/site/src/api/api";
import {
type GetInboxNotificationResponse,
Expand All @@ -23,6 +28,7 @@ import {
type RequestConfigWithMeta,
HttpClientLogLevel,
} from "../logging/types";
import { serializeValue, sizeOf } from "../logging/utils";
import { WsLogger } from "../logging/wsLogger";
import {
OneWayWebSocket,
Expand Down Expand Up @@ -207,7 +213,24 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) {
(config) => {
const configWithMeta = config as RequestConfigWithMeta;
configWithMeta.metadata = createRequestMeta();
logRequest(logger, configWithMeta, getLogLevel());

config.transformRequest = [
...wrapRequestTransform(
config.transformRequest || client.defaults.transformRequest || [],
configWithMeta,
),
(data) => {
// Log after setting the raw request size
logRequest(logger, configWithMeta, getLogLevel());
return data;
},
];

config.transformResponse = wrapResponseTransform(
config.transformResponse || client.defaults.transformResponse || [],
configWithMeta,
);

return config;
},
(error: unknown) => {
Expand All @@ -228,6 +251,66 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) {
);
}

function wrapRequestTransform(
transformer: AxiosResponseTransformer | AxiosResponseTransformer[],
config: RequestConfigWithMeta,
): AxiosResponseTransformer[] {
return [
(data: unknown, headers: AxiosHeaders) => {
const transformerArray = Array.isArray(transformer)
? transformer
: [transformer];

// Transform the request first then estimate the size
const result = transformerArray.reduce(
(d, fn) => fn.call(config, d, headers),
data,
);

config.rawRequestSize = getSize(config.headers, result);

return result;
},
];
}

function wrapResponseTransform(
transformer: AxiosResponseTransformer | AxiosResponseTransformer[],
config: RequestConfigWithMeta,
): AxiosResponseTransformer[] {
return [
(data: unknown, headers: AxiosResponseHeaders, status?: number) => {
// estimate the size before transforming the response
config.rawResponseSize = getSize(headers, data);

const transformerArray = Array.isArray(transformer)
? transformer
: [transformer];

return transformerArray.reduce(
(d, fn) => fn.call(config, d, headers, status),
data,
);
},
];
}

function getSize(headers: AxiosHeaders, data: unknown): number | undefined {
const contentLength = headers["content-length"];
if (contentLength !== undefined) {
return parseInt(contentLength, 10);
}

const size = sizeOf(data);
if (size !== undefined) {
return size;
}

// Fallback
const stringified = serializeValue(data);
return stringified === null ? undefined : Buffer.byteLength(stringified);
}

function getLogLevel(): HttpClientLogLevel {
const logLevelStr = vscode.workspace
.getConfiguration()
Expand Down
2 changes: 1 addition & 1 deletion src/core/cliManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export class CliManager {
if (Number.isNaN(contentLength)) {
this.output.warn(
"Got invalid or missing content length",
rawContentLength,
rawContentLength ?? "",
);
} else {
this.output.info("Got content length", prettyBytes(contentLength));
Expand Down
35 changes: 8 additions & 27 deletions src/logging/formatters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import prettyBytes from "pretty-bytes";

import type { InternalAxiosRequestConfig } from "axios";
import { serializeValue } from "./utils";

import type { AxiosRequestConfig } from "axios";

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

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

export function formatMethod(method: string | undefined): string {
return (method ?? "GET").toUpperCase();
return method?.toUpperCase() || "GET";
}

/**
* Formats content-length for display. Returns the header value if available,
* otherwise estimates size by serializing the data body (prefixed with ~).
*/
export function formatContentLength(
headers: Record<string, unknown>,
data: unknown,
): string {
const len = headers["content-length"];
if (len && typeof len === "string") {
const bytes = parseInt(len, 10);
return isNaN(bytes) ? "(?b)" : `(${prettyBytes(bytes)})`;
}

// Estimate from data if no header
if (data !== undefined && data !== null) {
const estimated = Buffer.byteLength(JSON.stringify(data), "utf8");
return `(~${prettyBytes(estimated)})`;
}

return `(${prettyBytes(0)})`;
export function formatSize(size: number | undefined): string {
return size === undefined ? "(? B)" : `(${prettyBytes(size)})`;
}

export function formatUri(
config: InternalAxiosRequestConfig | undefined,
): string {
export function formatUri(config: AxiosRequestConfig | undefined): string {
return config?.url || "<no url>";
}

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

export function formatBody(body: unknown): string {
if (body) {
return JSON.stringify(body);
return serializeValue(body) ?? "<invalid body>";
} else {
return "<no body>";
}
Expand Down
18 changes: 11 additions & 7 deletions src/logging/httpLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { getErrorDetail } from "../error";

import {
formatBody,
formatContentLength,
formatHeaders,
formatMethod,
formatSize,
formatTime,
formatUri,
} from "./formatters";
Expand Down Expand Up @@ -42,11 +42,10 @@ export function logRequest(
return;
}

const { requestId, method, url } = parseConfig(config);
const len = formatContentLength(config.headers, config.data);
const { requestId, method, url, requestSize } = parseConfig(config);

const msg = [
`→ ${shortId(requestId)} ${method} ${url} ${len}`,
`→ ${shortId(requestId)} ${method} ${url} ${requestSize}`,
...buildExtraLogs(config.headers, config.data, logLevel),
];
logger.trace(msg.join("\n"));
Expand All @@ -64,11 +63,12 @@ export function logResponse(
return;
}

const { requestId, method, url, time } = parseConfig(response.config);
const len = formatContentLength(response.headers, response.data);
const { requestId, method, url, time, responseSize } = parseConfig(
response.config,
);

const msg = [
`← ${shortId(requestId)} ${response.status} ${method} ${url} ${len} ${time}`,
`← ${shortId(requestId)} ${response.status} ${method} ${url} ${responseSize} ${time}`,
...buildExtraLogs(response.headers, response.data, logLevel),
];
logger.trace(msg.join("\n"));
Expand Down Expand Up @@ -150,12 +150,16 @@ function parseConfig(config: RequestConfigWithMeta | undefined): {
method: string;
url: string;
time: string;
requestSize: string;
responseSize: string;
} {
const meta = config?.metadata;
return {
requestId: meta?.requestId || "unknown",
method: formatMethod(config?.method),
url: formatUri(config),
time: meta ? formatTime(Date.now() - meta.startedAt) : "?ms",
requestSize: formatSize(config?.rawRequestSize),
responseSize: formatSize(config?.rawResponseSize),
};
}
2 changes: 2 additions & 0 deletions src/logging/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ export interface RequestMeta {

export type RequestConfigWithMeta = InternalAxiosRequestConfig & {
metadata?: RequestMeta;
rawRequestSize?: number;
rawResponseSize?: number;
};
43 changes: 36 additions & 7 deletions src/logging/utils.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
import { Buffer } from "node:buffer";
import crypto from "node:crypto";
import util from "node:util";

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

export function createRequestId(): string {
return crypto.randomUUID().replace(/-/g, "");
}

/**
* Returns the byte size of the data if it can be determined from the data's intrinsic properties,
* otherwise returns undefined (e.g., for plain objects and arrays that would require serialization).
*/
export function sizeOf(data: unknown): number | undefined {
if (data === null || data === undefined) {
return 0;
}
if (typeof data === "string") {
return Buffer.byteLength(data);
if (typeof data === "boolean") {
return 4;
}
if (typeof data === "number") {
return 8;
}
if (Buffer.isBuffer(data)) {
return data.length;
if (typeof data === "string" || typeof data === "bigint") {
return Buffer.byteLength(data.toString());
}
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
if (
Buffer.isBuffer(data) ||
data instanceof ArrayBuffer ||
ArrayBuffer.isView(data)
) {
return data.byteLength;
}
if (
Expand All @@ -28,6 +44,19 @@ export function sizeOf(data: unknown): number | undefined {
return undefined;
}

export function createRequestId(): string {
return crypto.randomUUID().replace(/-/g, "");
export function serializeValue(data: unknown): string | null {
try {
return util.inspect(data, {
showHidden: false,
depth: Infinity,
maxArrayLength: Infinity,
maxStringLength: Infinity,
breakLength: Infinity,
compact: true,
getters: false, // avoid side-effects
});
} catch {
// Should rarely happen but just in case
return null;
}
}
2 changes: 1 addition & 1 deletion src/logging/wsLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,6 @@ export class WsLogger {

private formatBytes(): string {
const bytes = prettyBytes(this.byteCount);
return this.unknownByteCount ? `>=${bytes}` : bytes;
return this.unknownByteCount ? `>= ${bytes}` : bytes;
}
}
12 changes: 12 additions & 0 deletions test/mocks/testHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { vi } from "vitest";
import * as vscode from "vscode";

import { type Logger } from "@/logging/logger";

/**
* Mock configuration provider that integrates with the vscode workspace configuration mock.
* Use this to set configuration values that will be returned by vscode.workspace.getConfiguration().
Expand Down Expand Up @@ -286,3 +288,13 @@ export class InMemorySecretStorage implements vscode.SecretStorage {
this.listeners.forEach((listener) => listener(event));
}
}

export function createMockLogger(): Logger {
return {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
Loading