From 94b1427fdfa17771b245e35be0e7bc3f38c12e6d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 13:03:15 +0100 Subject: [PATCH] fix(discord): log gateway websocket close details --- .../src/monitor/gateway-plugin.test.ts | 50 ++++++- .../discord/src/monitor/gateway-plugin.ts | 128 +++++++++++++++++- 2 files changed, 171 insertions(+), 7 deletions(-) diff --git a/extensions/discord/src/monitor/gateway-plugin.test.ts b/extensions/discord/src/monitor/gateway-plugin.test.ts index 23c86cc74b61..42fcb954147d 100644 --- a/extensions/discord/src/monitor/gateway-plugin.test.ts +++ b/extensions/discord/src/monitor/gateway-plugin.test.ts @@ -66,6 +66,7 @@ vi.mock("openclaw/plugin-sdk/proxy-capture", () => ({ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ danger: (value: string) => value, + warn: (value: string) => value, })); describe("createDiscordGatewayPlugin", () => { @@ -86,14 +87,15 @@ describe("createDiscordGatewayPlugin", () => { function createPlugin( testing?: NonNullable[0]["testing"]>, discordConfig: Parameters[0]["discordConfig"] = {}, + runtime: Parameters[0]["runtime"] = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, ) { return createDiscordGatewayPlugin({ discordConfig, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }, + runtime, ...(testing ? { testing } : {}), }); } @@ -313,4 +315,42 @@ describe("createDiscordGatewayPlugin", () => { expect(activitySpy).not.toHaveBeenCalled(); }); + + it("logs Discord gateway websocket error and abnormal close details", () => { + const socket = new EventEmitter() as EventEmitter & { binaryType?: string }; + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const plugin = createPlugin( + { + webSocketCtor: function WebSocketCtor() { + return socket; + } as unknown as NonNullable< + Parameters[0]["testing"] + >["webSocketCtor"], + }, + {}, + runtime, + ); + const createdSocket = ( + plugin as unknown as { createWebSocket: (url: string) => typeof socket } + ).createWebSocket("wss://gateway.discord.gg"); + const receiverLimitError = Object.assign(new Error("Too many buffered parts"), { + code: "WS_ERR_TOO_MANY_BUFFERED_PARTS", + }); + + createdSocket.emit("error", receiverLimitError); + createdSocket.emit("close", 1008, Buffer.from("policy violation")); + + const logs = runtime.log.mock.calls.map((call) => String(call[0])).join("\n"); + expect(logs).toContain("discord: gateway websocket error"); + expect(logs).toContain("code=WS_ERR_TOO_MANY_BUFFERED_PARTS"); + expect(logs).toContain("discord: gateway websocket closed"); + expect(logs).toContain("code=1008"); + expect(logs).toContain("reason=policy violation"); + expect(logs).toContain("lastErrorCode=WS_ERR_TOO_MANY_BUFFERED_PARTS"); + expect(logs).toContain("hint=possible ws receiver buffered-parts limit"); + }); }); diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 09a29fec1dc2..99f8a025881c 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -8,7 +8,7 @@ import { resolveEffectiveDebugProxyUrl, resolveDebugProxySettings, } from "openclaw/plugin-sdk/proxy-capture"; -import { danger } from "openclaw/plugin-sdk/runtime-env"; +import { danger, warn } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import * as ws from "ws"; import * as discordGateway from "../internal/gateway.js"; @@ -31,6 +31,9 @@ export { } from "./gateway-metadata.js"; const DISCORD_GATEWAY_HANDSHAKE_TIMEOUT_MS = 30_000; +const DISCORD_GATEWAY_POLICY_VIOLATION_CLOSE_CODE = 1008; +const DISCORD_GATEWAY_WS_RECEIVER_LIMIT_CODE = "WS_ERR_TOO_MANY_BUFFERED_PARTS"; +const DISCORD_GATEWAY_CLOSE_REASON_LOG_MAX_CHARS = 240; const discordDnsLookup = createDiscordDnsLookup(); type DiscordGatewayWebSocketCtor = new ( @@ -55,6 +58,13 @@ type DiscordGatewayRegistrationState = { ws?: unknown; isConnecting?: boolean; }; +type DiscordGatewayTransportErrorDetails = { + name?: string; + message: string; + code?: string; + closeCode?: number; + statusCode?: number; +}; function assignGatewayClient( plugin: discordGateway.GatewayPlugin, @@ -68,6 +78,94 @@ function hasGatewaySocketStarted(plugin: discordGateway.GatewayPlugin): boolean return state.ws != null || state.isConnecting === true; } +function readStringProperty(value: object, key: string): string | undefined { + const property = (value as Record)[key]; + return typeof property === "string" && property ? property : undefined; +} + +function readNumberProperty(value: object, key: string): number | undefined { + const property = (value as Record)[key]; + return typeof property === "number" && Number.isFinite(property) ? property : undefined; +} + +function describeDiscordGatewayTransportError(error: Error): DiscordGatewayTransportErrorDetails { + const code = readStringProperty(error, "code"); + const closeCode = readNumberProperty(error, "closeCode"); + const statusCode = readNumberProperty(error, "statusCode"); + return { + ...(error.name ? { name: error.name } : {}), + message: error.message, + ...(code ? { code } : {}), + ...(closeCode !== undefined ? { closeCode } : {}), + ...(statusCode !== undefined ? { statusCode } : {}), + }; +} + +function formatDiscordGatewayCloseReason(reason: Buffer): string { + if (!reason.length) { + return ""; + } + const text = reason.toString("utf8").replaceAll(/\s+/g, " ").trim(); + if (!text) { + return `<${reason.length} bytes>`; + } + if (text.length <= DISCORD_GATEWAY_CLOSE_REASON_LOG_MAX_CHARS) { + return text; + } + return `${text.slice(0, DISCORD_GATEWAY_CLOSE_REASON_LOG_MAX_CHARS)}...`; +} + +function formatDiscordGatewayTransportErrorLog(params: { + flowId: string; + error: DiscordGatewayTransportErrorDetails; +}): string { + const details = [ + `flow=${params.flowId}`, + params.error.name ? `name=${params.error.name}` : undefined, + params.error.code ? `code=${params.error.code}` : undefined, + typeof params.error.closeCode === "number" ? `closeCode=${params.error.closeCode}` : undefined, + typeof params.error.statusCode === "number" + ? `statusCode=${params.error.statusCode}` + : undefined, + `message=${params.error.message}`, + ].filter(Boolean); + return `discord: gateway websocket error ${details.join(" ")}`; +} + +function formatDiscordGatewayTransportCloseLog(params: { + flowId: string; + code: number; + reason: Buffer; + lastError?: DiscordGatewayTransportErrorDetails; +}): string { + const receiverLimit = + params.code === DISCORD_GATEWAY_POLICY_VIOLATION_CLOSE_CODE || + params.lastError?.code === DISCORD_GATEWAY_WS_RECEIVER_LIMIT_CODE; + const details = [ + `flow=${params.flowId}`, + `code=${params.code}`, + `reasonBytes=${params.reason.length}`, + `reason=${formatDiscordGatewayCloseReason(params.reason)}`, + params.lastError?.code ? `lastErrorCode=${params.lastError.code}` : undefined, + params.lastError?.message ? `lastError=${params.lastError.message}` : undefined, + receiverLimit ? "hint=possible ws receiver buffered-parts limit" : undefined, + ].filter(Boolean); + return `discord: gateway websocket closed ${details.join(" ")}`; +} + +function shouldLogDiscordGatewayTransportClose(params: { + code: number; + reason: Buffer; + lastError?: DiscordGatewayTransportErrorDetails; +}): boolean { + return ( + (params.code !== 1000 && params.code !== 1001) || + params.reason.length > 0 || + params.lastError !== undefined || + params.code === DISCORD_GATEWAY_POLICY_VIOLATION_CLOSE_CODE + ); +} + type ResolveDiscordGatewayIntentsParams = { intentsConfig?: import("openclaw/plugin-sdk/config-contracts").DiscordIntentsConfig; voiceEnabled?: boolean; @@ -170,6 +268,7 @@ function createGatewayPlugin(params: { handshakeTimeout: DISCORD_GATEWAY_HANDSHAKE_TIMEOUT_MS, ...(params.wsAgent ? { agent: params.wsAgent } : {}), }); + let lastTransportError: DiscordGatewayTransportErrorDetails | undefined; const emitTransportActivity = () => { if ((this as unknown as { ws?: unknown }).ws !== socket) { return; @@ -195,17 +294,37 @@ function createGatewayPlugin(params: { }); }); socket.on?.("close", (code: number, reason: Buffer) => { + const closeReason = Buffer.isBuffer(reason) ? reason : Buffer.from(String(reason ?? "")); captureWsEvent({ url, direction: "local", kind: "ws-close", flowId: wsFlowId, closeCode: code, - payload: reason, + payload: closeReason, meta: { subsystem: "discord-gateway" }, }); + if ( + shouldLogDiscordGatewayTransportClose({ + code, + reason: closeReason, + lastError: lastTransportError, + }) + ) { + params.runtime?.log?.( + warn( + formatDiscordGatewayTransportCloseLog({ + flowId: wsFlowId, + code, + reason: closeReason, + lastError: lastTransportError, + }), + ), + ); + } }); socket.on?.("error", (error: Error) => { + lastTransportError = describeDiscordGatewayTransportError(error); captureWsEvent({ url, direction: "local", @@ -214,6 +333,11 @@ function createGatewayPlugin(params: { errorText: error.message, meta: { subsystem: "discord-gateway" }, }); + params.runtime?.log?.( + warn( + formatDiscordGatewayTransportErrorLog({ flowId: wsFlowId, error: lastTransportError }), + ), + ); }); if ("binaryType" in socket) { try {