Добавить авто-регистрацию тикетов (hash/dedupe/500/WS). Refs EventHub/EventHubBack#35
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
%% Упорядоченный реестр миграций (добавлять новые модули в конец списка).
|
||||
-define(ALL_MIGRATIONS, [
|
||||
'20260501120000_base_schema',
|
||||
'20260504150000_test_migration'
|
||||
'20260504150000_test_migration',
|
||||
'20260716230000_ticket_source_and_hash_index'
|
||||
]).
|
||||
|
||||
%% ------------------------------
|
||||
|
||||
+208
-29
@@ -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.
|
||||
Reference in New Issue
Block a user