diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ef0699d..120609202 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro - Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete - Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow +- Discord: log gateway disconnect/reconnect events at info and add verbose gateway metrics. (#595) — thanks @steipete - Commands: accept /models as an alias for /model. - Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp - Models/Auth: add MiniMax Anthropic-compatible API onboarding (minimax-api). (#590) — thanks @mneves75 diff --git a/src/discord/gateway-logging.test.ts b/src/discord/gateway-logging.test.ts new file mode 100644 index 000000000..958c42b70 --- /dev/null +++ b/src/discord/gateway-logging.test.ts @@ -0,0 +1,88 @@ +import { EventEmitter } from "node:events"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../globals.js", () => ({ + logVerbose: vi.fn(), +})); + +import { logVerbose } from "../globals.js"; +import { attachDiscordGatewayLogging } from "./gateway-logging.js"; + +const makeRuntime = () => ({ + log: vi.fn(), +}); + +describe("attachDiscordGatewayLogging", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("logs debug events and promotes reconnect/close to info", () => { + const emitter = new EventEmitter(); + const runtime = makeRuntime(); + + const cleanup = attachDiscordGatewayLogging({ + emitter, + runtime, + }); + + emitter.emit("debug", "WebSocket connection opened"); + emitter.emit("debug", "WebSocket connection closed with code 1001"); + emitter.emit("debug", "Reconnecting with backoff: 1000ms after code 1001"); + + const logVerboseMock = vi.mocked(logVerbose); + expect(logVerboseMock).toHaveBeenCalledTimes(3); + expect(runtime.log).toHaveBeenCalledTimes(2); + expect(runtime.log).toHaveBeenNthCalledWith( + 1, + "discord gateway: WebSocket connection closed with code 1001", + ); + expect(runtime.log).toHaveBeenNthCalledWith( + 2, + "discord gateway: Reconnecting with backoff: 1000ms after code 1001", + ); + + cleanup(); + }); + + it("logs warnings and metrics only to verbose", () => { + const emitter = new EventEmitter(); + const runtime = makeRuntime(); + + const cleanup = attachDiscordGatewayLogging({ + emitter, + runtime, + }); + + emitter.emit("warning", "High latency detected: 1200ms"); + emitter.emit("metrics", { latency: 42, errors: 1 }); + + const logVerboseMock = vi.mocked(logVerbose); + expect(logVerboseMock).toHaveBeenCalledTimes(2); + expect(runtime.log).not.toHaveBeenCalled(); + + cleanup(); + }); + + it("removes listeners on cleanup", () => { + const emitter = new EventEmitter(); + const runtime = makeRuntime(); + + const cleanup = attachDiscordGatewayLogging({ + emitter, + runtime, + }); + cleanup(); + + const logVerboseMock = vi.mocked(logVerbose); + logVerboseMock.mockClear(); + + emitter.emit("debug", "WebSocket connection closed with code 1001"); + emitter.emit("warning", "High latency detected: 1200ms"); + emitter.emit("metrics", { latency: 42 }); + + expect(logVerboseMock).not.toHaveBeenCalled(); + expect(runtime.log).not.toHaveBeenCalled(); + }); +}); diff --git a/src/discord/gateway-logging.ts b/src/discord/gateway-logging.ts new file mode 100644 index 000000000..19ba6c7f2 --- /dev/null +++ b/src/discord/gateway-logging.ts @@ -0,0 +1,66 @@ +import type { EventEmitter } from "node:events"; + +import { logVerbose } from "../globals.js"; +import type { RuntimeEnv } from "../runtime.js"; + +type GatewayEmitter = Pick; + +const INFO_DEBUG_MARKERS = [ + "WebSocket connection closed", + "Reconnecting with backoff", + "Attempting resume with backoff", +]; + +const shouldPromoteGatewayDebug = (message: string) => + INFO_DEBUG_MARKERS.some((marker) => message.includes(marker)); + +const formatGatewayMetrics = (metrics: unknown) => { + if (metrics === null || metrics === undefined) return String(metrics); + if (typeof metrics === "string") return metrics; + if ( + typeof metrics === "number" || + typeof metrics === "boolean" || + typeof metrics === "bigint" + ) { + return String(metrics); + } + try { + return JSON.stringify(metrics); + } catch { + return "[unserializable metrics]"; + } +}; + +export function attachDiscordGatewayLogging(params: { + emitter?: GatewayEmitter; + runtime: RuntimeEnv; +}) { + const { emitter, runtime } = params; + if (!emitter) return () => {}; + + const onGatewayDebug = (msg: unknown) => { + const message = String(msg); + logVerbose(`discord gateway: ${message}`); + if (shouldPromoteGatewayDebug(message)) { + runtime.log?.(`discord gateway: ${message}`); + } + }; + + const onGatewayWarning = (warning: unknown) => { + logVerbose(`discord gateway warning: ${String(warning)}`); + }; + + const onGatewayMetrics = (metrics: unknown) => { + logVerbose(`discord gateway metrics: ${formatGatewayMetrics(metrics)}`); + }; + + emitter.on("debug", onGatewayDebug); + emitter.on("warning", onGatewayWarning); + emitter.on("metrics", onGatewayMetrics); + + return () => { + emitter.removeListener("debug", onGatewayDebug); + emitter.removeListener("warning", onGatewayWarning); + emitter.removeListener("metrics", onGatewayMetrics); + }; +} diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 0e66c9e37..4d1af57bc 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -68,6 +68,7 @@ import { truncateUtf16Safe } from "../utils.js"; import { loadWebMedia } from "../web/media.js"; import { resolveDiscordAccount } from "./accounts.js"; import { chunkDiscordText } from "./chunk.js"; +import { attachDiscordGatewayLogging } from "./gateway-logging.js"; import { getDiscordGatewayEmitter, waitForDiscordGatewayStop, @@ -499,12 +500,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const gateway = client.getPlugin("gateway"); const gatewayEmitter = getDiscordGatewayEmitter(gateway); - const onGatewayWarning = (warning: unknown) => { - logVerbose(`discord gateway warning: ${String(warning)}`); - }; - if (shouldLogVerbose()) { - gatewayEmitter?.on("warning", onGatewayWarning); - } + const stopGatewayLogging = attachDiscordGatewayLogging({ + emitter: gatewayEmitter, + runtime, + }); try { await waitForDiscordGatewayStop({ gateway: gateway @@ -526,7 +525,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }, }); } finally { - gatewayEmitter?.removeListener("warning", onGatewayWarning); + stopGatewayLogging(); } }