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