Добавить авто-регистрацию тикетов (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
+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) ->