diff --git a/include/records.hrl b/include/records.hrl index 6acb8d0..a3001cb 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -282,12 +282,21 @@ created_at :: calendar:datetime() }). --record(stats, { - id :: binary(), - metric :: atom(), - entity_id :: binary(), - value :: integer() | {integer(), integer()}, - timestamp :: calendar:datetime() +%% Upsert-счётчики админ-статистики (абсолютные значения). +%% key: atom() | {atom(), atom()} | {atom(), atom(), atom()} +%% users_total-подобные totals не храним (table_info), +%% размерности: {user_by_status, pending}, {event_by_type, single}, ... +-record(stats_counter, { + key :: term(), + value :: integer(), + updated_at :: calendar:datetime() +}). + +%% Дневные бакеты: key = {Metric :: atom(), Date :: calendar:date()}. +-record(stats_daily, { + key :: {atom(), calendar:date()}, + value :: integer(), + updated_at :: calendar:datetime() }). -record(node_metric, { diff --git a/src/core/core_calendar.erl b/src/core/core_calendar.erl index 96086c3..f65c5de 100644 --- a/src/core/core_calendar.erl +++ b/src/core/core_calendar.erl @@ -163,17 +163,19 @@ unfreeze(Id, Reason) -> -spec count_calendars_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. count_calendars_by_date(From, To) -> - All = mnesia:dirty_match_object(#calendar{_ = '_'}), - Filtered = lists:filter(fun(C) -> C#calendar.created_at >= From andalso - C#calendar.created_at =< To end, All), - Counts = lists:foldl(fun(C, Acc) -> - Day = date_part(C#calendar.created_at), - case lists:keyfind(Day, 1, Acc) of - false -> [{Day, 1} | Acc]; - {Day, Cnt} -> lists:keyreplace(Day, 1, Acc, {Day, Cnt+1}) - end - end, [], Filtered), - lists:sort(Counts). + core_stats:with_daily(calendars_created, From, To, fun() -> + All = mnesia:dirty_match_object(#calendar{_ = '_'}), + Filtered = lists:filter(fun(C) -> C#calendar.created_at >= From andalso + C#calendar.created_at =< To end, All), + Counts = lists:foldl(fun(C, Acc) -> + Day = date_part(C#calendar.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, Cnt} -> lists:keyreplace(Day, 1, Acc, {Day, Cnt+1}) + end + end, [], Filtered), + lists:sort(Counts) + end). date_part({{Y,M,D}, _}) -> {Y,M,D}. @@ -183,13 +185,15 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}. %%%------------------------------------------------------------------- -spec count_calendars_by_type() -> [{atom(), non_neg_integer()}]. count_calendars_by_type() -> - Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), - lists:foldl(fun(#calendar{type = Type}, Acc) -> - case lists:keyfind(Type, 1, Acc) of - false -> [{Type, 1} | Acc]; - {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) - end - end, [], Calendars). + core_stats:with_dim(calendar, type, fun() -> + Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), + lists:foldl(fun(#calendar{type = Type}, Acc) -> + case lists:keyfind(Type, 1, Acc) of + false -> [{Type, 1} | Acc]; + {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) + end + end, [], Calendars) + end). %%%------------------------------------------------------------------- %%% @doc Подсчёт календарей по статусам (active, frozen, deleted). @@ -197,13 +201,15 @@ count_calendars_by_type() -> %%%------------------------------------------------------------------- -spec count_calendars_by_status() -> [{atom(), non_neg_integer()}]. count_calendars_by_status() -> - Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), - lists:foldl(fun(#calendar{status = Status}, Acc) -> - case lists:keyfind(Status, 1, Acc) of - false -> [{Status, 1} | Acc]; - {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) - end - end, [], Calendars). + core_stats:with_dim(calendar, status, fun() -> + Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), + lists:foldl(fun(#calendar{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Calendars) + end). %%%------------------------------------------------------------------- %%% @doc Топ-N календарей по количеству отзывов. @@ -211,11 +217,15 @@ count_calendars_by_status() -> %%%------------------------------------------------------------------- -spec get_top_calendars_by_reviews(N :: non_neg_integer()) -> [#calendar{}]. get_top_calendars_by_reviews(N) -> - Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), - Sorted = lists:reverse(lists:sort( - fun(A, B) -> A#calendar.rating_count =< B#calendar.rating_count end, - Calendars)), - lists:sublist(Sorted, N). + case stats_tops:get_top_calendars_by_reviews(N) of + {ok, Calendars} -> Calendars; + {error, _} -> + Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), + Sorted = lists:reverse(lists:sort( + fun(A, B) -> A#calendar.rating_count =< B#calendar.rating_count end, + Calendars)), + lists:sublist(Sorted, N) + end. %%%------------------------------------------------------------------- %%% @doc Топ-N календарей по количеству позитивных отзывов (rating >= 4). @@ -223,15 +233,17 @@ get_top_calendars_by_reviews(N) -> %%%------------------------------------------------------------------- -spec get_top_calendars_by_positive_reviews(N :: non_neg_integer()) -> [#calendar{}]. get_top_calendars_by_positive_reviews(N) -> - top_by_review_filter(N, fun(R) -> R#review.rating >= 4 end). + case stats_tops:get_top_calendars_by_positive_reviews(N) of + {ok, Calendars} -> Calendars; + {error, _} -> top_by_review_filter(N, fun(R) -> R#review.rating >= 4 end) + end. -%%%------------------------------------------------------------------- -%%% @doc Топ-N календарей по количеству негативных отзывов (rating <= 2). -%%% @end -%%%------------------------------------------------------------------- -spec get_top_calendars_by_negative_reviews(N :: non_neg_integer()) -> [#calendar{}]. get_top_calendars_by_negative_reviews(N) -> - top_by_review_filter(N, fun(R) -> R#review.rating =< 2 end). + case stats_tops:get_top_calendars_by_negative_reviews(N) of + {ok, Calendars} -> Calendars; + {error, _} -> top_by_review_filter(N, fun(R) -> R#review.rating =< 2 end) + end. %%%------------------------------------------------------------------- %%% @doc Топ-N календарей по среднему рейтингу. @@ -239,11 +251,15 @@ get_top_calendars_by_negative_reviews(N) -> %%%------------------------------------------------------------------- -spec get_top_calendars_by_rating(N :: non_neg_integer()) -> [#calendar{}]. get_top_calendars_by_rating(N) -> - Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), - Sorted = lists:reverse(lists:sort( - fun(A, B) -> A#calendar.rating_avg =< B#calendar.rating_avg end, - Calendars)), - lists:sublist(Sorted, N). + case stats_tops:get_top_calendars_by_rating(N) of + {ok, Calendars} -> Calendars; + {error, _} -> + Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), + Sorted = lists:reverse(lists:sort( + fun(A, B) -> A#calendar.rating_avg =< B#calendar.rating_avg end, + Calendars)), + lists:sublist(Sorted, N) + end. %% @private Вспомогательная функция для фильтрации отзывов и подсчёта по календарям. top_by_review_filter(N, FilterFun) -> diff --git a/src/core/core_event.erl b/src/core/core_event.erl index c44d8e7..f76643c 100644 --- a/src/core/core_event.erl +++ b/src/core/core_event.erl @@ -240,17 +240,19 @@ list_all() -> -spec count_events_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. count_events_by_date(From, To) -> - All = mnesia:dirty_match_object(#event{_ = '_'}), - Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso - E#event.created_at =< To end, All), - Counts = lists:foldl(fun(E, Acc) -> - Day = date_part(E#event.created_at), - case lists:keyfind(Day, 1, Acc) of - false -> [{Day, 1} | Acc]; - {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) - end - end, [], Filtered), - lists:sort(Counts). + core_stats:with_daily(events_created, From, To, fun() -> + All = mnesia:dirty_match_object(#event{_ = '_'}), + Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso + E#event.created_at =< To end, All), + Counts = lists:foldl(fun(E, Acc) -> + Day = date_part(E#event.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts) + end). %%%------------------------------------------------------------------- %%% @doc Подсчёт событий по типам (single, recurring). @@ -258,13 +260,15 @@ count_events_by_date(From, To) -> %%%------------------------------------------------------------------- -spec count_events_by_type() -> [{atom(), non_neg_integer()}]. count_events_by_type() -> - Events = mnesia:dirty_match_object(#event{_ = '_'}), - lists:foldl(fun(#event{event_type = Type}, Acc) -> - case lists:keyfind(Type, 1, Acc) of - false -> [{Type, 1} | Acc]; - {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) - end - end, [], Events). + core_stats:with_dim(event, type, fun() -> + Events = mnesia:dirty_match_object(#event{_ = '_'}), + lists:foldl(fun(#event{event_type = Type}, Acc) -> + case lists:keyfind(Type, 1, Acc) of + false -> [{Type, 1} | Acc]; + {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) + end + end, [], Events) + end). %%%------------------------------------------------------------------- %%% @doc Подсчёт событий по статусам (active, cancelled, completed). @@ -272,13 +276,15 @@ count_events_by_type() -> %%%------------------------------------------------------------------- -spec count_events_by_status() -> [{atom(), non_neg_integer()}]. count_events_by_status() -> - Events = mnesia:dirty_match_object(#event{_ = '_'}), - lists:foldl(fun(#event{status = Status}, Acc) -> - case lists:keyfind(Status, 1, Acc) of - false -> [{Status, 1} | Acc]; - {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) - end - end, [], Events). + core_stats:with_dim(event, status, fun() -> + Events = mnesia:dirty_match_object(#event{_ = '_'}), + lists:foldl(fun(#event{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Events) + end). %%%------------------------------------------------------------------- %%% @doc Возвращает топ-N событий по среднему рейтингу (по убыванию). @@ -286,11 +292,15 @@ count_events_by_status() -> %%%------------------------------------------------------------------- -spec get_top_events_by_rating(N :: non_neg_integer()) -> [#event{}]. get_top_events_by_rating(N) -> - Events = mnesia:dirty_match_object(#event{_ = '_'}), - Sorted = lists:reverse(lists:sort( - fun(A, B) -> A#event.rating_avg =< B#event.rating_avg end, - Events)), - lists:sublist(Sorted, N). + case stats_tops:get_top_events_by_rating(N) of + {ok, Events} -> Events; + {error, _} -> + Events = mnesia:dirty_match_object(#event{_ = '_'}), + Sorted = lists:reverse(lists:sort( + fun(A, B) -> A#event.rating_avg =< B#event.rating_avg end, + Events)), + lists:sublist(Sorted, N) + end. %%%------------------------------------------------------------------- %%% @doc Выделить из datetime только дату `{Y,M,D}`. diff --git a/src/core/core_report.erl b/src/core/core_report.erl index be7aea1..16e4596 100644 --- a/src/core/core_report.erl +++ b/src/core/core_report.erl @@ -129,8 +129,10 @@ count_reports() -> -spec count_reports_by_status(Status :: pending | reviewed | dismissed) -> non_neg_integer(). count_reports_by_status(Status) -> - Match = #report{status = Status, _ = '_'}, - length(mnesia:dirty_match_object(Match)). + core_stats:with_counter({report_by_status, Status}, fun() -> + Match = #report{status = Status, _ = '_'}, + length(mnesia:dirty_match_object(Match)) + end). %%%------------------------------------------------------------------- %%% @doc Количество жалоб, разрешённых конкретным администратором. @@ -139,8 +141,10 @@ count_reports_by_status(Status) -> -spec count_reports_resolved_by_admin(AdminId :: binary(), Status :: reviewed | dismissed) -> non_neg_integer(). count_reports_resolved_by_admin(AdminId, Status) -> - Match = #report{resolved_by = AdminId, status = Status, _ = '_'}, - length(mnesia:dirty_match_object(Match)). + Rows = index_read(report, AdminId, #report.resolved_by, fun() -> + mnesia:dirty_match_object(#report{resolved_by = AdminId, _ = '_'}) + end), + length([R || R <- Rows, R#report.status =:= Status]). %%%------------------------------------------------------------------- %%% @doc Среднее время разрешения жалоб заданного статуса (в часах). @@ -148,16 +152,22 @@ count_reports_resolved_by_admin(AdminId, Status) -> %%%------------------------------------------------------------------- -spec avg_resolution_time(Status :: reviewed | dismissed) -> float(). avg_resolution_time(Status) -> - Match = #report{status = Status, _ = '_'}, - Reports = mnesia:dirty_match_object(Match), - case Reports of - [] -> 0.0; - _ -> - Total = lists:sum([calendar:datetime_to_gregorian_seconds(R#report.resolved_at) - - calendar:datetime_to_gregorian_seconds(R#report.created_at) - || R <- Reports, R#report.resolved_at /= undefined]), - Total / length(Reports) / 3600.0 - end. + core_stats:with_avg_hours( + {report_resolved_count, Status}, + {report_resolved_seconds_sum, Status}, + fun() -> + Match = #report{status = Status, _ = '_'}, + Reports = mnesia:dirty_match_object(Match), + Resolved = [R || R <- Reports, R#report.resolved_at /= undefined], + case Resolved of + [] -> 0.0; + _ -> + Total = lists:sum([calendar:datetime_to_gregorian_seconds(R#report.resolved_at) - + calendar:datetime_to_gregorian_seconds(R#report.created_at) + || R <- Resolved]), + Total / length(Resolved) / 3600.0 + end + end). %%%------------------------------------------------------------------- %%% @doc Подсчёт жалоб, созданных в заданном временном диапазоне. @@ -166,17 +176,19 @@ avg_resolution_time(Status) -> -spec count_reports_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. count_reports_by_date(From, To) -> - All = mnesia:dirty_match_object(#report{_ = '_'}), - Filtered = lists:filter(fun(R) -> R#report.created_at >= From andalso - R#report.created_at =< To end, All), - Counts = lists:foldl(fun(R, Acc) -> - Day = date_part(R#report.created_at), - case lists:keyfind(Day, 1, Acc) of - false -> [{Day, 1} | Acc]; - {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) - end - end, [], Filtered), - lists:sort(Counts). + core_stats:with_daily(reports_created, From, To, fun() -> + All = mnesia:dirty_match_object(#report{_ = '_'}), + Filtered = lists:filter(fun(R) -> R#report.created_at >= From andalso + R#report.created_at =< To end, All), + Counts = lists:foldl(fun(R, Acc) -> + Day = date_part(R#report.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts) + end). %%%------------------------------------------------------------------- %%% @doc Подсчёт жалоб по типам цели (event, calendar, review). @@ -184,13 +196,15 @@ count_reports_by_date(From, To) -> %%%------------------------------------------------------------------- -spec count_reports_by_target_type() -> [{atom(), non_neg_integer()}]. count_reports_by_target_type() -> - Reports = mnesia:dirty_match_object(#report{_ = '_'}), - lists:foldl(fun(#report{target_type = Type}, Acc) -> - case lists:keyfind(Type, 1, Acc) of - false -> [{Type, 1} | Acc]; - {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) - end - end, [], Reports). + core_stats:with_dim(report, target_type, fun() -> + Reports = mnesia:dirty_match_object(#report{_ = '_'}), + lists:foldl(fun(#report{target_type = Type}, Acc) -> + case lists:keyfind(Type, 1, Acc) of + false -> [{Type, 1} | Acc]; + {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) + end + end, [], Reports) + end). %%%------------------------------------------------------------------- %%% @doc Подсчёт жалоб по статусам. @@ -198,13 +212,15 @@ count_reports_by_target_type() -> %%%------------------------------------------------------------------- -spec count_reports_by_status() -> [{atom(), non_neg_integer()}]. count_reports_by_status() -> - Reports = mnesia:dirty_match_object(#report{_ = '_'}), - lists:foldl(fun(#report{status = Status}, Acc) -> - case lists:keyfind(Status, 1, Acc) of - false -> [{Status, 1} | Acc]; - {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) - end - end, [], Reports). + core_stats:with_dim(report, status, fun() -> + Reports = mnesia:dirty_match_object(#report{_ = '_'}), + lists:foldl(fun(#report{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Reports) + end). %%%------------------------------------------------------------------- %%% @doc Топ-N целей по количеству жалоб. @@ -214,19 +230,26 @@ count_reports_by_status() -> -spec get_top_targets_by_reports(N :: non_neg_integer()) -> [{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}]. get_top_targets_by_reports(N) -> - Reports = mnesia:dirty_match_object(#report{_ = '_'}), - % Группируем по {TargetType, TargetId} - Dict = lists:foldl(fun(#report{target_type = Type, target_id = Id}, Acc) -> - Key = {Type, Id}, - case lists:keyfind(Key, 1, Acc) of - false -> [{Key, 1} | Acc]; - {Key, C} -> lists:keyreplace(Key, 1, Acc, {Key, C+1}) - end - end, [], Reports), - % Сортируем по убыванию Count - Sorted = lists:reverse(lists:sort(fun({_, C1}, {_, C2}) -> C1 =< C2 end, Dict)), - % Преобразуем в тройки и берём первые N - Top = lists:sublist([{Type, Id, Count} || {{Type, Id}, Count} <- Sorted], N), - Top. + case stats_tops:get_top_targets_by_reports(N) of + {ok, List} -> List; + {error, _} -> + Reports = mnesia:dirty_match_object(#report{_ = '_'}), + Dict = lists:foldl(fun(#report{target_type = Type, target_id = Id}, Acc) -> + Key = {Type, Id}, + case lists:keyfind(Key, 1, Acc) of + false -> [{Key, 1} | Acc]; + {Key, C} -> lists:keyreplace(Key, 1, Acc, {Key, C+1}) + end + end, [], Reports), + Sorted = lists:reverse(lists:sort(fun({_, C1}, {_, C2}) -> C1 =< C2 end, Dict)), + lists:sublist([{Type, Id, Count} || {{Type, Id}, Count} <- Sorted], N) + end. -date_part({{Y,M,D}, _}) -> {Y,M,D}. \ No newline at end of file +date_part({{Y,M,D}, _}) -> {Y,M,D}. + +%% @private Index read with fallback to full match (tests without index). +index_read(Table, Key, Pos, FallbackFun) -> + case catch mnesia:dirty_index_read(Table, Key, Pos) of + {'EXIT', _} -> FallbackFun(); + Rows when is_list(Rows) -> Rows + end. diff --git a/src/core/core_review.erl b/src/core/core_review.erl index ec423d9..abd654f 100644 --- a/src/core/core_review.erl +++ b/src/core/core_review.erl @@ -193,17 +193,19 @@ list_all() -> -spec count_reviews_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. count_reviews_by_date(From, To) -> - All = mnesia:dirty_match_object(#review{_ = '_'}), - Filtered = lists:filter(fun(R) -> R#review.created_at >= From andalso - R#review.created_at =< To end, All), - Counts = lists:foldl(fun(R, Acc) -> - Day = date_part(R#review.created_at), - case lists:keyfind(Day, 1, Acc) of - false -> [{Day, 1} | Acc]; - {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) - end - end, [], Filtered), - lists:sort(Counts). + core_stats:with_daily(reviews_created, From, To, fun() -> + All = mnesia:dirty_match_object(#review{_ = '_'}), + Filtered = lists:filter(fun(R) -> R#review.created_at >= From andalso + R#review.created_at =< To end, All), + Counts = lists:foldl(fun(R, Acc) -> + Day = date_part(R#review.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts) + end). date_part({{Y,M,D}, _}) -> {Y,M,D}. @@ -213,13 +215,15 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}. %%%------------------------------------------------------------------- -spec count_reviews_by_target_type() -> [{atom(), non_neg_integer()}]. count_reviews_by_target_type() -> - Reviews = mnesia:dirty_match_object(#review{_ = '_'}), - lists:foldl(fun(#review{target_type = Type}, Acc) -> - case lists:keyfind(Type, 1, Acc) of - false -> [{Type, 1} | Acc]; - {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) - end - end, [], Reviews). + core_stats:with_dim(review, target_type, fun() -> + Reviews = mnesia:dirty_match_object(#review{_ = '_'}), + lists:foldl(fun(#review{target_type = Type}, Acc) -> + case lists:keyfind(Type, 1, Acc) of + false -> [{Type, 1} | Acc]; + {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) + end + end, [], Reviews) + end). %%%------------------------------------------------------------------- %%% @doc Подсчёт отзывов по статусам (visible, hidden, deleted). @@ -227,13 +231,15 @@ count_reviews_by_target_type() -> %%%------------------------------------------------------------------- -spec count_reviews_by_status() -> [{atom(), non_neg_integer()}]. count_reviews_by_status() -> - Reviews = mnesia:dirty_match_object(#review{_ = '_'}), - lists:foldl(fun(#review{status = Status}, Acc) -> - case lists:keyfind(Status, 1, Acc) of - false -> [{Status, 1} | Acc]; - {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) - end - end, [], Reviews). + core_stats:with_dim(review, status, fun() -> + Reviews = mnesia:dirty_match_object(#review{_ = '_'}), + lists:foldl(fun(#review{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Reviews) + end). %%%------------------------------------------------------------------- %%% @doc Топ-N целей по общему количеству отзывов. @@ -242,25 +248,26 @@ count_reviews_by_status() -> -spec get_top_targets_by_reviews(N :: non_neg_integer()) -> [{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}]. get_top_targets_by_reviews(N) -> - aggregate_targets_by(fun(_Review) -> true end, N). + case stats_tops:get_top_targets_by_reviews(N) of + {ok, List} -> List; + {error, _} -> aggregate_targets_by(fun(_Review) -> true end, N) + end. -%%%------------------------------------------------------------------- -%%% @doc Топ-N целей по количеству позитивных отзывов (rating >= 4). -%%% @end -%%%------------------------------------------------------------------- -spec get_top_targets_by_positive_reviews(N :: non_neg_integer()) -> [{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}]. get_top_targets_by_positive_reviews(N) -> - aggregate_targets_by(fun(#review{rating = R}) -> R >= 4 end, N). + case stats_tops:get_top_targets_by_positive_reviews(N) of + {ok, List} -> List; + {error, _} -> aggregate_targets_by(fun(#review{rating = R}) -> R >= 4 end, N) + end. -%%%------------------------------------------------------------------- -%%% @doc Топ-N целей по количеству негативных отзывов (rating <= 2). -%%% @end -%%%------------------------------------------------------------------- -spec get_top_targets_by_negative_reviews(N :: non_neg_integer()) -> [{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}]. get_top_targets_by_negative_reviews(N) -> - aggregate_targets_by(fun(#review{rating = R}) -> R =< 2 end, N). + case stats_tops:get_top_targets_by_negative_reviews(N) of + {ok, List} -> List; + {error, _} -> aggregate_targets_by(fun(#review{rating = R}) -> R =< 2 end, N) + end. %% @private Вспомогательная функция агрегации целей с фильтром. aggregate_targets_by(Pred, N) -> diff --git a/src/core/core_stats.erl b/src/core/core_stats.erl new file mode 100644 index 0000000..7229da5 --- /dev/null +++ b/src/core/core_stats.erl @@ -0,0 +1,108 @@ +%%%------------------------------------------------------------------- +%%% @doc Чтение upsert-агрегатов admin-статистики. +%%% Live-значения в ETS коллектора; Mnesia — persistence. +%%% Если collector не запущен — {error, not_running} (caller может fallback-скан). +%%% @end +%%%------------------------------------------------------------------- +-module(core_stats). + +-include("records.hrl"). + +-export([ + available/0, + get_counter/1, + get_dim/2, + get_daily/3, + rebuild/0, + with_counter/2, + with_dim/3, + with_daily/4, + with_avg_hours/3 +]). + +%%%=================================================================== +%%% API +%%%=================================================================== + +-spec available() -> boolean(). +available() -> + case whereis(stats_collector) of + undefined -> false; + _Pid -> stats_collector:is_ready() + end. + +-spec get_counter(term()) -> {ok, integer()} | {error, not_running | not_ready}. +get_counter(Key) -> + call_collector({get_counter, Key}). + +%% @doc Размерность: get_dim(user, status) → [{pending, N}, {active, M}, ...]. +-spec get_dim(atom(), atom()) -> {ok, [{atom(), integer()}]} | {error, not_running | not_ready}. +get_dim(Entity, Field) -> + call_collector({get_dim, Entity, Field}). + +%% @doc Дневной ряд Metric за [From, To] (по дате, включительно). +-spec get_daily(atom(), calendar:datetime(), calendar:datetime()) -> + {ok, [{calendar:date(), non_neg_integer()}]} | {error, not_running | not_ready}. +get_daily(Metric, From, To) -> + call_collector({get_daily, Metric, From, To}). + +-spec rebuild() -> ok | {error, not_running}. +rebuild() -> + case whereis(stats_collector) of + undefined -> {error, not_running}; + _Pid -> stats_collector:rebuild() + end. + +%% @doc Счётчик или FallbackFun/0 при недоступном коллекторе. +-spec with_counter(term(), fun(() -> integer())) -> integer(). +with_counter(Key, FallbackFun) -> + case get_counter(Key) of + {ok, V} -> V; + {error, _} -> FallbackFun() + end. + +-spec with_dim(atom(), atom(), fun(() -> [{atom(), integer()}])) -> [{atom(), integer()}]. +with_dim(Entity, Field, FallbackFun) -> + case get_dim(Entity, Field) of + {ok, List} -> List; + {error, _} -> FallbackFun() + end. + +-spec with_daily(atom(), calendar:datetime(), calendar:datetime(), + fun(() -> [{calendar:date(), non_neg_integer()}])) -> + [{calendar:date(), non_neg_integer()}]. +with_daily(Metric, From, To, FallbackFun) -> + case get_daily(Metric, From, To) of + {ok, List} -> List; + {error, _} -> FallbackFun() + end. + +%% @doc Avg hours from count+seconds_sum counters, or FallbackFun/0. +-spec with_avg_hours(term(), term(), fun(() -> float())) -> float(). +with_avg_hours(CountKey, SumKey, FallbackFun) -> + case {get_counter(CountKey), get_counter(SumKey)} of + {{ok, Count}, {ok, Sum}} when Count > 0 -> + (Sum / Count) / 3600.0; + {{ok, _}, {ok, _}} -> + 0.0; + _ -> + FallbackFun() + end. + +%%%=================================================================== +%%% Internal +%%%=================================================================== + +call_collector(Msg) -> + case whereis(stats_collector) of + undefined -> + {error, not_running}; + _Pid -> + try stats_collector:call(Msg) of + {error, not_ready} -> {error, not_ready}; + Other -> Other + catch + exit:{noproc, _} -> {error, not_running}; + exit:{timeout, _} -> {error, not_running} + end + end. diff --git a/src/core/core_subscription.erl b/src/core/core_subscription.erl index 526cbc0..1c5eef7 100644 --- a/src/core/core_subscription.erl +++ b/src/core/core_subscription.erl @@ -310,17 +310,19 @@ count_subscriptions() -> -spec count_subscriptions_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. count_subscriptions_by_date(From, To) -> - All = mnesia:dirty_match_object(#subscription{_ = '_'}), - Filtered = lists:filter(fun(S) -> S#subscription.created_at >= From andalso - S#subscription.created_at =< To end, All), - Counts = lists:foldl(fun(S, Acc) -> - Day = date_part(S#subscription.created_at), - case lists:keyfind(Day, 1, Acc) of - false -> [{Day, 1} | Acc]; - {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) - end - end, [], Filtered), - lists:sort(Counts). + core_stats:with_daily(subscriptions_created, From, To, fun() -> + All = mnesia:dirty_match_object(#subscription{_ = '_'}), + Filtered = lists:filter(fun(S) -> S#subscription.created_at >= From andalso + S#subscription.created_at =< To end, All), + Counts = lists:foldl(fun(S, Acc) -> + Day = date_part(S#subscription.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts) + end). date_part({{Y,M,D}, _}) -> {Y,M,D}. @@ -330,13 +332,15 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}. %%%------------------------------------------------------------------- -spec count_subscriptions_by_plan() -> [{atom(), non_neg_integer()}]. count_subscriptions_by_plan() -> - Subs = mnesia:dirty_match_object(#subscription{_ = '_'}), - lists:foldl(fun(#subscription{plan = Plan}, Acc) -> - case lists:keyfind(Plan, 1, Acc) of - false -> [{Plan, 1} | Acc]; - {Plan, C} -> lists:keyreplace(Plan, 1, Acc, {Plan, C+1}) - end - end, [], Subs). + core_stats:with_dim(subscription, plan, fun() -> + Subs = mnesia:dirty_match_object(#subscription{_ = '_'}), + lists:foldl(fun(#subscription{plan = Plan}, Acc) -> + case lists:keyfind(Plan, 1, Acc) of + false -> [{Plan, 1} | Acc]; + {Plan, C} -> lists:keyreplace(Plan, 1, Acc, {Plan, C+1}) + end + end, [], Subs) + end). %%%------------------------------------------------------------------- %%% @doc Подсчёт подписок по статусам. @@ -344,13 +348,15 @@ count_subscriptions_by_plan() -> %%%------------------------------------------------------------------- -spec count_subscriptions_by_status() -> [{atom(), non_neg_integer()}]. count_subscriptions_by_status() -> - Subs = mnesia:dirty_match_object(#subscription{_ = '_'}), - lists:foldl(fun(#subscription{status = Status}, Acc) -> - case lists:keyfind(Status, 1, Acc) of - false -> [{Status, 1} | Acc]; - {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) - end - end, [], Subs). + core_stats:with_dim(subscription, status, fun() -> + Subs = mnesia:dirty_match_object(#subscription{_ = '_'}), + lists:foldl(fun(#subscription{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Subs) + end). %%%------------------------------------------------------------------- %%% @doc Количество пробных подписок (trial_used = false). @@ -358,8 +364,10 @@ count_subscriptions_by_status() -> %%%------------------------------------------------------------------- -spec count_trial_subscriptions() -> non_neg_integer(). count_trial_subscriptions() -> - Match = #subscription{trial_used = false, _ = '_'}, - length(mnesia:dirty_match_object(Match)). + core_stats:with_counter(trial_subscriptions, fun() -> + Match = #subscription{trial_used = false, _ = '_'}, + length(mnesia:dirty_match_object(Match)) + end). %%%------------------------------------------------------------------- %%% @doc Платные подписки (trial_used = true), истекающие в течение Days дней. diff --git a/src/core/core_ticket.erl b/src/core/core_ticket.erl index dea8250..2f42a3f 100644 --- a/src/core/core_ticket.erl +++ b/src/core/core_ticket.erl @@ -218,8 +218,10 @@ count_tickets() -> -spec count_tickets_by_status(Status :: open | in_progress | resolved | closed) -> non_neg_integer(). count_tickets_by_status(Status) -> - Match = #ticket{status = Status, _ = '_'}, - length(mnesia:dirty_match_object(Match)). + core_stats:with_counter({ticket_by_status, Status}, fun() -> + Match = #ticket{status = Status, _ = '_'}, + length(mnesia:dirty_match_object(Match)) + end). %%%------------------------------------------------------------------- %%% @doc Среднее время разрешения тикетов (в часах). @@ -227,34 +229,29 @@ count_tickets_by_status(Status) -> %%%------------------------------------------------------------------- -spec avg_resolution_time() -> float(). avg_resolution_time() -> - Tickets = list_all(), - Closed = [T || T <- Tickets, T#ticket.closed_at /= ?DEFAULT_CLOSED_AT], - case Closed of - [] -> 0.0; - _ -> - Total = lists:sum([calendar:datetime_to_gregorian_seconds(T#ticket.closed_at) - - calendar:datetime_to_gregorian_seconds(T#ticket.first_seen) - || T <- Closed]), - Total / length(Closed) / 3600.0 - end. + core_stats:with_avg_hours(ticket_resolved_count, ticket_resolved_seconds_sum, fun() -> + Tickets = list_all(), + Closed = [T || T <- Tickets, T#ticket.closed_at /= ?DEFAULT_CLOSED_AT], + case Closed of + [] -> 0.0; + _ -> + Total = lists:sum([calendar:datetime_to_gregorian_seconds(T#ticket.closed_at) - + calendar:datetime_to_gregorian_seconds(T#ticket.first_seen) + || T <- Closed]), + Total / length(Closed) / 3600.0 + end + end). %% @doc Статистика по тикетам (используется в admin_handler_ticket_stats) stats() -> - Tickets = list_all(), #{ - total => length(Tickets), - open => count_by_status(open, Tickets), - in_progress => count_by_status(in_progress, Tickets), - resolved => count_by_status(resolved, Tickets), - closed => count_by_status(closed, Tickets) + total => count_tickets(), + open => count_tickets_by_status(open), + in_progress => count_tickets_by_status(in_progress), + resolved => count_tickets_by_status(resolved), + closed => count_tickets_by_status(closed) }. -%% ── функции подсчёта с нормализацией статуса ───────────── - -%% @private Подсчитывает тикеты с заданным статусом (атом или бинарный) -count_by_status(Status, Tickets) -> - length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]). - %%%------------------------------------------------------------------- %%% @doc Количество тикетов, назначенных на администратора. %%% @end @@ -262,11 +259,14 @@ count_by_status(Status, Tickets) -> -spec count_tickets_by_admin(AdminId :: binary(), StatusOrAll :: atom()) -> non_neg_integer(). count_tickets_by_admin(AdminId, all) -> - Match = #ticket{assigned_to = AdminId, _ = '_'}, - length(mnesia:dirty_match_object(Match)); + length(index_read(ticket, AdminId, #ticket.assigned_to, fun() -> + mnesia:dirty_match_object(#ticket{assigned_to = AdminId, _ = '_'}) + end)); count_tickets_by_admin(AdminId, Status) -> - Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'}, - length(mnesia:dirty_match_object(Match)). + Rows = index_read(ticket, AdminId, #ticket.assigned_to, fun() -> + mnesia:dirty_match_object(#ticket{assigned_to = AdminId, _ = '_'}) + end), + length([T || T <- Rows, T#ticket.status =:= Status]). %%%------------------------------------------------------------------- %%% @doc Подсчёт тикетов, созданных в заданном временном диапазоне. @@ -275,32 +275,33 @@ count_tickets_by_admin(AdminId, Status) -> -spec count_tickets_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. count_tickets_by_date(From, To) -> - All = mnesia:dirty_match_object(#ticket{_ = '_'}), - Filtered = lists:filter(fun(T) -> T#ticket.first_seen >= From andalso - T#ticket.first_seen =< To end, All), - Counts = lists:foldl(fun(T, Acc) -> - Day = date_part(T#ticket.first_seen), - case lists:keyfind(Day, 1, Acc) of - false -> [{Day, 1} | Acc]; - {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) - end - end, [], Filtered), - lists:sort(Counts). + core_stats:with_daily(tickets_created, From, To, fun() -> + All = mnesia:dirty_match_object(#ticket{_ = '_'}), + Filtered = lists:filter(fun(T) -> T#ticket.first_seen >= From andalso + T#ticket.first_seen =< To end, All), + Counts = lists:foldl(fun(T, Acc) -> + Day = date_part(T#ticket.first_seen), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts) + end). date_part({{Y,M,D}, _}) -> {Y,M,D}. +%% @private Index read with fallback to full match (tests without index). +index_read(Table, Key, Pos, FallbackFun) -> + case catch mnesia:dirty_index_read(Table, Key, Pos) of + {'EXIT', _} -> FallbackFun(); + Rows when is_list(Rows) -> Rows + end. + %%%=================================================================== %%% ВНУТРЕННИЕ ФУНКЦИИ %%%=================================================================== -%% @private Преобразует бинарный статус в атом, если нужно. -%% Атомы возвращает без изменений. -normalize_status(Status) when is_atom(Status) -> Status; -normalize_status(Status) when is_binary(Status) -> - try binary_to_existing_atom(Status, utf8) - catch error:badarg -> Status - end. - apply_updates(Ticket, Updates) -> Updated = lists:foldl(fun({Field, Value}, T) -> set_field(Field, Value, T) end, Ticket, Updates), diff --git a/src/core/core_user.erl b/src/core/core_user.erl index 1f830b6..25633b2 100644 --- a/src/core/core_user.erl +++ b/src/core/core_user.erl @@ -266,17 +266,19 @@ list_all() -> -spec count_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. count_users_by_date(From, To) -> - All = mnesia:dirty_match_object(#user{_ = '_'}), - Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso - U#user.created_at =< To end, All), - Counts = lists:foldl(fun(U, Acc) -> - Day = date_part(U#user.created_at), - case lists:keyfind(Day, 1, Acc) of - false -> [{Day, 1} | Acc]; - {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) - end - end, [], Filtered), - lists:sort(Counts). + core_stats:with_daily(users_created, From, To, fun() -> + All = mnesia:dirty_match_object(#user{_ = '_'}), + Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso + U#user.created_at =< To end, All), + Counts = lists:foldl(fun(U, Acc) -> + Day = date_part(U#user.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts) + end). %%%------------------------------------------------------------------- %%% @doc Количество неподтверждённых пользователей (status = pending). @@ -284,8 +286,10 @@ count_users_by_date(From, To) -> %%%------------------------------------------------------------------- -spec count_pending_users() -> non_neg_integer(). count_pending_users() -> - Match = #user{status = pending, _ = '_'}, - length(mnesia:dirty_match_object(Match)). + core_stats:with_counter({user_by_status, pending}, fun() -> + Match = #user{status = pending, _ = '_'}, + length(mnesia:dirty_match_object(Match)) + end). %%%------------------------------------------------------------------- %%% @doc Подсчёт неподтверждённых пользователей (status=pending) по дням. @@ -294,18 +298,20 @@ count_pending_users() -> -spec count_pending_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. count_pending_users_by_date(From, To) -> - All = mnesia:dirty_match_object(#user{_ = '_'}), - Pending = lists:filter(fun(U) -> U#user.status =:= pending end, All), - Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso - U#user.created_at =< To end, Pending), - Counts = lists:foldl(fun(U, Acc) -> - Day = date_part(U#user.created_at), - case lists:keyfind(Day, 1, Acc) of - false -> [{Day, 1} | Acc]; - {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) - end - end, [], Filtered), - lists:sort(Counts). + core_stats:with_daily(pending_users_created, From, To, fun() -> + All = mnesia:dirty_match_object(#user{_ = '_'}), + Pending = lists:filter(fun(U) -> U#user.status =:= pending end, All), + Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso + U#user.created_at =< To end, Pending), + Counts = lists:foldl(fun(U, Acc) -> + Day = date_part(U#user.created_at), + case lists:keyfind(Day, 1, Acc) of + false -> [{Day, 1} | Acc]; + {Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1}) + end + end, [], Filtered), + lists:sort(Counts) + end). %%%------------------------------------------------------------------- %%% @doc Подсчёт пользователей по ролям. @@ -314,14 +320,15 @@ count_pending_users_by_date(From, To) -> %%%------------------------------------------------------------------- -spec count_users_by_role() -> [{atom(), non_neg_integer()}]. count_users_by_role() -> - Users = mnesia:dirty_match_object(#user{_ = '_'}), - Dict = lists:foldl(fun(#user{role = Role}, Acc) -> - case lists:keyfind(Role, 1, Acc) of - false -> [{Role, 1} | Acc]; - {Role, C} -> lists:keyreplace(Role, 1, Acc, {Role, C+1}) - end - end, [], Users), - Dict. + core_stats:with_dim(user, role, fun() -> + Users = mnesia:dirty_match_object(#user{_ = '_'}), + lists:foldl(fun(#user{role = Role}, Acc) -> + case lists:keyfind(Role, 1, Acc) of + false -> [{Role, 1} | Acc]; + {Role, C} -> lists:keyreplace(Role, 1, Acc, {Role, C+1}) + end + end, [], Users) + end). %%%------------------------------------------------------------------- %%% @doc Подсчёт пользователей по статусам. @@ -330,14 +337,15 @@ count_users_by_role() -> %%%------------------------------------------------------------------- -spec count_users_by_status() -> [{atom(), non_neg_integer()}]. count_users_by_status() -> - Users = mnesia:dirty_match_object(#user{_ = '_'}), - Dict = lists:foldl(fun(#user{status = Status}, Acc) -> - case lists:keyfind(Status, 1, Acc) of - false -> [{Status, 1} | Acc]; - {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) - end - end, [], Users), - Dict. + core_stats:with_dim(user, status, fun() -> + Users = mnesia:dirty_match_object(#user{_ = '_'}), + lists:foldl(fun(#user{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Users) + end). %%%------------------------------------------------------------------- %%% @doc Выделить из datetime только дату `{Y,M,D}`. diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index d311d31..7d4f968 100644 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -20,7 +20,7 @@ review, report, banned_word, automod_settings, automod_hit, ticket, subscription, admin_audit, notification, - stats, node_metric, schema_migration + stats_counter, stats_daily, node_metric, schema_migration ]). -define(DISC_TABLES, ?TABLES -- [session, verification, admin_session, node_metric]). @@ -303,7 +303,8 @@ table_opts(ticket) -> [{disc_copies, [node()]}, {attributes, record_info(fields, table_opts(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}]; table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}]; table_opts(notification) -> [{disc_copies, [node()]}, {attributes, record_info(fields, notification)}]; -table_opts(stats) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats)}]; +table_opts(stats_counter) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_counter)}]; +table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_daily)}]; table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}]; table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}]; table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}]; @@ -340,4 +341,6 @@ create_indices() -> mnesia:add_table_index(notification, is_read), mnesia:add_table_index(auth_session, family_id), mnesia:add_table_index(auth_session, subject_id), + mnesia:add_table_index(report, resolved_by), + mnesia:add_table_index(ticket, assigned_to), ok. \ No newline at end of file diff --git a/src/infra/migration_engine.erl b/src/infra/migration_engine.erl index de5e875..4e87c73 100644 --- a/src/infra/migration_engine.erl +++ b/src/infra/migration_engine.erl @@ -21,7 +21,9 @@ -define(ALL_MIGRATIONS, [ '20260501120000_base_schema', '20260504150000_test_migration', - '20260716230000_ticket_source_and_hash_index' + '20260716230000_ticket_source_and_hash_index', + '20260717180000_stats_counters', + '20260717190000_admin_stats_indexes' ]). %% ------------------------------ diff --git a/src/infra/stats_collector.erl b/src/infra/stats_collector.erl index f0cd353..468c3d4 100644 --- a/src/infra/stats_collector.erl +++ b/src/infra/stats_collector.erl @@ -1,133 +1,569 @@ +%%%------------------------------------------------------------------- +%%% @doc Сбор admin-статистики через Mnesia detailed-subscribe. +%%% ETS держит абсолютные upsert-счётчики и дневные бакеты; +%%% периодический flush пишет в stats_counter / stats_daily. +%%% @end +%%%------------------------------------------------------------------- -module(stats_collector). -behaviour(gen_server). -include("records.hrl"). --export([start_link/0, get_stats/0, subscribe/0]). --export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). +-export([start_link/0, subscribe/0, rebuild/0, is_ready/0, call/1, + get_stats/0]). +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, + terminate/2, code_change/3]). --define(FLUSH_INTERVAL, 300000). +-define(FLUSH_INTERVAL, 300000). %% 5 min +-define(CLEANUP_INTERVAL, 3600000). %% 1 h +-define(DAILY_RETENTION_DAYS, 730). +-define(COUNTER_ETS, stats_counter_ets). +-define(DAILY_ETS, stats_daily_ets). +-define(SUB_TABLES, [ + user, event, calendar, booking, review, report, ticket, subscription +]). -start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). -get_stats() -> gen_server:call(?MODULE, get_stats). -subscribe() -> gen_server:call(?MODULE, subscribe). +-record(state, { + ready = false :: boolean(), + subscribed = false :: boolean() +}). + +%%%=================================================================== +%%% API +%%%=================================================================== + +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +subscribe() -> + gen_server:call(?MODULE, subscribe, infinity). + +rebuild() -> + gen_server:call(?MODULE, rebuild, infinity). + +is_ready() -> + gen_server:call(?MODULE, is_ready). + +call(Msg) -> + gen_server:call(?MODULE, Msg). + +%% @doc Совместимость: снимок counter ETS. +get_stats() -> + gen_server:call(?MODULE, get_stats). + +%%%=================================================================== +%%% gen_server +%%%=================================================================== init([]) -> - ets:new(stats_ets, [set, public, named_table, {keypos, 1}]), + ets:new(?COUNTER_ETS, [named_table, public, set, {keypos, 1}, + {write_concurrency, true}]), + ets:new(?DAILY_ETS, [named_table, public, set, {keypos, 1}, + {write_concurrency, true}]), + stats_tops:init_tables(), erlang:send_after(?FLUSH_INTERVAL, self(), flush), - {ok, #{}}. + erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup_daily), + {ok, #state{}}. handle_call(subscribe, _From, State) -> - mnesia:subscribe({table, event, simple}), - mnesia:subscribe({table, booking, simple}), - mnesia:subscribe({table, review, simple}), - {reply, ok, State}; + NewState = do_subscribe(State), + {reply, ok, NewState}; +handle_call(rebuild, _From, State) -> + do_rebuild(), + {reply, ok, State#state{ready = true}}; +handle_call(is_ready, _From, State) -> + {reply, State#state.ready, State}; handle_call(get_stats, _From, State) -> - {reply, ets:tab2list(stats_ets), State}; + {reply, ets:tab2list(?COUNTER_ETS), State}; +handle_call({get_counter, _Key}, _From, #state{ready = false} = State) -> + {reply, {error, not_ready}, State}; +handle_call({get_counter, Key}, _From, State) -> + {reply, {ok, ets_get(?COUNTER_ETS, Key)}, State}; +handle_call({get_dim, _E, _F}, _From, #state{ready = false} = State) -> + {reply, {error, not_ready}, State}; +handle_call({get_dim, Entity, Field}, _From, State) -> + {reply, {ok, read_dim(Entity, Field)}, State}; +handle_call({get_daily, _M, _F, _T}, _From, #state{ready = false} = State) -> + {reply, {error, not_ready}, State}; +handle_call({get_daily, Metric, From, To}, _From, State) -> + {reply, {ok, read_daily(Metric, From, To)}, State}; handle_call(_Msg, _From, State) -> {reply, {error, unknown}, State}. -handle_cast(_Msg, State) -> {noreply, State}. +handle_cast(_Msg, State) -> + {noreply, State}. handle_info(flush, State) -> - flush_stats(), + flush_to_mnesia(), erlang:send_after(?FLUSH_INTERVAL, self(), flush), {noreply, State}; -handle_info({mnesia_table_event, {write, Record, _ActivityId}}, State) -> - Table = element(1, Record), - process_write(Table, Record, []), +handle_info(cleanup_daily, State) -> + cleanup_old_daily(?DAILY_RETENTION_DAYS), + erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup_daily), {noreply, State}; -handle_info({write, Table, New, Old, _ActivityId}, State) -> - process_write(Table, New, Old), +handle_info({mnesia_table_event, Event}, State) -> + handle_mnesia_event(Event), {noreply, State}; -handle_info(_Info, State) -> {noreply, State}. +handle_info(_Info, State) -> + {noreply, State}. -terminate(_Reason, _State) -> ok. -code_change(_OldVsn, State, _Extra) -> {ok, State}. +terminate(_Reason, _State) -> + flush_to_mnesia(), + ok. -ensure_counter(Key) -> - case ets:member(stats_ets, Key) of - false -> ets:insert(stats_ets, {Key, 0}); - true -> ok +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%%=================================================================== +%%% Bootstrap +%%%=================================================================== + +do_subscribe(#state{subscribed = true} = State) -> + State; +do_subscribe(State) -> + load_from_mnesia(), + case ets:info(?COUNTER_ETS, size) =:= 0 andalso ets:info(?DAILY_ETS, size) =:= 0 of + true -> do_rebuild(); + false -> stats_tops:rebuild() + end, + lists:foreach(fun(Table) -> + mnesia:subscribe({table, Table, detailed}) + end, ?SUB_TABLES), + State#state{ready = true, subscribed = true}. + +do_rebuild() -> + ets:delete_all_objects(?COUNTER_ETS), + ets:delete_all_objects(?DAILY_ETS), + backfill_all(), + stats_tops:rebuild(), + flush_to_mnesia(), + ok. + +load_from_mnesia() -> + case lists:member(stats_counter, mnesia:system_info(tables)) of + true -> + lists:foreach(fun(#stats_counter{key = K, value = V}) -> + ets:insert(?COUNTER_ETS, {K, V}) + end, mnesia:dirty_match_object(#stats_counter{_ = '_'})); + false -> ok + end, + case lists:member(stats_daily, mnesia:system_info(tables)) of + true -> + lists:foreach(fun(#stats_daily{key = K, value = V}) -> + ets:insert(?DAILY_ETS, {K, V}) + end, mnesia:dirty_match_object(#stats_daily{_ = '_'})); + false -> ok + end, + ok. + +%%%=================================================================== +%%% Mnesia events +%%%=================================================================== + +handle_mnesia_event({write, Table, New, Olds, _ActivityId}) -> + Old = case Olds of + [O | _] -> O; + [] -> undefined + end, + process_write(Table, New, Old); +handle_mnesia_event({delete, _Table, _Key, Olds, _ActivityId}) -> + lists:foreach(fun(Old) -> process_delete(element(1, Old), Old) end, Olds); +handle_mnesia_event({delete_object, Table, Old, _Olds, _ActivityId}) -> + process_delete(Table, Old); +handle_mnesia_event(_) -> + ok. + +%%%=================================================================== +%%% Write / delete handlers +%%%=================================================================== + +process_write(user, New, undefined) -> + #user{status = St, role = Role, created_at = Created} = New, + add_dim(user, status, St, 1), + add_dim(user, role, Role, 1), + add_daily(users_created, date_of(Created), 1), + case St of + pending -> add_daily(pending_users_created, date_of(Created), 1); + _ -> ok + end; +process_write(user, New, Old) -> + #user{status = StNew, role = RoleNew, created_at = Created} = New, + #user{status = StOld, role = RoleOld} = Old, + move_dim(user, status, StOld, StNew), + move_dim(user, role, RoleOld, RoleNew), + case {StOld, StNew} of + {pending, pending} -> ok; + {pending, _} -> add_daily(pending_users_created, date_of(Created), -1); + {_, pending} -> add_daily(pending_users_created, date_of(Created), 1); + _ -> ok + end; + +process_write(event, New, undefined) -> + #event{status = St, event_type = Type, created_at = Created} = New, + add_dim(event, status, St, 1), + add_dim(event, type, Type, 1), + add_daily(events_created, date_of(Created), 1), + stats_tops:on_event(New, undefined); +process_write(event, New, Old) -> + #event{status = StNew, event_type = TypeNew} = New, + #event{status = StOld, event_type = TypeOld} = Old, + move_dim(event, status, StOld, StNew), + move_dim(event, type, TypeOld, TypeNew), + stats_tops:on_event(New, Old); + +process_write(calendar, New, undefined) -> + #calendar{status = St, type = Type, created_at = Created} = New, + add_dim(calendar, status, St, 1), + add_dim(calendar, type, Type, 1), + add_daily(calendars_created, date_of(Created), 1), + stats_tops:on_calendar(New, undefined); +process_write(calendar, New, Old) -> + #calendar{status = StNew, type = TypeNew} = New, + #calendar{status = StOld, type = TypeOld} = Old, + move_dim(calendar, status, StOld, StNew), + move_dim(calendar, type, TypeOld, TypeNew), + stats_tops:on_calendar(New, Old); + +process_write(booking, New, undefined) -> + #booking{status = St, created_at = Created} = New, + add_dim(booking, status, St, 1), + add_daily(bookings_created, date_of(Created), 1); +process_write(booking, New, Old) -> + move_dim(booking, status, Old#booking.status, New#booking.status); + +process_write(review, New, undefined) -> + #review{status = St, target_type = TT, created_at = Created} = New, + add_dim(review, status, St, 1), + add_dim(review, target_type, TT, 1), + add_daily(reviews_created, date_of(Created), 1), + stats_tops:on_review(New, undefined); +process_write(review, New, Old) -> + #review{status = StNew, target_type = TTNew} = New, + #review{status = StOld, target_type = TTOld} = Old, + move_dim(review, status, StOld, StNew), + move_dim(review, target_type, TTOld, TTNew), + stats_tops:on_review(New, Old); + +process_write(report, New, undefined) -> + #report{status = St, target_type = TT, created_at = Created} = New, + add_dim(report, status, St, 1), + add_dim(report, target_type, TT, 1), + add_daily(reports_created, date_of(Created), 1), + apply_report_resolution(report_resolution(New)), + stats_tops:on_report(New, undefined); +process_write(report, New, Old) -> + #report{status = StNew, target_type = TTNew} = New, + #report{status = StOld, target_type = TTOld} = Old, + move_dim(report, status, StOld, StNew), + move_dim(report, target_type, TTOld, TTNew), + move_resolution(report_resolution(Old), report_resolution(New)), + stats_tops:on_report(New, Old); + +process_write(ticket, New, undefined) -> + #ticket{status = St, count = Count, first_seen = First} = New, + add_dim(ticket, status, St, 1), + add_counter(ticket_errors_sum, Count), + add_daily(tickets_created, date_of(First), 1), + apply_ticket_resolution(ticket_resolution(New)); +process_write(ticket, New, Old) -> + #ticket{status = StNew, count = CountNew} = New, + #ticket{status = StOld, count = CountOld} = Old, + move_dim(ticket, status, StOld, StNew), + add_counter(ticket_errors_sum, CountNew - CountOld), + move_resolution(ticket_resolution(Old), ticket_resolution(New)); + +process_write(subscription, New, undefined) -> + #subscription{status = St, plan = Plan, trial_used = Trial, + created_at = Created} = New, + add_dim(subscription, status, St, 1), + add_dim(subscription, plan, Plan, 1), + case Trial of + false -> add_counter(trial_subscriptions, 1); + _ -> ok + end, + add_daily(subscriptions_created, date_of(Created), 1); +process_write(subscription, New, Old) -> + #subscription{status = StNew, plan = PlanNew, trial_used = TrialNew} = New, + #subscription{status = StOld, plan = PlanOld, trial_used = TrialOld} = Old, + move_dim(subscription, status, StOld, StNew), + move_dim(subscription, plan, PlanOld, PlanNew), + case {TrialOld, TrialNew} of + {false, false} -> ok; + {false, _} -> add_counter(trial_subscriptions, -1); + {_, false} -> add_counter(trial_subscriptions, 1); + _ -> ok + end; + +process_write(_, _, _) -> + ok. + +process_delete(user, #user{status = St, role = Role, created_at = Created}) -> + add_dim(user, status, St, -1), + add_dim(user, role, Role, -1), + add_daily(users_created, date_of(Created), -1), + case St of + pending -> add_daily(pending_users_created, date_of(Created), -1); + _ -> ok + end; +process_delete(event, #event{status = St, event_type = Type, created_at = Created} = E) -> + add_dim(event, status, St, -1), + add_dim(event, type, Type, -1), + add_daily(events_created, date_of(Created), -1), + stats_tops:remove_event(E); +process_delete(calendar, #calendar{status = St, type = Type, created_at = Created} = C) -> + add_dim(calendar, status, St, -1), + add_dim(calendar, type, Type, -1), + add_daily(calendars_created, date_of(Created), -1), + stats_tops:remove_calendar(C); +process_delete(booking, #booking{status = St, created_at = Created}) -> + add_dim(booking, status, St, -1), + add_daily(bookings_created, date_of(Created), -1); +process_delete(review, #review{status = St, target_type = TT, created_at = Created} = R) -> + add_dim(review, status, St, -1), + add_dim(review, target_type, TT, -1), + add_daily(reviews_created, date_of(Created), -1), + stats_tops:remove_review(R); +process_delete(report, #report{status = St, target_type = TT, created_at = Created} = R) -> + add_dim(report, status, St, -1), + add_dim(report, target_type, TT, -1), + add_daily(reports_created, date_of(Created), -1), + remove_report_resolution(report_resolution(R)), + stats_tops:remove_report(R); +process_delete(ticket, #ticket{status = St, count = Count, first_seen = First} = T) -> + add_dim(ticket, status, St, -1), + add_counter(ticket_errors_sum, -Count), + add_daily(tickets_created, date_of(First), -1), + remove_ticket_resolution(ticket_resolution(T)); +process_delete(subscription, #subscription{status = St, plan = Plan, + trial_used = Trial, created_at = Created}) -> + add_dim(subscription, status, St, -1), + add_dim(subscription, plan, Plan, -1), + case Trial of + false -> add_counter(trial_subscriptions, -1); + _ -> ok + end, + add_daily(subscriptions_created, date_of(Created), -1); +process_delete(_, _) -> + ok. + +%%%=================================================================== +%%% Backfill +%%%=================================================================== + +backfill_all() -> + lists:foreach(fun(U) -> process_write(user, U, undefined) end, + safe_match(user, #user{_ = '_'})), + lists:foreach(fun(E) -> process_write(event, E, undefined) end, + safe_match(event, #event{_ = '_'})), + lists:foreach(fun(C) -> process_write(calendar, C, undefined) end, + safe_match(calendar, #calendar{_ = '_'})), + lists:foreach(fun(B) -> process_write(booking, B, undefined) end, + safe_match(booking, #booking{_ = '_'})), + lists:foreach(fun(R) -> process_write(review, R, undefined) end, + safe_match(review, #review{_ = '_'})), + lists:foreach(fun(R) -> process_write(report, R, undefined) end, + safe_match(report, #report{_ = '_'})), + lists:foreach(fun(T) -> process_write(ticket, T, undefined) end, + safe_match(ticket, #ticket{_ = '_'})), + lists:foreach(fun(S) -> process_write(subscription, S, undefined) end, + safe_match(subscription, #subscription{_ = '_'})), + ok. + +safe_match(Table, Pattern) -> + case lists:member(Table, mnesia:system_info(tables)) of + true -> mnesia:dirty_match_object(Pattern); + false -> [] end. -process_write(event, New, Old) -> - CId = element(#event.calendar_id, New), - StNew = element(#event.status, New), - StOld = if Old =:= [] -> []; true -> element(#event.status, Old) end, - if StNew =:= active, StOld =/= active -> - ensure_counter({events_created, CId}), - ets:update_counter(stats_ets, {events_created, CId}, {2, 1}); - StNew =:= cancelled, StOld =/= cancelled -> - ensure_counter({events_cancelled, CId}), - ets:update_counter(stats_ets, {events_cancelled, CId}, {2, 1}); - true -> ok - end; -process_write(booking, New, Old) -> - EvId = element(#booking.event_id, New), - StNew = element(#booking.status, New), - StOld = if Old =:= [] -> []; true -> element(#booking.status, Old) end, - CId = case mnesia:dirty_read({event, EvId}) of - [#event{calendar_id = C}] -> C; - _ -> undefined - end, - if CId =/= undefined -> - if StNew =:= confirmed, StOld =/= confirmed -> - ensure_counter({bookings_confirmed, CId}), - ets:update_counter(stats_ets, {bookings_confirmed, CId}, {2, 1}); - StNew =:= cancelled, StOld =/= cancelled -> - ensure_counter({bookings_cancelled, CId}), - ets:update_counter(stats_ets, {bookings_cancelled, CId}, {2, 1}); - true -> ok - end, - if Old =:= [] -> - ensure_counter({bookings_created, CId}), - ets:update_counter(stats_ets, {bookings_created, CId}, {2, 1}); - true -> ok - end; - true -> ok - end; -process_write(review, New, Old) -> - CId = case element(#review.target_type, New) of - event -> - EvId = element(#review.target_id, New), - case mnesia:dirty_read({event, EvId}) of - [#event{calendar_id = C}] -> C; - _ -> undefined - end; - calendar -> - element(#review.target_id, New) - end, - Rating = element(#review.rating, New), - if CId =/= undefined -> - if Old =:= [] -> - ensure_counter({reviews_created, CId}), - ets:update_counter(stats_ets, {reviews_created, CId}, {2, 1}), - ensure_counter({reviews_sum, CId}), - ets:update_counter(stats_ets, {reviews_sum, CId}, {2, Rating}), - ensure_counter({reviews_count, CId}), - ets:update_counter(stats_ets, {reviews_count, CId}, {2, 1}); - true -> - OldRating = element(#review.rating, Old), - if Rating =/= OldRating -> - ensure_counter({reviews_sum, CId}), - ets:update_counter(stats_ets, {reviews_sum, CId}, {2, Rating - OldRating}); - true -> ok - end - end; - true -> ok - end; -process_write(_, _, _) -> ok. +%%%=================================================================== +%%% ETS helpers +%%%=================================================================== -flush_stats() -> - All = ets:tab2list(stats_ets), - lists:foreach(fun({{Metric, EntityId}, Value}) -> - mnesia:dirty_write(#stats{ - id = list_to_binary(io_lib:format("~p_~p_~p", [Metric, EntityId, os:system_time(millisecond)])), - metric = Metric, - entity_id = EntityId, - value = Value, - timestamp = calendar:local_time() - }) - end, All), - ets:match_delete(stats_ets, '_'). \ No newline at end of file +dim_key(Entity, Field, Value) -> + {list_to_atom(atom_to_list(Entity) ++ "_by_" ++ atom_to_list(Field)), Value}. + +add_dim(Entity, Field, Value, Delta) when is_atom(Value) -> + add_counter(dim_key(Entity, Field, Value), Delta); +add_dim(_, _, _, _) -> + ok. + +move_dim(_E, _F, Same, Same) -> + ok; +move_dim(Entity, Field, OldVal, NewVal) -> + add_dim(Entity, Field, OldVal, -1), + add_dim(Entity, Field, NewVal, 1). + +add_counter(_Key, 0) -> + ok; +add_counter(Key, Delta) -> + ensure_key(?COUNTER_ETS, Key), + ets:update_counter(?COUNTER_ETS, Key, {2, Delta}), + %% clamp at 0 for safety under races + case ets:lookup(?COUNTER_ETS, Key) of + [{Key, V}] when V < 0 -> ets:insert(?COUNTER_ETS, {Key, 0}); + _ -> ok + end. + +add_daily(_Metric, undefined, _) -> + ok; +add_daily(_Metric, _Date, 0) -> + ok; +add_daily(Metric, Date, Delta) -> + Key = {Metric, Date}, + ensure_key(?DAILY_ETS, Key), + ets:update_counter(?DAILY_ETS, Key, {2, Delta}), + case ets:lookup(?DAILY_ETS, Key) of + [{Key, V}] when V < 0 -> ets:insert(?DAILY_ETS, {Key, 0}); + _ -> ok + end. + +ensure_key(Tab, Key) -> + case ets:member(Tab, Key) of + true -> ok; + false -> ets:insert(Tab, {Key, 0}) + end. + +ets_get(Tab, Key) -> + case ets:lookup(Tab, Key) of + [{Key, V}] -> V; + [] -> 0 + end. + +read_dim(Entity, Field) -> + Prefix = list_to_atom(atom_to_list(Entity) ++ "_by_" ++ atom_to_list(Field)), + lists:filtermap(fun + ({{P, Val}, V}) when P =:= Prefix, V > 0, is_atom(Val) -> {true, {Val, V}}; + (_) -> false + end, ets:tab2list(?COUNTER_ETS)). + +read_daily(Metric, From, To) -> + FromDate = date_of(From), + ToDate = date_of(To), + Matches = lists:filtermap(fun + ({{M, Day}, V}) when M =:= Metric, V > 0, + Day >= FromDate, Day =< ToDate -> + {true, {Day, V}}; + (_) -> false + end, ets:tab2list(?DAILY_ETS)), + lists:sort(Matches). + +date_of({{Y, M, D}, _}) -> {Y, M, D}; +date_of({Y, M, D} = Date) when is_integer(Y), is_integer(M), is_integer(D) -> Date; +date_of(_) -> undefined. + +%%%=================================================================== +%%% Resolution time (avg) +%%%=================================================================== + +-define(TICKET_EPOCH, {{1970, 1, 1}, {0, 0, 0}}). + +%% Ticket: same semantics as core_ticket:avg_resolution_time — closed_at set. +ticket_resolution(#ticket{first_seen = First, closed_at = Closed}) -> + case is_set_datetime(Closed) of + true -> + case resolution_seconds(First, Closed) of + undefined -> undefined; + Sec -> {ticket, Sec} + end; + false -> undefined + end. + +%% Report: only reviewed|dismissed with resolved_at. +report_resolution(#report{status = St, created_at = Created, resolved_at = Resolved}) + when St =:= reviewed; St =:= dismissed -> + case is_set_datetime(Resolved) of + true -> + case resolution_seconds(Created, Resolved) of + undefined -> undefined; + Sec -> {{report, St}, Sec} + end; + false -> undefined + end; +report_resolution(_) -> + undefined. + +move_resolution(Same, Same) -> + ok; +move_resolution(Old, New) -> + remove_resolution(Old), + apply_resolution(New). + +apply_resolution(undefined) -> ok; +apply_resolution({ticket, Sec}) -> apply_ticket_resolution({ticket, Sec}); +apply_resolution({{report, St}, Sec}) -> apply_report_resolution({{report, St}, Sec}). + +remove_resolution(undefined) -> ok; +remove_resolution({ticket, Sec}) -> remove_ticket_resolution({ticket, Sec}); +remove_resolution({{report, St}, Sec}) -> remove_report_resolution({{report, St}, Sec}). + +apply_ticket_resolution(undefined) -> ok; +apply_ticket_resolution({ticket, Sec}) -> + add_counter(ticket_resolved_count, 1), + add_counter(ticket_resolved_seconds_sum, Sec). + +remove_ticket_resolution(undefined) -> ok; +remove_ticket_resolution({ticket, Sec}) -> + add_counter(ticket_resolved_count, -1), + add_counter(ticket_resolved_seconds_sum, -Sec). + +apply_report_resolution(undefined) -> ok; +apply_report_resolution({{report, St}, Sec}) -> + add_counter({report_resolved_count, St}, 1), + add_counter({report_resolved_seconds_sum, St}, Sec). + +remove_report_resolution(undefined) -> ok; +remove_report_resolution({{report, St}, Sec}) -> + add_counter({report_resolved_count, St}, -1), + add_counter({report_resolved_seconds_sum, St}, -Sec). + +is_set_datetime(undefined) -> false; +is_set_datetime(?TICKET_EPOCH) -> false; +is_set_datetime({{_, _, _}, {_, _, _}}) -> true; +is_set_datetime(_) -> false. + +resolution_seconds(Start, End) -> + case {is_set_datetime(Start), is_set_datetime(End)} of + {true, true} -> + max(0, calendar:datetime_to_gregorian_seconds(End) - + calendar:datetime_to_gregorian_seconds(Start)); + _ -> undefined + end. + +%%%=================================================================== +%%% Persist / cleanup +%%%=================================================================== + +flush_to_mnesia() -> + Now = calendar:universal_time(), + case lists:member(stats_counter, mnesia:system_info(tables)) of + true -> + lists:foreach(fun({Key, Value}) -> + mnesia:dirty_write(#stats_counter{key = Key, value = Value, updated_at = Now}) + end, ets:tab2list(?COUNTER_ETS)); + false -> ok + end, + case lists:member(stats_daily, mnesia:system_info(tables)) of + true -> + lists:foreach(fun({Key, Value}) -> + mnesia:dirty_write(#stats_daily{key = Key, value = Value, updated_at = Now}) + end, ets:tab2list(?DAILY_ETS)); + false -> ok + end, + ok. + +cleanup_old_daily(RetentionDays) -> + case lists:member(stats_daily, mnesia:system_info(tables)) of + false -> ok; + true -> + {Date, _} = calendar:universal_time(), + CutoffSec = calendar:datetime_to_gregorian_seconds({Date, {0, 0, 0}}) + - RetentionDays * 86400, + Cutoff = element(1, calendar:gregorian_seconds_to_datetime(CutoffSec)), + lists:foreach(fun + (#stats_daily{key = {_, Day}} = Row) when Day < Cutoff -> + mnesia:dirty_delete_object(Row), + ets:delete(?DAILY_ETS, Row#stats_daily.key); + (_) -> ok + end, mnesia:dirty_match_object(#stats_daily{_ = '_'})), + ok + end. diff --git a/src/infra/stats_tops.erl b/src/infra/stats_tops.erl new file mode 100644 index 0000000..46925d5 --- /dev/null +++ b/src/infra/stats_tops.erl @@ -0,0 +1,329 @@ +%%%------------------------------------------------------------------- +%%% @doc ETS ordered_set tops для admin stats. +%%% Класс A: denormalized rating на event/calendar. +%%% Класс B: агрегаты целей review/report (+ calendar pos/neg). +%%% @end +%%%------------------------------------------------------------------- +-module(stats_tops). + +-include("records.hrl"). + +-export([ + init_tables/0, + clear/0, + rebuild/0, + on_event/2, + on_calendar/2, + on_review/2, + on_report/2, + remove_event/1, + remove_calendar/1, + remove_review/1, + remove_report/1, + get_top_events_by_rating/1, + get_top_calendars_by_rating/1, + get_top_calendars_by_reviews/1, + get_top_calendars_by_positive_reviews/1, + get_top_calendars_by_negative_reviews/1, + get_top_targets_by_reviews/1, + get_top_targets_by_positive_reviews/1, + get_top_targets_by_negative_reviews/1, + get_top_targets_by_reports/1 +]). + +-define(IDX, stats_tops_idx). +-define(ORD_EVENT_RATING, stats_top_event_rating). +-define(ORD_CAL_RATING, stats_top_calendar_rating). +-define(ORD_CAL_REVIEWS, stats_top_calendar_reviews). +-define(ORD_CAL_POS, stats_top_calendar_pos). +-define(ORD_CAL_NEG, stats_top_calendar_neg). +-define(ORD_REVIEWS_ALL, stats_top_reviews_all). +-define(ORD_REVIEWS_POS, stats_top_reviews_pos). +-define(ORD_REVIEWS_NEG, stats_top_reviews_neg). +-define(ORD_REPORTS_ALL, stats_top_reports_all). + +-define(ALL_ORD, [ + ?ORD_EVENT_RATING, ?ORD_CAL_RATING, ?ORD_CAL_REVIEWS, + ?ORD_CAL_POS, ?ORD_CAL_NEG, + ?ORD_REVIEWS_ALL, ?ORD_REVIEWS_POS, ?ORD_REVIEWS_NEG, ?ORD_REPORTS_ALL +]). + +%%%=================================================================== +%%% Lifecycle +%%%=================================================================== + +init_tables() -> + ensure_ets(?IDX, [named_table, public, set, {read_concurrency, true}]), + lists:foreach(fun(T) -> + ensure_ets(T, [named_table, public, ordered_set, {read_concurrency, true}]) + end, ?ALL_ORD), + ok. + +clear() -> + case ready() of + true -> + ets:delete_all_objects(?IDX), + lists:foreach(fun(T) -> ets:delete_all_objects(T) end, ?ALL_ORD); + false -> ok + end, + ok. + +rebuild() -> + clear(), + foreach_table(event, #event{_ = '_'}, fun(E) -> on_event(E, undefined) end), + foreach_table(calendar, #calendar{_ = '_'}, fun(C) -> on_calendar(C, undefined) end), + foreach_table(review, #review{_ = '_'}, fun(R) -> on_review(R, undefined) end), + foreach_table(report, #report{_ = '_'}, fun(R) -> on_report(R, undefined) end), + ok. + +ready() -> + ets:info(?IDX) =/= undefined. + +foreach_table(Table, Pattern, Fun) -> + case lists:member(Table, mnesia:system_info(tables)) of + true -> lists:foreach(Fun, mnesia:dirty_match_object(Pattern)); + false -> ok + end. + +%%%=================================================================== +%%% Entity rating updates (class A) +%%%=================================================================== + +on_event(undefined, _) -> ok; +on_event(#event{id = Id, rating_avg = Avg}, Old) -> + when_ready(fun() -> + OldAvg = case Old of #event{rating_avg = A} -> A; _ -> undefined end, + set_score(?ORD_EVENT_RATING, {event_rating, Id}, Id, OldAvg, Avg) + end); +on_event(_, _) -> ok. + +on_calendar(undefined, _) -> ok; +on_calendar(#calendar{id = Id, rating_avg = Avg, rating_count = Count}, Old) -> + when_ready(fun() -> + {OldAvg, OldCount} = case Old of + #calendar{rating_avg = A, rating_count = C} -> {A, C}; + _ -> {undefined, undefined} + end, + set_score(?ORD_CAL_RATING, {calendar_rating, Id}, Id, OldAvg, Avg), + set_score(?ORD_CAL_REVIEWS, {calendar_reviews, Id}, Id, OldCount, Count) + end); +on_calendar(_, _) -> ok. + +remove_event(#event{id = Id, rating_avg = Avg}) -> + when_ready(fun() -> clear_score(?ORD_EVENT_RATING, {event_rating, Id}, Id, Avg) end); +remove_event(_) -> ok. + +remove_calendar(#calendar{id = Id, rating_avg = Avg, rating_count = Count}) -> + when_ready(fun() -> + clear_score(?ORD_CAL_RATING, {calendar_rating, Id}, Id, Avg), + clear_score(?ORD_CAL_REVIEWS, {calendar_reviews, Id}, Id, Count) + end); +remove_calendar(_) -> ok. + +%%%=================================================================== +%%% Review / report target updates (class B) +%%%=================================================================== + +on_review(undefined, _) -> ok; +on_review(New, undefined) -> + when_ready(fun() -> apply_review_delta(New, 1) end); +on_review(New, Old) when is_tuple(Old) -> + when_ready(fun() -> + apply_review_delta(Old, -1), + apply_review_delta(New, 1) + end); +on_review(_, _) -> ok. + +remove_review(Review) -> + when_ready(fun() -> apply_review_delta(Review, -1) end). + +on_report(undefined, _) -> ok; +on_report(New, undefined) -> + when_ready(fun() -> apply_report_delta(New, 1) end); +on_report(New, Old) when is_tuple(Old) -> + when_ready(fun() -> + apply_report_delta(Old, -1), + apply_report_delta(New, 1) + end); +on_report(_, _) -> ok. + +remove_report(Report) -> + when_ready(fun() -> apply_report_delta(Report, -1) end). + +apply_review_delta(#review{target_type = Type, target_id = Id, rating = Rating}, Delta) + when is_atom(Type), is_binary(Id) -> + Target = {Type, Id}, + bump(?ORD_REVIEWS_ALL, {reviews_all, Type, Id}, Target, Delta), + case Rating >= 4 of + true -> + bump(?ORD_REVIEWS_POS, {reviews_pos, Type, Id}, Target, Delta), + case Type of + calendar -> bump(?ORD_CAL_POS, {calendar_pos, Id}, Id, Delta); + _ -> ok + end; + false -> ok + end, + case Rating =< 2 of + true -> + bump(?ORD_REVIEWS_NEG, {reviews_neg, Type, Id}, Target, Delta), + case Type of + calendar -> bump(?ORD_CAL_NEG, {calendar_neg, Id}, Id, Delta); + _ -> ok + end; + false -> ok + end; +apply_review_delta(_, _) -> ok. + +apply_report_delta(#report{target_type = Type, target_id = Id}, Delta) + when is_atom(Type), is_binary(Id) -> + bump(?ORD_REPORTS_ALL, {reports_all, Type, Id}, {Type, Id}, Delta); +apply_report_delta(_, _) -> ok. + +%%%=================================================================== +%%% Reads +%%%=================================================================== + +get_top_events_by_rating(N) -> + get_entities(?ORD_EVENT_RATING, N, fun load_event/1). + +get_top_calendars_by_rating(N) -> + get_entities(?ORD_CAL_RATING, N, fun load_calendar/1). + +get_top_calendars_by_reviews(N) -> + get_entities(?ORD_CAL_REVIEWS, N, fun load_calendar/1). + +get_top_calendars_by_positive_reviews(N) -> + get_entities(?ORD_CAL_POS, N, fun load_calendar/1). + +get_top_calendars_by_negative_reviews(N) -> + get_entities(?ORD_CAL_NEG, N, fun load_calendar/1). + +get_top_targets_by_reviews(N) -> + get_target_triples(?ORD_REVIEWS_ALL, N). + +get_top_targets_by_positive_reviews(N) -> + get_target_triples(?ORD_REVIEWS_POS, N). + +get_top_targets_by_negative_reviews(N) -> + get_target_triples(?ORD_REVIEWS_NEG, N). + +get_top_targets_by_reports(N) -> + get_target_triples(?ORD_REPORTS_ALL, N). + +%%%=================================================================== +%%% Internal +%%%=================================================================== + +when_ready(Fun) -> + case ready() of + true -> Fun(); + false -> ok + end. + +ensure_ets(Name, Opts) -> + case ets:info(Name) of + undefined -> ets:new(Name, Opts); + _ -> Name + end. + +set_score(_Ord, _IdxKey, _Entity, Same, Same) -> + ok; +set_score(Ord, IdxKey, Entity, _OldScore, NewScore) -> + case ets:lookup(?IDX, IdxKey) of + [{IdxKey, Prev}] -> ets:delete(Ord, ord_key(Prev, Entity)); + [] -> ok + end, + ets:insert(Ord, {ord_key(NewScore, Entity), Entity}), + ets:insert(?IDX, {IdxKey, NewScore}), + ok. + +clear_score(Ord, IdxKey, Entity, Score) -> + case ets:lookup(?IDX, IdxKey) of + [{IdxKey, Prev}] -> + ets:delete(Ord, ord_key(Prev, Entity)), + ets:delete(?IDX, IdxKey); + [] -> + ets:delete(Ord, ord_key(Score, Entity)) + end, + ok. + +bump(_Ord, _IdxKey, _Entity, 0) -> + ok; +bump(Ord, IdxKey, Entity, Delta) -> + Old = case ets:lookup(?IDX, IdxKey) of + [{IdxKey, C}] -> C; + [] -> 0 + end, + case Old > 0 of + true -> ets:delete(Ord, ord_key(Old, Entity)); + false -> ok + end, + New = max(0, Old + Delta), + case New > 0 of + true -> + ets:insert(Ord, {ord_key(New, Entity), Entity}), + ets:insert(?IDX, {IdxKey, New}); + false -> + ets:delete(?IDX, IdxKey) + end, + ok. + +ord_key(Score, Entity) when is_number(Score) -> + {-Score, Entity}; +ord_key(_, Entity) -> + {0, Entity}. + +get_entities(Ord, N, LoadFun) -> + case ready() of + false -> {error, not_ready}; + true -> + Keys = take_entities(Ord, ets:first(Ord), N, []), + {ok, lists:filtermap(LoadFun, Keys)} + end. + +get_target_triples(Ord, N) -> + case ready() of + false -> {error, not_ready}; + true -> + Items = take_entities(Ord, ets:first(Ord), N, []), + Triples = lists:filtermap(fun + ({Type, Id}) -> + case ets:lookup(?IDX, idx_for_ord(Ord, Type, Id)) of + [{_, Count}] when Count > 0 -> {true, {Type, Id, Count}}; + _ -> false + end; + (_) -> false + end, Items), + {ok, Triples} + end. + +idx_for_ord(?ORD_REVIEWS_ALL, Type, Id) -> {reviews_all, Type, Id}; +idx_for_ord(?ORD_REVIEWS_POS, Type, Id) -> {reviews_pos, Type, Id}; +idx_for_ord(?ORD_REVIEWS_NEG, Type, Id) -> {reviews_neg, Type, Id}; +idx_for_ord(?ORD_REPORTS_ALL, Type, Id) -> {reports_all, Type, Id}. + +take_entities(_Ord, '$end_of_table', _N, Acc) -> + lists:reverse(Acc); +take_entities(_Ord, _Key, 0, Acc) -> + lists:reverse(Acc); +take_entities(Ord, Key, N, Acc) -> + case ets:lookup(Ord, Key) of + [{Key, Entity}] -> + take_entities(Ord, ets:next(Ord, Key), N - 1, [Entity | Acc]); + [] -> + take_entities(Ord, ets:next(Ord, Key), N, Acc) + end. + +load_event(Id) when is_binary(Id) -> + case mnesia:dirty_read({event, Id}) of + [E] -> {true, E}; + _ -> false + end; +load_event(_) -> false. + +load_calendar(Id) when is_binary(Id) -> + case mnesia:dirty_read({calendar, Id}) of + [C] -> {true, C}; + _ -> false + end; +load_calendar(_) -> false. diff --git a/src/logic/logic_ticket.erl b/src/logic/logic_ticket.erl index 1185ab7..9329130 100644 --- a/src/logic/logic_ticket.erl +++ b/src/logic/logic_ticket.erl @@ -141,26 +141,19 @@ delete_ticket(AdminId, TicketId) -> get_statistics(AdminId) -> case admin_utils:is_admin(AdminId) of true -> - All = core_ticket:list_all(), - Open = count_by_status(All, open), - InProgress = count_by_status(All, in_progress), - Resolved = count_by_status(All, resolved), - Closed = count_by_status(All, closed), - TotalErrors = lists:sum([T#ticket.count || T <- All]), #{ - total_tickets => length(All), - open => Open, - in_progress => InProgress, - resolved => Resolved, - closed => Closed, - total_errors => TotalErrors + total_tickets => core_ticket:count_tickets(), + open => core_ticket:count_tickets_by_status(open), + in_progress => core_ticket:count_tickets_by_status(in_progress), + resolved => core_ticket:count_tickets_by_status(resolved), + closed => core_ticket:count_tickets_by_status(closed), + total_errors => core_stats:with_counter(ticket_errors_sum, fun() -> + lists:sum([T#ticket.count || T <- core_ticket:list_all()]) + end) }; false -> {error, access_denied} end. -count_by_status(Tickets, Status) -> - length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]). - normalize_status(Status) when is_atom(Status) -> Status; normalize_status(Status) when is_binary(Status) -> try binary_to_existing_atom(Status, utf8) diff --git a/src/migrations/20260717180000_stats_counters.erl b/src/migrations/20260717180000_stats_counters.erl new file mode 100644 index 0000000..584c80e --- /dev/null +++ b/src/migrations/20260717180000_stats_counters.erl @@ -0,0 +1,42 @@ +%% @doc Replace append-only `stats` with upsert `stats_counter` + `stats_daily`. +-module('20260717180000_stats_counters'). + +-export([up/0, down/0]). + +-include("records.hrl"). + +up() -> + ensure_table(stats_counter, record_info(fields, stats_counter)), + ensure_table(stats_daily, record_info(fields, stats_daily)), + drop_table(stats), + ok. + +down() -> + %% Old append-only shape (record removed from records.hrl). + ensure_table(stats, [id, metric, entity_id, value, timestamp]), + drop_table(stats_counter), + drop_table(stats_daily), + ok. + +ensure_table(Table, Attrs) -> + case lists:member(Table, mnesia:system_info(tables)) of + true -> + ok; + false -> + case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of + {atomic, ok} -> ok; + {aborted, {already_exists, Table}} -> ok; + {aborted, Reason} -> error({create_table_failed, Table, Reason}) + end + end. + +drop_table(Table) -> + case lists:member(Table, mnesia:system_info(tables)) of + true -> + case mnesia:delete_table(Table) of + {atomic, ok} -> ok; + {aborted, Reason} -> error({delete_table_failed, Table, Reason}) + end; + false -> + ok + end. diff --git a/src/migrations/20260717190000_admin_stats_indexes.erl b/src/migrations/20260717190000_admin_stats_indexes.erl new file mode 100644 index 0000000..5a49cec --- /dev/null +++ b/src/migrations/20260717190000_admin_stats_indexes.erl @@ -0,0 +1,53 @@ +%% @doc Indexes for per-admin stats: report.resolved_by, ticket.assigned_to. +-module('20260717190000_admin_stats_indexes'). + +-export([up/0, down/0]). + +-include("records.hrl"). + +up() -> + ensure_index(report, resolved_by, #report.resolved_by), + ensure_index(ticket, assigned_to, #ticket.assigned_to), + ok. + +down() -> + del_index(report, resolved_by), + del_index(ticket, assigned_to), + ok. + +ensure_index(Table, Attr, Pos) -> + case lists:member(Table, mnesia:system_info(tables)) of + false -> + ok; + true -> + Indexes = mnesia:table_info(Table, index), + HasIndex = lists:any(fun + (A) when A =:= Attr -> true; + (N) when is_integer(N) -> N =:= Pos; + (_) -> false + end, Indexes), + case HasIndex of + true -> + ok; + false -> + case mnesia:add_table_index(Table, Attr) of + {atomic, ok} -> ok; + {aborted, {already_exists, _}} -> ok; + {aborted, {already_exists, _, _}} -> ok; + {aborted, Reason} -> error({add_index_failed, Table, Attr, Reason}) + end + end + end. + +del_index(Table, Attr) -> + case lists:member(Table, mnesia:system_info(tables)) of + false -> + ok; + true -> + case catch mnesia:del_table_index(Table, Attr) of + {atomic, ok} -> ok; + {aborted, {no_exists, _}} -> ok; + {aborted, {no_exists, _, _}} -> ok; + _ -> ok + end + end. diff --git a/test/unit/core_admin_stats_index_tests.erl b/test/unit/core_admin_stats_index_tests.erl new file mode 100644 index 0000000..cad750e --- /dev/null +++ b/test/unit/core_admin_stats_index_tests.erl @@ -0,0 +1,73 @@ +-module(core_admin_stats_index_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [report, ticket, user]). +-define(ADMIN_A, <<"adm_a">>). +-define(ADMIN_B, <<"adm_b">>). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + ok. + +cleanup(_) -> + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +admin_stats_index_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"reports resolved_by index filters by admin", fun test_reports_by_admin/0}, + {"tickets assigned_to index filters by admin", fun test_tickets_by_admin/0} + ]}. + +test_reports_by_admin() -> + Now = calendar:universal_time(), + ok = mnesia:dirty_write(#report{ + id = <<"r1">>, reporter_id = <<"u1">>, target_type = calendar, + target_id = <<"c1">>, reason = <<"spam">>, status = reviewed, + created_at = Now, resolved_at = Now, resolved_by = ?ADMIN_A + }), + ok = mnesia:dirty_write(#report{ + id = <<"r2">>, reporter_id = <<"u1">>, target_type = calendar, + target_id = <<"c2">>, reason = <<"spam">>, status = reviewed, + created_at = Now, resolved_at = Now, resolved_by = ?ADMIN_B + }), + ok = mnesia:dirty_write(#report{ + id = <<"r3">>, reporter_id = <<"u1">>, target_type = event, + target_id = <<"e1">>, reason = <<"spam">>, status = dismissed, + created_at = Now, resolved_at = Now, resolved_by = ?ADMIN_A + }), + ?assertEqual(1, core_report:count_reports_resolved_by_admin(?ADMIN_A, reviewed)), + ?assertEqual(1, core_report:count_reports_resolved_by_admin(?ADMIN_A, dismissed)), + ?assertEqual(1, core_report:count_reports_resolved_by_admin(?ADMIN_B, reviewed)), + ?assertEqual(0, core_report:count_reports_resolved_by_admin(?ADMIN_B, dismissed)). + +test_tickets_by_admin() -> + Now = calendar:universal_time(), + ok = mnesia:dirty_write(#ticket{ + id = <<"t1">>, reporter_id = <<"u1">>, error_hash = <<"h1">>, + error_message = <<"e">>, stacktrace = <<>>, context = <<>>, + count = 1, first_seen = Now, last_seen = Now, status = open, + assigned_to = ?ADMIN_A, resolution_note = <<>>, closed_at = undefined, + source = <<"backend">> + }), + ok = mnesia:dirty_write(#ticket{ + id = <<"t2">>, reporter_id = <<"u1">>, error_hash = <<"h2">>, + error_message = <<"e">>, stacktrace = <<>>, context = <<>>, + count = 1, first_seen = Now, last_seen = Now, status = resolved, + assigned_to = ?ADMIN_A, resolution_note = <<>>, closed_at = Now, + source = <<"backend">> + }), + ok = mnesia:dirty_write(#ticket{ + id = <<"t3">>, reporter_id = <<"u1">>, error_hash = <<"h3">>, + error_message = <<"e">>, stacktrace = <<>>, context = <<>>, + count = 1, first_seen = Now, last_seen = Now, status = open, + assigned_to = ?ADMIN_B, resolution_note = <<>>, closed_at = undefined, + source = <<"backend">> + }), + ?assertEqual(2, core_ticket:count_tickets_by_admin(?ADMIN_A, all)), + ?assertEqual(1, core_ticket:count_tickets_by_admin(?ADMIN_A, open)), + ?assertEqual(1, core_ticket:count_tickets_by_admin(?ADMIN_A, resolved)), + ?assertEqual(1, core_ticket:count_tickets_by_admin(?ADMIN_B, all)). diff --git a/test/unit/eh_test_support.erl b/test/unit/eh_test_support.erl index aa70283..e7a2993 100644 --- a/test/unit/eh_test_support.erl +++ b/test/unit/eh_test_support.erl @@ -72,8 +72,27 @@ stop_mnesia() -> ensure_tables(Tables) when is_list(Tables) -> lists:foreach(fun ensure_table/1, Tables), + maybe_add_admin_stats_indexes(Tables), ok. +maybe_add_admin_stats_indexes(Tables) -> + case lists:member(report, Tables) of + true -> add_index(report, resolved_by); + false -> ok + end, + case lists:member(ticket, Tables) of + true -> add_index(ticket, assigned_to); + false -> ok + end. + +add_index(Table, Attr) -> + case catch mnesia:add_table_index(Table, Attr) of + {atomic, ok} -> ok; + {aborted, {already_exists, _}} -> ok; + {aborted, {already_exists, _, _}} -> ok; + _ -> ok + end. + ensure_table(Table) -> case lists:member(Table, mnesia:system_info(tables)) of true -> @@ -133,8 +152,10 @@ table_opts(admin_audit) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin_audit)}]; table_opts(notification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, notification)}]; -table_opts(stats) -> - [{ram_copies, [node()]}, {attributes, record_info(fields, stats)}]; +table_opts(stats_counter) -> + [{ram_copies, [node()]}, {attributes, record_info(fields, stats_counter)}]; +table_opts(stats_daily) -> + [{ram_copies, [node()]}, {attributes, record_info(fields, stats_daily)}]; table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}]; table_opts(verification) -> diff --git a/test/unit/stats_collector_tests.erl b/test/unit/stats_collector_tests.erl new file mode 100644 index 0000000..74c9a70 --- /dev/null +++ b/test/unit/stats_collector_tests.erl @@ -0,0 +1,137 @@ +-module(stats_collector_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [user, event, calendar, booking, review, report, ticket, + subscription, stats_counter, stats_daily]). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + {ok, _} = stats_collector:start_link(), + ok = stats_collector:subscribe(), + ok. + +cleanup(_) -> + case whereis(stats_collector) of + undefined -> ok; + Pid -> + unlink(Pid), + exit(Pid, kill), + wait_dead(Pid, 50) + end, + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +wait_dead(_Pid, 0) -> ok; +wait_dead(Pid, N) -> + case is_process_alive(Pid) of + true -> + timer:sleep(20), + wait_dead(Pid, N - 1); + false -> ok + end. + +stats_collector_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"backfill from existing rows", fun test_backfill/0}, + {"live write updates dims and daily", fun test_live_write/0}, + {"status transition moves counters", fun test_status_move/0}, + {"core_stats reads via collector", fun test_core_stats_api/0}, + {"ticket avg resolution from counters", fun test_ticket_avg_resolution/0}, + {"report avg resolution from counters", fun test_report_avg_resolution/0} + ]}. + +test_backfill() -> + Now = calendar:universal_time(), + ok = mnesia:dirty_write(eh_test_support:make_user(#{ + id => <<"u1">>, status => pending, role => user, created_at => Now + })), + ok = mnesia:dirty_write(eh_test_support:make_user(#{ + id => <<"u2">>, status => active, role => user, created_at => Now + })), + ok = stats_collector:rebuild(), + ?assertEqual(1, core_stats:with_counter({user_by_status, pending}, fun() -> -1 end)), + ?assertEqual(1, core_stats:with_counter({user_by_status, active}, fun() -> -1 end)), + {Date, _} = Now, + From = {Date, {0, 0, 0}}, + To = {Date, {23, 59, 59}}, + Daily = core_stats:with_daily(users_created, From, To, fun() -> [] end), + ?assertEqual([{Date, 2}], Daily). + +test_live_write() -> + Now = calendar:universal_time(), + ok = mnesia:dirty_write(eh_test_support:make_user(#{ + id => <<"live1">>, status => active, created_at => Now + })), + wait_counter({user_by_status, active}, 1, 20), + ?assertEqual(1, core_stats:with_counter({user_by_status, active}, fun() -> 0 end)). + +test_status_move() -> + Now = calendar:universal_time(), + User = eh_test_support:make_user(#{ + id => <<"move1">>, status => pending, created_at => Now + }), + ok = mnesia:dirty_write(User), + wait_counter({user_by_status, pending}, 1, 20), + ok = mnesia:dirty_write(User#user{status = active}), + wait_counter({user_by_status, pending}, 0, 20), + wait_counter({user_by_status, active}, 1, 20), + ?assertEqual(0, core_stats:with_counter({user_by_status, pending}, fun() -> -1 end)), + ?assertEqual(1, core_stats:with_counter({user_by_status, active}, fun() -> -1 end)). + +test_core_stats_api() -> + ?assert(core_stats:available()), + Now = calendar:universal_time(), + ok = mnesia:dirty_write(#report{ + id = <<"r1">>, + reporter_id = <<"u1">>, + target_type = calendar, + target_id = <<"c1">>, + reason = <<"spam">>, + status = pending, + created_at = Now, + resolved_at = undefined, + resolved_by = <<>> + }), + wait_counter({report_by_status, pending}, 1, 20), + {ok, Dim} = core_stats:get_dim(report, status), + ?assertEqual(1, proplists:get_value(pending, Dim, 0)). + +test_ticket_avg_resolution() -> + First = {{2026, 1, 1}, {0, 0, 0}}, + Closed = {{2026, 1, 1}, {2, 0, 0}}, %% 2 hours + ok = mnesia:dirty_write(#ticket{ + id = <<"t_avg">>, reporter_id = <<"u1">>, error_hash = <<"h">>, + error_message = <<"e">>, stacktrace = <<>>, context = <<>>, + count = 1, first_seen = First, last_seen = Closed, status = closed, + assigned_to = <<>>, resolution_note = <<>>, closed_at = Closed, + source = <<"backend">> + }), + wait_counter(ticket_resolved_count, 1, 20), + Avg = core_ticket:avg_resolution_time(), + ?assertEqual(2.0, Avg). + +test_report_avg_resolution() -> + Created = {{2026, 1, 1}, {0, 0, 0}}, + Resolved = {{2026, 1, 1}, {3, 0, 0}}, %% 3 hours + ok = mnesia:dirty_write(#report{ + id = <<"r_avg">>, reporter_id = <<"u1">>, target_type = calendar, + target_id = <<"c1">>, reason = <<"spam">>, status = reviewed, + created_at = Created, resolved_at = Resolved, resolved_by = <<"adm">> + }), + wait_counter({report_resolved_count, reviewed}, 1, 20), + Avg = core_report:avg_resolution_time(reviewed), + ?assertEqual(3.0, Avg). + +wait_counter(Key, Expected, 0) -> + Actual = core_stats:with_counter(Key, fun() -> -999 end), + ?assertEqual(Expected, Actual); +wait_counter(Key, Expected, N) -> + case core_stats:with_counter(Key, fun() -> -999 end) of + Expected -> ok; + _ -> + timer:sleep(25), + wait_counter(Key, Expected, N - 1) + end. diff --git a/test/unit/stats_tops_tests.erl b/test/unit/stats_tops_tests.erl new file mode 100644 index 0000000..3167a4f --- /dev/null +++ b/test/unit/stats_tops_tests.erl @@ -0,0 +1,198 @@ +-module(stats_tops_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [event, calendar, review, report, stats_counter, stats_daily]). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + {ok, _} = stats_collector:start_link(), + ok = stats_collector:subscribe(), + ok. + +cleanup(_) -> + case whereis(stats_collector) of + undefined -> ok; + Pid -> + unlink(Pid), + exit(Pid, kill), + wait_dead(Pid, 50) + end, + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +wait_dead(_Pid, 0) -> ok; +wait_dead(Pid, N) -> + case is_process_alive(Pid) of + true -> timer:sleep(20), wait_dead(Pid, N - 1); + false -> ok + end. + +stats_tops_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"top events by rating order", fun test_event_rating_top/0}, + {"top calendars by rating and reviews", fun test_calendar_tops/0}, + {"rating update reorders top", fun test_rating_update/0}, + {"review target tops all/pos/neg", fun test_review_target_tops/0}, + {"report target tops", fun test_report_target_tops/0}, + {"calendar pos/neg from reviews", fun test_calendar_pos_neg/0} + ]}. + +test_event_rating_top() -> + Now = calendar:universal_time(), + ok = mnesia:dirty_write(make_event(<<"e_low">>, 2.0, Now)), + ok = mnesia:dirty_write(make_event(<<"e_high">>, 5.0, Now)), + ok = mnesia:dirty_write(make_event(<<"e_mid">>, 3.5, Now)), + wait_top_event(<<"e_high">>, 30), + Top = core_event:get_top_events_by_rating(2), + Ids = [E#event.id || E <- Top], + ?assertEqual([<<"e_high">>, <<"e_mid">>], Ids). + +test_calendar_tops() -> + Now = calendar:universal_time(), + ok = mnesia:dirty_write(make_calendar(<<"c1">>, 4.0, 10, Now)), + ok = mnesia:dirty_write(make_calendar(<<"c2">>, 5.0, 2, Now)), + ok = mnesia:dirty_write(make_calendar(<<"c3">>, 3.0, 20, Now)), + wait_top_cal_rating(<<"c2">>, 30), + ByRating = [C#calendar.id || C <- core_calendar:get_top_calendars_by_rating(2)], + ?assertEqual([<<"c2">>, <<"c1">>], ByRating), + ByReviews = [C#calendar.id || C <- core_calendar:get_top_calendars_by_reviews(2)], + ?assertEqual([<<"c3">>, <<"c1">>], ByReviews). + +test_rating_update() -> + Now = calendar:universal_time(), + E1 = make_event(<<"eu1">>, 4.0, Now), + E2 = make_event(<<"eu2">>, 3.0, Now), + ok = mnesia:dirty_write(E1), + ok = mnesia:dirty_write(E2), + wait_top_event(<<"eu1">>, 30), + ok = mnesia:dirty_write(E2#event{rating_avg = 4.5}), + wait_top_event(<<"eu2">>, 30), + Top = core_event:get_top_events_by_rating(1), + ?assertEqual([<<"eu2">>], [E#event.id || E <- Top]). + +test_review_target_tops() -> + Now = calendar:universal_time(), + ok = mnesia:dirty_write(make_review(<<"r1">>, calendar, <<"cA">>, 5, Now)), + ok = mnesia:dirty_write(make_review(<<"r2">>, calendar, <<"cA">>, 5, Now)), + ok = mnesia:dirty_write(make_review(<<"r3">>, event, <<"eA">>, 1, Now)), + ok = mnesia:dirty_write(make_review(<<"r4">>, calendar, <<"cB">>, 2, Now)), + wait_review_top({calendar, <<"cA">>}, 30), + All = core_review:get_top_targets_by_reviews(3), + ?assertMatch([{calendar, <<"cA">>, 2} | _], All), + AllKeys = [{T, I} || {T, I, _} <- All], + ?assert(lists:member({event, <<"eA">>}, AllKeys)), + ?assert(lists:member({calendar, <<"cB">>}, AllKeys)), + Pos = core_review:get_top_targets_by_positive_reviews(1), + ?assertEqual([{calendar, <<"cA">>, 2}], Pos), + Neg = core_review:get_top_targets_by_negative_reviews(2), + NegIds = [{T, I} || {T, I, _} <- Neg], + ?assert(lists:member({event, <<"eA">>}, NegIds)), + ?assert(lists:member({calendar, <<"cB">>}, NegIds)). + +test_report_target_tops() -> + Now = calendar:universal_time(), + ok = mnesia:dirty_write(make_report(<<"p1">>, calendar, <<"cX">>, Now)), + ok = mnesia:dirty_write(make_report(<<"p2">>, calendar, <<"cX">>, Now)), + ok = mnesia:dirty_write(make_report(<<"p3">>, event, <<"eX">>, Now)), + wait_report_top({calendar, <<"cX">>}, 30), + Top = core_report:get_top_targets_by_reports(2), + ?assertEqual([{calendar, <<"cX">>, 2}, {event, <<"eX">>, 1}], Top). + +test_calendar_pos_neg() -> + Now = calendar:universal_time(), + ok = mnesia:dirty_write(make_calendar(<<"cp1">>, 0.0, 0, Now)), + ok = mnesia:dirty_write(make_calendar(<<"cp2">>, 0.0, 0, Now)), + ok = mnesia:dirty_write(make_review(<<"rp1">>, calendar, <<"cp1">>, 5, Now)), + ok = mnesia:dirty_write(make_review(<<"rp2">>, calendar, <<"cp1">>, 4, Now)), + ok = mnesia:dirty_write(make_review(<<"rn1">>, calendar, <<"cp2">>, 1, Now)), + wait_cal_pos(<<"cp1">>, 30), + Pos = [C#calendar.id || C <- core_calendar:get_top_calendars_by_positive_reviews(1)], + ?assertEqual([<<"cp1">>], Pos), + Neg = [C#calendar.id || C <- core_calendar:get_top_calendars_by_negative_reviews(1)], + ?assertEqual([<<"cp2">>], Neg). + +make_review(Id, Type, TargetId, Rating, Now) -> + #review{ + id = Id, user_id = <<"u1">>, target_type = Type, target_id = TargetId, + rating = Rating, comment = <<>>, status = visible, reason = <<>>, + likes = 0, dislikes = 0, created_at = Now, updated_at = Now + }. + +make_report(Id, Type, TargetId, Now) -> + #report{ + id = Id, reporter_id = <<"u1">>, target_type = Type, target_id = TargetId, + reason = <<"spam">>, status = pending, created_at = Now, + resolved_at = undefined, resolved_by = <<>> + }. + +make_event(Id, Avg, Now) -> + #event{ + id = Id, calendar_id = <<"cal">>, title = Id, description = <<>>, + event_type = single, start_time = Now, duration = 60, + recurrence_rule = <<>>, master_id = <<>>, is_instance = false, + specialist_id = <<>>, location = undefined, tags = [], + capacity = 10, online_link = <<>>, status = active, reason = <<>>, + rating_avg = Avg, rating_count = 1, attachments = [], edit_history = [], + created_at = Now, updated_at = Now + }. + +make_calendar(Id, Avg, Count, Now) -> + #calendar{ + id = Id, owner_id = <<"u1">>, title = Id, description = <<>>, + short_name = Id, category = <<>>, color = <<>>, image_url = <<>>, + settings = #{}, tags = [], type = personal, confirmation = auto, + rating_avg = Avg, rating_count = Count, status = active, reason = <<>>, + created_at = Now, updated_at = Now + }. + +wait_top_event(ExpectedId, 0) -> + Top = core_event:get_top_events_by_rating(1), + ?assertEqual([ExpectedId], [E#event.id || E <- Top]); +wait_top_event(ExpectedId, N) -> + case core_event:get_top_events_by_rating(1) of + [#event{id = ExpectedId}] -> ok; + _ -> timer:sleep(25), wait_top_event(ExpectedId, N - 1) + end. + +wait_top_cal_rating(ExpectedId, 0) -> + Top = core_calendar:get_top_calendars_by_rating(1), + ?assertEqual([ExpectedId], [C#calendar.id || C <- Top]); +wait_top_cal_rating(ExpectedId, N) -> + case core_calendar:get_top_calendars_by_rating(1) of + [#calendar{id = ExpectedId}] -> ok; + _ -> timer:sleep(25), wait_top_cal_rating(ExpectedId, N - 1) + end. + +wait_review_top(Expected, 0) -> + case core_review:get_top_targets_by_reviews(1) of + [{T, I, _} | _] -> ?assertEqual(Expected, {T, I}); + Other -> ?assertEqual(Expected, Other) + end; +wait_review_top(Expected = {T, I}, N) -> + case core_review:get_top_targets_by_reviews(1) of + [{T, I, _} | _] -> ok; + _ -> timer:sleep(25), wait_review_top(Expected, N - 1) + end. + +wait_report_top(Expected, 0) -> + case core_report:get_top_targets_by_reports(1) of + [{T, I, _} | _] -> ?assertEqual(Expected, {T, I}); + Other -> ?assertEqual(Expected, Other) + end; +wait_report_top(Expected = {T, I}, N) -> + case core_report:get_top_targets_by_reports(1) of + [{T, I, _} | _] -> ok; + _ -> timer:sleep(25), wait_report_top(Expected, N - 1) + end. + +wait_cal_pos(ExpectedId, 0) -> + Top = core_calendar:get_top_calendars_by_positive_reviews(1), + ?assertEqual([ExpectedId], [C#calendar.id || C <- Top]); +wait_cal_pos(ExpectedId, N) -> + case core_calendar:get_top_calendars_by_positive_reviews(1) of + [#calendar{id = ExpectedId}] -> ok; + _ -> timer:sleep(25), wait_cal_pos(ExpectedId, N - 1) + end.