Добавить авто-регистрацию тикетов (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
+208 -29
View File
@@ -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])).