Добавить авто-регистрацию тикетов (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_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">>
}).
%% ------------------- Подписки ----------------------------------------
+3 -1
View File
@@ -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
+14 -3
View File
@@ -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(
+34 -14
View File
@@ -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.
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,
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) ->
+2 -1
View File
@@ -20,7 +20,8 @@
%% Упорядоченный реестр миграций (добавлять новые модули в конец списка).
-define(ALL_MIGRATIONS, [
'20260501120000_base_schema',
'20260504150000_test_migration'
'20260504150000_test_migration',
'20260716230000_ticket_source_and_hash_index'
]).
%% ------------------------------
+208 -29
View File
@@ -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.
-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.
%% ============ 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">>}),
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) ->
@@ -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, [
{"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">>),
+4 -1
View File
@@ -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()).