diff --git a/include/records.hrl b/include/records.hrl index e54d2a0..8d66b3c 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -210,14 +210,15 @@ error_hash :: binary(), error_message :: binary(), stacktrace :: binary(), - context :: binary(), % ← новое поле (term_to_binary) + context :: binary(), count :: non_neg_integer(), first_seen :: calendar:datetime(), last_seen :: calendar:datetime(), status :: open | in_progress | resolved | closed, assigned_to :: binary(), resolution_note :: binary(), - closed_at :: calendar:datetime() | undefined + closed_at :: calendar:datetime() | undefined, + source :: binary() % <<"backend">> | <<"frontend">> | <<"manual">> }). %% ------------------- Подписки ---------------------------------------- diff --git a/src/core/core_ticket.erl b/src/core/core_ticket.erl index e2a56f9..dea8250 100644 --- a/src/core/core_ticket.erl +++ b/src/core/core_ticket.erl @@ -29,6 +29,7 @@ create_ticket(Data) -> ErrorMessage = maps:get(<<"error_message">>, Data), Stacktrace = maps:get(<<"stacktrace">>, Data, <<>>), Context = maps:get(<<"context">>, Data, ?DEFAULT_CONTEXT), + Source = maps:get(<<"source">>, Data, <<"backend">>), Id = infra_utils:generate_id(16), Now = calendar:universal_time(), Ticket = #ticket{ @@ -44,7 +45,8 @@ create_ticket(Data) -> status = open, assigned_to = ?DEFAULT_ASSIGNED_TO, resolution_note = ?DEFAULT_RESOLUTION_NOTE, - closed_at = ?DEFAULT_CLOSED_AT + closed_at = ?DEFAULT_CLOSED_AT, + source = Source }, F = fun() -> mnesia:write(Ticket), {ok, Ticket} end, case mnesia:transaction(F) of diff --git a/src/handlers/admin/admin_handler_tickets.erl b/src/handlers/admin/admin_handler_tickets.erl index a11f619..b10e385 100644 --- a/src/handlers/admin/admin_handler_tickets.erl +++ b/src/handlers/admin/admin_handler_tickets.erl @@ -21,6 +21,7 @@ trails() -> tags => [<<"Tickets">>], parameters => [ #{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, description => <<"Filter by status">>}, + #{name => <<"source">>, in => <<"query">>, schema => #{type => string, enum => [<<"backend">>, <<"frontend">>, <<"manual">>]}, description => <<"Filter by source">>}, #{name => <<"assigned_to">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by assigned admin ID">>}, #{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search in error_message">>}, #{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"first_seen">>, <<"last_seen">>, <<"status">>]}, description => <<"Sort field">>}, @@ -65,7 +66,8 @@ ticket_schema() -> last_seen => #{type => string, format => <<"date-time">>}, status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, assigned_to => #{type => string, nullable => true}, - resolution_note => #{type => string, nullable => true} + resolution_note => #{type => string, nullable => true}, + source => #{type => string, enum => [<<"backend">>, <<"frontend">>, <<"manual">>]} } }. @@ -144,22 +146,31 @@ parse_ticket_filters(Req) -> Qs = cowboy_req:parse_qs(Req), #{ status => proplists:get_value(<<"status">>, Qs), + source => proplists:get_value(<<"source">>, Qs), assigned_to => proplists:get_value(<<"assigned_to">>, Qs), q => proplists:get_value(<<"q">>, Qs) }. apply_ticket_filters(Tickets, Filters) -> Assigned = maps:get(assigned_to, Filters, undefined), + Source = maps:get(source, Filters, undefined), Q = maps:get(q, Filters, undefined), - F1 = case Assigned of + F0 = case Source of undefined -> Tickets; - _ -> [T || T <- Tickets, T#ticket.assigned_to =:= Assigned] + _ -> [T || T <- Tickets, ticket_source(T) =:= Source] + end, + F1 = case Assigned of + undefined -> F0; + _ -> [T || T <- F0, T#ticket.assigned_to =:= Assigned] end, case Q of undefined -> F1; _ -> [T || T <- F1, string:str(binary_to_list(T#ticket.error_message), binary_to_list(Q)) > 0] end. +ticket_source(#ticket{source = S}) when is_binary(S), S =/= <<>> -> S; +ticket_source(_) -> <<"backend">>. + sort_tickets(Tickets, #{sort := Sort, order := Order}) -> Field = binary_to_existing_atom(Sort, utf8), lists:sort( diff --git a/src/handlers/handler_tickets.erl b/src/handlers/handler_tickets.erl index 7e43bb7..df73786 100644 --- a/src/handlers/handler_tickets.erl +++ b/src/handlers/handler_tickets.erl @@ -4,7 +4,7 @@ %%% GET – получить список тикетов. %%% Администраторы видят все тикеты, %%% обычные пользователи – только свои. -%%% POST – создать новый тикет об ошибке. +%%% POST – создать/дедуплицировать тикет об ошибке через logic_ticket:report_error. %%% @end %%%------------------------------------------------------------------- -module(handler_tickets). @@ -47,7 +47,7 @@ trails() -> #{ % POST create path => <<"/v1/tickets">>, method => <<"POST">>, - description => <<"Create a new ticket (bug report)">>, + description => <<"Create or bump a ticket (bug report / frontend error)">>, tags => [<<"Tickets">>], requestBody => #{ required => true, @@ -57,14 +57,16 @@ trails() -> properties => #{ error_message => #{type => string}, stacktrace => #{type => string}, - context => #{type => string} + context => #{type => object}, + source => #{type => string, enum => [<<"frontend">>, <<"manual">>]} } }}} }, responses => #{ - 201 => #{description => <<"Ticket created">>}, + 201 => #{description => <<"Ticket created or updated">>}, 400 => #{description => <<"Missing required fields or invalid JSON">>}, - 401 => #{description => <<"Unauthorized">>} + 401 => #{description => <<"Unauthorized">>}, + 429 => #{description => <<"Rate limited">>} } } ]. @@ -84,7 +86,8 @@ ticket_schema() -> last_seen => #{type => string, format => <<"date-time">>}, status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, assigned_to => #{type => string, nullable => true}, - resolution_note => #{type => string, nullable => true} + resolution_note => #{type => string, nullable => true}, + source => #{type => string, enum => [<<"backend">>, <<"frontend">>, <<"manual">>]} } }. @@ -118,21 +121,23 @@ list_tickets(Req) -> handler_utils:send_error(Req1, Code, Message) end. -%% @doc POST /v1/tickets — создание тикета. +%% @doc POST /v1/tickets — создание/дедуп тикета. -spec create_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. create_ticket(Req) -> case handler_utils:auth_user(Req) of {ok, UserId, Req1} -> {ok, Body, Req2} = cowboy_req:read_body(Req1), try jsx:decode(Body, [return_maps]) of - #{<<"error_message">> := _} = Data -> - TicketData = maps:merge( - #{<<"reporter_id">> => UserId, <<"status">> => <<"open">>}, - Data - ), - case core_ticket:create_ticket(TicketData) of + #{<<"error_message">> := ErrorMessage} = Data -> + Stacktrace = maps:get(<<"stacktrace">>, Data, <<>>), + Source = resolve_source(Data, Stacktrace), + Context0 = maps:get(<<"context">>, Data, #{}), + Context = merge_context(Context0, UserId), + case logic_ticket:report_error(Source, ErrorMessage, Stacktrace, Context) of {ok, Ticket} -> handler_utils:send_json(Req2, 201, handler_utils:ticket_to_json(Ticket)); + {error, rate_limited} -> + handler_utils:send_error(Req2, 429, <<"Too many new tickets">>); {error, Reason} -> handler_utils:send_error(Req2, 500, Reason) end; @@ -143,4 +148,19 @@ create_ticket(Req) -> end; {error, Code, Message, Req1} -> handler_utils:send_error(Req1, Code, Message) - end. \ No newline at end of file + end. + +resolve_source(#{<<"source">> := <<"frontend">>}, _) -> frontend; +resolve_source(#{<<"source">> := <<"manual">>}, _) -> manual; +resolve_source(#{<<"source">> := <<"backend">>}, _) -> backend; +resolve_source(_, Stacktrace) when is_binary(Stacktrace), Stacktrace =/= <<>> -> + frontend; +resolve_source(_, _) -> + manual. + +merge_context(Ctx, UserId) when is_map(Ctx) -> + Ctx#{<<"reporter_id">> => UserId}; +merge_context(Ctx, UserId) when is_binary(Ctx) -> + #{<<"reporter_id">> => UserId, <<"raw">> => Ctx}; +merge_context(_, UserId) -> + #{<<"reporter_id">> => UserId}. diff --git a/src/handlers/handler_utils.erl b/src/handlers/handler_utils.erl index 07401c2..d40d161 100644 --- a/src/handlers/handler_utils.erl +++ b/src/handlers/handler_utils.erl @@ -27,7 +27,9 @@ subscription_to_json/1, trails_for_crud/4, is_superadmin/1, - pagination_headers/2 + pagination_headers/2, + maybe_report_internal_error/3, + report_and_send_error/4 ]). -export([admin_to_json/1, audit_to_json/1]). @@ -91,15 +93,91 @@ send_json(Req, Status, Data, ExtraHeaders) -> Req1 = cowboy_req:reply(Status, Headers, Body, Req), {ok, Body, Req1}. -%% @doc Отправляет JSON-ошибку. --spec send_error(cowboy_req:req(), cowboy:http_status(), binary()) -> +%% @doc Отправляет JSON-ошибку. При Status >= 500 асинхронно регистрирует тикет. +-spec send_error(cowboy_req:req(), cowboy:http_status(), binary() | term()) -> {ok, binary(), cowboy_req:req()}. +send_error(Req, Status, Message) when Status >= 500 -> + MsgBin = ensure_error_binary(Message), + maybe_report_internal_error(Req, MsgBin, #{}), + do_send_error(Req, Status, MsgBin); send_error(Req, Status, Message) -> + do_send_error(Req, Status, ensure_error_binary(Message)). + +%% @doc Отправить ошибку и явно зарегистрировать тикет с доп. контекстом. +-spec report_and_send_error(cowboy_req:req(), cowboy:http_status(), + binary() | term(), map()) -> {ok, binary(), cowboy_req:req()}. +report_and_send_error(Req, Status, Message, Context) when Status >= 500 -> + MsgBin = ensure_error_binary(Message), + maybe_report_internal_error(Req, MsgBin, Context), + do_send_error(Req, Status, MsgBin); +report_and_send_error(Req, Status, Message, _Context) -> + do_send_error(Req, Status, ensure_error_binary(Message)). + +%% @doc Асинхронно зарегистрировать внутреннюю ошибку как тикет (backend). +-spec maybe_report_internal_error(cowboy_req:req() | map(), binary() | term(), map()) -> ok. +maybe_report_internal_error(ReqOrCtx, Message, ExtraContext) -> + case get(eventhub_reporting_ticket) of + true -> ok; + _ -> + case should_skip_ticket_report(ReqOrCtx) of + true -> ok; + false -> + MsgBin = ensure_error_binary(Message), + Context = maps:merge(request_error_context(ReqOrCtx), ExtraContext), + Stack = maps:get(<<"stacktrace">>, Context, <<>>), + Context1 = maps:remove(<<"stacktrace">>, Context), + spawn(fun() -> + try logic_ticket:report_error(backend, MsgBin, Stack, Context1) + catch + _:_ -> ok + end + end), + ok + end + end. + +should_skip_ticket_report(Ctx) when is_map(Ctx) -> + Route = maps:get(<<"route">>, Ctx, maps:get(route, Ctx, <<>>)), + is_ticket_route(ensure_error_binary(Route)); +should_skip_ticket_report(Req) -> + try is_ticket_route(cowboy_req:path(Req)) + catch + _:_ -> false + end. + +is_ticket_route(Path) when is_binary(Path) -> + binary:match(Path, <<"/tickets">>) =/= nomatch; +is_ticket_route(_) -> + false. + +do_send_error(Req, Status, Message) -> Body = jsx:encode(#{error => Message}), Headers = #{<<"content-type">> => <<"application/json">>}, Req1 = cowboy_req:reply(Status, Headers, Body, Req), {ok, Body, Req1}. +ensure_error_binary(V) when is_binary(V) -> V; +ensure_error_binary(V) when is_atom(V) -> atom_to_binary(V, utf8); +ensure_error_binary(V) when is_list(V) -> + try unicode:characters_to_binary(V) + catch _:_ -> list_to_binary(io_lib:format("~p", [V])) + end; +ensure_error_binary(V) -> + list_to_binary(io_lib:format("~p", [V])). + +request_error_context(Ctx) when is_map(Ctx) -> Ctx; +request_error_context(Req) -> + try + Method = cowboy_req:method(Req), + Path = cowboy_req:path(Req), + #{ + <<"method">> => Method, + <<"route">> => Path + } + catch + _:_ -> #{} + end. + %%%=================================================================== %%% Парсинг параметров запроса %%%=================================================================== @@ -330,9 +408,15 @@ ticket_to_json(Ticket) -> status => Ticket#ticket.status, assigned_to => Ticket#ticket.assigned_to, resolution_note => Ticket#ticket.resolution_note, - closed_at => datetime_to_iso8601(Ticket#ticket.closed_at) + closed_at => datetime_to_iso8601(Ticket#ticket.closed_at), + source => ticket_source(Ticket) }. +ticket_source(#ticket{source = Source}) when is_binary(Source), Source =/= <<>> -> + Source; +ticket_source(_) -> + <<"backend">>. + %% @doc Преобразует #calendar{} в JSON-карту. -spec calendar_to_json(#calendar{}) -> map(). calendar_to_json(Calendar) -> diff --git a/src/infra/migration_engine.erl b/src/infra/migration_engine.erl index bfee2a8..de5e875 100644 --- a/src/infra/migration_engine.erl +++ b/src/infra/migration_engine.erl @@ -20,7 +20,8 @@ %% Упорядоченный реестр миграций (добавлять новые модули в конец списка). -define(ALL_MIGRATIONS, [ '20260501120000_base_schema', - '20260504150000_test_migration' + '20260504150000_test_migration', + '20260716230000_ticket_source_and_hash_index' ]). %% ------------------------------ diff --git a/src/logic/logic_ticket.erl b/src/logic/logic_ticket.erl index 93f2551..1185ab7 100644 --- a/src/logic/logic_ticket.erl +++ b/src/logic/logic_ticket.erl @@ -2,6 +2,7 @@ -include("records.hrl"). -export([report_error/3, + report_error/4, get_ticket/2, list_tickets/1, list_tickets_by_status/2, @@ -12,40 +13,57 @@ get_statistics/1]). -export([delete_ticket/2]). -export([get_user_ticket/2]). +-export([compute_error_hash/3]). -%% Зарегистрировать ошибку (создать или обновить тикет) +-define(STACK_FINGERPRINT_MAX, 500). +-define(NEW_TICKET_RATE_LIMIT, 30). +-define(RATE_WINDOW_MS, 60000). +-define(RATE_ETS, eventhub_ticket_rate). + +%% Зарегистрировать ошибку (source по умолчанию backend) report_error(ErrorMessage, Stacktrace, Context) -> - Existing = [T || T <- core_ticket:list_all(), T#ticket.error_message =:= ErrorMessage], - case Existing of - [Ticket] -> - % Увеличить счётчик и обновить last_seen - Updated = Ticket#ticket{ - count = Ticket#ticket.count + 1, - last_seen = calendar:universal_time() - }, - mnesia:dirty_write(Updated), - {ok, Updated}; + report_error(backend, ErrorMessage, Stacktrace, Context). + +%% @doc Создать или обновить тикет по error_hash. +%% Source: backend | frontend | manual (atom или binary). +report_error(Source0, ErrorMessage0, Stacktrace0, Context0) -> + case get(eventhub_reporting_ticket) of + true -> + {error, reporting_recursion}; + _ -> + put(eventhub_reporting_ticket, true), + try + do_report_error(Source0, ErrorMessage0, Stacktrace0, Context0) + after + erase(eventhub_reporting_ticket) + end + end. + +do_report_error(Source0, ErrorMessage0, Stacktrace0, Context0) -> + Source = normalize_source(Source0), + ErrorMessage = ensure_binary(ErrorMessage0), + Stacktrace = ensure_binary(Stacktrace0), + ContextMap = normalize_context(Context0), + ReporterId = maps:get(<<"reporter_id">>, ContextMap, + maps:get(reporter_id, ContextMap, <<"anonymous">>)), + ReporterIdBin = ensure_binary(ReporterId), + Hash = compute_error_hash(Source, ErrorMessage, Stacktrace, ReporterIdBin), + ContextBin = encode_context(ContextMap#{<<"source">> => Source}), + case find_open_by_hash(Hash) of + [Ticket | _] -> + bump_ticket(Ticket, Stacktrace, ContextBin); [] -> - ReporterId = maps:get(<<"reporter_id">>, Context, - maps:get(reporter_id, Context, <<"anonymous">>)), - Data = #{ - <<"reporter_id">> => ReporterId, - <<"error_message">> => ErrorMessage, - <<"stacktrace">> => Stacktrace, - <<"context">> => list_to_binary(io_lib:format("~p", [Context])) - }, - case core_ticket:create_ticket(Data) of - {ok, Ticket} = Result -> - % Уведомление администраторов (заглушка) - notify_admins(Ticket), - Result; - Error -> Error + case allow_new_ticket() of + false -> + {error, rate_limited}; + true -> + create_and_notify(ReporterIdBin, Hash, ErrorMessage, Stacktrace, ContextBin, Source) end end. %% Получить тикет, проверяя, что пользователь является репортером get_user_ticket(UserId, TicketId) -> - case core_ticket:get_by_id(TicketId) of % используем базовую функцию без проверки прав + case core_ticket:get_by_id(TicketId) of {ok, Ticket} -> case Ticket#ticket.reporter_id =:= UserId of true -> {ok, Ticket}; @@ -149,7 +167,168 @@ normalize_status(Status) when is_binary(Status) -> catch error:badarg -> Status end. -%% ============ Вспомогательные функции ============ +%% ============ Hash / dedupe ============ -notify_admins(_Ticket) -> - ok. \ No newline at end of file +-spec compute_error_hash(binary() | atom(), binary(), binary()) -> binary(). +compute_error_hash(Source, ErrorMessage, Stacktrace) -> + compute_error_hash(Source, ErrorMessage, Stacktrace, <<"anonymous">>). + +compute_error_hash(Source0, ErrorMessage, Stacktrace, ReporterId) -> + Source = normalize_source(Source0), + Msg = normalize_message(ErrorMessage), + Fingerprint = stack_fingerprint(Stacktrace), + Payload = case Source of + <<"manual">> -> + <<"manual:", ReporterId/binary, ":", Msg/binary>>; + _ -> + <> + end, + HashBin = crypto:hash(sha256, Payload), + binary:encode_hex(HashBin, lowercase). + +stack_fingerprint(<<>>) -> <<>>; +stack_fingerprint(Stack) when is_binary(Stack) -> + Normalized = re:replace(Stack, <<"0x[0-9a-fA-F]+">>, <<"0x*">>, + [global, {return, binary}]), + case byte_size(Normalized) > ?STACK_FINGERPRINT_MAX of + true -> binary_part(Normalized, 0, ?STACK_FINGERPRINT_MAX); + false -> Normalized + end; +stack_fingerprint(_) -> <<>>. + +normalize_message(Msg) when is_binary(Msg) -> + string:trim(Msg); +normalize_message(Msg) -> + string:trim(ensure_binary(Msg)). + +find_open_by_hash(Hash) -> + Candidates = try + mnesia:dirty_index_read(ticket, Hash, #ticket.error_hash) + catch + _:_ -> + [T || T <- core_ticket:list_all(), T#ticket.error_hash =:= Hash] + end, + [T || T <- Candidates, + lists:member(normalize_status(T#ticket.status), [open, in_progress])]. + +bump_ticket(Ticket, Stacktrace, ContextBin) -> + Updated0 = Ticket#ticket{ + count = Ticket#ticket.count + 1, + last_seen = calendar:universal_time() + }, + Updated1 = case Updated0#ticket.stacktrace =:= <<>> andalso Stacktrace =/= <<>> of + true -> Updated0#ticket{stacktrace = Stacktrace}; + false -> Updated0 + end, + Updated = case Updated1#ticket.context =:= <<>> andalso ContextBin =/= <<>> of + true -> Updated1#ticket{context = ContextBin}; + false -> Updated1 + end, + mnesia:dirty_write(Updated), + {ok, Updated}. + +create_and_notify(ReporterId, Hash, ErrorMessage, Stacktrace, ContextBin, Source) -> + Data = #{ + <<"reporter_id">> => ReporterId, + <<"error_hash">> => Hash, + <<"error_message">> => ErrorMessage, + <<"stacktrace">> => Stacktrace, + <<"context">> => ContextBin, + <<"source">> => Source + }, + case core_ticket:create_ticket(Data) of + {ok, Ticket} = Result -> + notify_admins(Ticket), + Result; + Error -> Error + end. + +%% ============ Rate limit (new tickets only) ============ + +allow_new_ticket() -> + ensure_rate_ets(), + Now = erlang:monotonic_time(millisecond), + WindowStart = Now - ?RATE_WINDOW_MS, + ets:insert(?RATE_ETS, {Now, true}), + ets:select_delete(?RATE_ETS, [{{'$1', '_'}, [{'<', '$1', WindowStart}], [true]}]), + Count = ets:info(?RATE_ETS, size), + Count =< ?NEW_TICKET_RATE_LIMIT. + +ensure_rate_ets() -> + case ets:info(?RATE_ETS) of + undefined -> + try + ets:new(?RATE_ETS, [named_table, public, set, {write_concurrency, true}]) + catch + error:badarg -> ok + end; + _ -> ok + end. + +%% ============ Notify ============ + +notify_admins(Ticket) -> + logic_notification:notify_admin(ticket_created, #{ + ticket_id => Ticket#ticket.id, + error_message => Ticket#ticket.error_message, + error_hash => Ticket#ticket.error_hash, + source => Ticket#ticket.source, + count => Ticket#ticket.count, + status => Ticket#ticket.status + }), + ok. + +%% ============ Helpers ============ + +normalize_source(backend) -> <<"backend">>; +normalize_source(frontend) -> <<"frontend">>; +normalize_source(manual) -> <<"manual">>; +normalize_source(<<"backend">>) -> <<"backend">>; +normalize_source(<<"frontend">>) -> <<"frontend">>; +normalize_source(<<"manual">>) -> <<"manual">>; +normalize_source(_) -> <<"backend">>. + +normalize_context(Ctx) when is_map(Ctx) -> + maps:fold(fun(K, V, Acc) -> + Acc#{ensure_binary(K) => V} + end, #{}, Ctx); +normalize_context(Ctx) when is_binary(Ctx) -> + try jsx:decode(Ctx, [return_maps]) of + Map when is_map(Map) -> Map; + _ -> #{<<"raw">> => Ctx} + catch + _:_ -> #{<<"raw">> => Ctx} + end; +normalize_context(Ctx) -> + #{<<"raw">> => list_to_binary(io_lib:format("~p", [Ctx]))}. + +encode_context(Map) when is_map(Map) -> + try jsx:encode(sanitize_for_json(Map)) + catch + _:_ -> list_to_binary(io_lib:format("~p", [Map])) + end. + +sanitize_for_json(Map) when is_map(Map) -> + maps:fold(fun(K, V, Acc) -> + Acc#{ensure_binary(K) => sanitize_value(V)} + end, #{}, Map). + +sanitize_value(V) when is_binary(V); is_integer(V); is_float(V); is_boolean(V); V =:= null -> + V; +sanitize_value(V) when is_atom(V) -> + atom_to_binary(V, utf8); +sanitize_value(V) when is_list(V) -> + case io_lib:printable_unicode_list(V) of + true -> unicode:characters_to_binary(V); + false -> [sanitize_value(X) || X <- V] + end; +sanitize_value(V) when is_map(V) -> + sanitize_for_json(V); +sanitize_value(V) -> + list_to_binary(io_lib:format("~p", [V])). + +ensure_binary(V) when is_binary(V) -> V; +ensure_binary(V) when is_atom(V) -> atom_to_binary(V, utf8); +ensure_binary(V) when is_list(V) -> unicode:characters_to_binary(V); +ensure_binary(V) when is_integer(V) -> integer_to_binary(V); +ensure_binary(V) -> list_to_binary(io_lib:format("~p", [V])). diff --git a/src/migrations/20260716230000_ticket_source_and_hash_index.erl b/src/migrations/20260716230000_ticket_source_and_hash_index.erl new file mode 100644 index 0000000..adb6792 --- /dev/null +++ b/src/migrations/20260716230000_ticket_source_and_hash_index.erl @@ -0,0 +1,76 @@ +%% @doc Add ticket.source field and index on error_hash. +%% No-op if `ticket` table is absent (e.g. migration_engine unit tests). +-module('20260716230000_ticket_source_and_hash_index'). + +-export([up/0, down/0]). + +-include("records.hrl"). + +up() -> + case ticket_table_exists() of + false -> + ok; + true -> + ensure_source_field(), + ensure_error_hash_index(), + ok + end. + +down() -> + case ticket_table_exists() of + false -> + ok; + true -> + case catch mnesia:del_table_index(ticket, error_hash) of + {atomic, ok} -> ok; + {aborted, {no_exists, _}} -> ok; + {aborted, {no_exists, _, _}} -> ok; + _ -> ok + end + end. + +ticket_table_exists() -> + try lists:member(ticket, mnesia:system_info(tables)) + catch + _:_ -> false + end. + +ensure_source_field() -> + Attrs = mnesia:table_info(ticket, attributes), + case lists:member(source, Attrs) of + true -> + ok; + false -> + Fun = fun(Rec) -> + case tuple_size(Rec) of + 14 -> + %% ticket + 13 fields (pre-source) + list_to_tuple(tuple_to_list(Rec) ++ [<<"backend">>]); + _ -> + Rec + end + end, + case mnesia:transform_table(ticket, Fun, record_info(fields, ticket)) of + {atomic, ok} -> ok; + {aborted, Reason} -> error({transform_ticket_failed, Reason}) + end + end. + +ensure_error_hash_index() -> + Indexes = mnesia:table_info(ticket, index), + HasIndex = lists:any(fun + (error_hash) -> true; + (N) when is_integer(N) -> N =:= #ticket.error_hash; + (_) -> false + end, Indexes), + case HasIndex of + true -> + ok; + false -> + case mnesia:add_table_index(ticket, error_hash) of + {atomic, ok} -> ok; + {aborted, {already_exists, _}} -> ok; + {aborted, {already_exists, _, _}} -> ok; + {aborted, Reason} -> error({add_index_failed, Reason}) + end + end. diff --git a/test/api/users/user_tickets_tests.erl b/test/api/users/user_tickets_tests.erl index a790863..d5900d4 100644 --- a/test/api/users/user_tickets_tests.erl +++ b/test/api/users/user_tickets_tests.erl @@ -36,6 +36,8 @@ test() -> #{error_message => <<"Something broke">>, stacktrace => <<"line 42">>}), test_create_ticket(Token), + test_create_ticket_dedupe(Token), + test_create_ticket_manual(Token), test_create_ticket_missing_fields(Token), test_create_ticket_unauthorized(), test_list_tickets(Token, TicketId), @@ -47,7 +49,6 @@ test() -> ct:pal("=== All user tickets tests passed ==="), ok. - %%%=================================================================== %%% Тестовые функции %%%=================================================================== @@ -57,13 +58,57 @@ test() -> test_create_ticket(Token) -> ct:pal(" TEST: Create a ticket"), Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token, - jsx:encode(#{error_message => <<"Test bug">>, stacktrace => <<"trace">>})), + jsx:encode(#{ + error_message => <<"Test bug">>, + stacktrace => <<"trace">>, + source => <<"frontend">>, + context => #{route => <<"/test">>, build => <<"dev">>} + })), {ok, 201, _, Body} = Resp, - #{<<"id">> := Id, <<"status">> := Status} = jsx:decode(list_to_binary(Body), [return_maps]), + Decoded = jsx:decode(list_to_binary(Body), [return_maps]), + #{<<"id">> := Id, <<"status">> := Status, <<"source">> := Source, <<"error_hash">> := Hash} = Decoded, ?assert(is_binary(Id)), ?assertEqual(<<"open">>, Status), + ?assertEqual(<<"frontend">>, Source), + ?assert(is_binary(Hash)), + ?assert(byte_size(Hash) > 0), ct:pal(" OK: ticket ~s created", [Id]). +%% @doc Повторный POST с тем же сообщением увеличивает count. +-spec test_create_ticket_dedupe(binary()) -> ok. +test_create_ticket_dedupe(Token) -> + ct:pal(" TEST: Dedupe ticket by hash"), + Payload = jsx:encode(#{ + error_message => <<"Dedupe me">>, + stacktrace => <<"same stack">>, + source => <<"frontend">> + }), + {ok, 201, _, Body1} = api_test_runner:client_request(post, <<"/v1/tickets">>, Token, Payload), + #{<<"id">> := Id1, <<"count">> := Count1} = jsx:decode(list_to_binary(Body1), [return_maps]), + {ok, 201, _, Body2} = api_test_runner:client_request(post, <<"/v1/tickets">>, Token, Payload), + #{<<"id">> := Id2, <<"count">> := Count2} = jsx:decode(list_to_binary(Body2), [return_maps]), + ?assertEqual(Id1, Id2), + ?assertEqual(Count1 + 1, Count2), + ct:pal(" OK: count bumped to ~p", [Count2]). + +%% @doc Ручной клиентский репорт: source=manual без stacktrace. +-spec test_create_ticket_manual(binary()) -> ok. +test_create_ticket_manual(Token) -> + ct:pal(" TEST: Create manual ticket"), + Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token, + jsx:encode(#{ + error_message => <<"Button does nothing">>, + source => <<"manual">>, + context => #{steps => <<"1. Open calendar\n2. Click share">>} + })), + {ok, 201, _, Body} = Resp, + #{<<"source">> := Source, <<"error_hash">> := Hash} = + jsx:decode(list_to_binary(Body), [return_maps]), + ?assertEqual(<<"manual">>, Source), + ?assert(is_binary(Hash)), + ?assert(byte_size(Hash) > 0), + ct:pal(" OK: manual ticket created"). + %% @doc Отсутствие обязательного поля error_message: 400 Bad Request. -spec test_create_ticket_missing_fields(binary()) -> ok. test_create_ticket_missing_fields(Token) -> diff --git a/test/unit/handler_ticket_auto_report_tests.erl b/test/unit/handler_ticket_auto_report_tests.erl new file mode 100644 index 0000000..ff33305 --- /dev/null +++ b/test/unit/handler_ticket_auto_report_tests.erl @@ -0,0 +1,90 @@ +-module(handler_ticket_auto_report_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +%% ---------------------------------------------------------------- +%% Фикстуры +%% ---------------------------------------------------------------- +setup() -> + catch mnesia:stop(), + case mnesia:start() of + {atomic, ok} -> ok; + ok -> ok + end, + {atomic, ok} = mnesia:create_table(ticket, [ + {attributes, record_info(fields, ticket)}, {ram_copies, [node()]}]), + %% rate-limit ETS может остаться между тестами + catch ets:delete(eventhub_ticket_rate), + ok. + +cleanup(_) -> + catch mnesia:delete_table(ticket), + catch ets:delete(eventhub_ticket_rate), + mnesia:stop(). + +%% ---------------------------------------------------------------- +%% Тесты: авто-регистрация backend-ошибок через handler_utils +%% ---------------------------------------------------------------- +handler_ticket_auto_report_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"maybe_report creates backend ticket async", fun test_maybe_report_creates/0}, + {"maybe_report skips /tickets routes", fun test_maybe_report_skips_tickets/0}, + {"duplicate maybe_report bumps count", fun test_maybe_report_dedupe/0} + ]}. + +test_maybe_report_creates() -> + Before = length(core_ticket:list_all()), + ok = handler_utils:maybe_report_internal_error( + #{<<"route">> => <<"/v1/calendars">>, <<"method">> => <<"GET">>}, + <<"simulated_500">>, + #{} + ), + Ticket = wait_for_ticket(<<"simulated_500">>, 20), + ?assertEqual(Before + 1, length(core_ticket:list_all())), + ?assertEqual(<<"backend">>, Ticket#ticket.source), + ?assert(is_binary(Ticket#ticket.error_hash)), + ?assert(byte_size(Ticket#ticket.error_hash) > 0). + +test_maybe_report_skips_tickets() -> + Before = length(core_ticket:list_all()), + ok = handler_utils:maybe_report_internal_error( + #{<<"route">> => <<"/v1/tickets">>, <<"method">> => <<"POST">>}, + <<"ticket_path_should_not_report">>, + #{} + ), + timer:sleep(200), + ?assertEqual(Before, length(core_ticket:list_all())). + +test_maybe_report_dedupe() -> + Msg = <<"same_backend_error">>, + ok = handler_utils:maybe_report_internal_error( + #{<<"route">> => <<"/v1/events">>, <<"method">> => <<"POST">>}, Msg, #{}), + T1 = wait_for_ticket(Msg, 20), + ok = handler_utils:maybe_report_internal_error( + #{<<"route">> => <<"/v1/events">>, <<"method">> => <<"POST">>}, Msg, #{}), + T2 = wait_for_ticket_count(Msg, 2, 20), + ?assertEqual(T1#ticket.id, T2#ticket.id), + ?assertEqual(2, T2#ticket.count). + +%% ---------------------------------------------------------------- +%% Helpers +%% ---------------------------------------------------------------- +wait_for_ticket(Msg, 0) -> + error({timeout_waiting_ticket, Msg}); +wait_for_ticket(Msg, Retries) -> + case [T || T <- core_ticket:list_all(), T#ticket.error_message =:= Msg] of + [T | _] -> T; + [] -> + timer:sleep(50), + wait_for_ticket(Msg, Retries - 1) + end. + +wait_for_ticket_count(Msg, MinCount, 0) -> + error({timeout_waiting_count, Msg, MinCount}); +wait_for_ticket_count(Msg, MinCount, Retries) -> + case [T || T <- core_ticket:list_all(), T#ticket.error_message =:= Msg] of + [T | _] when T#ticket.count >= MinCount -> T; + _ -> + timer:sleep(50), + wait_for_ticket_count(Msg, MinCount, Retries - 1) + end. diff --git a/test/unit/logic_ticket_tests.erl b/test/unit/logic_ticket_tests.erl index 2f596bc..1b1e031 100644 --- a/test/unit/logic_ticket_tests.erl +++ b/test/unit/logic_ticket_tests.erl @@ -42,6 +42,8 @@ logic_ticket_test_() -> {foreach, fun setup/0, fun cleanup/1, [ {"Report error creates ticket", fun test_report_error/0}, {"Report duplicate error increments count", fun test_report_duplicate/0}, + {"Report after close creates new ticket", fun test_report_after_close/0}, + {"Frontend source set on ticket", fun test_report_frontend_source/0}, {"List tickets as admin", fun test_list_tickets/0}, {"List tickets as non-admin returns error", fun test_list_tickets_forbidden/0}, {"Update status as admin", fun test_update_status/0}, @@ -55,12 +57,18 @@ logic_ticket_test_() -> report(ErrorMsg) -> logic_ticket:report_error(ErrorMsg, <<"stack">>, #{<<"reporter_id">> => <<"user1">>}). +report(Source, ErrorMsg) -> + logic_ticket:report_error(Source, ErrorMsg, <<"stack">>, #{<<"reporter_id">> => <<"user1">>}). + %% --- Тесты --- test_report_error() -> {ok, Ticket} = report(<<"Error1">>), ?assertEqual(<<"Error1">>, Ticket#ticket.error_message), - ?assertEqual(1, Ticket#ticket.count). + ?assertEqual(1, Ticket#ticket.count), + ?assertEqual(<<"backend">>, Ticket#ticket.source), + ?assert(is_binary(Ticket#ticket.error_hash)), + ?assert(byte_size(Ticket#ticket.error_hash) > 0). test_report_duplicate() -> {ok, T1} = report(<<"Dup">>), @@ -70,6 +78,17 @@ test_report_duplicate() -> % Проверяем, что это тот же тикет, а не новый ?assertEqual(T1#ticket.id, T2#ticket.id). +test_report_after_close() -> + {ok, T1} = report(<<"ClosedThenAgain">>), + {ok, _} = logic_ticket:close_ticket(<<"admin1">>, T1#ticket.id), + {ok, T2} = report(<<"ClosedThenAgain">>), + ?assertEqual(1, T2#ticket.count), + ?assertNotEqual(T1#ticket.id, T2#ticket.id). + +test_report_frontend_source() -> + {ok, Ticket} = report(frontend, <<"FE crash">>), + ?assertEqual(<<"frontend">>, Ticket#ticket.source). + test_list_tickets() -> {ok, _} = report(<<"E1">>), Tickets = logic_ticket:list_tickets(<<"admin1">>), diff --git a/test/unit/migration_engine_tests.erl b/test/unit/migration_engine_tests.erl index 224de15..3b2ba66 100644 --- a/test/unit/migration_engine_tests.erl +++ b/test/unit/migration_engine_tests.erl @@ -38,7 +38,8 @@ test_ensure_applied() -> ?assertEqual([], maps:get(pending, Status)), Applied = maps:get(applied, Status), ?assert(lists:member("20260501120000_base_schema", Applied)), - ?assert(lists:member("20260504150000_test_migration", Applied)). + ?assert(lists:member("20260504150000_test_migration", Applied)), + ?assert(lists:member("20260716230000_ticket_source_and_hash_index", Applied)). test_ensure_applied_idempotent() -> ?assertEqual(ok, migration_engine:ensure_applied()), @@ -58,6 +59,8 @@ test_join_wait() -> version = "20260501120000_base_schema", applied_at = Now}), mnesia:dirty_write(#schema_migration{ version = "20260504150000_test_migration", applied_at = Now}), + mnesia:dirty_write(#schema_migration{ + version = "20260716230000_ticket_source_and_hash_index", applied_at = Now}), mnesia:dirty_delete({schema_migration, ?LOCK_VERSION}) end), ?assertEqual(ok, migration_engine:ensure_applied()).