diff --git a/.gitignore b/.gitignore index d347422..c482074 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,14 @@ # The directory Mix will write compiled artifacts to. -/_build/ +_build/ # If you run "mix test --cover", coverage assets end up here. /cover/ # The directory Mix downloads your dependencies sources to. -/deps/ +deps/ # Where third-party dependencies like ExDoc output generated docs. -/doc/ +doc/ # Ignore .fetch files in case you like to edit your project deps locally. /.fetch diff --git a/mix.exs b/mix.exs index 8dc207b..a2de815 100644 --- a/mix.exs +++ b/mix.exs @@ -28,7 +28,8 @@ defmodule StatsigEx.MixProject do defp deps do [ {:httpoison, "~> 1.4"}, - {:jason, "~> 1.2"} + {:jason, "~> 1.2"}, + {:statsig, path: "./statsig_erl", only: [:test]} ] end end diff --git a/mix.lock b/mix.lock index 893cd8c..a254dac 100644 --- a/mix.lock +++ b/mix.lock @@ -1,9 +1,10 @@ %{ "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, - "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~>2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "hackney": {:hex, :hackney, "1.18.2", "d7ff544ddae5e1cb49e9cf7fa4e356d7f41b283989a1c304bfc47a8cc1cf966f", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "af94d5c9f97857db257090a4a10e5426ecb6f4918aa5cc666798566ae14b65fd"}, "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "jiffy": {:hex, :jiffy, "1.1.2", "a9b6c9a7ec268e7cf493d028f0a4c9144f59ccb878b1afe42841597800840a1b", [:rebar3], [], "hexpm", "bb61bc42a720bbd33cb09a410e48bb79a61012c74cb8b3e75f26d988485cf381"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, diff --git a/statsig_erl/LICENSE b/statsig_erl/LICENSE new file mode 100644 index 0000000..a4c5546 --- /dev/null +++ b/statsig_erl/LICENSE @@ -0,0 +1,14 @@ +ISC License (ISC) +Copyright (c) 2022, Statsig, Inc. + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. \ No newline at end of file diff --git a/statsig_erl/README.md b/statsig_erl/README.md new file mode 100644 index 0000000..d3fbbc1 --- /dev/null +++ b/statsig_erl/README.md @@ -0,0 +1,22 @@ +statsig +===== + +A feature flagging and experimentation library for erlang + +Build +----- + + $ rebar3 compile + +Run +----- + $ rebar3 shell --apps statsig + +## Usage: +``` +application:set_env(statsig, statsig_api_key, ApiKey), +application:start(statsig), +statsig:check_gate(User, GateName), +statsig:log_event(#{<<"userID">> => <<"321">>}, <<"custom_event">>, 12, #{<<"test">> => <<"val">>}), +statsig:flush(Pid). +``` \ No newline at end of file diff --git a/statsig_erl/mix.exs b/statsig_erl/mix.exs new file mode 100644 index 0000000..7fcf9e1 --- /dev/null +++ b/statsig_erl/mix.exs @@ -0,0 +1,43 @@ +defmodule Statsig.MixProject do + use Mix.Project + + def project() do + [ + app: :statsig, + version: "0.0.3", + elixir: "~> 1.0", + deps: deps(), + description: description(), + package: package(), + name: "statsig", + source_url: "https://github.com/statsig-io/erlang-sdk" + ] + end + + defp description() do + "An erlang/elixir SDK for Statsig feature gates and experiments" + end + + def application() do + [ + mod: {:statsig, []} + ] + end + + defp deps() do + [ + {:hackney, "~> 1.18.1"}, + {:jiffy, "~> 1.1.1"}, + {:ex_doc, "~> 0.27", only: :dev, runtime: false}, + ] + end + + defp package() do + [ + # These are the default files included in the package + files: ~w(mix.exs rebar.config LICENSE* README* src), + licenses: ["ISC"], + links: %{"GitHub" => "https://github.com/statsig-io/erlang-sdk"} + ] + end +end diff --git a/statsig_erl/mix.lock b/statsig_erl/mix.lock new file mode 100644 index 0000000..0f627fa --- /dev/null +++ b/statsig_erl/mix.lock @@ -0,0 +1,17 @@ +%{ + "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.31", "a93921cdc6b9b869f519213d5bc79d9e218ba768d7270d46fdcf1c01bacff9e2", [:mix], [], "hexpm", "317d367ee0335ef037a87e46c91a2269fef6306413f731e8ec11fc45a7efd059"}, + "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, + "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jiffy": {:hex, :jiffy, "1.1.1", "aca10f47aa91697bf24ab9582c74e00e8e95474c7ef9f76d4f1a338d0f5de21b", [:rebar3], [], "hexpm", "62e1f0581c3c19c33a725c781dfa88410d8bff1bbafc3885a2552286b4785c4c"}, + "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.3.0", "9e18a119d9efc3370a3ef2a937bf0b24c088d9c4bf0ba9d7c3751d49d347d035", [:mix], [], "hexpm", "7977f183127a7cbe9346981e2f480dc04c55ffddaef746bd58debd566070eef8"}, + "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, +} diff --git a/statsig_erl/rebar.config b/statsig_erl/rebar.config new file mode 100644 index 0000000..6ab2b1d --- /dev/null +++ b/statsig_erl/rebar.config @@ -0,0 +1,16 @@ +{erl_opts, [debug_info, {src_dirs, ["src", "test"]}]}. +{project_plugins, [rebar3_format, steamroller]}. +{plugins, [rebar3_format, steamroller]}. +{deps, [jiffy, hackney]}. +{format, [ + {files, ["src/*.erl", "include/*.hrl"]}, + {ignore, ["src/*_ignore.erl", "src/ignored_file_config.erl"]}, + {formatter, sr_formatter}, %% The steamroller formatter. + {options, #{line_length => 80}} +]}. +{relx, [ + {release, {statsig, "0.0.3"}, [statsig]}, + {dev_mode, false}, + {include_erts, false}, + {extended_start_script, true}] +}. diff --git a/statsig_erl/rebar.lock b/statsig_erl/rebar.lock new file mode 100644 index 0000000..50c6241 --- /dev/null +++ b/statsig_erl/rebar.lock @@ -0,0 +1,35 @@ +{"1.2.0", +[{<<"certifi">>,{pkg,<<"certifi">>,<<"2.9.0">>},1}, + {<<"hackney">>,{pkg,<<"hackney">>,<<"1.18.1">>},0}, + {<<"idna">>,{pkg,<<"idna">>,<<"6.1.1">>},1}, + {<<"jiffy">>,{pkg,<<"jiffy">>,<<"1.1.1">>},0}, + {<<"jsone">>,{pkg,<<"jsone">>,<<"1.7.0">>},0}, + {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1}, + {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.2.0">>},1}, + {<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.3.1">>},1}, + {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.6">>},1}, + {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.0">>},1}]}. +[ +{pkg_hash,[ + {<<"certifi">>, <<"6F2A475689DD47F19FB74334859D460A2DC4E3252A3324BD2111B8F0429E7E21">>}, + {<<"hackney">>, <<"F48BF88F521F2A229FC7BAE88CF4F85ADC9CD9BCF23B5DC8EB6A1788C662C4F6">>}, + {<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>}, + {<<"jiffy">>, <<"ACA10F47AA91697BF24AB9582C74E00E8E95474C7EF9F76D4F1A338D0F5DE21B">>}, + {<<"jsone">>, <<"1E3BD7D5DD44BB2EB0797DDDEA1CBF2DDAB8D9F29E499A467CA171C23F5984EA">>}, + {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>}, + {<<"mimerl">>, <<"67E2D3F571088D5CFD3E550C383094B47159F3EEE8FFA08E64106CDF5E981BE3">>}, + {<<"parse_trans">>, <<"16328AB840CC09919BD10DAB29E431DA3AF9E9E7E7E6F0089DD5A2D2820011D8">>}, + {<<"ssl_verify_fun">>, <<"CF344F5692C82D2CD7554F5EC8FD961548D4FD09E7D22F5B62482E5AEAEBD4B0">>}, + {<<"unicode_util_compat">>, <<"BC84380C9AB48177092F43AC89E4DFA2C6D62B40B8BD132B1059ECC7232F9A78">>}]}, +{pkg_hash_ext,[ + {<<"certifi">>, <<"266DA46BDB06D6C6D35FDE799BCB28D36D985D424AD7C08B5BB48F5B5CDD4641">>}, + {<<"hackney">>, <<"A4ECDAFF44297E9B5894AE499E9A070EA1888C84AFDD1FD9B7B2BC384950128E">>}, + {<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>}, + {<<"jiffy">>, <<"62E1F0581C3C19C33A725C781DFA88410D8BFF1BBAFC3885A2552286B4785C4C">>}, + {<<"jsone">>, <<"A3A33712EE6BC8BE10CFA21C7C425A299DE4C5A8533F9F931E577A6D0E8F5DBD">>}, + {<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>}, + {<<"mimerl">>, <<"F278585650AA581986264638EBF698F8BB19DF297F66AD91B18910DFC6E19323">>}, + {<<"parse_trans">>, <<"07CD9577885F56362D414E8C4C4E6BDF10D43A8767ABB92D24CBE8B24C54888B">>}, + {<<"ssl_verify_fun">>, <<"BDB0D2471F453C88FF3908E7686F86F9BE327D065CC1EC16FA4540197EA04680">>}, + {<<"unicode_util_compat">>, <<"25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521">>}]} +]. diff --git a/statsig_erl/src/evaluator.erl b/statsig_erl/src/evaluator.erl new file mode 100644 index 0000000..8bec700 --- /dev/null +++ b/statsig_erl/src/evaluator.erl @@ -0,0 +1,571 @@ +-module(evaluator). + +-export([find_and_eval/3]). + +-spec find_and_eval(map(), list(), atom()) -> + {map(), boolean(), map(), list(), list()}. +find_and_eval(User, Name, Type) -> + Specs = ets:match(statsig_store, {Name, Type, '$1'}), + case length(Specs) of + 1 -> + [[Spec]] = Specs, + eval(User, Spec); + + _Other -> {#{}, false, #{}, "Unrecognized", []} + end. + + +eval(User, ConfigDefinition) -> + Enabled = maps:get(<<"enabled">>, ConfigDefinition, false), + if + map_size(ConfigDefinition) == 0 -> {#{}, false, #{}, <<"">>, []}; + + Enabled -> + Rules = maps:get(<<"rules">>, ConfigDefinition, []), + eval_rules(User, Rules, ConfigDefinition, []); + + true -> + { + #{}, + false, + maps:get(<<"defaultValue">>, ConfigDefinition, #{}), + <<"disabled">>, + [] + } + end. + + +eval_rules(_User, [], Config, Exposures) -> + {#{}, false, maps:get(<<"defaultValue">>, Config, #{}), <<"default">>, Exposures}; + +eval_rules(User, [Rule | Rules], Config, Exposures) -> + {RuleResult, RuleJson, RuleID, SecondaryExposures} = eval_rule(User, Rule), + AllExposures = lists:append(SecondaryExposures, Exposures), + if + RuleResult -> + Pass = eval_pass_percent(User, Rule, Config), + if + Pass -> {Rule, Pass, RuleJson, RuleID, AllExposures}; + + true -> + {Rule, Pass, maps:get(<<"defaultValue">>, Config, #{}), RuleID, AllExposures} + end; + + true -> eval_rules(User, Rules, Config, AllExposures) + end. + + +eval_rule(User, Rule) -> + Conditions = maps:get(<<"conditions">>, Rule, []), + Results = + lists:map( + fun (Condition) -> eval_condition(User, Condition) end, + Conditions + ), + Exposures = lists:foldl(fun({_Result, Exp}, Acc) -> lists:append(Exp, Acc) end, [], Results), + Pass = lists:filter(fun ({Res, _Exposures}) -> Res == false end, Results), + if + length(Pass) > 0 -> + {false, maps:get(<<"returnValue">>, Rule, []), maps:get(<<"id">>, Rule, <<"">>), Exposures}; + + true -> {true, maps:get(<<"returnValue">>, Rule, []), maps:get(<<"id">>, Rule, <<"">>), Exposures} + end. + + +eval_condition(User, Condition) -> + {ConditionResult, EvaluationComplete, Value, Exposures} = + get_evaluation_value(User, Condition), + if + EvaluationComplete == false -> + {get_evaluation_comparison(Condition, Value), Exposures}; + + true -> {ConditionResult, Exposures} + end. + +get_evaluation_comparison(Condition, Value) -> + Operator = string:casefold(maps:get(<<"operator">>, Condition, "")), + Target = maps:get(<<"targetValue">>, Condition, ""), + compare(Value, Operator, Target). + + +compare(Value, Operator, Target) -> + case Operator of + <<"gt">> -> + if + Value == null orelse Target == null -> false; + true -> Value > Target + end; + + <<"gte">> -> + if + Value == null orelse Target == null -> false; + true -> Value >= Target + end; + + <<"lt">> -> + if + Value == null orelse Target == null -> false; + true -> Value < Target + end; + + <<"lte">> -> + if + Value == null orelse Target == null -> false; + true -> Value =< Target + end; + + <<"version_gt">> -> + if + Value == null orelse Target == null -> false; + true -> version_compare(Value, Target, fun (Result) -> Result > 0 end) + end; + + <<"version_gte">> -> + if + Value == null orelse Target == null -> false; + true -> version_compare(Value, Target, fun (Result) -> Result >= 0 end) + end; + + <<"version_lt">> -> + if + Value == null orelse Target == null -> false; + true -> version_compare(Value, Target, fun (Result) -> Result < 0 end) + end; + + <<"version_lte">> -> + if + Value == null orelse Target == null -> false; + true -> version_compare(Value, Target, fun (Result) -> Result =< 0 end) + end; + + <<"version_eq">> -> + if + Value == null orelse Target == null -> false; + true -> version_compare(Value, Target, fun (Result) -> Result == 0 end) + end; + + <<"version_neq">> -> + if + Value == null orelse Target == null -> false; + true -> version_compare(Value, Target, fun (Result) -> Result /= 0 end) + end; + + <<"any">> -> + if + Value == null orelse Target == null -> false; + + true -> + list_any( + Value, + Target, + generic_compare(true, fun (A, B) -> A == B end) + ) + end; + + <<"none">> -> + if + Value == null orelse Target == null -> true; + + true -> + ( + not + list_any( + Value, + Target, + generic_compare(true, fun (A, B) -> A =:= B end) + ) + ) + end; + + <<"any_case_sensitive">> -> + if + Value == null orelse Target == null -> false; + + true -> + list_any( + Value, + Target, + generic_compare(false, fun (A, B) -> A =:= B end) + ) + end; + + <<"none_case_sensitive">> -> + if + Value == null orelse Target == null -> true; + + true -> + not + list_any( + Value, + Target, + generic_compare(false, fun (A, B) -> A =:= B end) + ) + end; + + <<"str_starts_with_any">> -> + if + Value == null orelse Target == null -> false; + + true -> + list_any( + Value, + Target, + generic_compare( + true, + fun + (Str, Prefix) -> + Prefix ++ string:find(Str, Prefix, trailing) =:= Str + end + ) + ) + end; + + <<"str_ends_with_any">> -> + if + Value == null orelse Target == null -> false; + + true -> + list_any( + Value, + Target, + generic_compare( + true, + fun + (Str, Postfix) -> + string:find(Str, Postfix, trailing) ++ Postfix =:= Str + end + ) + ) + end; + + <<"str_contains_any">> -> + if + Value == null orelse Target == null -> false; + + true -> + list_any( + Value, + Target, + generic_compare( + true, + fun (A, B) -> string:find(A, B) /= nomatch end + ) + ) + end; + + <<"str_contains_none">> -> + if + Value == null orelse Target == null -> true; + + true -> + not + list_any( + Value, + Target, + generic_compare( + true, + fun (A, B) -> string:find(A, B) /= nomatch end + ) + ) + end; + + <<"str_matches">> -> + if + Value == null orelse Target == null -> false; + + true -> + case re:run(Value, Target) of + {match, _Captured} -> true; + nomatch -> false + end + end; + + <<"eq">> -> (Value =:= Target); + <<"neq">> -> not (Value =:= Target); + <<"before">> -> get_number(Value) < get_number(Target); + <<"after">> -> get_number(Value) > get_number(Target); + + <<"on">> -> + {{ValueYear, ValueMonth, ValueDay}, _ValueTime} = + calendar:system_time_to_universal_time(round(get_number(Value)), 1000), + {{TargetYear, TargetMonth, TargetDay}, _TargetTime} = + calendar:system_time_to_universal_time(round(get_number(Target)), 1000), + (ValueYear == TargetYear) + and + (ValueMonth == TargetMonth) + and + (ValueDay == TargetDay); + + _ -> + erlang:display("UNSUPPORTED OPERATOR"), + erlang:display(operator), + false + end. + + +generic_compare(IgnoreCase, Comparison) -> + fun + (A, B) -> + if + not (is_binary(A) and is_binary(B)) -> number_compare(A, B, Comparison); + true -> string_compare(A, B, Comparison, IgnoreCase) + end + end. + + +string_compare(A, B, Comparison, IgnoreCase) -> + StrA = binary_to_list(A), + StrB = binary_to_list(B), + if + IgnoreCase -> Comparison(string:casefold(StrA), string:casefold(StrB)); + true -> Comparison(StrA, StrB) + end. + + +number_compare(A, B, Comparison) -> + NumA = get_number(A), + NumB = get_number(B), + Comparison(NumA, NumB). + +get_number(Val) -> + if + is_integer(Val) -> + Val * 1.0; + is_binary(Val) -> + list_to_num(binary_to_list(Val)); + is_list(Val) -> + list_to_num(Val); + is_float(Val) -> + Val; + true -> + Val + end. + +list_to_num([]) -> + 0.0; +list_to_num([N]) -> + list_to_num(N); +list_to_num(N) -> + case string:to_float(N) of + {error, no_float} -> + list_to_integer(N) * 1.0; + {F, _Rest} -> F + end. + + +list_any(_Value, [], _Comparison) -> false; + +list_any(Value, [El | TargetList], Comparison) -> + Match = Comparison(Value, El), + if + Match == true -> true; + true -> list_any(Value, TargetList, Comparison) + end. + + +version_compare(Value, Target, Comparison) -> + Version1 = trim(Value), + Version2 = trim(Target), + if + length(Version1) == 0 -> false; + length(Version2) == 0 -> false; + true -> Comparison(version_compare_helper(Version1, Version2)) + end. + + +pad_list(0) -> []; +pad_list(N) when N > 0 -> ["0" | pad_list(N - 1)]. + +version_compare_helper(Version1, Version2) -> + Parts1 = string:tokens(Version1, "."), + Parts2 = string:tokens(Version2, "."), + if + length(Parts1) > length(Parts2) -> + compare_version_part( + Parts1, + lists:append(Parts2, pad_list(length(Parts1) - length(Parts2))) + ); + + length(Parts2) > length(Parts1) -> + compare_version_part( + lists:append(Parts1, pad_list(length(Parts2) - length(Parts1))), + Parts2 + ); + + true -> compare_version_part(Parts1, Parts2) + end. + + +compare_version_part([], []) -> 0; + +compare_version_part([V | Rest], [V2 | Rest2]) -> + if + V > V2 -> 1; + V < V2 -> -1; + true -> compare_version_part(Rest, Rest2) + end. + + +trim(Val) -> + Str = binary_to_list(Val), + Rest = string:find(Str, "-"), + if + Rest == nomatch -> Str; + + length(Rest) /= length(Str) -> + string:slice(Str, 0, length(Str) - length(Rest)); + + true -> Str + end. + + +get_evaluation_value(User, Condition) -> + Type = maps:get(<<"type">>, Condition, ""), + Target = maps:get(<<"targetValue">>, Condition, ""), + Field = maps:get(<<"field">>, Condition, ""), + IdType = maps:get(<<"idType">>, Condition, "userID"), + case Type of + <<"public">> -> {true, true, null, []}; + + <<"pass_gate">> -> + {_, Result, _JsonResult, NestedRuleID, NestedExposures} = + find_and_eval(User, Target, feature_gate), + Secondary = lists:append(NestedExposures, [#{ + <<"gate">> => Target, + <<"gateValue">> => utils:get_bool_as_string(Result), + <<"ruleID">> => NestedRuleID + }]), + {Result, true, null, Secondary}; + + <<"fail_gate">> -> + {_, Result, _JsonResult, NestedRuleID, NestedExposures} = + find_and_eval(User, Target, feature_gate), + Secondary = lists:append(NestedExposures, [#{ + <<"gate">> => Target, + <<"gateValue">> => utils:get_bool_as_string(Result), + <<"ruleID">> => NestedRuleID + }]), + {not Result, true, null, Secondary}; + + <<"user_field">> -> + Val = get_from_user(User, Field), + {false, false, Val, []}; + + <<"environment_field">> -> + {false, false, get_from_environment(User, Field), []}; + + <<"current_time">> -> {false, false, utils:get_timestamp(), []}; + + <<"user_bucket">> -> + AdditionValues = maps:get(<<"additionalValues">>, Condition, #{}), + Salt = binary_to_list(maps:get(<<"salt">>, AdditionValues, <<"">>)), + UnitID = get_unit_id(User, IdType), + UserHash = compute_user_hash(Salt ++ "." ++ UnitID), + {false, false, UserHash rem 1000, []}; + + <<"unit_id">> -> {false, false, get_unit_id(User, IdType), []}; + % TODO ip_based, ua_based + _ -> + erlang:display("UNSUPPORTED TYPE"), + erlang:display(Type), + {false, true, null, []} + end. + + +get_from_environment(User, Field) -> + Environment = maps:get(<<"statsigEnvironment">>, User, #{}), + get_or_lower(Field, Environment). + + +get_from_user(User, Field) -> + Value = get_or_lower(Field, User), + if + Value == null -> + Custom = maps:get(<<"custom">>, User, null), + CustomValue = get_or_lower(Field, Custom), + if + CustomValue == null -> + Private = maps:get(<<"privateAttributes">>, User, null), + get_or_lower(Field, Private); + + true -> CustomValue + end; + + true -> Value + end. + + +get_or_lower(Field, Map) -> + if + Map == null -> null; + + true -> + Value = maps:get(Field, Map, null), + if + Value == null -> + LowerField = string:casefold(Field), + maps:get(LowerField, Map, null); + + true -> Value + end + end. + + +get_unit_id(User, IdType) -> + LowerId = string:casefold(IdType), + if + LowerId /= <<"userid">> -> + Custom = get_or_lower(IdType, maps:get(<<"customIDs">>, User, null)), + if + Custom == null -> <<"">>; + Custom == [] -> <<"">>; + true -> + get_string_value(Custom) + end; + + true -> + UserID = maps:get(IdType, User, null), + if + UserID == null -> <<"">>; + true -> get_string_value(UserID) + end + end. + + +compute_user_hash(Value) -> + Bits = crypto:hash(sha256, Value), + <> = Bits, + Hash. + + +eval_pass_percent(User, Rule, ConfigSpec) -> + PassPercent = maps:get(<<"passPercentage">>, Rule, 0), + case PassPercent of + 100 -> + true; + 0 -> + false; + _ -> + ConfigSalt = binary_to_list(maps:get(<<"salt">>, ConfigSpec, <<"">>)), + RuleSalt = + binary_to_list(maps:get(<<"salt">>, Rule, maps:get(<<"id">>, Rule, <<"">>))), + IdType = maps:get(<<"idType">>, Rule, ""), + UnitID = get_unit_id(User, IdType), + Hash = compute_user_hash(ConfigSalt ++ "." ++ RuleSalt ++ "." ++ UnitID), + + (Hash rem 10000) < (PassPercent * 100) + end. + +get_string_value(UnitID) -> + if + is_integer(UnitID) -> + integer_to_binary(UnitID); + is_binary(UnitID) -> + UnitID; + is_float(UnitID) -> + float_to_binary(UnitID); + is_list(UnitID) -> + list_to_binary(UnitID); + true -> + UnitID + end. diff --git a/statsig_erl/src/logging.erl b/statsig_erl/src/logging.erl new file mode 100644 index 0000000..8e377cb --- /dev/null +++ b/statsig_erl/src/logging.erl @@ -0,0 +1,23 @@ +-module(logging). + +-export([get_event/4]). +-export([get_exposure/4]). + +get_event(User, EventName, Value, Metadata) -> + PublicUser = maps:remove(<<"privateAttributes">>, User), + Event = + #{ + <<"eventName">> => EventName, + <<"metadata">> => Metadata, + <<"user">> => PublicUser, + <<"time">> => list_to_integer(utils:get_timestamp()) + }, + case Value == undefined of + true -> Event; + _HasValue -> maps:put(<<"value">>, Value, Event) + end. + + +get_exposure(User, EventName, Metadata, SecondaryExposures) -> + Event = get_event(User, EventName, undefined, Metadata), + maps:put(<<"secondaryExposures">>, SecondaryExposures, Event). diff --git a/statsig_erl/src/network.erl b/statsig_erl/src/network.erl new file mode 100644 index 0000000..9592fb2 --- /dev/null +++ b/statsig_erl/src/network.erl @@ -0,0 +1,40 @@ +-module(network). + +-export([request/3]). + +normalize_api() -> + Url = application:get_env(statsig, statsig_api, "https://statsigapi.net/v1/"), + TrailingSlash = string:find(Url, "/", trailing), + case TrailingSlash of + "/" -> Url; + _Other -> Url ++ "/" + end. + + +request(ApiKey, Endpoint, Input) -> + Api = normalize_api(), + ClientModule = application:get_env(statsig, http_client, hackney_client), + RequestHeaders = + [ + {"STATSIG-API-KEY", ApiKey}, + {"STATSIG-CLIENT-TIME", utils:get_timestamp()}, + {"STATSIG-SDK-TYPE", utils:get_sdk_type()}, + {"STATSIG-SDK-VERSION", utils:get_sdk_version()}, + {"Content-Type", <<"application/json">>} + ], + RequestBody = + jiffy:encode( + maps:put(<<"statsigMetadata">>, utils:get_statsig_metadata(), Input) + ), + + case ClientModule:request(post, Api ++ Endpoint, RequestBody, RequestHeaders) of + {ok, #{status_code := StatusCode, body := Body}} -> + if + StatusCode < 300 -> + Body; + true -> false + end; + + {error, _} -> false; + true -> false + end. diff --git a/statsig_erl/src/request/hackney_client.erl b/statsig_erl/src/request/hackney_client.erl new file mode 100644 index 0000000..92dc034 --- /dev/null +++ b/statsig_erl/src/request/hackney_client.erl @@ -0,0 +1,20 @@ +-module(hackney_client). + +-behavior(http_client). + +-export([request/4]). + +request(Method, Url, ReqBody, ReqHeaders) -> + case hackney:request(Method, Url, ReqHeaders, ReqBody, []) of + {ok, StatusCode, RespHeaders, ClientRef} -> + if + StatusCode < 300 -> + {ok, Body} = hackney:body(ClientRef), + {ok, #{status_code =>StatusCode, headers => RespHeaders, body => Body }}; + + true -> {error, #{reason => "Failed with status code " ++ StatusCode}} + end; + {error, Reason} -> + {error, #{reason => Reason}} + end. + \ No newline at end of file diff --git a/statsig_erl/src/request/http_client.erl b/statsig_erl/src/request/http_client.erl new file mode 100644 index 0000000..8ed5b12 --- /dev/null +++ b/statsig_erl/src/request/http_client.erl @@ -0,0 +1,13 @@ +-module(http_client). + +-type http_method() :: get | post | delete | options | head. + +-callback request( + method: http_method(), + url: binary(), + req_body: binary(), + headers: list({binary, binary}) +) -> {ok, #{status_code => integer(), headers => any()}} + | {ok, #{status_code => integer(), headers => any(), body => binary()}} + | {error, #{reason => any()}}. + diff --git a/statsig_erl/src/statsig.app.src b/statsig_erl/src/statsig.app.src new file mode 100644 index 0000000..6214f85 --- /dev/null +++ b/statsig_erl/src/statsig.app.src @@ -0,0 +1,15 @@ +{application, statsig, + [{description, "Statsig SDK for erlang and elixir"}, + {vsn, "0.0.1"}, + {registered, []}, + {mod, {statsig, []}}, + {applications, + [kernel, + stdlib, + hackney + ]}, + {env,[]}, + {modules, [gen_server, ets]}, + {licenses, ["ISC"]}, + {links, []} + ]}. diff --git a/statsig_erl/src/statsig.erl b/statsig_erl/src/statsig.erl new file mode 100644 index 0000000..84cae0c --- /dev/null +++ b/statsig_erl/src/statsig.erl @@ -0,0 +1,70 @@ +-module(statsig). + +-behaviour(application). + +-export( + [ + start/2, + stop/1, + check_gate/2, + get_config/2, + get_experiment/2, + log_event/3, + log_event/4, + flush/0, + flush_sync/0 + ] +). + +start(_Type, _Args) -> statsig_sup:start_link(). + +-spec check_gate(map(), binary()) -> boolean(). +check_gate(User, Gate) -> + NormalizedUser = utils:get_user_with_environment(User), + gen_server:call(statsig_server, {gate, NormalizedUser, Gate}). + + +-spec get_config(map(), binary()) -> map(). +get_config(User, Config) -> + NormalizedUser = utils:get_user_with_environment(User), + gen_server:call(statsig_server, {config, NormalizedUser, Config}). + + +-spec get_experiment(map(), binary()) -> map(). +get_experiment(User, Experiment) -> + NormalizedUser = utils:get_user_with_environment(User), + gen_server:call(statsig_server, {config, NormalizedUser, Experiment}). + + +-spec log_event(map(), binary(), map()) -> ok. +log_event(User, EventName, Metadata) -> + NormalizedUser = utils:get_user_with_environment(User), + gen_server:cast( + statsig_server, + {log, NormalizedUser, EventName, undefined, Metadata} + ), + ok. + + +-spec log_event(map(), binary(), binary() | number(), map()) -> ok. +log_event(User, EventName, Value, Metadata) -> + NormalizedUser = utils:get_user_with_environment(User), + gen_server:cast( + statsig_server, + {log, NormalizedUser, EventName, Value, Metadata} + ), + ok. + + +-spec flush() -> ok. +flush() -> + gen_server:cast(statsig_server, {flush}), + ok. + + +-spec flush_sync() -> ok. +flush_sync() -> + gen_server:call(statsig_server, {flush}), + ok. + +stop(_State) -> ok. diff --git a/statsig_erl/src/statsig_server.erl b/statsig_erl/src/statsig_server.erl new file mode 100644 index 0000000..b2876cf --- /dev/null +++ b/statsig_erl/src/statsig_server.erl @@ -0,0 +1,157 @@ +-module(statsig_server). + +-behaviour(gen_server). + +-export( + [ + start_link/1, + init/1, + terminate/2, + handle_call/3, + handle_cast/2, + handle_info/2 + ] +). + +start_link(ApiKey) -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [ApiKey], []). + + +init([ApiKey]) -> + ets:new( + statsig_store, + [set, named_table, {keypos, 1}, {heir, none}, {read_concurrency, true}] + ), + Time = get_and_save_config_specs(ApiKey, 0), + Delay = application:get_env(statsig, statsig_polling_interval, 60000), + FlushDelay = application:get_env(statsig, statsig_flush_interval, 60000), + erlang:send_after(Delay, self(), download_specs), + erlang:send_after(FlushDelay, self(), handle_events), + {ok, [{log_events, []}, {api_key, ApiKey}, {last_sync_time, Time}]}. + +save_specs([], _Type) -> ok; + +save_specs([H | T], Type) -> + Name = maps:get(<<"name">>, H, undefined), + case Name of + undefined -> ok; + _ -> ets:insert(statsig_store, {Name, Type, H}) + end, + save_specs(T, Type). + +handle_cast( + {log, User, EventName, Value, Metadata}, + [{log_events, Events}, {api_key, ApiKey}, {last_sync_time, Time}] +) -> + Event = logging:get_event(User, EventName, Value, Metadata), + {noreply, [{log_events, [Event | Events]}, {api_key, ApiKey}, {last_sync_time, Time}]}; + +handle_cast({flush}, [{log_events, Events}, {api_key, ApiKey}, {last_sync_time, Time}]) -> + Unsent = handle_events(Events, ApiKey), + {noreply, [{log_events, Unsent}, {api_key, ApiKey}, {last_sync_time, Time}]}. + +handle_info(handle_events, [{log_events, Events}, {api_key, ApiKey}, {last_sync_time, Time}]) -> + Unsent = handle_events(Events, ApiKey), + FlushDelay = application:get_env(statsig, statsig_flush_interval, 60000), + erlang:send_after(FlushDelay, self(), handle_events), + {noreply, [{log_events, Unsent}, {api_key, ApiKey}, {last_sync_time, Time}]}; + +handle_info(download_specs, [{log_events, Events}, {api_key, ApiKey}, {last_sync_time, Time}]) -> + NewTime = get_and_save_config_specs(ApiKey, Time), + Delay = application:get_env(statsig, statsig_polling_interval, 60000), + erlang:send_after(Delay, self(), download_specs), + {noreply, [{log_events, Events}, {api_key, ApiKey}, {last_sync_time, NewTime}]}; + +handle_info(flush, [{log_events, Events}, {api_key, ApiKey}, {last_sync_time, Time}]) -> + Unsent = handle_events(Events, ApiKey), + {noreply, [{log_events, Unsent}, {api_key, ApiKey}, {last_sync_time, Time}]}; + +handle_info(_In, State) -> {noreply, State}. + +handle_call({flush}, _From, [{log_events, Events}, {api_key, ApiKey}, {last_sync_time, Time}]) -> + Unsent = handle_events(Events, ApiKey), + {reply, length(Unsent), [{log_events, Unsent}, {api_key, ApiKey}, {last_sync_time, Time}]}; +handle_call({Type, User, Name}, _From, State) -> + case Type of + gate -> handle_gate(User, Name, State); + config -> handle_config(User, Name, State); + _ -> {noreply, State} + end. + + +handle_gate(User, Gate, [{log_events, Events}, {api_key, ApiKey}, {last_sync_time, Time}]) -> + {_Rule, GateValue, _JsonValue, RuleID, SecondaryExposures} = + evaluator:find_and_eval(User, Gate, feature_gate), + GateExposure = + logging:get_exposure( + User, + <<"statsig::gate_exposure">>, + #{ + <<"gate">> => Gate, + <<"gateValue">> => utils:get_bool_as_string(GateValue), + <<"ruleID">> => RuleID + }, + SecondaryExposures + ), + NextEvents = [GateExposure | Events], + {reply, GateValue, [{log_events, NextEvents}, {api_key, ApiKey}, {last_sync_time, Time}]}. + + +handle_config(User, Config, [{log_events, Events}, {api_key, ApiKey}, {last_sync_time, Time}]) -> + {_Rule, _GateValue, JsonValue, RuleID, SecondaryExposures} = + evaluator:find_and_eval(User, Config, dynamic_config), + ConfigExposure = + logging:get_exposure( + User, + <<"statsig::config_exposure">>, + #{<<"config">> => Config, <<"ruleID">> => RuleID}, + SecondaryExposures + ), + NextEvents = [ConfigExposure | Events], + {reply, #{value => JsonValue, rule_id => RuleID}, [{log_events, NextEvents}, {api_key, ApiKey}, {last_sync_time, Time}]}. + + +handle_events([], _ApiKey) -> + []; +handle_events(Events, ApiKey) -> + BatchSize = application:get_env(statsig, statsig_flush_batch_size, 500), + BucketsOfEvents = utils:partition(Events, BatchSize), + Unsent = unsent_events(BucketsOfEvents, ApiKey), + lists:flatten(Unsent). + +unsent_events([], _ApiKey) -> + []; +unsent_events([HEvents|TEvents], ApiKey) -> + Input = #{<<"events">> => HEvents}, + case network:request(ApiKey, "rgstr", Input) of + false -> + [HEvents | unsent_events(TEvents, ApiKey)]; + _ -> + unsent_events(TEvents, ApiKey) + end. + + +parse_and_save_specs(Body) -> + try + Specs = jiffy:decode(Body, [return_maps]), + Gates = maps:get(<<"feature_gates">>, Specs, []), + save_specs(Gates, feature_gate), + Configs = maps:get(<<"dynamic_configs">>, Specs, []), + save_specs(Configs, dynamic_config), + maps:get(<<"time">>, Specs, 0) + catch + error : _Error -> + % no op - will be retried after the polling interval + 0 + end. + +get_and_save_config_specs(ApiKey, Time) -> + case network:request(ApiKey, "download_config_specs", #{<<"sinceTime">> => Time}) of + false -> Time; + Body -> + parse_and_save_specs(Body) + end. + +terminate(_Reason, [{log_events, Events}, {api_key, ApiKey, {last_sync_time, _Time}}]) -> + handle_events(Events, ApiKey), + ok. diff --git a/statsig_erl/src/statsig_sup.erl b/statsig_erl/src/statsig_sup.erl new file mode 100644 index 0000000..a1850a0 --- /dev/null +++ b/statsig_erl/src/statsig_sup.erl @@ -0,0 +1,30 @@ +-module(statsig_sup). + +-behaviour(supervisor). + +-export([start_link/0]). +-export([init/1, shutdown/0]). + +start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + case application:get_env(statsig, statsig_api_key) of + {ok, ApiKey} -> + RestartStrategy = {one_for_one, 10, 60}, + Server = + { + stasig_serv, + {statsig_server, start_link, [ApiKey]}, + permanent, + infinity, + worker, + [statsig_server] + }, + Children = [Server], + {ok, {RestartStrategy, Children}}; + + Other -> {error, Other} + end. + + +shutdown() -> exit(whereis(?MODULE), shutdown). diff --git a/statsig_erl/src/utils.erl b/statsig_erl/src/utils.erl new file mode 100644 index 0000000..4807d5a --- /dev/null +++ b/statsig_erl/src/utils.erl @@ -0,0 +1,61 @@ +-module(utils). + +-export( + [ + get_timestamp/0, + get_sdk_type/0, + get_sdk_version/0, + get_statsig_metadata/0, + get_user_with_environment/1, + get_bool_as_string/1, + partition/2 + ] +). + +-spec get_timestamp() -> list(). +get_timestamp() -> integer_to_list(os:system_time(millisecond)). + +-spec get_statsig_metadata() -> map(). +get_statsig_metadata() -> + #{ + <<"sdkType">> => list_to_binary(get_sdk_type()), + <<"sdkVersion">> => list_to_binary(get_sdk_version()) + }. + +-spec get_sdk_version() -> list(). +get_sdk_version() -> "0.0.3". + +-spec get_sdk_type() -> list(). +get_sdk_type() -> "erlang-server". + +-spec get_user_with_environment(map()) -> map(). +get_user_with_environment(User) -> + case maps:is_key(<<"statsigEnvironment">>, User) of + true -> User; + false -> set_env(User) + end. + + +set_env(User) -> + case application:get_env(statsig, statsig_environment_tier) of + undefined -> User; + + {ok, Tier} -> + StatsigEnvironment = #{<<"tier">> => Tier}, + maps:put(<<"statsigEnvironment">>, StatsigEnvironment, User) + end. + +-spec get_bool_as_string(boolean()) -> binary(). +get_bool_as_string(Bool) -> + case Bool of + true -> <<"true">>; + false -> <<"false">> + end. + +-spec partition(list(), integer()) -> list(). +partition([],_) -> []; +partition(List,Len) when Len > length(List) -> + [List]; +partition(List,Len) -> + {Head,Tail} = lists:split(Len,List), + [Head | partition(Tail,Len)]. diff --git a/statsig_erl/test/consistency_test.erl b/statsig_erl/test/consistency_test.erl new file mode 100644 index 0000000..7325966 --- /dev/null +++ b/statsig_erl/test/consistency_test.erl @@ -0,0 +1,62 @@ +-module(consistency_test). + +-include_lib("eunit/include/eunit.hrl"). + +gate_test() -> + ApiKey = os:getenv("test_api_key"), + application:set_env(statsig, statsig_api_key, ApiKey), + application:set_env(statsig, statsig_api, "https://api.statsig.com/v1"), + application:start(statsig), + {ok, _Apps} = application:ensure_all_started(statsig), + + Body = network:request(ApiKey, "rulesets_e2e_test", #{}), + + TestData = maps:get(<<"data">>, jiffy:decode(Body, [return_maps]), []), + lists:map(fun (Data) -> test_input(Data) end, TestData), + application:set_env(statsig, statsig_environment_tier, <<"staging">>), + statsig:log_event(#{<<"userID">> => <<"321">>}, <<"newevent">>, 12, #{<<"test">> => <<"val">>}), + statsig:log_event(#{<<"userID">> => <<"456">>}, <<"custom_event">>, <<"hello">>, #{<<"123">> => <<"444">>}), + statsig:log_event(#{<<"userID">> => <<"12345">>}, <<"custom_event">>, #{<<"test">> => <<"val">>}), + statsig:flush_sync(), + statsig:log_event(#{<<"userID">> => <<"12345">>}, <<"custom_event">>, #{<<"test">> => <<"val">>}), + statsig:flush(), + application:stop(statsig). + +test_input(Input) -> + User = maps:get(<<"user">>, Input, #{}), + FeatureGates = maps:get(<<"feature_gates_v2">>, Input, #{}), + maps:map(fun (K, V) -> test_gate(K, V, User) end, FeatureGates), + DynamicConfigs = maps:get(<<"dynamic_configs">>, Input, #{}), + maps:map(fun (K, V) -> test_config(K, V, User) end, DynamicConfigs). + +test_gate(Name, Gate, User) -> + if + Name == <<"test_country">> -> + false; + Name == <<"test_id_list">> -> + false; + Name == <<"test_not_in_id_list">> -> + false; + Name == <<"test_ua_os">> -> + false; + Name == <<"test_ua">> -> + false; + Name == <<"test_windows_7">> -> + false; + true -> + Result = statsig:check_gate(User, Name), + ServerResult = maps:get(<<"value">>, Gate, false), + ?assertEqual(Result, ServerResult) + end. + +test_config(Name, Config, User) -> + if + Name == <<"operating_system_config">> -> + false; + true -> + ResultConfig = statsig:get_config(User, Name), + ResultValue = maps:get(value, ResultConfig, #{}), + ServerResult = maps:get(<<"value">>, Config, false), + ?assertEqual(ResultValue, ServerResult) + end. + \ No newline at end of file diff --git a/statsig_erl/test/logging_test.erl b/statsig_erl/test/logging_test.erl new file mode 100644 index 0000000..865f16f --- /dev/null +++ b/statsig_erl/test/logging_test.erl @@ -0,0 +1,16 @@ +-module(logging_test). + +-include_lib("eunit/include/eunit.hrl"). + +private_attribute_test() -> + PrivateUser = #{<<"userID">> => <<"jkw">>, <<"privateAttributes">> => #{<<"gb">> => <<"goodbye">>}}, + Event = logging:get_event(PrivateUser, <<"test_event">>, 42, #{}), + EventUser = maps:get(<<"user">>, Event, undefined), + + ?assert(maps:get(<<"privateAttributes">>, EventUser, undefined) == undefined), + + PublicUser = #{<<"userID">> => <<"jkw">>, <<"email">> => <<"hello@statsig.com">>}, + Event2 = logging:get_event(PublicUser, <<"test_event">>, 42, #{}), + EventUser2 = maps:get(<<"user">>, Event2, undefined), + ?assert(maps:get(<<"privateAttributes">>, EventUser2, undefined) == undefined), + ?assert(maps:get(<<"email">>, EventUser2, <<"">>) == <<"hello@statsig.com">>). diff --git a/test/statsig_ex_test.exs b/test/statsig_ex_test.exs index e7dec6c..1d4e6be 100644 --- a/test/statsig_ex_test.exs +++ b/test/statsig_ex_test.exs @@ -128,7 +128,7 @@ defmodule StatsigExTest do @tag :flakey test "segmentation for basic 50/50 test is in expected tolerances" do {test, control} = - Enum.reduce(1..10_000, {0, 0}, fn i, {t, c} -> + Enum.reduce(1..10_000, {0, 0}, fn _, {t, c} -> id = :crypto.strong_rand_bytes(10) |> Base.encode64() case StatsigEx.get_experiment(%{"userID" => id}, "basic-a-b") do