From f0ec7309fc87d949d16aebf755b2c57512286afe Mon Sep 17 00:00:00 2001 From: Youssef Hemimy <53057646+itsuzef@users.noreply.github.com> Date: Sat, 23 May 2026 18:58:51 -0400 Subject: [PATCH] fix(whatsapp): serialize Error in auto-reply delivery log (#85777) The auto-reply "delivery failed" log path passes a raw Error under the `err` field. tslog's default JSON serialization renders bare Error instances as `{}` because Error own data properties are non-enumerable. Every delivery failure in production therefore logs `err: {}`, forcing operators to guess the underlying Baileys error from timestamp alone. Convert Error to `{ type, message, stack }` plus own-enumerable properties at the log site, so Boom-style subclass diagnostics (output.statusCode, data) and custom OutboundDeliveryError fields (stage, results) survive. Non-Error rejection values pass through unchanged. Tests cover Error, Error subclass (Boom-style), string rejection, and object rejection paths. AI-assisted: Claude Code (Opus 4.7) authored, codex review locally addressed. --- .../monitor/inbound-dispatch.test.ts | 121 +++++++++++++++++- .../auto-reply/monitor/inbound-dispatch.ts | 10 +- 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 647c6f4a10e1..0a831b646164 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -1086,7 +1086,7 @@ describe("whatsapp inbound dispatch", () => { expect(replyLogger.error).toHaveBeenCalledWith( { - err: error, + err: { type: "Error", message: "send failed", stack: error.stack }, replyKind: "final", correlationId: "msg-1", connectionId: "conn-1", @@ -1099,6 +1099,125 @@ describe("whatsapp inbound dispatch", () => { ); }); + it("preserves Error subclass own-enumerable fields (e.g. Boom output) in the logged err", async () => { + const replyLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as BufferedReplyParams["replyLogger"]; + + class BoomLikeError extends Error { + output: { statusCode: number; payload: { error: string } }; + data: { reason: string }; + constructor(message: string) { + super(message); + this.name = "BoomLikeError"; + this.output = { statusCode: 408, payload: { error: "Request Time-out" } }; + this.data = { reason: "transport-stale" }; + } + } + const error = new BoomLikeError("send timed out"); + + await dispatchBufferedReply({ + connectionId: "conn-boom", + conversationId: "+15550020000", + msg: makeMsg({ + id: "msg-boom", + from: "+15550020000", + to: "+15550021000", + chatId: "15550020000@s.whatsapp.net", + }), + replyLogger, + }); + + getCapturedOnError()?.(error, { kind: "final" }); + + expect(replyLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ + type: "BoomLikeError", + message: "send timed out", + stack: error.stack, + output: { statusCode: 408, payload: { error: "Request Time-out" } }, + data: { reason: "transport-stale" }, + }), + replyKind: "final", + correlationId: "msg-boom", + }), + "auto-reply delivery failed", + ); + }); + + it("logs delivery failures with non-Error rejection values via pass-through", async () => { + const replyLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as BufferedReplyParams["replyLogger"]; + + await dispatchBufferedReply({ + connectionId: "conn-2", + conversationId: "+15550003000", + msg: makeMsg({ + id: "msg-2", + from: "+15550003000", + to: "+15550004000", + chatId: "15550003000@s.whatsapp.net", + }), + replyLogger, + }); + + getCapturedOnError()?.("plain string rejection", { kind: "block" }); + + expect(replyLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: "plain string rejection", + replyKind: "block", + correlationId: "msg-2", + }), + "auto-reply delivery failed", + ); + }); + + it("preserves structured object rejections so diagnostic fields stay queryable", async () => { + const replyLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as BufferedReplyParams["replyLogger"]; + + await dispatchBufferedReply({ + connectionId: "conn-3", + conversationId: "+15550005000", + msg: makeMsg({ + id: "msg-3", + from: "+15550005000", + to: "+15550006000", + chatId: "15550005000@s.whatsapp.net", + }), + replyLogger, + }); + + const objectRejection = { + error: { message: "wrapped failure", code: "BAILEYS_NACK" }, + attempt: 2, + }; + + getCapturedOnError()?.(objectRejection, { kind: "tool" }); + + expect(replyLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: objectRejection, + replyKind: "tool", + correlationId: "msg-3", + }), + "auto-reply delivery failed", + ); + }); + it("updates main last route for DM when session key matches main session key", () => { const updateLastRoute = vi.fn(); diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 5c86eed25bd9..d72c424275fc 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -79,6 +79,14 @@ type WhatsAppMediaOnlyFlushResult = { droppedDuplicateMedia: number; }; +function normalizeErrForLog(err: unknown): unknown { + if (err instanceof Error) { + const ownEnumerableProps = Object.fromEntries(Object.entries(err)); + return { ...ownEnumerableProps, type: err.name, message: err.message, stack: err.stack }; + } + return err; +} + function logWhatsAppReplyDeliveryError(params: { err: unknown; info: ReplyDeliveryInfo; @@ -89,7 +97,7 @@ function logWhatsAppReplyDeliveryError(params: { }) { params.replyLogger.error( { - err: params.err, + err: normalizeErrForLog(params.err), replyKind: params.info.kind, correlationId: params.msg.id ?? null, connectionId: params.connectionId,