Skip to content

Commit d540346

Browse files
itsuzefsteipete
authored andcommitted
fix(whatsapp): serialize Error in auto-reply delivery log
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.
1 parent c4f0da0 commit d540346

2 files changed

Lines changed: 129 additions & 2 deletions

File tree

extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1086,7 +1086,7 @@ describe("whatsapp inbound dispatch", () => {
10861086

10871087
expect(replyLogger.error).toHaveBeenCalledWith(
10881088
{
1089-
err: error,
1089+
err: { type: "Error", message: "send failed", stack: error.stack },
10901090
replyKind: "final",
10911091
correlationId: "msg-1",
10921092
connectionId: "conn-1",
@@ -1099,6 +1099,125 @@ describe("whatsapp inbound dispatch", () => {
10991099
);
11001100
});
11011101

1102+
it("preserves Error subclass own-enumerable fields (e.g. Boom output) in the logged err", async () => {
1103+
const replyLogger = {
1104+
info: vi.fn(),
1105+
warn: vi.fn(),
1106+
error: vi.fn(),
1107+
debug: vi.fn(),
1108+
} as unknown as BufferedReplyParams["replyLogger"];
1109+
1110+
class BoomLikeError extends Error {
1111+
output: { statusCode: number; payload: { error: string } };
1112+
data: { reason: string };
1113+
constructor(message: string) {
1114+
super(message);
1115+
this.name = "BoomLikeError";
1116+
this.output = { statusCode: 408, payload: { error: "Request Time-out" } };
1117+
this.data = { reason: "transport-stale" };
1118+
}
1119+
}
1120+
const error = new BoomLikeError("send timed out");
1121+
1122+
await dispatchBufferedReply({
1123+
connectionId: "conn-boom",
1124+
conversationId: "+15550020000",
1125+
msg: makeMsg({
1126+
id: "msg-boom",
1127+
from: "+15550020000",
1128+
to: "+15550021000",
1129+
chatId: "15550020000@s.whatsapp.net",
1130+
}),
1131+
replyLogger,
1132+
});
1133+
1134+
getCapturedOnError()?.(error, { kind: "final" });
1135+
1136+
expect(replyLogger.error).toHaveBeenCalledWith(
1137+
expect.objectContaining({
1138+
err: expect.objectContaining({
1139+
type: "BoomLikeError",
1140+
message: "send timed out",
1141+
stack: error.stack,
1142+
output: { statusCode: 408, payload: { error: "Request Time-out" } },
1143+
data: { reason: "transport-stale" },
1144+
}),
1145+
replyKind: "final",
1146+
correlationId: "msg-boom",
1147+
}),
1148+
"auto-reply delivery failed",
1149+
);
1150+
});
1151+
1152+
it("logs delivery failures with non-Error rejection values via pass-through", async () => {
1153+
const replyLogger = {
1154+
info: vi.fn(),
1155+
warn: vi.fn(),
1156+
error: vi.fn(),
1157+
debug: vi.fn(),
1158+
} as unknown as BufferedReplyParams["replyLogger"];
1159+
1160+
await dispatchBufferedReply({
1161+
connectionId: "conn-2",
1162+
conversationId: "+15550003000",
1163+
msg: makeMsg({
1164+
id: "msg-2",
1165+
from: "+15550003000",
1166+
to: "+15550004000",
1167+
chatId: "15550003000@s.whatsapp.net",
1168+
}),
1169+
replyLogger,
1170+
});
1171+
1172+
getCapturedOnError()?.("plain string rejection", { kind: "block" });
1173+
1174+
expect(replyLogger.error).toHaveBeenCalledWith(
1175+
expect.objectContaining({
1176+
err: "plain string rejection",
1177+
replyKind: "block",
1178+
correlationId: "msg-2",
1179+
}),
1180+
"auto-reply delivery failed",
1181+
);
1182+
});
1183+
1184+
it("preserves structured object rejections so diagnostic fields stay queryable", async () => {
1185+
const replyLogger = {
1186+
info: vi.fn(),
1187+
warn: vi.fn(),
1188+
error: vi.fn(),
1189+
debug: vi.fn(),
1190+
} as unknown as BufferedReplyParams["replyLogger"];
1191+
1192+
await dispatchBufferedReply({
1193+
connectionId: "conn-3",
1194+
conversationId: "+15550005000",
1195+
msg: makeMsg({
1196+
id: "msg-3",
1197+
from: "+15550005000",
1198+
to: "+15550006000",
1199+
chatId: "15550005000@s.whatsapp.net",
1200+
}),
1201+
replyLogger,
1202+
});
1203+
1204+
const objectRejection = {
1205+
error: { message: "wrapped failure", code: "BAILEYS_NACK" },
1206+
attempt: 2,
1207+
};
1208+
1209+
getCapturedOnError()?.(objectRejection, { kind: "tool" });
1210+
1211+
expect(replyLogger.error).toHaveBeenCalledWith(
1212+
expect.objectContaining({
1213+
err: objectRejection,
1214+
replyKind: "tool",
1215+
correlationId: "msg-3",
1216+
}),
1217+
"auto-reply delivery failed",
1218+
);
1219+
});
1220+
11021221
it("updates main last route for DM when session key matches main session key", () => {
11031222
const updateLastRoute = vi.fn();
11041223

extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ type WhatsAppMediaOnlyFlushResult = {
7979
droppedDuplicateMedia: number;
8080
};
8181

82+
function normalizeErrForLog(err: unknown): unknown {
83+
if (err instanceof Error) {
84+
const ownEnumerableProps = Object.fromEntries(Object.entries(err));
85+
return { ...ownEnumerableProps, type: err.name, message: err.message, stack: err.stack };
86+
}
87+
return err;
88+
}
89+
8290
function logWhatsAppReplyDeliveryError(params: {
8391
err: unknown;
8492
info: ReplyDeliveryInfo;
@@ -89,7 +97,7 @@ function logWhatsAppReplyDeliveryError(params: {
8997
}) {
9098
params.replyLogger.error(
9199
{
92-
err: params.err,
100+
err: normalizeErrForLog(params.err),
93101
replyKind: params.info.kind,
94102
correlationId: params.msg.id ?? null,
95103
connectionId: params.connectionId,

0 commit comments

Comments
 (0)