feat(chat): log websocket traffic

This commit is contained in:
2026-06-24 10:48:36 +08:00
parent a571867620
commit f80b5215a9
6 changed files with 101 additions and 16 deletions
+99 -9
View File
@@ -11,7 +11,10 @@
* - onError(errorMessage)
*/
import { getApiConfig } from "@/core/net/config/api_config";
import { AppEnvUtil } from "@/utils";
import { AppEnvUtil, Logger } from "@/utils";
const log = new Logger("ChatWebSocket");
const RECONNECT_DELAY_MS = 3000;
export interface SentencePayload {
index: number;
@@ -36,6 +39,7 @@ export interface PaywallStatusPayload {
export class ChatWebSocket {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private connectionUrl: string | null = null;
private disposed = false;
onConnected: ((userId: string) => void) | null = null;
@@ -62,25 +66,53 @@ export class ChatWebSocket {
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return;
try {
const url = buildChatWebSocketUrl(this.serverUrl, this.token);
this.connectionUrl = url;
logWebSocketDebug(`↔ WS CONNECT ${redactWebSocketUrl(url)}`);
this.ws = new WebSocket(url);
this.ws.onopen = () => {
logWebSocketDebug(`↔ WS OPEN ${redactWebSocketUrl(url)}`);
// 连接成功;userId 由后端在第一条消息中下发
};
this.ws.onmessage = (e) => this.handleMessage(e.data);
this.ws.onerror = () => this.onError?.("WebSocket error");
this.ws.onclose = () => {
this.ws.onmessage = (e) => {
logWebSocketFrame("← WS MESSAGE", url, e.data);
this.handleMessage(e.data);
};
this.ws.onerror = () => {
logWebSocketError(`✕ WS ERROR ${redactWebSocketUrl(url)}`);
this.onError?.("WebSocket error");
};
this.ws.onclose = (event) => {
logWebSocketDebug(`↔ WS CLOSE ${redactWebSocketUrl(url)}`, {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
reconnectInMs: this.disposed ? null : RECONNECT_DELAY_MS,
});
if (!this.disposed) {
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
this.reconnectTimer = setTimeout(
() => this.connect(),
RECONNECT_DELAY_MS,
);
}
};
} catch (e) {
logWebSocketError("✕ WS CONNECT FAILED", e);
this.onError?.(e instanceof Error ? e.message : String(e));
}
}
sendMessage(content: string): boolean {
if (!this.isConnected) return false;
this.ws?.send(JSON.stringify({ type: "message", content }));
const payload = { type: "message", content };
if (!this.isConnected) {
logWebSocketWarn("→ WS SEND SKIPPED: socket is not open", payload);
return false;
}
logWebSocketFrame(
"→ WS SEND",
this.connectionUrl ?? this.serverUrl,
payload,
);
this.ws?.send(JSON.stringify(payload));
return true;
}
@@ -92,10 +124,14 @@ export class ChatWebSocket {
}
this.ws?.close();
this.ws = null;
this.connectionUrl = null;
}
private handleMessage(data: unknown): void {
if (typeof data !== "string") return;
if (typeof data !== "string") {
logWebSocketWarn("← WS MESSAGE IGNORED: non-string payload", data);
return;
}
let payload: {
type?: string;
userId?: string;
@@ -119,7 +155,8 @@ export class ChatWebSocket {
};
try {
payload = JSON.parse(data);
} catch {
} catch (e) {
logWebSocketError("← WS MESSAGE PARSE FAILED", e);
return;
}
switch (payload.type) {
@@ -175,6 +212,59 @@ function buildChatWebSocketUrl(serverUrl: string, token: string): string {
return `${serverUrl}?token=${encodeURIComponent(token)}`;
}
function redactWebSocketUrl(url: string): string {
try {
const parsed = new URL(url);
if (parsed.searchParams.has("token")) {
parsed.searchParams.set("token", "<redacted>");
}
return parsed.toString();
} catch {
return url.replace(/([?&]token=)[^&]+/i, "$1<redacted>");
}
}
function logWebSocketFrame(
label: string,
url: string,
payload: unknown,
): void {
if (AppEnvUtil.isProduction()) return;
const body = formatWebSocketLogValue(payload);
const bodyLabel = label.startsWith("→") ? "request body" : "response body";
log.debug(
`${label} ${redactWebSocketUrl(url)}${body ? `\n${bodyLabel}:\n${body}` : ""}`,
);
}
function logWebSocketDebug(message: string, payload?: unknown): void {
if (AppEnvUtil.isProduction()) return;
const body = formatWebSocketLogValue(payload);
log.debug(`${message}${body ? `\nbody:\n${body}` : ""}`);
}
function logWebSocketWarn(message: string, payload?: unknown): void {
if (AppEnvUtil.isProduction()) return;
const body = formatWebSocketLogValue(payload);
log.warn(`${message}${body ? `\nbody:\n${body}` : ""}`);
}
function logWebSocketError(message: string, payload?: unknown): void {
if (AppEnvUtil.isProduction()) return;
const body = formatWebSocketLogValue(payload);
log.error(`${message}${body ? `\nbody:\n${body}` : ""}`);
}
function formatWebSocketLogValue(payload: unknown): string {
if (payload instanceof Error) {
return Logger.formatValue({
message: payload.message,
stack: payload.stack,
});
}
return Logger.formatValue(payload);
}
/** 默认创建实例:从 env 读取 WS URL。 */
export function createChatWebSocket(token: string): ChatWebSocket {
const base = getApiConfig().wsUrl;