Добавить auto-capture ошибок и фильтр source у тикетов. Refs EventHub/EventHubBack#35
CI / test (push) Successful in 5m53s
CI / push-image (push) Successful in 10m1s
CI / ci-done (push) Successful in 0s
CI / deploy-ift (push) Successful in 47s
CI / e2e-ift (push) Successful in 2m49s
CI / deploy-stage (push) Successful in 7m58s
CI / e2e-stage (push) Successful in 1m20s

This commit is contained in:
2026-07-17 00:44:13 +03:00
parent b2cd4b53fc
commit b39a676e1a
10 changed files with 296 additions and 16 deletions
+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test';
import { seedAuthenticatedSession } from '../helpers/session';
/**
* Авто-capture фронтовых ошибок Admin SPA → POST /v1/tickets (source=frontend).
* Ручной «сообщить о проблеме» — клиентский сценарий, не UI Control Center.
*/
test.describe('frontend error auto-report (mock)', () => {
test('reportClientError шлёт POST /v1/tickets с source=frontend', async ({ page }) => {
await seedAuthenticatedSession(page);
let reported: { error_message?: string; source?: string } | null = null;
await page.route('**/v1/tickets', async (route) => {
if (route.request().method() === 'POST') {
reported = route.request().postDataJSON() as typeof reported;
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 't-fe-1',
source: 'frontend',
status: 'open',
count: 1,
error_hash: 'abc',
error_message: reported?.error_message ?? 'err',
}),
});
return;
}
await route.continue();
});
await page.goto('/dashboard');
await expect(page.getByTestId('layout-shell')).toBeVisible();
await page.waitForFunction(() => typeof window.__ehReportClientError === 'function');
await page.evaluate(async () => {
await window.__ehReportClientError!({
message: 'e2e-frontend-auto-report',
stack: 'Error: e2e-frontend-auto-report\n at e2e:1:1',
source: 'frontend',
});
});
await expect.poll(() => reported?.source, { timeout: 10_000 }).toBe('frontend');
expect(reported?.error_message).toContain('e2e-frontend-auto-report');
});
});
+14 -1
View File
@@ -1,6 +1,15 @@
import apiClient from './client'; import apiClient from './client';
import { Ticket, TicketListParams, TicketStats, PaginatedResponse } from '../types/api'; import { Ticket, TicketListParams, TicketStats, PaginatedResponse } from '../types/api';
export type TicketSource = 'backend' | 'frontend' | 'manual';
export interface CreateTicketPayload {
error_message: string;
stacktrace?: string;
context?: Record<string, unknown> | string;
source?: TicketSource;
}
export const ticketsApi = { export const ticketsApi = {
getTickets: async (params: TicketListParams): Promise<PaginatedResponse<Ticket>> => { getTickets: async (params: TicketListParams): Promise<PaginatedResponse<Ticket>> => {
const { data, headers } = await apiClient.get('/v1/admin/tickets', { params }); const { data, headers } = await apiClient.get('/v1/admin/tickets', { params });
@@ -11,6 +20,10 @@ export const ticketsApi = {
const { data } = await apiClient.get(`/v1/admin/tickets/${id}`); const { data } = await apiClient.get(`/v1/admin/tickets/${id}`);
return data; return data;
}, },
createTicket: async (payload: CreateTicketPayload): Promise<Ticket> => {
const { data } = await apiClient.post('/v1/tickets', payload);
return data;
},
updateTicket: async (id: string, payload: Partial<Ticket>): Promise<void> => { updateTicket: async (id: string, payload: Partial<Ticket>): Promise<void> => {
await apiClient.put(`/v1/admin/tickets/${id}`, payload); await apiClient.put(`/v1/admin/tickets/${id}`, payload);
}, },
@@ -21,4 +34,4 @@ export const ticketsApi = {
const { data } = await apiClient.get('/v1/admin/tickets/stats'); const { data } = await apiClient.get('/v1/admin/tickets/stats');
return data; return data;
}, },
}; };
+10
View File
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import i18n from '@/i18n'; import i18n from '@/i18n';
import { reportClientError } from '@/lib/errorReporter';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -13,6 +14,15 @@ export class ErrorBoundary extends React.Component<Props, State> {
return { error }; return { error };
} }
componentDidCatch(error: Error, info: React.ErrorInfo) {
void reportClientError({
message: error.message || 'React render error',
stack: error.stack || info.componentStack || undefined,
source: 'frontend',
context: { component_stack: info.componentStack || undefined },
});
}
render() { render() {
if (this.state.error) { if (this.state.error) {
return ( return (
+113
View File
@@ -0,0 +1,113 @@
import { ticketsApi, type TicketSource } from '@/api/ticketsApi';
import { ACCESS_TOKEN_KEY } from '@/utils/constants';
const THROTTLE_MS = 60_000;
const recentHashes = new Map<string, number>();
let reporting = false;
function simpleHash(input: string): string {
let h = 0;
for (let i = 0; i < input.length; i++) {
h = (Math.imul(31, h) + input.charCodeAt(i)) | 0;
}
return String(h);
}
function shouldThrottle(key: string): boolean {
const now = Date.now();
const last = recentHashes.get(key) ?? 0;
if (now - last < THROTTLE_MS) return true;
recentHashes.set(key, now);
// prune old entries
if (recentHashes.size > 200) {
for (const [k, ts] of recentHashes) {
if (now - ts > THROTTLE_MS) recentHashes.delete(k);
}
}
return false;
}
function buildContext(extra?: Record<string, unknown>): Record<string, unknown> {
return {
route: typeof window !== 'undefined' ? window.location.pathname : undefined,
user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
build: import.meta.env.VITE_APP_VERSION || '0.0',
git_sha: import.meta.env.VITE_GIT_SHA || 'dev',
...extra,
};
}
export type ReportErrorInput = {
message: string;
stack?: string;
source?: TicketSource;
context?: Record<string, unknown>;
};
/**
* Report an error to POST /v1/tickets. No-ops without auth token,
* while reporting is in flight for this call chain, or when throttled.
*/
export async function reportClientError(input: ReportErrorInput): Promise<void> {
if (typeof window === 'undefined') return;
if (!localStorage.getItem(ACCESS_TOKEN_KEY)) return;
if (reporting) return;
const source = input.source ?? (input.stack ? 'frontend' : 'manual');
const message = (input.message || 'Unknown error').slice(0, 2000);
const stack = (input.stack || '').slice(0, 8000);
const throttleKey = simpleHash(`${source}|${message}|${stack.slice(0, 500)}`);
if (shouldThrottle(throttleKey)) return;
reporting = true;
try {
await ticketsApi.createTicket({
error_message: message,
stacktrace: stack || undefined,
source,
context: buildContext(input.context),
});
} catch {
// Never rethrow — avoid error loops from the reporter itself.
} finally {
reporting = false;
}
}
export function installGlobalErrorHandlers(): () => void {
const onError = (event: ErrorEvent) => {
const msg = event.message || 'window.onerror';
if (msg.includes('/v1/tickets')) return;
void reportClientError({
message: msg,
stack: event.error?.stack || `${event.filename}:${event.lineno}:${event.colno}`,
source: 'frontend',
});
};
const onRejection = (event: PromiseRejectionEvent) => {
const reason = event.reason;
const message =
reason instanceof Error
? reason.message
: typeof reason === 'string'
? reason
: 'unhandledrejection';
const stack = reason instanceof Error ? reason.stack : undefined;
if (message.includes('/v1/tickets')) return;
// Skip expected auth failures
if (typeof message === 'string' && /\b(401|403)\b/.test(message)) return;
void reportClientError({
message,
stack,
source: 'frontend',
});
};
window.addEventListener('error', onError);
window.addEventListener('unhandledrejection', onRejection);
return () => {
window.removeEventListener('error', onError);
window.removeEventListener('unhandledrejection', onRejection);
};
}
+6 -1
View File
@@ -426,7 +426,12 @@
"detailTitle": "Ticket {{id}}", "detailTitle": "Ticket {{id}}",
"deleteTitle": "Delete ticket?", "deleteTitle": "Delete ticket?",
"notFound": "Ticket not found", "notFound": "Ticket not found",
"statsHint": "Explore · closed {{closed}}, total errors {{errors}}" "statsHint": "Explore · closed {{closed}}, total errors {{errors}}",
"source": "Source",
"sourceAll": "All sources",
"sourceBackend": "Backend",
"sourceFrontend": "Frontend",
"sourceManual": "Manual"
}, },
"subscriptions": { "subscriptions": {
"title": "Subscriptions", "title": "Subscriptions",
+6 -1
View File
@@ -426,7 +426,12 @@
"detailTitle": "Тикет {{id}}", "detailTitle": "Тикет {{id}}",
"deleteTitle": "Удалить тикет?", "deleteTitle": "Удалить тикет?",
"notFound": "Тикет не найден", "notFound": "Тикет не найден",
"statsHint": "Explore · закрыто {{closed}}, всего ошибок {{errors}}" "statsHint": "Explore · закрыто {{closed}}, всего ошибок {{errors}}",
"source": "Источник",
"sourceAll": "Все источники",
"sourceBackend": "Backend",
"sourceFrontend": "Frontend",
"sourceManual": "Ручной"
}, },
"subscriptions": { "subscriptions": {
"title": "Подписки", "title": "Подписки",
+12
View File
@@ -6,8 +6,20 @@ import './index.css';
import './i18n'; import './i18n';
import LocaleProvider from './components/LocaleProvider'; import LocaleProvider from './components/LocaleProvider';
import { initTableDensity } from './lib/density'; import { initTableDensity } from './lib/density';
import { installGlobalErrorHandlers, reportClientError } from './lib/errorReporter';
initTableDensity(); initTableDensity();
installGlobalErrorHandlers();
// E2E / debug hook for auto-report smoke (mock suite)
declare global {
interface Window {
__ehReportClientError?: typeof reportClientError;
}
}
if (typeof window !== 'undefined') {
window.__ehReportClientError = reportClientError;
}
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+10
View File
@@ -136,6 +136,16 @@ const TicketDetailPage: React.FC = () => {
<dt className="text-muted-foreground">{t('common.sender')}</dt> <dt className="text-muted-foreground">{t('common.sender')}</dt>
<dd>{getUserLink()}</dd> <dd>{getUserLink()}</dd>
</div> </div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('tickets.source')}</dt>
<dd>
{ticket.source === 'frontend'
? t('tickets.sourceFrontend')
: ticket.source === 'manual'
? t('tickets.sourceManual')
: t('tickets.sourceBackend')}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2"> <div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.errorHash')}</dt> <dt className="text-muted-foreground">{t('common.errorHash')}</dt>
<dd>{ticket.error_hash}</dd> <dd>{ticket.error_hash}</dd>
+75 -13
View File
@@ -23,9 +23,17 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
const TABLE_KEY = 'tickets'; const TABLE_KEY = 'tickets';
const SOURCE_ALL = '__all__';
const isBadValue = (val: unknown) => const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined; val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
@@ -37,6 +45,12 @@ const ticketStatusVariant = (status: string) => {
return 'outline' as const; return 'outline' as const;
}; };
function sourceLabel(source: string | undefined, t: TFunction): string {
if (source === 'frontend') return t('tickets.sourceFrontend');
if (source === 'manual') return t('tickets.sourceManual');
return t('tickets.sourceBackend');
}
function ticketActions( function ticketActions(
handlers: { onOpen: () => void; onDelete: () => void }, handlers: { onOpen: () => void; onDelete: () => void },
t: TFunction t: TFunction
@@ -111,6 +125,13 @@ const TicketListPage: React.FC = () => {
return isBadValue(text) ? '-' : text; return isBadValue(text) ? '-' : text;
}, },
}, },
{
accessorKey: 'source',
header: t('tickets.source'),
size: 100,
enableSorting: false,
cell: ({ getValue }) => sourceLabel(getValue<string | undefined>(), t),
},
{ {
accessorKey: 'status', accessorKey: 'status',
header: t('common.status'), header: t('common.status'),
@@ -139,10 +160,13 @@ const TicketListPage: React.FC = () => {
const record = row.original; const record = row.original;
return ( return (
<RowActionsMenu <RowActionsMenu
actions={ticketActions({ actions={ticketActions(
onOpen: () => navigate(`/tickets/${record.id}`), {
onDelete: () => handleDelete(record.id), onOpen: () => navigate(`/tickets/${record.id}`),
}, t)} onDelete: () => handleDelete(record.id),
},
t
)}
/> />
); );
}, },
@@ -163,16 +187,22 @@ const TicketListPage: React.FC = () => {
title, title,
subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined, subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined,
badges: ( badges: (
<Badge variant={ticketStatusVariant(record.status)}> <>
{formatStatusLabel(record.status)} <Badge variant="outline">{sourceLabel(record.source, t)}</Badge>
</Badge> <Badge variant={ticketStatusVariant(record.status)}>
{formatStatusLabel(record.status)}
</Badge>
</>
), ),
meta: `×${record.count} · ${record.last_seen}`, meta: `×${record.count} · ${record.last_seen}`,
onActivate: () => navigate(`/tickets/${record.id}`), onActivate: () => navigate(`/tickets/${record.id}`),
actions: ticketActions({ actions: ticketActions(
onOpen: () => navigate(`/tickets/${record.id}`), {
onDelete: () => handleDelete(record.id), onOpen: () => navigate(`/tickets/${record.id}`),
}, t), onDelete: () => handleDelete(record.id),
},
t
),
}; };
}); });
}, [data?.data, navigate, t]); }, [data?.data, navigate, t]);
@@ -189,6 +219,32 @@ const TicketListPage: React.FC = () => {
to: '/inbox/tickets', to: '/inbox/tickets',
actionLabel: t('common.openInbox'), actionLabel: t('common.openInbox'),
}} }}
toolbar={
<Select
value={params.source ?? SOURCE_ALL}
onValueChange={(value) => {
setParams((prev) => {
const next = { ...prev, offset: 0 };
if (value === SOURCE_ALL) {
delete next.source;
} else {
next.source = value as TicketListParams['source'];
}
return next;
});
}}
>
<SelectTrigger className="w-[180px]" data-testid="filter-ticket-source">
<SelectValue placeholder={t('tickets.source')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={SOURCE_ALL}>{t('tickets.sourceAll')}</SelectItem>
<SelectItem value="backend">{t('tickets.sourceBackend')}</SelectItem>
<SelectItem value="frontend">{t('tickets.sourceFrontend')}</SelectItem>
<SelectItem value="manual">{t('tickets.sourceManual')}</SelectItem>
</SelectContent>
</Select>
}
> >
<ExploreDataView <ExploreDataView
viewMode={viewMode} viewMode={viewMode}
@@ -203,14 +259,20 @@ const TicketListPage: React.FC = () => {
/> />
</ExploreListShell> </ExploreListShell>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}> <Dialog
open={deleteConfirm.open}
onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t('tickets.deleteTitle')}</DialogTitle> <DialogTitle>{t('tickets.deleteTitle')}</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p> <p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, ticketId: null })}> <Button
variant="outline"
onClick={() => setDeleteConfirm({ open: false, ticketId: null })}
>
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}> <Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}>
+2
View File
@@ -239,10 +239,12 @@ export interface Ticket {
status: 'open' | 'in_progress' | 'resolved' | 'closed'; status: 'open' | 'in_progress' | 'resolved' | 'closed';
assigned_to: string | null; assigned_to: string | null;
resolution_note: string | null; resolution_note: string | null;
source?: 'backend' | 'frontend' | 'manual';
} }
export interface TicketListParams { export interface TicketListParams {
status?: 'open' | 'in_progress' | 'resolved' | 'closed'; status?: 'open' | 'in_progress' | 'resolved' | 'closed';
source?: 'backend' | 'frontend' | 'manual';
assigned_to?: string; assigned_to?: string;
q?: string; q?: string;
limit?: number; limit?: number;