Skip to content

Commit b655844

Browse files
EhsanKeyASafaeirad
authored andcommitted
chore: improve error reporting diagnostics
1 parent c2a4d3f commit b655844

6 files changed

Lines changed: 153 additions & 18 deletions

File tree

src/api.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@ import { webhookCallback } from "grammy";
55
import { Hono } from "hono";
66
import * as z from "zod";
77

8+
import { reportWebhookError } from "./lib/github/webhooks/report.ts";
9+
810
export async function createApi() {
911
const api = new Hono();
1012

1113
if (!config.bot.polling)
1214
api.use(`/api/webhook/telegram`, webhookCallback(bot, "hono", { secretToken: config.bot.webhookSecret }));
1315

1416
api.post(`/api/webhook/github`, async (ctx) => {
17+
const deliveryId = ctx.req.header("X-GitHub-Delivery");
18+
const eventName = ctx.req.header("X-GitHub-Event");
19+
const signature = ctx.req.header("X-Hub-Signature-256");
1520
const payload = z
1621
.object({
1722
id: z.string(),
@@ -20,19 +25,33 @@ export async function createApi() {
2025
payload: z.string(),
2126
})
2227
.safeParse({
23-
id: ctx.req.header("X-GitHub-Delivery"),
24-
name: ctx.req.header("X-GitHub-Event"),
25-
signature: ctx.req.header("X-Hub-Signature-256"),
28+
id: deliveryId,
29+
name: eventName,
30+
signature,
2631
payload: await ctx.req.text(),
2732
});
2833

2934
if (!payload.success) {
3035
// eslint-disable-next-line no-console
3136
console.error("Invalid payload:", payload.error);
37+
await reportWebhookError(payload.error, {
38+
eventId: deliveryId,
39+
eventName,
40+
source: "request_validation",
41+
});
3242
return ctx.text("Bad Request", 400);
3343
}
3444

35-
await webhooks.verifyAndReceive(payload.data);
45+
try {
46+
await webhooks.verifyAndReceive(payload.data);
47+
} catch (error) {
48+
await reportWebhookError(error, {
49+
eventId: payload.data.id,
50+
eventName: payload.data.name,
51+
source: "request_processing",
52+
});
53+
throw error;
54+
}
3655

3756
return ctx.text("Accepted", 202);
3857
});

src/bot/middleware/logger.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,51 +4,51 @@ import type { BotContext } from "#bot";
44
import { config } from "#config";
55
import { GrammyError } from "grammy";
66

7+
import { formatErrorDetails } from "../../lib/error-details.ts";
8+
import { escapeHtml } from "../../lib/escape-html.ts";
9+
import { sendReport } from "../../lib/telegram/report.ts";
10+
711
export const logger = async (ctx: BotContext, next: () => Promise<unknown>) => {
812
ctx.logger = {
913
log: async (message: string) => {
1014
console.log(message);
11-
const reportId = config.bot.reportChatId;
12-
if (!reportId) return;
13-
14-
return ctx.api.sendMessage(reportId, message, { parse_mode: "HTML" });
15+
return sendReport(ctx.api, message);
1516
},
1617
error: async (message: string) => {
1718
console.log("Report for", config.bot.reportChatId);
1819

1920
console.error(message);
20-
const reportId = config.bot.reportChatId;
21-
if (!reportId) return;
22-
23-
return ctx.api.sendMessage(reportId, message, { parse_mode: "HTML" });
21+
return sendReport(ctx.api, message);
2422
},
2523
};
2624

2725
ctx.report = async (e: unknown) => {
2826
let message = "";
2927
const update = ctx.update.message;
28+
const command = update?.text ? escapeHtml(update.text) : "N/A";
29+
const firstName = update?.from?.first_name ? escapeHtml(update.from.first_name) : "Unknown";
3030
const link = update?.from.username
31-
? `@${update.from.username}`
32-
: `<a href="tg://user?id=${update?.from.id}">${update?.from.first_name}</a>`;
31+
? `@${escapeHtml(update.from.username)}`
32+
: `<a href="tg://user?id=${update?.from.id}">${firstName}</a>`;
3333

3434
message += [
3535
"<b>Error:</b>",
36-
`Command: <code>${update?.text}</code>`,
36+
`Command: <code>${command}</code>`,
3737
`Sender Name: ${link}`,
3838
"",
3939
"<b>Message:</b>",
4040
"",
4141
].join("\n");
4242

4343
if (e instanceof GrammyError) {
44-
message += `<pre>${e.description}</pre>\n`;
44+
message += `<pre>${escapeHtml(e.description)}</pre>\n`;
4545
} else {
46-
message += `<pre>${e}</pre>\n`;
46+
message += `<pre>${escapeHtml(formatErrorDetails(e))}</pre>\n`;
4747
}
4848

4949
message += `\n#error`;
5050

51-
ctx.logger.error(message);
51+
return ctx.logger.error(message);
5252
};
5353

5454
return next();

src/lib/error-details.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
function stringifyUnknownError(error: unknown): string {
2+
if (error instanceof Error) {
3+
return error.stack ?? `${error.name}: ${error.message}`;
4+
}
5+
6+
if (typeof error === "string") return error;
7+
8+
try {
9+
return JSON.stringify(error, null, 2) ?? String(error);
10+
} catch {
11+
return String(error);
12+
}
13+
}
14+
15+
export function formatErrorDetails(error: unknown): string {
16+
if (error instanceof AggregateError && error.errors.length > 0) {
17+
const showLabels = error.errors.length > 1;
18+
19+
return error.errors
20+
.map((item, index) => {
21+
const details = formatErrorDetails(item);
22+
return showLabels ? `Error ${index + 1}:\n${details}` : details;
23+
})
24+
.join("\n\n");
25+
}
26+
27+
return stringifyUnknownError(error);
28+
}

src/lib/github/webhooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { pullRequestOpenedCallback } from "./handlers/pull-request-opened.ts";
1010
import { releaseCreatedCallback } from "./handlers/release-created.ts";
1111
import { repositoryCreatedCallback } from "./handlers/repository-created.ts";
1212
import { starCreatedCallback } from "./handlers/star-created.ts";
13+
import { reportWebhookError } from "./report.ts";
1314
import { withGuards } from "./withGuards.ts";
1415

1516
export const webhooks = new Webhooks({ secret: config.github.webhookSecret });
@@ -24,3 +25,4 @@ webhooks.on("star.created", withGuards(starCreatedCallback));
2425
webhooks.on("issue_comment.created", withGuards(commentCreatedCallback));
2526
webhooks.on("pull_request_review_comment.created", withGuards(commentCreatedCallback));
2627
webhooks.on("projects_v2_item.edited", withGuards(projectItemEditedCallback, { skipRepositoryCheck: true }));
28+
webhooks.onError(reportWebhookError);

src/lib/github/webhooks/report.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/* eslint-disable no-console */
2+
import { bot } from "#bot";
3+
4+
import { formatErrorDetails } from "../../error-details.ts";
5+
import { escapeHtml } from "../../escape-html.ts";
6+
import { sendReport } from "../../telegram/report.ts";
7+
8+
const reportedWebhookError = Symbol("reportedWebhookError");
9+
10+
interface WebhookReportContext {
11+
eventId?: string;
12+
eventName?: string;
13+
source?: string;
14+
}
15+
16+
function isObject(value: unknown): value is Record<PropertyKey, unknown> {
17+
return typeof value === "object" && value !== null;
18+
}
19+
20+
function markWebhookErrorReported(error: unknown) {
21+
if (!isObject(error)) return;
22+
23+
error[reportedWebhookError] = true;
24+
}
25+
26+
function hasReportedWebhookError(error: unknown) {
27+
return isObject(error) && error[reportedWebhookError] === true;
28+
}
29+
30+
function getEventName(event: unknown) {
31+
if (!isObject(event)) return undefined;
32+
33+
const name = typeof event.name === "string" ? event.name : undefined;
34+
const payload = isObject(event.payload) ? event.payload : undefined;
35+
const action = typeof payload?.action === "string" ? payload.action : undefined;
36+
37+
if (!name) return undefined;
38+
if (!action || name.includes(".")) return name;
39+
40+
return `${name}.${action}`;
41+
}
42+
43+
function getEventInfo(error: unknown): WebhookReportContext {
44+
if (!isObject(error) || !isObject(error.event)) return {};
45+
46+
return {
47+
eventId: typeof error.event.id === "string" ? error.event.id : undefined,
48+
eventName: getEventName(error.event),
49+
};
50+
}
51+
52+
export function buildWebhookErrorReport(error: unknown, context: WebhookReportContext = {}) {
53+
const event = getEventInfo(error);
54+
const eventName = context.eventName ?? event.eventName;
55+
const eventId = context.eventId ?? event.eventId;
56+
const lines = ["<b>GitHub Webhook Error:</b>"];
57+
58+
if (eventName) lines.push(`Event: <code>${escapeHtml(eventName)}</code>`);
59+
if (eventId) lines.push(`Delivery: <code>${escapeHtml(eventId)}</code>`);
60+
if (context.source) lines.push(`Source: <code>${escapeHtml(context.source)}</code>`);
61+
62+
lines.push("", "<b>Message:</b>", "", `<pre>${escapeHtml(formatErrorDetails(error))}</pre>`, "", "#webhook #error");
63+
64+
return lines.join("\n");
65+
}
66+
67+
export async function reportWebhookError(error: unknown, context: WebhookReportContext = {}) {
68+
if (hasReportedWebhookError(error)) return;
69+
70+
markWebhookErrorReported(error);
71+
console.error(error);
72+
73+
return sendReport(bot.api, buildWebhookErrorReport(error, context));
74+
}

src/lib/telegram/report.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { config } from "#config";
2+
3+
interface TelegramReportApi {
4+
sendMessage: (chatId: number, text: string, other?: { parse_mode?: "HTML" }) => Promise<unknown>;
5+
}
6+
7+
export async function sendReport(api: TelegramReportApi, message: string) {
8+
const reportId = config.bot.reportChatId;
9+
if (!reportId) return;
10+
11+
return api.sendMessage(reportId, message, { parse_mode: "HTML" });
12+
}

0 commit comments

Comments
 (0)