From daef5958107bbc12ca21aefce1b5e2478b7351b1 Mon Sep 17 00:00:00 2001 From: ehsanKey Date: Thu, 23 Jul 2026 20:09:25 +0330 Subject: [PATCH] chore: improve error reporting diagnostics --- src/api.ts | 27 +++++++++-- src/bot/middleware/logger.ts | 28 ++++++------ src/lib/error-details.ts | 28 ++++++++++++ src/lib/github/webhooks/index.ts | 2 + src/lib/github/webhooks/report.ts | 74 +++++++++++++++++++++++++++++++ src/lib/telegram/report.ts | 12 +++++ 6 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 src/lib/error-details.ts create mode 100644 src/lib/github/webhooks/report.ts create mode 100644 src/lib/telegram/report.ts diff --git a/src/api.ts b/src/api.ts index 6cf1f9d..d75cfc4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -5,6 +5,8 @@ import { webhookCallback } from "grammy"; import { Hono } from "hono"; import * as z from "zod"; +import { reportWebhookError } from "./lib/github/webhooks/report.ts"; + export async function createApi() { const api = new Hono(); @@ -12,6 +14,9 @@ export async function createApi() { api.use(`/api/webhook/telegram`, webhookCallback(bot, "hono", { secretToken: config.bot.webhookSecret })); api.post(`/api/webhook/github`, async (ctx) => { + const deliveryId = ctx.req.header("X-GitHub-Delivery"); + const eventName = ctx.req.header("X-GitHub-Event"); + const signature = ctx.req.header("X-Hub-Signature-256"); const payload = z .object({ id: z.string(), @@ -20,19 +25,33 @@ export async function createApi() { payload: z.string(), }) .safeParse({ - id: ctx.req.header("X-GitHub-Delivery"), - name: ctx.req.header("X-GitHub-Event"), - signature: ctx.req.header("X-Hub-Signature-256"), + id: deliveryId, + name: eventName, + signature, payload: await ctx.req.text(), }); if (!payload.success) { // eslint-disable-next-line no-console console.error("Invalid payload:", payload.error); + await reportWebhookError(payload.error, { + eventId: deliveryId, + eventName, + source: "request_validation", + }); return ctx.text("Bad Request", 400); } - await webhooks.verifyAndReceive(payload.data); + try { + await webhooks.verifyAndReceive(payload.data); + } catch (error) { + await reportWebhookError(error, { + eventId: payload.data.id, + eventName: payload.data.name, + source: "request_processing", + }); + throw error; + } return ctx.text("Accepted", 202); }); diff --git a/src/bot/middleware/logger.ts b/src/bot/middleware/logger.ts index f07690d..4c4114c 100644 --- a/src/bot/middleware/logger.ts +++ b/src/bot/middleware/logger.ts @@ -4,36 +4,36 @@ import type { BotContext } from "#bot"; import { config } from "#config"; import { GrammyError } from "grammy"; +import { formatErrorDetails } from "../../lib/error-details.ts"; +import { escapeHtml } from "../../lib/escape-html.ts"; +import { sendReport } from "../../lib/telegram/report.ts"; + export const logger = async (ctx: BotContext, next: () => Promise) => { ctx.logger = { log: async (message: string) => { console.log(message); - const reportId = config.bot.reportChatId; - if (!reportId) return; - - return ctx.api.sendMessage(reportId, message, { parse_mode: "HTML" }); + return sendReport(ctx.api, message); }, error: async (message: string) => { console.log("Report for", config.bot.reportChatId); console.error(message); - const reportId = config.bot.reportChatId; - if (!reportId) return; - - return ctx.api.sendMessage(reportId, message, { parse_mode: "HTML" }); + return sendReport(ctx.api, message); }, }; ctx.report = async (e: unknown) => { let message = ""; const update = ctx.update.message; + const command = update?.text ? escapeHtml(update.text) : "N/A"; + const firstName = update?.from?.first_name ? escapeHtml(update.from.first_name) : "Unknown"; const link = update?.from.username - ? `@${update.from.username}` - : `${update?.from.first_name}`; + ? `@${escapeHtml(update.from.username)}` + : `${firstName}`; message += [ "Error:", - `Command: ${update?.text}`, + `Command: ${command}`, `Sender Name: ${link}`, "", "Message:", @@ -41,14 +41,14 @@ export const logger = async (ctx: BotContext, next: () => Promise) => { ].join("\n"); if (e instanceof GrammyError) { - message += `
${e.description}
\n`; + message += `
${escapeHtml(e.description)}
\n`; } else { - message += `
${e}
\n`; + message += `
${escapeHtml(formatErrorDetails(e))}
\n`; } message += `\n#error`; - ctx.logger.error(message); + return ctx.logger.error(message); }; return next(); diff --git a/src/lib/error-details.ts b/src/lib/error-details.ts new file mode 100644 index 0000000..a83be34 --- /dev/null +++ b/src/lib/error-details.ts @@ -0,0 +1,28 @@ +function stringifyUnknownError(error: unknown): string { + if (error instanceof Error) { + return error.stack ?? `${error.name}: ${error.message}`; + } + + if (typeof error === "string") return error; + + try { + return JSON.stringify(error, null, 2) ?? String(error); + } catch { + return String(error); + } +} + +export function formatErrorDetails(error: unknown): string { + if (error instanceof AggregateError && error.errors.length > 0) { + const showLabels = error.errors.length > 1; + + return error.errors + .map((item, index) => { + const details = formatErrorDetails(item); + return showLabels ? `Error ${index + 1}:\n${details}` : details; + }) + .join("\n\n"); + } + + return stringifyUnknownError(error); +} diff --git a/src/lib/github/webhooks/index.ts b/src/lib/github/webhooks/index.ts index accf929..b01e180 100644 --- a/src/lib/github/webhooks/index.ts +++ b/src/lib/github/webhooks/index.ts @@ -10,6 +10,7 @@ import { pullRequestOpenedCallback } from "./handlers/pull-request-opened.ts"; import { releaseCreatedCallback } from "./handlers/release-created.ts"; import { repositoryCreatedCallback } from "./handlers/repository-created.ts"; import { starCreatedCallback } from "./handlers/star-created.ts"; +import { reportWebhookError } from "./report.ts"; import { withGuards } from "./withGuards.ts"; export const webhooks = new Webhooks({ secret: config.github.webhookSecret }); @@ -24,3 +25,4 @@ webhooks.on("star.created", withGuards(starCreatedCallback)); webhooks.on("issue_comment.created", withGuards(commentCreatedCallback)); webhooks.on("pull_request_review_comment.created", withGuards(commentCreatedCallback)); webhooks.on("projects_v2_item.edited", withGuards(projectItemEditedCallback, { skipRepositoryCheck: true })); +webhooks.onError(reportWebhookError); diff --git a/src/lib/github/webhooks/report.ts b/src/lib/github/webhooks/report.ts new file mode 100644 index 0000000..8a147f0 --- /dev/null +++ b/src/lib/github/webhooks/report.ts @@ -0,0 +1,74 @@ +/* eslint-disable no-console */ +import { bot } from "#bot"; + +import { formatErrorDetails } from "../../error-details.ts"; +import { escapeHtml } from "../../escape-html.ts"; +import { sendReport } from "../../telegram/report.ts"; + +const reportedWebhookError = Symbol("reportedWebhookError"); + +interface WebhookReportContext { + eventId?: string; + eventName?: string; + source?: string; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function markWebhookErrorReported(error: unknown) { + if (!isObject(error)) return; + + error[reportedWebhookError] = true; +} + +function hasReportedWebhookError(error: unknown) { + return isObject(error) && error[reportedWebhookError] === true; +} + +function getEventName(event: unknown) { + if (!isObject(event)) return undefined; + + const name = typeof event.name === "string" ? event.name : undefined; + const payload = isObject(event.payload) ? event.payload : undefined; + const action = typeof payload?.action === "string" ? payload.action : undefined; + + if (!name) return undefined; + if (!action || name.includes(".")) return name; + + return `${name}.${action}`; +} + +function getEventInfo(error: unknown): WebhookReportContext { + if (!isObject(error) || !isObject(error.event)) return {}; + + return { + eventId: typeof error.event.id === "string" ? error.event.id : undefined, + eventName: getEventName(error.event), + }; +} + +export function buildWebhookErrorReport(error: unknown, context: WebhookReportContext = {}) { + const event = getEventInfo(error); + const eventName = context.eventName ?? event.eventName; + const eventId = context.eventId ?? event.eventId; + const lines = ["GitHub Webhook Error:"]; + + if (eventName) lines.push(`Event: ${escapeHtml(eventName)}`); + if (eventId) lines.push(`Delivery: ${escapeHtml(eventId)}`); + if (context.source) lines.push(`Source: ${escapeHtml(context.source)}`); + + lines.push("", "Message:", "", `
${escapeHtml(formatErrorDetails(error))}
`, "", "#webhook #error"); + + return lines.join("\n"); +} + +export async function reportWebhookError(error: unknown, context: WebhookReportContext = {}) { + if (hasReportedWebhookError(error)) return; + + markWebhookErrorReported(error); + console.error(error); + + return sendReport(bot.api, buildWebhookErrorReport(error, context)); +} diff --git a/src/lib/telegram/report.ts b/src/lib/telegram/report.ts new file mode 100644 index 0000000..8032651 --- /dev/null +++ b/src/lib/telegram/report.ts @@ -0,0 +1,12 @@ +import { config } from "#config"; + +interface TelegramReportApi { + sendMessage: (chatId: number, text: string, other?: { parse_mode?: "HTML" }) => Promise; +} + +export async function sendReport(api: TelegramReportApi, message: string) { + const reportId = config.bot.reportChatId; + if (!reportId) return; + + return api.sendMessage(reportId, message, { parse_mode: "HTML" }); +}