Добавить авто-регистрацию тикетов (hash/dedupe/500/WS). Refs EventHub/EventHubBack#35
This commit is contained in:
+3
-2
@@ -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">>
|
||||||
}).
|
}).
|
||||||
|
|
||||||
%% ------------------- Подписки ----------------------------------------
|
%% ------------------- Подписки ----------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}.
|
||||||
|
|||||||
@@ -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) ->
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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">>),
|
||||||
|
|||||||
@@ -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()).
|
||||||
|
|||||||
Reference in New Issue
Block a user