diff --git a/.clang-tidy b/.clang-tidy index f464d0864..40670c789 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,4 +1,5 @@ --- CheckOptions: - { key: readability-identifier-length.IgnoredParameterNames, value: 'i|j|k|c|os|it' } + - { key: readability-identifier-length.IgnoredVariableNames, value: 'ec' } - { key: readability-identifier-length.IgnoredLoopCounterNames, value: 'i|j|k|c|os|it' } diff --git a/libs/common/include/events/client_events.hpp b/libs/common/include/events/client_events.hpp index 775a4b9d9..a4d3ae292 100644 --- a/libs/common/include/events/client_events.hpp +++ b/libs/common/include/events/client_events.hpp @@ -19,6 +19,7 @@ struct FeatureEventParams { std::string key; Context context; EvaluationResult eval_result; + Value default_; }; struct FeatureEventBase { @@ -29,6 +30,8 @@ struct FeatureEventBase { Value value; std::optional reason; Value default_; + + explicit FeatureEventBase(FeatureEventParams const& params); }; struct FeatureEvent : public FeatureEventBase { diff --git a/libs/common/include/events/common_events.hpp b/libs/common/include/events/common_events.hpp index b6076e09f..104d9dcd2 100644 --- a/libs/common/include/events/common_events.hpp +++ b/libs/common/include/events/common_events.hpp @@ -24,27 +24,6 @@ struct Date { std::chrono::system_clock::time_point t; }; -struct VariationSummary { - std::size_t count; - Value value; -}; - -struct VariationKey { - Version version; - std::optional variation; - - struct Hash { - auto operator()(VariationKey const& p) const -> size_t { - if (p.variation) { - return std::hash{}(p.version) ^ - std::hash{}(*p.variation); - } else { - return std::hash{}(p.version); - } - } - }; -}; - struct TrackEventParams { Date creation_date; std::string key; diff --git a/libs/common/include/events/detail/asio_event_processor.hpp b/libs/common/include/events/detail/asio_event_processor.hpp index 3fa139548..0c475f9d0 100644 --- a/libs/common/include/events/detail/asio_event_processor.hpp +++ b/libs/common/include/events/detail/asio_event_processor.hpp @@ -12,7 +12,7 @@ #include "context_filter.hpp" #include "events/detail/conn_pool.hpp" #include "events/detail/outbox.hpp" -#include "events/detail/summary_state.hpp" +#include "events/detail/summarizer.hpp" #include "events/event_processor.hpp" #include "events/events.hpp" #include "logger.hpp" @@ -43,7 +43,7 @@ class AsioEventProcessor : public IEventProcessor { boost::asio::any_io_executor io_; Outbox outbox_; - SummaryState summary_state_; + Summarizer summarizer_; std::chrono::milliseconds flush_interval_; boost::asio::steady_timer timer_; diff --git a/libs/common/include/events/detail/summarizer.hpp b/libs/common/include/events/detail/summarizer.hpp new file mode 100644 index 000000000..0d4c05726 --- /dev/null +++ b/libs/common/include/events/detail/summarizer.hpp @@ -0,0 +1,110 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include "events/events.hpp" +#include "value.hpp" + +namespace launchdarkly::events::detail { + +/** + * Summarizer is responsible for accepting FeatureEventParams (the context + * related to a feature evaluation) and outputting summary events (which + * essentially condenses the various evaluation results into a single + * structure). + */ +class Summarizer { + public: + using Time = std::chrono::system_clock::time_point; + using FlagKey = std::string; + + /** + * Construct a Summarizer starting at the given time. + * @param start Start time of the summary. + */ + explicit Summarizer(Time start_time); + + /** + * Construct a Summarizer at time zero. + */ + Summarizer() = default; + + /** + * Updates the summary with a feature event. + * @param event Feature event. + */ + void Update(client::FeatureEventParams const& event); + + /** + * Marks the summary as finished at a given timestamp. + * @param end_time End time of the summary. + */ + Summarizer& Finish(Time end_time); + + /** + * Returns true if the summary is empty. + */ + [[nodiscard]] bool Empty() const; + + /** + * Returns the summary's start time as given in the constructor. + */ + [[nodiscard]] Time start_time() const; + + /** + * Returns the summary's end time as specified using Finish. + */ + [[nodiscard]] Time end_time() const; + + struct VariationSummary { + public: + explicit VariationSummary(Value value); + void Increment(); + [[nodiscard]] std::int32_t count() const; + [[nodiscard]] Value const& value() const; + + private: + std::int32_t count_; + Value value_; + }; + + struct VariationKey { + std::optional version; + std::optional variation; + + VariationKey(); + VariationKey(Version version, std::optional variation); + + bool operator==(VariationKey const& k) const { + return k.variation == variation && k.version == version; + } + + bool operator<(VariationKey const& k) const { + if (variation < k.variation) { + return true; + } + return version < k.version; + } + }; + + struct State { + Value default_; + std::set context_kinds; + std::map + counters; + + explicit State(Value defaultVal); + }; + + [[nodiscard]] std::unordered_map const& features() const; + + private: + Time start_time_; + Summarizer::Time end_time_; + std::unordered_map features_; +}; + +} // namespace launchdarkly::events::detail diff --git a/libs/common/include/events/detail/summary_state.hpp b/libs/common/include/events/detail/summary_state.hpp deleted file mode 100644 index 4807ca9d7..000000000 --- a/libs/common/include/events/detail/summary_state.hpp +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once -#include -#include -#include -#include "events/events.hpp" -#include "value.hpp" - -namespace launchdarkly::events::detail { - -class SummaryState { - public: - SummaryState(std::chrono::system_clock::time_point start); - void update(InputEvent event); - - private: - std::chrono::system_clock::time_point start_time_; - std::unordered_map - counters_; - Value default_; - std::unordered_set context_kinds_; -}; - -} // namespace launchdarkly::events::detail diff --git a/libs/common/include/serialization/events/json_events.hpp b/libs/common/include/serialization/events/json_events.hpp index 1a4467b66..b524f1dcb 100644 --- a/libs/common/include/serialization/events/json_events.hpp +++ b/libs/common/include/serialization/events/json_events.hpp @@ -2,6 +2,7 @@ #include +#include "events/detail/summarizer.hpp" #include "events/events.hpp" namespace launchdarkly::events::client { @@ -37,3 +38,13 @@ void tag_invoke(boost::json::value_from_tag const&, OutputEvent const& event); } // namespace launchdarkly::events + +namespace launchdarkly::events::detail { + +void tag_invoke(boost::json::value_from_tag const&, + boost::json::value& json_value, + Summarizer::State const& state); +void tag_invoke(boost::json::value_from_tag const&, + boost::json::value& json_value, + Summarizer const& summary); +} // namespace launchdarkly::events::detail diff --git a/libs/common/src/CMakeLists.txt b/libs/common/src/CMakeLists.txt index f385cd7ba..b276a1572 100644 --- a/libs/common/src/CMakeLists.txt +++ b/libs/common/src/CMakeLists.txt @@ -41,7 +41,8 @@ add_library(${LIBNAME} events/asio_event_processor.cpp events/outbox.cpp events/conn_pool.cpp - events/summary_state.cpp + events/summarizer.cpp + events/client_events.cpp config/http_properties.cpp config/data_source_builder.cpp config/http_properties_builder.cpp) diff --git a/libs/common/src/events/asio_event_processor.cpp b/libs/common/src/events/asio_event_processor.cpp index ef7839d73..4b43bedf6 100644 --- a/libs/common/src/events/asio_event_processor.cpp +++ b/libs/common/src/events/asio_event_processor.cpp @@ -23,17 +23,15 @@ AsioEventProcessor::AsioEventProcessor( Logger& logger) : io_(boost::asio::make_strand(io)), outbox_(config.capacity()), - summary_state_(std::chrono::system_clock::now()), + summarizer_(std::chrono::system_clock::now()), flush_interval_(config.flush_interval()), timer_(io_), host_(endpoints.events_base_url()), // TODO: parse and use host path_(config.path()), authorization_(std::move(authorization)), uuids_(), - conns_(), inbox_capacity_(config.capacity()), inbox_size_(0), - inbox_mutex_(), full_outbox_encountered_(false), full_inbox_encountered_(false), filter_(config.all_attributes_private(), config.private_attributes()), @@ -68,16 +66,14 @@ void AsioEventProcessor::AsyncSend(InputEvent input_event) { if (!InboxIncrement()) { return; } - boost::asio::post(io_, [this, e = std::move(input_event)]() mutable { + boost::asio::post(io_, [this, event = std::move(input_event)]() mutable { InboxDecrement(); - HandleSend(std::move(e)); + HandleSend(std::move(event)); }); } -void AsioEventProcessor::HandleSend(InputEvent e) { - summary_state_.update(e); - - std::vector output_events = Process(std::move(e)); +void AsioEventProcessor::HandleSend(InputEvent event) { + std::vector output_events = Process(std::move(event)); bool inserted = outbox_.PushDiscardingOverflow(std::move(output_events)); if (!inserted && !full_outbox_encountered_) { @@ -95,6 +91,7 @@ void AsioEventProcessor::Flush(FlushTrigger flush_type) { LD_LOG(logger_, LogLevel::kDebug) << "event-processor: nothing to flush"; } + summarizer_ = Summarizer(std::chrono::system_clock::now()); if (flush_type == FlushTrigger::Automatic) { ScheduleFlush(); } @@ -133,8 +130,15 @@ AsioEventProcessor::MakeRequest() { return std::nullopt; } + auto events = boost::json::value_from(outbox_.Consume()); + + if (!summarizer_.Finish(std::chrono::system_clock::now()).Empty()) { + events.as_array().push_back(boost::json::value_from(summarizer_)); + } + LD_LOG(logger_, LogLevel::kDebug) << "event-processor: generating http request"; + RequestType req; req.set(http::field::host, host_); @@ -145,8 +149,7 @@ AsioEventProcessor::MakeRequest() { req.set(kPayloadIdHeader, boost::lexical_cast(uuids_())); req.target(host_ + path_); - req.body() = - boost::json::serialize(boost::json::value_from(outbox_.Consume())); + req.body() = boost::json::serialize(events); req.prepare_payload(); return req; } @@ -160,53 +163,44 @@ struct overloaded : Ts... { template overloaded(Ts...) -> overloaded; -std::vector AsioEventProcessor::Process(InputEvent event) { +std::vector AsioEventProcessor::Process(InputEvent input_event) { std::vector out; std::visit( - overloaded{ - [&](client::FeatureEventParams&& e) { - if (!e.eval_result.track_events()) { - return; - } - std::optional reason; - - // TODO(cwaldren): should also add the reason if the variation - // method was VariationDetail(). - if (e.eval_result.track_reason()) { - reason = e.eval_result.detail().reason(); - } - - client::FeatureEventBase b = { - e.creation_date, std::move(e.key), e.eval_result.version(), - e.eval_result.detail().variation_index(), - e.eval_result.detail().value(), reason, - // TODO(cwaldren): change to actual default; figure out - // where this should be plumbed through. - Value::null()}; - - auto debug_until_date = e.eval_result.debug_events_until_date(); - bool emit_debug_event = - debug_until_date && - debug_until_date.value() > std::chrono::system_clock::now(); - - if (emit_debug_event) { - out.emplace_back( - client::DebugEvent{b, filter_.filter(e.context)}); - } - // TODO(cwaldren): see about not copying the keys / having the - // getter return a value. - out.emplace_back(client::FeatureEvent{ - std::move(b), e.context.kinds_to_keys()}); - }, - [&](client::IdentifyEventParams&& e) { - // Contexts should already have been checked for - // validity by this point. - assert(e.context.valid()); - out.emplace_back(client::IdentifyEvent{ - e.creation_date, filter_.filter(e.context)}); - }, - [&](TrackEventParams&& e) { out.emplace_back(std::move(e)); }}, - std::move(event)); + overloaded{[&](client::FeatureEventParams&& event) { + summarizer_.Update(event); + + if (!event.eval_result.track_events()) { + return; + } + + client::FeatureEventBase base{event}; + + auto debug_until_date = + event.eval_result.debug_events_until_date(); + + bool emit_debug_event = + debug_until_date && + debug_until_date.value() > + std::chrono::system_clock::now(); + + if (emit_debug_event) { + out.emplace_back(client::DebugEvent{ + base, filter_.filter(event.context)}); + } + out.emplace_back(client::FeatureEvent{ + std::move(base), event.context.kinds_to_keys()}); + }, + [&](client::IdentifyEventParams&& event) { + // Contexts should already have been checked for + // validity by this point. + assert(event.context.valid()); + out.emplace_back(client::IdentifyEvent{ + event.creation_date, filter_.filter(event.context)}); + }, + [&](TrackEventParams&& event) { + out.emplace_back(std::move(event)); + }}, + std::move(input_event)); return out; } diff --git a/libs/common/src/events/client_events.cpp b/libs/common/src/events/client_events.cpp new file mode 100644 index 000000000..5b1c4a898 --- /dev/null +++ b/libs/common/src/events/client_events.cpp @@ -0,0 +1,18 @@ +#include "events/client_events.hpp" + +namespace launchdarkly::events::client { +FeatureEventBase::FeatureEventBase(FeatureEventParams const& params) + : creation_date(params.creation_date), + key(params.key), + version(params.eval_result.version()), + variation(params.eval_result.detail().variation_index()), + value(params.eval_result.detail().value()), + default_(params.default_) { + // TODO(cwaldren): should also add the reason if the + // variation method was VariationDetail(). + if (params.eval_result.track_reason()) { + reason = params.eval_result.detail().reason(); + } +} + +} // namespace launchdarkly::events::client diff --git a/libs/common/src/events/summarizer.cpp b/libs/common/src/events/summarizer.cpp new file mode 100644 index 000000000..f27460048 --- /dev/null +++ b/libs/common/src/events/summarizer.cpp @@ -0,0 +1,93 @@ +#include "events/detail/summarizer.hpp" + +namespace launchdarkly::events::detail { + +Summarizer::Summarizer(std::chrono::system_clock::time_point start) + : start_time_(start) {} + +bool Summarizer::Empty() const { + return features_.empty(); +} + +std::unordered_map const& +Summarizer::features() const { + return features_; +} + +static bool FlagNotFound(client::FeatureEventParams const& event) { + if (auto reason = event.eval_result.detail().reason()) { + return reason->get().kind() == "ERROR" && + reason->get().error_kind() == "FLAG_NOT_FOUND"; + } + return false; +} + +void Summarizer::Update(client::FeatureEventParams const& event) { + auto const& kinds = event.context.kinds(); + + auto feature_state_iterator = + features_.try_emplace(event.key, event.default_).first; + + feature_state_iterator->second.context_kinds.insert(kinds.begin(), + kinds.end()); + + decltype(std::begin( + feature_state_iterator->second.counters)) summary_counter; + + if (FlagNotFound(event)) { + summary_counter = + feature_state_iterator->second.counters + .try_emplace(VariationKey(), + feature_state_iterator->second.default_) + .first; + + } else { + auto key = VariationKey(event.eval_result.version(), + event.eval_result.detail().variation_index()); + summary_counter = + feature_state_iterator->second.counters + .try_emplace(key, event.eval_result.detail().value()) + .first; + } + + summary_counter->second.Increment(); +} + +Summarizer& Summarizer::Finish(Time end_time) { + end_time_ = end_time; + return *this; +} + +Summarizer::Time Summarizer::start_time() const { + return start_time_; +} + +Summarizer::Time Summarizer::end_time() const { + return end_time_; +} + +Summarizer::VariationKey::VariationKey(Version version, + std::optional variation) + : version(version), variation(variation) {} + +Summarizer::VariationKey::VariationKey() + : version(std::nullopt), variation(std::nullopt) {} + +Summarizer::VariationSummary::VariationSummary(Value value) + : count_(0), value_(std::move(value)) {} + +void Summarizer::VariationSummary::Increment() { + count_++; +} + +Value const& Summarizer::VariationSummary::value() const { + return value_; +} + +std::int32_t Summarizer::VariationSummary::count() const { + return count_; +} + +Summarizer::State::State(Value default_value) + : default_(std::move(default_value)) {} +} // namespace launchdarkly::events::detail diff --git a/libs/common/src/events/summary_state.cpp b/libs/common/src/events/summary_state.cpp deleted file mode 100644 index 927da11d7..000000000 --- a/libs/common/src/events/summary_state.cpp +++ /dev/null @@ -1,9 +0,0 @@ -#include "events/detail/summary_state.hpp" - -namespace launchdarkly::events::detail { - -SummaryState::SummaryState(std::chrono::system_clock::time_point start) - : start_time_(start), counters_(), default_(), context_kinds_() {} - -void SummaryState::update(InputEvent event) {} -} // namespace launchdarkly::events::detail diff --git a/libs/common/src/serialization/events/json_events.cpp b/libs/common/src/serialization/events/json_events.cpp index e748a9a71..287179d58 100644 --- a/libs/common/src/serialization/events/json_events.cpp +++ b/libs/common/src/serialization/events/json_events.cpp @@ -23,7 +23,7 @@ void tag_invoke(boost::json::value_from_tag const& tag, json_value = std::move(base); } -void tag_invoke(boost::json::value_from_tag const&, +void tag_invoke(boost::json::value_from_tag const& tag, boost::json::value& json_value, FeatureEventBase const& event) { auto& obj = json_value.emplace_object(); @@ -40,7 +40,7 @@ void tag_invoke(boost::json::value_from_tag const&, obj.emplace("default", boost::json::value_from(event.default_)); } -void tag_invoke(boost::json::value_from_tag const&, +void tag_invoke(boost::json::value_from_tag const& tag, boost::json::value& json_value, IdentifyEvent const& event) { auto& obj = json_value.emplace_object(); @@ -52,7 +52,7 @@ void tag_invoke(boost::json::value_from_tag const&, namespace launchdarkly::events { -void tag_invoke(boost::json::value_from_tag const&, +void tag_invoke(boost::json::value_from_tag const& tag, boost::json::value& json_value, Date const& date) { json_value.emplace_int64() = @@ -61,7 +61,7 @@ void tag_invoke(boost::json::value_from_tag const&, .count(); } -void tag_invoke(boost::json::value_from_tag const&, +void tag_invoke(boost::json::value_from_tag const& tag, boost::json::value& json_value, TrackEvent const& event) { auto& obj = json_value.emplace_object(); @@ -80,8 +80,46 @@ void tag_invoke(boost::json::value_from_tag const&, void tag_invoke(boost::json::value_from_tag const& tag, boost::json::value& json_value, events::OutputEvent const& event) { - std::visit([&](auto const& e) mutable { tag_invoke(tag, json_value, e); }, - event); + std::visit( + [&](auto const& event) mutable { tag_invoke(tag, json_value, event); }, + event); } } // namespace launchdarkly::events + +namespace launchdarkly::events::detail { + +void tag_invoke(boost::json::value_from_tag const& tag, + boost::json::value& json_value, + Summarizer::State const& state) { + auto& obj = json_value.emplace_object(); + obj.emplace("default", boost::json::value_from(state.default_)); + obj.emplace("contextKinds", boost::json::value_from(state.context_kinds)); + boost::json::array counters; + for (auto const& kvp : state.counters) { + boost::json::object counter; + if (kvp.first.version) { + counter.emplace("version", *kvp.first.version); + } else { + counter.emplace("unknown", true); + } + if (kvp.first.variation) { + counter.emplace("variation", *kvp.first.variation); + } + counter.emplace("value", boost::json::value_from(kvp.second.value())); + counter.emplace("count", kvp.second.count()); + counters.push_back(std::move(counter)); + } + obj.emplace("counters", std::move(counters)); +} +void tag_invoke(boost::json::value_from_tag const& tag, + boost::json::value& json_value, + Summarizer const& summary) { + auto& obj = json_value.emplace_object(); + obj.emplace("kind", "summary"); + obj.emplace("startDate", + boost::json::value_from(Date{summary.start_time()})); + obj.emplace("endDate", boost::json::value_from(Date{summary.end_time()})); + obj.emplace("features", boost::json::value_from(summary.features())); +} +} // namespace launchdarkly::events::detail diff --git a/libs/common/tests/event_processor_test.cpp b/libs/common/tests/event_processor_test.cpp index 1be57ba07..db1750c1a 100644 --- a/libs/common/tests/event_processor_test.cpp +++ b/libs/common/tests/event_processor_test.cpp @@ -5,16 +5,26 @@ #include "config/client.hpp" #include "console_backend.hpp" #include "context_builder.hpp" +#include "events/client_events.hpp" #include "events/detail/asio_event_processor.hpp" -using namespace launchdarkly; -class EventProcessorTests : public ::testing::Test {}; +using namespace launchdarkly::events::detail; + +static std::chrono::system_clock::time_point TimeZero() { + return std::chrono::system_clock::time_point{}; +} + +static std::chrono::system_clock::time_point Time1000() { + return std::chrono::system_clock::from_time_t(1); +} // This test is a temporary test that exists only to ensure the event processor // compiles; it should be replaced by more robust tests (and contract tests.) -TEST_F(EventProcessorTests, ProcessorCompiles) { +TEST(EventProcessorTests, ProcessorCompiles) { + using namespace launchdarkly; + Logger logger{std::make_unique(LogLevel::kDebug, "test")}; - boost::asio::io_context io; + boost::asio::io_context ioc; auto config = client::EventsBuilder() .capacity(10) @@ -23,21 +33,21 @@ TEST_F(EventProcessorTests, ProcessorCompiles) { auto endpoints = client::Endpoints().build(); - events::detail::AsioEventProcessor ep(io.get_executor(), *config, - *endpoints, "password", logger); - std::thread t([&]() { io.run(); }); + events::detail::AsioEventProcessor processor( + ioc.get_executor(), *config, *endpoints, "password", logger); + std::thread ioc_thread([&]() { ioc.run(); }); - auto c = launchdarkly::ContextBuilder().kind("org", "ld").build(); - ASSERT_TRUE(c.valid()); + auto context = launchdarkly::ContextBuilder().kind("org", "ld").build(); + ASSERT_TRUE(context.valid()); - auto ev = events::client::IdentifyEventParams{ + auto identify_event = events::client::IdentifyEventParams{ std::chrono::system_clock::now(), - c, + context, }; for (std::size_t i = 0; i < 10; i++) { - ep.AsyncSend(ev); + processor.AsyncSend(identify_event); } - ep.AsyncClose(); - t.join(); + processor.AsyncClose(); + ioc_thread.join(); } diff --git a/libs/common/tests/event_serialization_test.cpp b/libs/common/tests/event_serialization_test.cpp index 233052a2a..90de09e08 100644 --- a/libs/common/tests/event_serialization_test.cpp +++ b/libs/common/tests/event_serialization_test.cpp @@ -13,61 +13,70 @@ namespace launchdarkly::events { TEST(EventSerialization, FeatureEvent) { auto creation_date = std::chrono::system_clock::from_time_t({}); - auto e = events::client::FeatureEvent{ - {creation_date, "key", 17, 2, "value", - EvaluationReason("foo", std::nullopt, std::nullopt, std::nullopt, - std::nullopt, false, std::nullopt), - - 17}, + auto event = events::client::FeatureEvent{ + client::FeatureEventBase(client::FeatureEventParams{ + creation_date, + "key", + ContextBuilder().kind("foo", "bar").build(), + EvaluationResult( + 1, std::nullopt, false, false, std::nullopt, + EvaluationDetailInternal( + Value(42), 2, + Reason("error", std::nullopt, std::nullopt, std::nullopt, + std::nullopt, false, std::nullopt))), + 3, + }), std::map{{"foo", "bar"}}}; - auto event = boost::json::value_from(e); + auto event_json = boost::json::value_from(event); auto result = boost::json::parse( - "{\"creationDate\":0,\"key\":\"key\",\"version\":17,\"variation\":2," - "\"value\":\"value\",\"reason\":{\"kind\":\"foo\"},\"default\":1.7E1," - "\"kind\":\"feature\",\"contextKeys\":{\"foo\":\"bar\"}}"); + R"({"creationDate":0,"key":"key","version":1,"variation":2,"value":4.2E1,"default":3E0,"kind":"feature","contextKeys":{"foo":"bar"}})"); - ASSERT_EQ(result, event); + ASSERT_EQ(result, event_json); } TEST(EventSerialization, DebugEvent) { auto creation_date = std::chrono::system_clock::from_time_t({}); AttributeReference::SetType attrs; ContextFilter filter(false, attrs); - auto e = events::client::DebugEvent{ - {creation_date, "key", 17, 2, "value", - EvaluationReason("foo", std::nullopt, std::nullopt, std::nullopt, - std::nullopt, false, std::nullopt), - - 17}, - filter.filter(ContextBuilder().kind("foo", "bar").build())}; - - auto event = boost::json::value_from(e); + auto context = ContextBuilder().kind("foo", "bar").build(); + auto event = events::client::DebugEvent{ + client::FeatureEventBase(client::FeatureEventParams{ + creation_date, + "key", + context, + EvaluationResult( + 1, std::nullopt, false, false, std::nullopt, + EvaluationDetailInternal( + Value(42), 2, + Reason("error", std::nullopt, std::nullopt, std::nullopt, + std::nullopt, false, std::nullopt))), + 3, + }), + filter.filter(context)}; + + auto event_json = boost::json::value_from(event); auto result = boost::json::parse( - "{\"creationDate\":0,\"key\":\"key\",\"version\":17,\"variation\":2," - "\"value\":\"value\",\"reason\":{\"kind\":\"foo\"},\"default\":1.7E1," - "\"kind\":\"debug\",\"context\":{\"key\":\"bar\",\"kind\":\"foo\"}}"); + R"({"creationDate":0,"key":"key","version":1,"variation":2,"value":4.2E1,"default":3E0,"kind":"debug","context":{"key":"bar","kind":"foo"}})"); - ASSERT_EQ(result, event); + ASSERT_EQ(result, event_json); } TEST(EventSerialization, IdentifyEvent) { auto creation_date = std::chrono::system_clock::from_time_t({}); AttributeReference::SetType attrs; ContextFilter filter(false, attrs); - auto e = events::client::IdentifyEvent{ + auto event = events::client::IdentifyEvent{ creation_date, filter.filter(ContextBuilder().kind("foo", "bar").build())}; - auto event = boost::json::value_from(e); + auto event_json = boost::json::value_from(event); auto result = boost::json::parse( - "{\"kind\":\"identify\",\"creationDate\":0,\"context\":{\"key\":" - "\"bar\",\"kind\":\"foo\"}}"); - - ASSERT_EQ(result, event); + R"({"kind":"identify","creationDate":0,"context":{"key":"bar","kind":"foo"}})"); + ASSERT_EQ(result, event_json); } } // namespace launchdarkly::events diff --git a/libs/common/tests/event_summarizer_test.cpp b/libs/common/tests/event_summarizer_test.cpp new file mode 100644 index 000000000..a2545b6e9 --- /dev/null +++ b/libs/common/tests/event_summarizer_test.cpp @@ -0,0 +1,362 @@ +#include +#include +#include +#include +#include "config/client.hpp" +#include "context_builder.hpp" +#include "events/client_events.hpp" +#include "events/detail/summarizer.hpp" +#include "serialization/events/json_events.hpp" + +using namespace launchdarkly::events; +using namespace launchdarkly::events::client; +using namespace launchdarkly::events::detail; + +static std::chrono::system_clock::time_point TimeZero() { + return std::chrono::system_clock::time_point{}; +} + +static std::chrono::system_clock::time_point Time1000() { + return std::chrono::system_clock::from_time_t(1); +} + +TEST(SummarizerTests, IsEmptyOnConstruction) { + Summarizer summarizer; + ASSERT_TRUE(summarizer.Empty()); +} + +TEST(SummarizerTests, DefaultConstructionUsesZeroStartTime) { + Summarizer summarizer; + ASSERT_EQ(summarizer.start_time(), TimeZero()); +} + +TEST(SummarizerTests, ExplicitStartTimeIsCorrect) { + auto start = std::chrono::system_clock::from_time_t(12345); + Summarizer summarizer(start); + ASSERT_EQ(summarizer.start_time(), start); +} + +struct EvaluationParams { + std::string feature_key; + Version feature_version; + VariationIndex feature_variation; + Value feature_value; + Value feature_default; +}; + +struct SummaryTestCase { + std::string test_name; + + // Each pair represents the params for a feature event, and how many + // events should be created. + std::vector> evaluations; + + // The test assertions check the expected summary counters, which are + // represented by a map from feature key to map of VariationKeys to counter + // value. Example: + // + // "some_feature_key" => { + // (version, variation) => 12, + // ... => 1, + // "other_feature_key" => { + // (version, variation) => 1, + // ... + std::unordered_map> + expected; +}; + +// Creates a FeatureEventParams from the test parameters - a convenience, since +// there are some FeatureEventParams that can be held constant throughout the +// tests and don't need to be specified for each case. +static FeatureEventParams FeatureEventFromParams(EvaluationParams params, + Context context) { + return FeatureEventParams{ + TimeZero(), + params.feature_key, + std::move(context), + launchdarkly::EvaluationResult( + params.feature_version, std::nullopt, false, false, std::nullopt, + launchdarkly::EvaluationDetailInternal( + params.feature_value, params.feature_variation, + launchdarkly::EvaluationReason( + "FALLTHROUGH", std::nullopt, std::nullopt, std::nullopt, + std::nullopt, false, std::nullopt))), + params.feature_default, + }; +} + +class SummaryCounterTestsFixture + : public ::testing::TestWithParam {}; + +TEST_P(SummaryCounterTestsFixture, EventsAreCounted) { + using namespace launchdarkly; + Summarizer summarizer; + + auto test_params = GetParam(); + + auto test_cases = test_params.evaluations; + + auto const context = ContextBuilder().kind("cat", "shadow").build(); + + // For each event in a test case, inject it into the summarizer N number of + // times (where N = the second value in the pair). + for (auto const& eval : test_cases) { + auto params = eval.first; + auto count = eval.second; + + auto const event = FeatureEventFromParams(params, context); + + for (size_t i = 0; i < count; i++) { + summarizer.Update(event); + } + } + + // Then, for each recorded feature, ensure it was expected to be recorded, + // and then ensure the expected count is correct. + for (auto kvp : summarizer.features()) { + auto expected_count = test_params.expected.find(kvp.first); + ASSERT_TRUE(expected_count != test_params.expected.end()); + + for (auto variation : expected_count->second) { + auto const& counter = kvp.second.counters.find(variation.first); + ASSERT_TRUE(counter != kvp.second.counters.end()); + ASSERT_EQ(counter->second.count(), variation.second); + } + } +} + +INSTANTIATE_TEST_SUITE_P( + SummaryCounterTests, + SummaryCounterTestsFixture, + ::testing::Values( + SummaryTestCase{"no evaluations means no counter updates", + {{EvaluationParams{}, 0}}}, + SummaryTestCase{ + "single evaluation of a feature", + {{EvaluationParams{ + "cat-food-amount", + Version(1), + VariationIndex(0), + Value(150.0), + Value(100.0), + }, + 1}}, + {{"cat-food-amount", {{Summarizer::VariationKey(1, 0), 1}}}}}, + SummaryTestCase{ + "100,000 evaluations of same feature", + {{EvaluationParams{ + "cat-food-amount", + Version(1), + VariationIndex(0), + Value(150.0), + Value(100.0), + }, + 100000}}, + {{"cat-food-amount", {{Summarizer::VariationKey(1, 0), 100000}}}}}, + SummaryTestCase{ + "two features, one evaluation each", + {{EvaluationParams{ + "cat-food-amount", + Version(1), + VariationIndex(0), + Value(150.0), + Value(100.0), + }, + 1}, + {EvaluationParams{ + "cat-water-amount", + Version(1), + VariationIndex(0), + Value(500.0), + Value(250.0), + }, + 1}}, + {{"cat-food-amount", {{Summarizer::VariationKey(1, 0), 1}}}, + {"cat-water-amount", {{Summarizer::VariationKey(1, 0), 1}}}}}, + SummaryTestCase{ + "two features, 100,000 evaluations each", + {{EvaluationParams{ + "cat-food-amount", + Version(1), + VariationIndex(0), + Value(150.0), + Value(100.0), + }, + 100000}, + {EvaluationParams{ + "cat-water-amount", + Version(1), + VariationIndex(0), + Value(500.0), + Value(250.0), + }, + 100000}}, + {{"cat-food-amount", {{Summarizer::VariationKey(1, 0), 100000}}}, + {"cat-water-amount", {{Summarizer::VariationKey(1, 0), 100000}}}}}, + SummaryTestCase{"one feature, different variations", + {{EvaluationParams{ + "cat-food-amount", + Version(1), + VariationIndex(0), + Value(100.0), + Value(100.0), + }, + 1}, + {EvaluationParams{ + "cat-food-amount", + Version(1), + VariationIndex(1), + Value(200.0), + Value(100.0), + }, + 1}, + {EvaluationParams{ + "cat-food-amount", + Version(1), + VariationIndex(2), + Value(300.0), + Value(100.0), + }, + 1}}, + {{"cat-food-amount", + {{Summarizer::VariationKey(1, 0), 1}, + {Summarizer::VariationKey(1, 1), 1}, + {Summarizer::VariationKey(1, 2), 1}}}}}, + SummaryTestCase{"one feature, same variation but different versions", + {{EvaluationParams{ + "cat-food-amount", + Version(1), + VariationIndex(0), + Value(100.0), + Value(100.0), + }, + 1}, + {EvaluationParams{ + "cat-food-amount", + Version(2), + VariationIndex(0), + Value(200.0), + Value(100.0), + }, + 1}, + {EvaluationParams{ + "cat-food-amount", + Version(3), + VariationIndex(0), + Value(300.0), + Value(100.0), + }, + 1}}, + {{"cat-food-amount", + {{Summarizer::VariationKey(1, 0), 1}, + {Summarizer::VariationKey(2, 0), 1}, + {Summarizer::VariationKey(3, 0), 1}}}}}, + SummaryTestCase{"one feature, different variation/version combos", + {{EvaluationParams{ + "cat-food-amount", + Version(0), + VariationIndex(0), + Value(0), + Value(0), + }, + 1}, + {EvaluationParams{ + "cat-food-amount", + Version(1), + VariationIndex(0), + Value(1), + Value(1), + }, + 1}, + {EvaluationParams{ + "cat-food-amount", + Version(0), + VariationIndex(1), + Value(2), + Value(2), + }, + 1}, + {EvaluationParams{ + "cat-food-amount", + Version(1), + VariationIndex(1), + Value(3), + Value(3), + }, + 1}}, + {{"cat-food-amount", + {{Summarizer::VariationKey(0, 0), 1}, + {Summarizer::VariationKey(1, 0), 1}, + {Summarizer::VariationKey(0, 1), 1}, + {Summarizer::VariationKey(1, 1), 1}}}}})); + +TEST(SummarizerTests, MissingFlagCreatesCounterUsingDefaultValue) { + using namespace launchdarkly::events::client; + using namespace launchdarkly; + Summarizer summarizer; + + auto const feature_key = "cat-food-amount"; + auto const context = ContextBuilder().kind("cat", "shadow").build(); + auto const feature_default = Value(1); + + auto const event = FeatureEventParams{ + TimeZero(), + feature_key, + context, + EvaluationResult( + 0, std::nullopt, false, false, std::nullopt, + EvaluationDetailInternal( + Value(), std::nullopt, + EvaluationReason("ERROR", "FLAG_NOT_FOUND", std::nullopt, + std::nullopt, std::nullopt, false, + std::nullopt))), + feature_default, + }; + + summarizer.Update(event); + + auto const& features = summarizer.features(); + auto const& feature = features.find(feature_key); + + // There should be an entry for this feature, even though the result was + // FLAG_NOT_FOUND. + ASSERT_TRUE(feature != features.end()); + + // The entry will be keyed on an empty (variation, version) pair, which is + // represented by a default-constructed VariationKey. + auto const& default_counter = + feature->second.counters.find(Summarizer::VariationKey()); + + ASSERT_TRUE(default_counter != feature->second.counters.end()); + + // The counter should contain the default value given in the evaluation. + ASSERT_EQ(default_counter->second.value(), feature_default); + ASSERT_EQ(default_counter->second.count(), 1); +} + +TEST(SummarizerTests, JsonSerialization) { + using namespace launchdarkly::events::client; + using namespace launchdarkly; + Summarizer summarizer; + + auto evaluate = [&](char const* feature_key, std::int32_t count) { + EvaluationParams params{feature_key, 1, 0, Value(3), Value(1)}; + + auto const event = FeatureEventFromParams( + params, ContextBuilder().kind("cat", "shadow").build()); + + for (size_t i = 0; i < count; i++) { + summarizer.Update(event); + } + }; + + evaluate("cat-food-amount", 1); + evaluate("cat-water-amount", 10); + evaluate("cat-toy-amount", 10000); + + auto json = boost::json::value_from(summarizer.Finish(Time1000())); + auto expected = boost::json::parse( + R"({"kind":"summary","startDate":0,"endDate":1000,"features":{"cat-toy-amount":{"default":1E0,"contextKinds":["cat"],"counters":[{"version":1,"variation":0,"value":3E0,"count":10000}]},"cat-water-amount":{"default":1E0,"contextKinds":["cat"],"counters":[{"version":1,"variation":0,"value":3E0,"count":10}]},"cat-food-amount":{"default":1E0,"contextKinds":["cat"],"counters":[{"version":1,"variation":0,"value":3E0,"count":1}]}}} +)"); + ASSERT_EQ(json, expected); +}