Current section
Files
Jump to
Current section
Files
src/cfclient_metrics.erl
%% @doc
%% Functions to record, process, and send cached metric data.
%% @end
-module(cfclient_metrics).
-include_lib("kernel/include/logger.hrl").
-include("cfclient_config.hrl").
-include("cfclient_metrics_attributes.hrl").
-export([process_metrics/1, record/5]).
-type config() :: map().
% @doc Record metrics for request.
-spec record(binary(), cfclient:target(), binary(), binary(), config()) -> atom().
record(FlagId, Target, VariationId, VariationValue, #{analytics_enabled := true} = Config) ->
record_metrics(FlagId, Target, VariationId, VariationValue, Config),
record_target(Target, Config),
ok;
record(_, _, _, _, _) -> ok.
% @doc Gather metrics and send them to server.
% Called periodically by cfclient_instance.
-spec process_metrics(config()) -> ok | {error, api}.
process_metrics(Config) ->
?LOG_DEBUG("Gathering and sending metrics"),
Timestamp = os:system_time(millisecond),
{ok, MetricsData} = collect_metrics_data(Timestamp, Config),
{ok, MetricsTargetData} = collect_metrics_target_data(Config),
case post_metrics(MetricsData, MetricsTargetData, Config) of
noop ->
?LOG_DEBUG("No metrics to post"),
ok;
{ok, Response} ->
?LOG_DEBUG("Posted metrics to server: ~p", [Response]),
% TODO: race condition, will lose any metrics made during call to
% post_metrics
clear_caches(Config),
ok;
{error, Response} ->
?LOG_ERROR("Error posting metrics to server: ~p", [Response]),
{error, api}
end.
% @doc Send metrics to server via API.
-spec post_metrics([map()], [map()], config()) -> {ok, term()} | {error, term()} | noop.
post_metrics([], [], _Config) -> noop;
post_metrics(MetricsData, MetricsTargetData, Config) ->
#{auth_token := AuthToken, project := Project, events_url := EventsUrl} = Config,
#{environment := Environment, clusterIdentifier := Cluster} = Project,
Opts =
#{
cfg => #{auth => #{'BearerAuth' => <<"Bearer ", AuthToken/binary>>}, host => EventsUrl},
params => #{metricsData => MetricsData, targetData => MetricsTargetData}
},
case cfapi_metrics_api:post_metrics(ctx:new(), #{cluster => Cluster}, Environment, Opts) of
{ok, Response, _} -> {ok, Response};
{error, Response, _} -> {error, Response}
end.
% @doc Store evaluation metrics.
-spec record_metrics(binary(), cfclient:target(), binary(), binary(), config()) -> ok.
record_metrics(FlagId, Target, VariationId, VariationValue, Config) ->
#{metrics_cache_table := CacheTable, metrics_counter_table := CounterTable} = Config,
Evaluation =
#{
feature_name => FlagId,
variation_identifier => VariationId,
variation_value => VariationValue
},
% We store metrics two places:
% 1. A counter so we can count how many times it has occurred.
% 2. The target for the unique evaluation.
% We currently use the so-called Global Target when posting metrics to the
% server, but we store the actual target as in the future we want to
% enable real target posting for debugging.
true = ets:insert(CacheTable, {Evaluation, Target}),
Counter = ets:update_counter(CounterTable, Evaluation, 1, {0, 0}),
?LOG_DEBUG("Metrics counter ~w = ~w", [Evaluation, Counter]),
ok.
% @doc Store target metrics.
-spec record_target(cfclient:target(), config()) -> ok | noop.
record_target(#{anonymous := true} = Target, _Config) ->
?LOG_DEBUG("Metrics target skipped for anonymous target ~w", [Target]),
noop;
record_target(Target, Config) ->
#{metrics_target_table := MetricsTargetTable} = Config,
#{identifier := Id} = Target,
true = ets:insert(MetricsTargetTable, {Id, Target}),
?LOG_DEBUG("Metrics target stored for target ~w", [Target]),
ok.
% @doc Read all metrics from the cache and format them.
-spec collect_metrics_data(integer(), config()) ->
{ok, Metrics :: [map()]} | {error, Reason :: term()}.
collect_metrics_data(Timestamp, Config) ->
#{metrics_cache_table := Table} = Config,
case list_table(Table) of
{ok, Pairs} ->
Metrics =
lists:map(
fun
({Evaluation, Target}) ->
Count =
case get_counter(Evaluation, Config) of
{ok, Value} -> Value;
{error, undefined} -> 1
end,
format_metric(Evaluation, Target, Count, Timestamp)
end,
Pairs
),
{ok, Metrics};
{error, Reason} -> {error, Reason}
end.
% @doc Format cached metric for sending to the server.
% @end
%
% TODO: The Target parameter is currenly unused. We only use
% the the Global target per ff-server requirements. In the future, we will add
% an option to disable that global config and use the actual target.
format_metric(Evaluation, _Target, Count, Timestamp) ->
#{
feature_name := FeatureName,
variation_value := VariationValue,
variation_identifier := VariationId
} = Evaluation,
Attributes =
[
#{key => ?FEATURE_IDENTIFIER_ATTRIBUTE, value => FeatureName},
#{key => ?FEATURE_NAME_ATTRIBUTE, value => FeatureName},
#{key => ?TARGET_ATTRIBUTE, value => ?TARGET_GLOBAL_IDENTIFIER},
#{key => ?VARIATION_IDENTIFIER_ATTRIBUTE, value => VariationId},
#{key => ?VARIATION_VALUE_ATTRIBUTE, value => VariationValue},
#{key => ?SDK_VERSION_ATTRIBUTE, value => ?SDK_VERSION_ATTRIBUTE_VALUE},
#{key => ?SDK_TYPE_ATTRIBUTE, value => ?SDK_TYPE_ATTRIBUTE_VALUE},
#{key => ?SDK_LANGUAGE_ATTRIBUTE, value => ?SDK_LANGUAGE_ATTRIBUTE_VALUE}
],
#{
timestamp => Timestamp,
count => Count,
%% Camel case to honour the API.
metricsType => ?METRICS_TYPE,
attributes => Attributes
}.
% @doc Read metrics target data from the cache and format it.
-spec collect_metrics_target_data(config()) -> {ok, Metrics :: [map()]} | {error, Reason :: term()}.
collect_metrics_target_data(Config) ->
#{metrics_target_table := Table} = Config,
case list_table(Table) of
{ok, Pairs} ->
Metrics = lists:map(fun ({_Id, Target}) -> format_target(Target) end, Pairs),
{ok, Metrics};
{error, Reason} -> {error, Reason}
end.
% @doc Format cached target data for sending to the server.
-spec format_target(cfclient:target()) -> map().
format_target(Target) ->
%% As users can supply the identifier, name and attributes in different formats, we must standardise them all to binary as the cfapi works in binary
SanitisedIdentifier = target_field_to_binary(maps:get(identifier, Target)),
SanitisedName = target_field_to_binary(maps:get(name, Target, SanitisedIdentifier)),
SanitisedAttributes = target_attributes_to_metrics(Target),
#{identifier => SanitisedIdentifier, name => SanitisedName, attributes => SanitisedAttributes}.
target_field_to_binary(TargetName) when is_binary(TargetName) ->
TargetName;
target_field_to_binary(TargetName) when is_atom(TargetName) ->
atom_to_binary(TargetName);
target_field_to_binary(TargetName) when is_list(TargetName) ->
list_to_binary(TargetName).
-spec target_attributes_to_metrics(cfclient:target()) -> [map()].
target_attributes_to_metrics(#{attributes := Values}) when is_map(Values) ->
lists:map(fun target_attribute_to_metric/1, maps:to_list(Values));
target_attributes_to_metrics(_) -> [].
-spec target_attribute_to_metric({binary(), term()}) -> map().
target_attribute_to_metric({K, V}) ->
#{key => K, value => cfclient_evaluator:custom_attribute_to_binary(V)}.
-spec get_counter(term(), config()) -> {ok, integer()} | {error, undefined}.
get_counter(Key, Config) ->
#{metrics_counter_table := Table} = Config,
case ets:lookup(Table, Key) of
[] -> {error, undefined};
[{Key, Value}] -> {ok, Value}
end.
% @doc Get all contents of ETS table.
-spec list_table(ets:table()) -> {ok, list()} | {error, Reason :: term()}.
list_table(TableName) -> try {ok, ets:tab2list(TableName)} catch error : R -> {error, R} end.
% @doc Delete all objects in metrics caches.
-spec clear_caches(config()) -> ok.
clear_caches(Config) ->
#{
metrics_cache_table := CacheTable,
metrics_target_table := TargetTable,
metrics_counter_table := CounterTable
} = Config,
ets:delete_all_objects(CacheTable),
ets:delete_all_objects(CounterTable),
ets:delete_all_objects(TargetTable),
ok.