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.
This commit is contained in:
Youssef Hemimy
2026-05-23 18:58:51 -04:00
committed by GitHub
parent 0050245bc7
commit f0ec7309fc
2 changed files with 129 additions and 2 deletions

View File

@@ -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();

View File

@@ -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,