Добавить авто-регистрацию тикетов (hash/dedupe/500/WS). Refs EventHub/EventHubBack#35
CI / test (push) Successful in 21m45s
CI / deploy-ift (push) Successful in 6m8s
CI / e2e-ift (push) Failing after 6m13s
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped

This commit is contained in:
2026-07-17 00:44:09 +03:00
parent 0a52712258
commit a696d25486
12 changed files with 590 additions and 59 deletions
+3 -2
View File
@@ -210,14 +210,15 @@
error_hash :: binary(), error_hash :: binary(),
error_message :: binary(), error_message :: binary(),
stacktrace :: binary(), stacktrace :: binary(),
context :: binary(), % ← новое поле (term_to_binary) context :: binary(),
count :: non_neg_integer(), count :: non_neg_integer(),
first_seen :: calendar:datetime(), first_seen :: calendar:datetime(),
last_seen :: calendar:datetime(), last_seen :: calendar:datetime(),
status :: open | in_progress | resolved | closed, status :: open | in_progress | resolved | closed,
assigned_to :: binary(), assigned_to :: binary(),
resolution_note :: binary(), resolution_note :: binary(),
closed_at :: calendar:datetime() | undefined closed_at :: calendar:datetime() | undefined,
source :: binary() % <<"backend">> | <<"frontend">> | <<"manual">>
}). }).
%% ------------------- Подписки ---------------------------------------- %% ------------------- Подписки ----------------------------------------
+3 -1
View File
@@ -29,6 +29,7 @@ create_ticket(Data) ->
ErrorMessage = maps:get(<<"error_message">>, Data), ErrorMessage = maps:get(<<"error_message">>, Data),
Stacktrace = maps:get(<<"stacktrace">>, Data, <<>>), Stacktrace = maps:get(<<"stacktrace">>, Data, <<>>),
Context = maps:get(<<"context">>, Data, ?DEFAULT_CONTEXT), Context = maps:get(<<"context">>, Data, ?DEFAULT_CONTEXT),
Source = maps:get(<<"source">>, Data, <<"backend">>),
Id = infra_utils:generate_id(16), Id = infra_utils:generate_id(16),
Now = calendar:universal_time(), Now = calendar:universal_time(),
Ticket = #ticket{ Ticket = #ticket{
@@ -44,7 +45,8 @@ create_ticket(Data) ->
status = open, status = open,
assigned_to = ?DEFAULT_ASSIGNED_TO, assigned_to = ?DEFAULT_ASSIGNED_TO,
resolution_note = ?DEFAULT_RESOLUTION_NOTE, 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, F = fun() -> mnesia:write(Ticket), {ok, Ticket} end,
case mnesia:transaction(F) of case mnesia:transaction(F) of
+14 -3
View File
@@ -21,6 +21,7 @@ trails() ->
tags => [<<"Tickets">>], tags => [<<"Tickets">>],
parameters => [ parameters => [
#{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, description => <<"Filter by status">>}, #{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 => <<"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 => <<"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">>}, #{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">>}, last_seen => #{type => string, format => <<"date-time">>},
status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]},
assigned_to => #{type => string, nullable => true}, 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), Qs = cowboy_req:parse_qs(Req),
#{ #{
status => proplists:get_value(<<"status">>, Qs), status => proplists:get_value(<<"status">>, Qs),
source => proplists:get_value(<<"source">>, Qs),
assigned_to => proplists:get_value(<<"assigned_to">>, Qs), assigned_to => proplists:get_value(<<"assigned_to">>, Qs),
q => proplists:get_value(<<"q">>, Qs) q => proplists:get_value(<<"q">>, Qs)
}. }.
apply_ticket_filters(Tickets, Filters) -> apply_ticket_filters(Tickets, Filters) ->
Assigned = maps:get(assigned_to, Filters, undefined), Assigned = maps:get(assigned_to, Filters, undefined),
Source = maps:get(source, Filters, undefined),
Q = maps:get(q, Filters, undefined), Q = maps:get(q, Filters, undefined),
F1 = case Assigned of F0 = case Source of
undefined -> Tickets; 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, end,
case Q of case Q of
undefined -> F1; undefined -> F1;
_ -> [T || T <- F1, string:str(binary_to_list(T#ticket.error_message), binary_to_list(Q)) > 0] _ -> [T || T <- F1, string:str(binary_to_list(T#ticket.error_message), binary_to_list(Q)) > 0]
end. end.
ticket_source(#ticket{source = S}) when is_binary(S), S =/= <<>> -> S;
ticket_source(_) -> <<"backend">>.
sort_tickets(Tickets, #{sort := Sort, order := Order}) -> sort_tickets(Tickets, #{sort := Sort, order := Order}) ->
Field = binary_to_existing_atom(Sort, utf8), Field = binary_to_existing_atom(Sort, utf8),
lists:sort( lists:sort(
+33 -13
View File
@@ -4,7 +4,7 @@
%%% GET – получить список тикетов. %%% GET – получить список тикетов.
%%% Администраторы видят все тикеты, %%% Администраторы видят все тикеты,
%%% обычные пользователи – только свои. %%% обычные пользователи – только свои.
%%% POST создать новый тикет об ошибке. %%% POST создать/дедуплицировать тикет об ошибке через logic_ticket:report_error.
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(handler_tickets). -module(handler_tickets).
@@ -47,7 +47,7 @@ trails() ->
#{ % POST create #{ % POST create
path => <<"/v1/tickets">>, path => <<"/v1/tickets">>,
method => <<"POST">>, method => <<"POST">>,
description => <<"Create a new ticket (bug report)">>, description => <<"Create or bump a ticket (bug report / frontend error)">>,
tags => [<<"Tickets">>], tags => [<<"Tickets">>],
requestBody => #{ requestBody => #{
required => true, required => true,
@@ -57,14 +57,16 @@ trails() ->
properties => #{ properties => #{
error_message => #{type => string}, error_message => #{type => string},
stacktrace => #{type => string}, stacktrace => #{type => string},
context => #{type => string} context => #{type => object},
source => #{type => string, enum => [<<"frontend">>, <<"manual">>]}
} }
}}} }}}
}, },
responses => #{ responses => #{
201 => #{description => <<"Ticket created">>}, 201 => #{description => <<"Ticket created or updated">>},
400 => #{description => <<"Missing required fields or invalid JSON">>}, 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">>}, last_seen => #{type => string, format => <<"date-time">>},
status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]},
assigned_to => #{type => string, nullable => true}, 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) handler_utils:send_error(Req1, Code, Message)
end. end.
%% @doc POST /v1/tickets — создание тикета. %% @doc POST /v1/tickets — создание/дедуп тикета.
-spec create_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -spec create_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
create_ticket(Req) -> create_ticket(Req) ->
case handler_utils:auth_user(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"error_message">> := _} = Data -> #{<<"error_message">> := ErrorMessage} = Data ->
TicketData = maps:merge( Stacktrace = maps:get(<<"stacktrace">>, Data, <<>>),
#{<<"reporter_id">> => UserId, <<"status">> => <<"open">>}, Source = resolve_source(Data, Stacktrace),
Data Context0 = maps:get(<<"context">>, Data, #{}),
), Context = merge_context(Context0, UserId),
case core_ticket:create_ticket(TicketData) of case logic_ticket:report_error(Source, ErrorMessage, Stacktrace, Context) of
{ok, Ticket} -> {ok, Ticket} ->
handler_utils:send_json(Req2, 201, handler_utils:ticket_to_json(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} -> {error, Reason} ->
handler_utils:send_error(Req2, 500, Reason) handler_utils:send_error(Req2, 500, Reason)
end; end;
@@ -144,3 +149,18 @@ create_ticket(Req) ->
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. 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}.
+88 -4
View File
@@ -27,7 +27,9 @@
subscription_to_json/1, subscription_to_json/1,
trails_for_crud/4, trails_for_crud/4,
is_superadmin/1, 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]). -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), Req1 = cowboy_req:reply(Status, Headers, Body, Req),
{ok, Body, Req1}. {ok, Body, Req1}.
%% @doc Отправляет JSON-ошибку. %% @doc Отправляет JSON-ошибку. При Status >= 500 асинхронно регистрирует тикет.
-spec send_error(cowboy_req:req(), cowboy:http_status(), binary()) -> -spec send_error(cowboy_req:req(), cowboy:http_status(), binary() | term()) ->
{ok, binary(), cowboy_req:req()}. {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) -> 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}), Body = jsx:encode(#{error => Message}),
Headers = #{<<"content-type">> => <<"application/json">>}, Headers = #{<<"content-type">> => <<"application/json">>},
Req1 = cowboy_req:reply(Status, Headers, Body, Req), Req1 = cowboy_req:reply(Status, Headers, Body, Req),
{ok, Body, Req1}. {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, status => Ticket#ticket.status,
assigned_to => Ticket#ticket.assigned_to, assigned_to => Ticket#ticket.assigned_to,
resolution_note => Ticket#ticket.resolution_note, 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-карту. %% @doc Преобразует #calendar{} в JSON-карту.
-spec calendar_to_json(#calendar{}) -> map(). -spec calendar_to_json(#calendar{}) -> map().
calendar_to_json(Calendar) -> calendar_to_json(Calendar) ->
+2 -1
View File
@@ -20,7 +20,8 @@
%% Упорядоченный реестр миграций (добавлять новые модули в конец списка). %% Упорядоченный реестр миграций (добавлять новые модули в конец списка).
-define(ALL_MIGRATIONS, [ -define(ALL_MIGRATIONS, [
'20260501120000_base_schema', '20260501120000_base_schema',
'20260504150000_test_migration' '20260504150000_test_migration',
'20260716230000_ticket_source_and_hash_index'
]). ]).
%% ------------------------------ %% ------------------------------
+207 -28
View File
@@ -2,6 +2,7 @@
-include("records.hrl"). -include("records.hrl").
-export([report_error/3, -export([report_error/3,
report_error/4,
get_ticket/2, get_ticket/2,
list_tickets/1, list_tickets/1,
list_tickets_by_status/2, list_tickets_by_status/2,
@@ -12,40 +13,57 @@
get_statistics/1]). get_statistics/1]).
-export([delete_ticket/2]). -export([delete_ticket/2]).
-export([get_user_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) -> report_error(ErrorMessage, Stacktrace, Context) ->
Existing = [T || T <- core_ticket:list_all(), T#ticket.error_message =:= ErrorMessage], report_error(backend, ErrorMessage, Stacktrace, Context).
case Existing of
[Ticket] -> %% @doc Создать или обновить тикет по error_hash.
% Увеличить счётчик и обновить last_seen %% Source: backend | frontend | manual (atom или binary).
Updated = Ticket#ticket{ report_error(Source0, ErrorMessage0, Stacktrace0, Context0) ->
count = Ticket#ticket.count + 1, case get(eventhub_reporting_ticket) of
last_seen = calendar:universal_time() true ->
}, {error, reporting_recursion};
mnesia:dirty_write(Updated), _ ->
{ok, Updated}; 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, case allow_new_ticket() of
maps:get(reporter_id, Context, <<"anonymous">>)), false ->
Data = #{ {error, rate_limited};
<<"reporter_id">> => ReporterId, true ->
<<"error_message">> => ErrorMessage, create_and_notify(ReporterIdBin, Hash, ErrorMessage, Stacktrace, ContextBin, Source)
<<"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
end end
end. end.
%% Получить тикет, проверяя, что пользователь является репортером %% Получить тикет, проверяя, что пользователь является репортером
get_user_ticket(UserId, TicketId) -> get_user_ticket(UserId, TicketId) ->
case core_ticket:get_by_id(TicketId) of % используем базовую функцию без проверки прав case core_ticket:get_by_id(TicketId) of
{ok, Ticket} -> {ok, Ticket} ->
case Ticket#ticket.reporter_id =:= UserId of case Ticket#ticket.reporter_id =:= UserId of
true -> {ok, Ticket}; true -> {ok, Ticket};
@@ -149,7 +167,168 @@ normalize_status(Status) when is_binary(Status) ->
catch error:badarg -> Status catch error:badarg -> Status
end. end.
%% ============ Вспомогательные функции ============ %% ============ Hash / dedupe ============
notify_admins(_Ticket) -> -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>>;
_ ->
<<Source/binary, ":", Msg/binary, ":", Fingerprint/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. 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])).
@@ -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.
+48 -3
View File
@@ -36,6 +36,8 @@ test() ->
#{error_message => <<"Something broke">>, stacktrace => <<"line 42">>}), #{error_message => <<"Something broke">>, stacktrace => <<"line 42">>}),
test_create_ticket(Token), test_create_ticket(Token),
test_create_ticket_dedupe(Token),
test_create_ticket_manual(Token),
test_create_ticket_missing_fields(Token), test_create_ticket_missing_fields(Token),
test_create_ticket_unauthorized(), test_create_ticket_unauthorized(),
test_list_tickets(Token, TicketId), test_list_tickets(Token, TicketId),
@@ -47,7 +49,6 @@ test() ->
ct:pal("=== All user tickets tests passed ==="), ct:pal("=== All user tickets tests passed ==="),
ok. ok.
%%%=================================================================== %%%===================================================================
%%% Тестовые функции %%% Тестовые функции
%%%=================================================================== %%%===================================================================
@@ -57,13 +58,57 @@ test() ->
test_create_ticket(Token) -> test_create_ticket(Token) ->
ct:pal(" TEST: Create a ticket"), ct:pal(" TEST: Create a ticket"),
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token, 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, {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)), ?assert(is_binary(Id)),
?assertEqual(<<"open">>, Status), ?assertEqual(<<"open">>, Status),
?assertEqual(<<"frontend">>, Source),
?assert(is_binary(Hash)),
?assert(byte_size(Hash) > 0),
ct:pal(" OK: ticket ~s created", [Id]). 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. %% @doc Отсутствие обязательного поля error_message: 400 Bad Request.
-spec test_create_ticket_missing_fields(binary()) -> ok. -spec test_create_ticket_missing_fields(binary()) -> ok.
test_create_ticket_missing_fields(Token) -> test_create_ticket_missing_fields(Token) ->
@@ -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.
+20 -1
View File
@@ -42,6 +42,8 @@ logic_ticket_test_() ->
{foreach, fun setup/0, fun cleanup/1, [ {foreach, fun setup/0, fun cleanup/1, [
{"Report error creates ticket", fun test_report_error/0}, {"Report error creates ticket", fun test_report_error/0},
{"Report duplicate error increments count", fun test_report_duplicate/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 admin", fun test_list_tickets/0},
{"List tickets as non-admin returns error", fun test_list_tickets_forbidden/0}, {"List tickets as non-admin returns error", fun test_list_tickets_forbidden/0},
{"Update status as admin", fun test_update_status/0}, {"Update status as admin", fun test_update_status/0},
@@ -55,12 +57,18 @@ logic_ticket_test_() ->
report(ErrorMsg) -> report(ErrorMsg) ->
logic_ticket:report_error(ErrorMsg, <<"stack">>, #{<<"reporter_id">> => <<"user1">>}). 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() -> test_report_error() ->
{ok, Ticket} = report(<<"Error1">>), {ok, Ticket} = report(<<"Error1">>),
?assertEqual(<<"Error1">>, Ticket#ticket.error_message), ?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() -> test_report_duplicate() ->
{ok, T1} = report(<<"Dup">>), {ok, T1} = report(<<"Dup">>),
@@ -70,6 +78,17 @@ test_report_duplicate() ->
% Проверяем, что это тот же тикет, а не новый % Проверяем, что это тот же тикет, а не новый
?assertEqual(T1#ticket.id, T2#ticket.id). ?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() -> test_list_tickets() ->
{ok, _} = report(<<"E1">>), {ok, _} = report(<<"E1">>),
Tickets = logic_ticket:list_tickets(<<"admin1">>), Tickets = logic_ticket:list_tickets(<<"admin1">>),
+4 -1
View File
@@ -38,7 +38,8 @@ test_ensure_applied() ->
?assertEqual([], maps:get(pending, Status)), ?assertEqual([], maps:get(pending, Status)),
Applied = maps:get(applied, Status), Applied = maps:get(applied, Status),
?assert(lists:member("20260501120000_base_schema", Applied)), ?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() -> test_ensure_applied_idempotent() ->
?assertEqual(ok, migration_engine:ensure_applied()), ?assertEqual(ok, migration_engine:ensure_applied()),
@@ -58,6 +59,8 @@ test_join_wait() ->
version = "20260501120000_base_schema", applied_at = Now}), version = "20260501120000_base_schema", applied_at = Now}),
mnesia:dirty_write(#schema_migration{ mnesia:dirty_write(#schema_migration{
version = "20260504150000_test_migration", applied_at = Now}), 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}) mnesia:dirty_delete({schema_migration, ?LOCK_VERSION})
end), end),
?assertEqual(ok, migration_engine:ensure_applied()). ?assertEqual(ok, migration_engine:ensure_applied()).