Добавить 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');
});
});
+13
View File
@@ -1,6 +1,15 @@
import apiClient from './client';
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 = {
getTickets: async (params: TicketListParams): Promise<PaginatedResponse<Ticket>> => {
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}`);
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> => {
await apiClient.put(`/v1/admin/tickets/${id}`, payload);
},
+10
View File
@@ -1,5 +1,6 @@
import React from 'react';
import i18n from '@/i18n';
import { reportClientError } from '@/lib/errorReporter';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -13,6 +14,15 @@ export class ErrorBoundary extends React.Component<Props, State> {
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() {
if (this.state.error) {
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}}",
"deleteTitle": "Delete ticket?",
"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": {
"title": "Subscriptions",
+6 -1
View File
@@ -426,7 +426,12 @@
"detailTitle": "Тикет {{id}}",
"deleteTitle": "Удалить тикет?",
"notFound": "Тикет не найден",
"statsHint": "Explore · закрыто {{closed}}, всего ошибок {{errors}}"
"statsHint": "Explore · закрыто {{closed}}, всего ошибок {{errors}}",
"source": "Источник",
"sourceAll": "Все источники",
"sourceBackend": "Backend",
"sourceFrontend": "Frontend",
"sourceManual": "Ручной"
},
"subscriptions": {
"title": "Подписки",
+12
View File
@@ -6,8 +6,20 @@ import './index.css';
import './i18n';
import LocaleProvider from './components/LocaleProvider';
import { initTableDensity } from './lib/density';
import { installGlobalErrorHandlers, reportClientError } from './lib/errorReporter';
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(
<React.StrictMode>
+10
View File
@@ -136,6 +136,16 @@ const TicketDetailPage: React.FC = () => {
<dt className="text-muted-foreground">{t('common.sender')}</dt>
<dd>{getUserLink()}</dd>
</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">
<dt className="text-muted-foreground">{t('common.errorHash')}</dt>
<dd>{ticket.error_hash}</dd>
+75 -13
View File
@@ -23,9 +23,17 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
const TABLE_KEY = 'tickets';
const SOURCE_ALL = '__all__';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
@@ -37,6 +45,12 @@ const ticketStatusVariant = (status: string) => {
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(
handlers: { onOpen: () => void; onDelete: () => void },
t: TFunction
@@ -111,6 +125,13 @@ const TicketListPage: React.FC = () => {
return isBadValue(text) ? '-' : text;
},
},
{
accessorKey: 'source',
header: t('tickets.source'),
size: 100,
enableSorting: false,
cell: ({ getValue }) => sourceLabel(getValue<string | undefined>(), t),
},
{
accessorKey: 'status',
header: t('common.status'),
@@ -139,10 +160,13 @@ const TicketListPage: React.FC = () => {
const record = row.original;
return (
<RowActionsMenu
actions={ticketActions({
onOpen: () => navigate(`/tickets/${record.id}`),
onDelete: () => handleDelete(record.id),
}, t)}
actions={ticketActions(
{
onOpen: () => navigate(`/tickets/${record.id}`),
onDelete: () => handleDelete(record.id),
},
t
)}
/>
);
},
@@ -163,16 +187,22 @@ const TicketListPage: React.FC = () => {
title,
subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined,
badges: (
<Badge variant={ticketStatusVariant(record.status)}>
{formatStatusLabel(record.status)}
</Badge>
<>
<Badge variant="outline">{sourceLabel(record.source, t)}</Badge>
<Badge variant={ticketStatusVariant(record.status)}>
{formatStatusLabel(record.status)}
</Badge>
</>
),
meta: `×${record.count} · ${record.last_seen}`,
onActivate: () => navigate(`/tickets/${record.id}`),
actions: ticketActions({
onOpen: () => navigate(`/tickets/${record.id}`),
onDelete: () => handleDelete(record.id),
}, t),
actions: ticketActions(
{
onOpen: () => navigate(`/tickets/${record.id}`),
onDelete: () => handleDelete(record.id),
},
t
),
};
});
}, [data?.data, navigate, t]);
@@ -189,6 +219,32 @@ const TicketListPage: React.FC = () => {
to: '/inbox/tickets',
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
viewMode={viewMode}
@@ -203,14 +259,20 @@ const TicketListPage: React.FC = () => {
/>
</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>
<DialogHeader>
<DialogTitle>{t('tickets.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, ticketId: null })}>
<Button
variant="outline"
onClick={() => setDeleteConfirm({ open: false, ticketId: null })}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}>
+2
View File
@@ -239,10 +239,12 @@ export interface Ticket {
status: 'open' | 'in_progress' | 'resolved' | 'closed';
assigned_to: string | null;
resolution_note: string | null;
source?: 'backend' | 'frontend' | 'manual';
}
export interface TicketListParams {
status?: 'open' | 'in_progress' | 'resolved' | 'closed';
source?: 'backend' | 'frontend' | 'manual';
assigned_to?: string;
q?: string;
limit?: number;