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