bug report tool — screenshot, console logs, and context sent to discord

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 18:09:51 +00:00
co-authored by Claude Opus 4.6
parent 30f06e91e4
commit 74ace56120
10 changed files with 315 additions and 0 deletions
+3
View File
@@ -70,6 +70,7 @@
"helpers": "workspace:*", "helpers": "workspace:*",
"hono": "^4.11.1", "hono": "^4.11.1",
"hooks": "workspace:*", "hooks": "workspace:*",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"i18n": "workspace:*", "i18n": "workspace:*",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
@@ -1556,6 +1557,8 @@
"hooks": ["hooks@workspace:src/workspaces/hooks"], "hooks": ["hooks@workspace:src/workspaces/hooks"],
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
+1
View File
@@ -94,6 +94,7 @@
"helpers": "workspace:*", "helpers": "workspace:*",
"hono": "^4.11.1", "hono": "^4.11.1",
"hooks": "workspace:*", "hooks": "workspace:*",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"i18n": "workspace:*", "i18n": "workspace:*",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
@@ -0,0 +1,27 @@
import { Bug } from 'lucide-react';
import { useBugReport } from './use-bug-report';
import { BugReportDialog } from './BugReportDialog';
export const BugReportButton = () => {
const bugReport = useBugReport();
return (
<>
<button
onClick={bugReport.handleOpen}
className="fixed bottom-20 right-4 z-[90] flex h-10 w-10 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-lg transition-transform hover:scale-110"
aria-label="Report a bug"
>
<Bug size={20} />
</button>
<BugReportDialog
open={bugReport.open}
capturing={bugReport.capturing}
submitting={bugReport.submitting}
screenshot={bugReport.screenshot}
onClose={bugReport.handleClose}
onSubmit={bugReport.handleSubmit}
/>
</>
);
};
@@ -0,0 +1,75 @@
import { useState, useMemo } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
type BugReportDialogProps = {
open: boolean;
capturing: boolean;
submitting: boolean;
screenshot: Blob | null;
onClose: () => void;
onSubmit: (description: string) => void;
};
export const BugReportDialog = ({ open, capturing, submitting, screenshot, onClose, onSubmit }: BugReportDialogProps) => {
const [description, setDescription] = useState('');
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
const handleSubmit = () => {
onSubmit(description);
setDescription('');
};
const canSubmit = description.trim().length > 0 && !capturing && !submitting;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Report a Bug</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="overflow-hidden rounded-md border bg-muted">
{capturing ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : previewUrl ? (
<img src={previewUrl} alt="Screenshot" className="max-h-48 w-full object-contain" />
) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
No screenshot captured
</div>
)}
</div>
<Textarea
placeholder="Describe what went wrong..."
value={description}
onChange={(ev) => setDescription(ev.target.value)}
rows={4}
autoFocus
/>
<p className="text-xs text-muted-foreground">
Console logs, browser info, and recent API errors are collected automatically.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Submit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,72 @@
import { useState, useCallback } from 'react';
import { toBlob } from 'html-to-image';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useGlobal } from 'hooks/useGlobal';
import type { ErrorDetails } from 'hooks/useClient';
import { getConsoleBuffer } from '@/lib/console-buffer';
import { toast } from 'sonner';
export const useBugReport = () => {
const [open, setOpen] = useState(false);
const [screenshot, setScreenshot] = useState<Blob | null>(null);
const [capturing, setCapturing] = useState(false);
const [submitting, setSubmitting] = useState(false);
const client = useClient();
const { user } = useAuth();
const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
const handleOpen = useCallback(async () => {
setCapturing(true);
try {
const blob = await toBlob(document.body, { pixelRatio: 0.75 });
setScreenshot(blob);
} catch (err) {
console.error('Screenshot capture failed:', err);
} finally {
setCapturing(false);
setOpen(true);
}
}, []);
const handleClose = useCallback(() => {
setOpen(false);
setScreenshot(null);
setCapturing(false);
}, []);
const handleSubmit = useCallback(
async (description: string) => {
setSubmitting(true);
try {
const context = {
url: window.location.href,
userAgent: navigator.userAgent,
viewport: { width: window.innerWidth, height: window.innerHeight },
timestamp: new Date().toISOString(),
consoleLogs: getConsoleBuffer(),
apiError,
user: user ? { id: user.id, email: user.email, name: user.name } : null,
};
const formData = new FormData();
formData.append('description', description);
formData.append('context', JSON.stringify(context));
if (screenshot) {
formData.append('screenshot', screenshot, 'screenshot.png');
}
await client.post('/bug-report', formData);
toast.success('Bug report submitted');
handleClose();
} catch {
toast.error('Failed to submit bug report');
} finally {
setSubmitting(false);
}
},
[screenshot, client, user, apiError, handleClose],
);
return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit };
};
@@ -3,6 +3,7 @@ import { Background } from './Background';
import { Header } from './Header'; import { Header } from './Header';
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock'; import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
import { useIsTouch } from './useIsTouch'; import { useIsTouch } from './useIsTouch';
import { BugReportButton } from './BugReport/BugReportButton';
type DashboardLayoutProps = { type DashboardLayoutProps = {
children?: React.ReactNode; children?: React.ReactNode;
@@ -21,6 +22,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{children} {children}
</div> </div>
</section> </section>
<BugReportButton />
</div> </div>
); );
}; };
+1
View File
@@ -1,3 +1,4 @@
import './lib/console-buffer';
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { App } from './App'; import { App } from './App';
@@ -0,0 +1,37 @@
type LogLevel = 'log' | 'warn' | 'error';
type LogEntry = {
level: LogLevel;
message: string;
timestamp: number;
};
const MAX_ENTRIES = 100;
const buffer: LogEntry[] = [];
const originalLog = console.log;
const originalWarn = console.warn;
const originalError = console.error;
function pushEntry(level: LogLevel, args: unknown[]) {
const message = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ');
buffer.push({ level, message, timestamp: Date.now() });
if (buffer.length > MAX_ENTRIES) buffer.shift();
}
console.log = (...args: unknown[]) => {
pushEntry('log', args);
originalLog.apply(console, args);
};
console.warn = (...args: unknown[]) => {
pushEntry('warn', args);
originalWarn.apply(console, args);
};
console.error = (...args: unknown[]) => {
pushEntry('error', args);
originalError.apply(console, args);
};
export const getConsoleBuffer = (): LogEntry[] => [...buffer];
+95
View File
@@ -0,0 +1,95 @@
import { createRouter } from '../../create-router';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { DATA_PATH } from '@@/data-path';
const DISCORD_WEBHOOK_URL = process.env.DISCORD_BUG_REPORT_WEBHOOK;
export const bugReportRouter = createRouter();
bugReportRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as Record<string, unknown>;
const description = body.description as string | undefined;
const contextRaw = body.context as string | undefined;
const screenshot = body.screenshot as File | undefined;
if (!description?.trim()) {
return ctx.text('Description is required', 400);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const uuid = crypto.randomUUID().slice(0, 8);
const dirName = `${timestamp}-${uuid}`;
const reportDir = join(DATA_PATH, 'bug-reports', dirName);
await mkdir(reportDir, { recursive: true });
const context = contextRaw ? JSON.parse(contextRaw) : null;
const report = {
description: description.trim(),
context,
reporter: { id: user.id, email: user.email, name: user.name },
createdAt: new Date().toISOString(),
};
await Bun.write(join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
let screenshotBuffer: Buffer | null = null;
if (screenshot instanceof File) {
screenshotBuffer = Buffer.from(await screenshot.arrayBuffer());
await Bun.write(join(reportDir, 'screenshot.png'), screenshotBuffer);
}
if (DISCORD_WEBHOOK_URL) {
await sendToDiscord(report, screenshotBuffer);
}
return ctx.json({ ok: true, id: dirName });
});
type BugReport = {
description: string;
context: { url?: string; userAgent?: string; viewport?: { width: number; height: number }; apiError?: { status: number; message: string } | null } | null;
reporter: { id: number; email: string; name: string };
createdAt: string;
};
async function sendToDiscord(report: BugReport, screenshot: Buffer | null) {
const embed = {
title: 'Bug Report',
description: report.description,
color: 0xed4245,
fields: [
{ name: 'Reporter', value: `${report.reporter.name} (${report.reporter.email})`, inline: true },
{ name: 'URL', value: report.context?.url ?? 'N/A', inline: false },
{ name: 'Viewport', value: report.context?.viewport ? `${report.context.viewport.width}x${report.context.viewport.height}` : 'N/A', inline: true },
{ name: 'Browser', value: shortenUA(report.context?.userAgent), inline: true },
],
timestamp: report.createdAt,
};
if (report.context?.apiError) {
embed.fields.push({ name: 'Last API Error', value: `${report.context.apiError.status}: ${report.context.apiError.message}`, inline: false });
}
const form = new FormData();
form.append('payload_json', JSON.stringify({ embeds: [embed] }));
if (screenshot) {
form.append('files[0]', new Blob([screenshot], { type: 'image/png' }), 'screenshot.png');
}
const res = await fetch(DISCORD_WEBHOOK_URL!, { method: 'POST', body: form });
if (!res.ok) {
console.error('[bug-report] Discord webhook failed:', res.status, await res.text());
}
}
function shortenUA(ua?: string): string {
if (!ua) return 'N/A';
const browser = ua.match(/(Chrome|Firefox|Safari|Edge|Brave|OPR)\/[\d.]+/)?.[0] ?? '';
const os = ua.match(/\(([^)]+)\)/)?.[1]?.split(';')[0] ?? '';
return [browser, os].filter(Boolean).join(' — ') || ua.slice(0, 80);
}
+2
View File
@@ -27,6 +27,7 @@ import { emailRouter } from './api/email/email';
import { channelsRouter } from './channels/routes'; import { channelsRouter } from './channels/routes';
import { browserRouter } from './api/browser/router'; import { browserRouter } from './api/browser/router';
import { appsRouter, appServeRouter } from './api/apps'; import { appsRouter, appServeRouter } from './api/apps';
import { bugReportRouter } from './api/bug-report/bug-report';
import { CustomError } from './custom-errors'; import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares'; import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
@@ -86,6 +87,7 @@ protectedRouter.route('/email', emailRouter);
protectedRouter.route('/channels', channelsRouter); protectedRouter.route('/channels', channelsRouter);
protectedRouter.route('/browser', browserRouter); protectedRouter.route('/browser', browserRouter);
protectedRouter.route('/apps', appsRouter); protectedRouter.route('/apps', appsRouter);
protectedRouter.route('/bug-report', bugReportRouter);
protectedRouter.route('/', piRestRouter); protectedRouter.route('/', piRestRouter);
honoServer.route('/api', protectedRouter); honoServer.route('/api', protectedRouter);