diff --git a/contract-tests/server-contract-tests/src/main.cpp b/contract-tests/server-contract-tests/src/main.cpp index 9ec539c7b..2b8e1316e 100644 --- a/contract-tests/server-contract-tests/src/main.cpp +++ b/contract-tests/server-contract-tests/src/main.cpp @@ -47,6 +47,8 @@ int main(int argc, char* argv[]) { srv.add_capability("tls:custom-ca"); srv.add_capability("filtering"); srv.add_capability("filtering-strict"); + srv.add_capability("client-prereq-events"); + net::signal_set signals{ioc, SIGINT, SIGTERM}; boost::asio::spawn(ioc.get_executor(), [&](auto yield) mutable { diff --git a/libs/internal/include/launchdarkly/events/data/common_events.hpp b/libs/internal/include/launchdarkly/events/data/common_events.hpp index 8b2ea48ca..1d88e22cc 100644 --- a/libs/internal/include/launchdarkly/events/data/common_events.hpp +++ b/libs/internal/include/launchdarkly/events/data/common_events.hpp @@ -8,7 +8,6 @@ #include #include -#include namespace launchdarkly::events { diff --git a/libs/internal/include/launchdarkly/events/data/events.hpp b/libs/internal/include/launchdarkly/events/data/events.hpp index a0e00eae6..af5a3a27f 100644 --- a/libs/internal/include/launchdarkly/events/data/events.hpp +++ b/libs/internal/include/launchdarkly/events/data/events.hpp @@ -3,6 +3,8 @@ #include #include +#include + namespace launchdarkly::events { using InputEvent = std::variant #include #include +#include namespace launchdarkly::server_side { @@ -62,6 +63,14 @@ class AllFlagsState { bool track_reason, std::optional debug_events_until_date); + State(std::uint64_t version, + std::optional variation, + std::optional reason, + bool track_events, + bool track_reason, + std::optional debug_events_until_date, + std::vector prerequisites); + /** * @return The flag's version number when it was evaluated. */ @@ -110,6 +119,12 @@ class AllFlagsState { */ [[nodiscard]] bool OmitDetails() const; + /** + * @return The list of prerequisites for this flag in the order they + * were evaluated. + */ + [[nodiscard]] std::vector const& Prerequisites() const; + friend class AllFlagsStateBuilder; private: @@ -120,6 +135,7 @@ class AllFlagsState { bool track_reason_; std::optional debug_events_until_date_; bool omit_details_; + std::vector prerequisites_; }; /** diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index 7b93be27f..15543fb4a 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -30,6 +30,8 @@ target_sources(${LIBNAME} all_flags_state/json_all_flags_state.cpp all_flags_state/all_flags_state_builder.cpp integrations/data_reader/kinds.cpp + prereq_event_recorder/prereq_event_recorder.cpp + prereq_event_recorder/prereq_event_recorder.hpp data_components/change_notifier/change_notifier.hpp data_components/change_notifier/change_notifier.cpp data_components/dependency_tracker/dependency_tracker.hpp diff --git a/libs/server-sdk/src/all_flags_state/all_flags_state.cpp b/libs/server-sdk/src/all_flags_state/all_flags_state.cpp index d2b1254ab..8f33291d5 100644 --- a/libs/server-sdk/src/all_flags_state/all_flags_state.cpp +++ b/libs/server-sdk/src/all_flags_state/all_flags_state.cpp @@ -3,19 +3,36 @@ namespace launchdarkly::server_side { AllFlagsState::State::State( - std::uint64_t version, - std::optional variation, + std::uint64_t const version, + std::optional const variation, std::optional reason, - bool track_events, - bool track_reason, - std::optional debug_events_until_date) + bool const track_events, + bool const track_reason, + std::optional const debug_events_until_date) + : State(version, + variation, + std::move(reason), + track_events, + track_reason, + debug_events_until_date, + std::vector{}) {} + +AllFlagsState::State::State( + std::uint64_t const version, + std::optional const variation, + std::optional reason, + bool const track_events, + bool const track_reason, + std::optional const debug_events_until_date, + std::vector prerequisites) : version_(version), variation_(variation), - reason_(reason), + reason_(std::move(reason)), track_events_(track_events), track_reason_(track_reason), debug_events_until_date_(debug_events_until_date), - omit_details_(false) {} + omit_details_(false), + prerequisites_(std::move(prerequisites)) {} std::uint64_t AllFlagsState::State::Version() const { return version_; @@ -37,6 +54,10 @@ bool AllFlagsState::State::TrackReason() const { return track_reason_; } +std::vector const& AllFlagsState::State::Prerequisites() const { + return prerequisites_; +} + std::optional const& AllFlagsState::State::DebugEventsUntilDate() const { return debug_events_until_date_; @@ -80,7 +101,8 @@ bool operator==(AllFlagsState::State const& lhs, lhs.TrackEvents() == rhs.TrackEvents() && lhs.TrackReason() == rhs.TrackReason() && lhs.DebugEventsUntilDate() == rhs.DebugEventsUntilDate() && - lhs.OmitDetails() == rhs.OmitDetails(); + lhs.OmitDetails() == rhs.OmitDetails() && + lhs.Prerequisites() == rhs.Prerequisites(); } } // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/all_flags_state/json_all_flags_state.cpp b/libs/server-sdk/src/all_flags_state/json_all_flags_state.cpp index 25a095fdd..e4981dad0 100644 --- a/libs/server-sdk/src/all_flags_state/json_all_flags_state.cpp +++ b/libs/server-sdk/src/all_flags_state/json_all_flags_state.cpp @@ -31,6 +31,11 @@ void tag_invoke(boost::json::value_from_tag const& unused, obj.emplace("debugEventsUntilDate", boost::json::value_from(*date)); } } + + if (auto const& prerequisites = state.Prerequisites(); + !prerequisites.empty()) { + obj.emplace("prerequisites", boost::json::value_from(prerequisites)); + } } void tag_invoke(boost::json::value_from_tag const& unused, diff --git a/libs/server-sdk/src/client_impl.cpp b/libs/server-sdk/src/client_impl.cpp index 37eaf1b18..b09f85c48 100644 --- a/libs/server-sdk/src/client_impl.cpp +++ b/libs/server-sdk/src/client_impl.cpp @@ -5,6 +5,7 @@ #include "data_systems/lazy_load/lazy_load_system.hpp" #include "data_systems/offline.hpp" #include "evaluation/evaluation_stack.hpp" +#include "prereq_event_recorder/prereq_event_recorder.hpp" #include "data_interfaces/system/idata_system.hpp" @@ -181,8 +182,6 @@ AllFlagsState ClientImpl::AllFlagsState(Context const& context, AllFlagsStateBuilder builder{options}; - EventScope no_events; - auto all_flags = data_system_->AllFlags(); // Because evaluating the flags may access many segments, tell the data @@ -191,7 +190,7 @@ AllFlagsState ClientImpl::AllFlagsState(Context const& context, // memory.) auto _ = data_system_->AllSegments(); - for (auto const& [k, v] : all_flags) { + for (auto const& [key, v] : all_flags) { if (!v || !v->item) { continue; } @@ -203,15 +202,20 @@ AllFlagsState ClientImpl::AllFlagsState(Context const& context, continue; } - EvaluationDetail detail = - evaluator_.Evaluate(flag, context, no_events); + PrereqEventRecorder recorder{key}; + + EvaluationDetail detail = evaluator_.Evaluate( + flag, context, + EventScope{&recorder, EventFactory::WithoutReasons()}); bool in_experiment = flag.IsExperimentationEnabled(detail.Reason()); - builder.AddFlag(k, detail.Value(), + + builder.AddFlag(key, detail.Value(), AllFlagsState::State{ flag.Version(), detail.VariationIndex(), detail.Reason(), flag.trackEvents || in_experiment, - in_experiment, flag.debugEventsUntilDate}); + in_experiment, flag.debugEventsUntilDate, + std::move(recorder).TakePrerequisites()}); } return builder.Build(); diff --git a/libs/server-sdk/src/events/event_factory.cpp b/libs/server-sdk/src/events/event_factory.cpp index 2be43f6fe..b781de018 100644 --- a/libs/server-sdk/src/events/event_factory.cpp +++ b/libs/server-sdk/src/events/event_factory.cpp @@ -3,8 +3,7 @@ #include namespace launchdarkly::server_side { -EventFactory::EventFactory( - launchdarkly::server_side::EventFactory::ReasonPolicy reason_policy) +EventFactory::EventFactory(ReasonPolicy const reason_policy) : reason_policy_(reason_policy), now_([]() { return events::Date{std::chrono::system_clock::now()}; }) {} diff --git a/libs/server-sdk/src/prereq_event_recorder/prereq_event_recorder.cpp b/libs/server-sdk/src/prereq_event_recorder/prereq_event_recorder.cpp new file mode 100644 index 000000000..5396b988d --- /dev/null +++ b/libs/server-sdk/src/prereq_event_recorder/prereq_event_recorder.cpp @@ -0,0 +1,30 @@ +#include "prereq_event_recorder.hpp" + +namespace launchdarkly::server_side { + +PrereqEventRecorder::PrereqEventRecorder(std::string flag_key) + : flag_key_(std::move(flag_key)) {} + +void PrereqEventRecorder::SendAsync(events::InputEvent const event) { + if (auto const* feat = std::get_if(&event)) { + if (auto const prereq_of = feat->prereq_of) { + if (*prereq_of == flag_key_) { + prereqs_.push_back(feat->key); + } + } + } +} + +void PrereqEventRecorder::FlushAsync() {} + +void PrereqEventRecorder::ShutdownAsync() {} + +std::vector const& PrereqEventRecorder::Prerequisites() const { + return prereqs_; +} + +std::vector&& PrereqEventRecorder::TakePrerequisites() && { + return std::move(prereqs_); +} + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/prereq_event_recorder/prereq_event_recorder.hpp b/libs/server-sdk/src/prereq_event_recorder/prereq_event_recorder.hpp new file mode 100644 index 000000000..f25cc0fa8 --- /dev/null +++ b/libs/server-sdk/src/prereq_event_recorder/prereq_event_recorder.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include +#include + +namespace launchdarkly::server_side { + +/** + * This class is meant only to record direct prerequisites of a flag. That is, + * although it will be passed events for all prerequisites seen during an + * evaluation via SendAsync, it will only store those that are a direct + * prerequisite of the parent flag passed in the constructor. + * + * As a future improvement, it would be possible to unify the EventScope + * mechanism currently used by the Evaluator to send events with a class + * similar to this one, or to refactor the Evaluator to include prerequisite + * information in the returned EvaluationDetail (or a new Result class, which + * would be a composite of the EvaluationDetail and a vector of prerequisites.) + */ +class PrereqEventRecorder final : public events::IEventProcessor { + public: + explicit PrereqEventRecorder(std::string flag_key); + + void SendAsync(events::InputEvent event) override; + + /* No-op */ + void FlushAsync() override; + + /* No-op */ + void ShutdownAsync() override; + + std::vector const& Prerequisites() const; + + std::vector&& TakePrerequisites() &&; + + private: + std::string const flag_key_; + std::vector prereqs_; +}; + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/tests/all_flags_state_test.cpp b/libs/server-sdk/tests/all_flags_state_test.cpp index 96dbc4f8f..390e9ff10 100644 --- a/libs/server-sdk/tests/all_flags_state_test.cpp +++ b/libs/server-sdk/tests/all_flags_state_test.cpp @@ -42,6 +42,37 @@ TEST(AllFlagsTest, DefaultOptions) { ASSERT_EQ(got, expected); } +TEST(AllFlagsTest, DefaultOptionsExposesPrerequisiteRelations) { + AllFlagsStateBuilder builder{AllFlagsState::Options::Default}; + + builder.AddFlag("myFlag", true, + AllFlagsState::State{42, + 1, + std::nullopt, + false, + false, + std::nullopt, + {"prereq1", "prereq2"}}); + + auto state = builder.Build(); + ASSERT_TRUE(state.Valid()); + + auto expected = boost::json::parse(R"({ + "myFlag": true, + "$flagsState": { + "myFlag": { + "version": 42, + "variation": 1, + "prerequisites": ["prereq1", "prereq2"] + } + }, + "$valid": true + })"); + + auto got = boost::json::value_from(state); + ASSERT_EQ(got, expected); +} + TEST(AllFlagsTest, DetailsOnlyForTrackedFlags) { AllFlagsStateBuilder builder{ AllFlagsState::Options::DetailsOnlyForTrackedFlags}; @@ -81,6 +112,55 @@ TEST(AllFlagsTest, DetailsOnlyForTrackedFlags) { ASSERT_EQ(got, expected); } +TEST(AllFlagsTest, DetailsOnlyForTrackedFlagsExposesPrerequisiteRelations) { + AllFlagsStateBuilder builder{ + AllFlagsState::Options::DetailsOnlyForTrackedFlags}; + builder.AddFlag("myFlagTracked", true, + AllFlagsState::State{42, + 1, + EvaluationReason::Fallthrough(false), + true, + true, + std::nullopt, + {"prereq1", "prereq2"}}); + builder.AddFlag("myFlagUntracked", true, + AllFlagsState::State{42, + 1, + EvaluationReason::Fallthrough(false), + false, + false, + std::nullopt, + {"prereq1", "prereq2"}}); + + auto state = builder.Build(); + ASSERT_TRUE(state.Valid()); + + auto expected = boost::json::parse(R"({ + "myFlagTracked" : true, + "myFlagUntracked" : true, + "$flagsState": { + "myFlagTracked": { + "version": 42, + "variation": 1, + "reason":{ + "kind" : "FALLTHROUGH" + }, + "trackReason" : true, + "trackEvents" : true, + "prerequisites" : ["prereq1", "prereq2"] + }, + "myFlagUntracked" : { + "variation" : 1, + "prerequisites" : ["prereq1", "prereq2"] + } + }, + "$valid": true + })"); + + auto got = boost::json::value_from(state); + ASSERT_EQ(got, expected); +} + TEST(AllFlagsTest, IncludeReasons) { AllFlagsStateBuilder builder{AllFlagsState::Options::IncludeReasons}; builder.AddFlag( @@ -108,6 +188,38 @@ TEST(AllFlagsTest, IncludeReasons) { ASSERT_EQ(got, expected); } +TEST(AllFlagsTest, IncludeReasonsExposesPrerequisiteRelations) { + AllFlagsStateBuilder builder{AllFlagsState::Options::IncludeReasons}; + builder.AddFlag("myFlag", true, + AllFlagsState::State{42, + 1, + EvaluationReason::Fallthrough(false), + false, + false, + std::nullopt, + {"prereq1", "prereq2"}}); + auto state = builder.Build(); + ASSERT_TRUE(state.Valid()); + + auto expected = boost::json::parse(R"({ + "myFlag": true, + "$flagsState": { + "myFlag": { + "version": 42, + "variation": 1, + "reason" : { + "kind": "FALLTHROUGH" + }, + "prerequisites": ["prereq1", "prereq2"] + } + }, + "$valid": true + })"); + + auto got = boost::json::value_from(state); + ASSERT_EQ(got, expected); +} + TEST(AllFlagsTest, FlagValues) { AllFlagsStateBuilder builder{AllFlagsState::Options::Default}; @@ -130,12 +242,38 @@ TEST(AllFlagsTest, FlagValues) { })); } -TEST(AllFlagsTest, FlagState) { +TEST(AllFlagsTest, FlagStatePassedInIsEquivalentToRetrievedState) { + AllFlagsStateBuilder builder{AllFlagsState::Options::Default}; + + std::size_t const kNumFlags = 10; + + AllFlagsState::State state{ + 42, 1, std::nullopt, false, false, std::nullopt, {"a", "b", "c"}}; + for (std::size_t i = 0; i < kNumFlags; i++) { + builder.AddFlag("myFlag" + std::to_string(i), "value", state); + } + + auto all_flags_state = builder.Build(); + + auto const& states = all_flags_state.States(); + + ASSERT_EQ(states.size(), kNumFlags); + + ASSERT_TRUE(std::all_of(states.begin(), states.end(), [&](auto const& kvp) { + return kvp.second == state; + })); +} + +// Similar to the test above but with the prerequisite list reversed, as a +// sanity check that the list order is preserved. +TEST(AllFlagsTest, + FlagStatePassedInIsEquivalentToRetrievedState_ReversedPrereqs) { AllFlagsStateBuilder builder{AllFlagsState::Options::Default}; std::size_t const kNumFlags = 10; - AllFlagsState::State state{42, 1, std::nullopt, false, false, std::nullopt}; + AllFlagsState::State state{ + 42, 1, std::nullopt, false, false, std::nullopt, {"c", "b", "a"}}; for (std::size_t i = 0; i < kNumFlags; i++) { builder.AddFlag("myFlag" + std::to_string(i), "value", state); } diff --git a/libs/server-sdk/tests/prereq_event_recorder_test.cpp b/libs/server-sdk/tests/prereq_event_recorder_test.cpp new file mode 100644 index 000000000..00d32c56e --- /dev/null +++ b/libs/server-sdk/tests/prereq_event_recorder_test.cpp @@ -0,0 +1,94 @@ +#include + +#include "events/event_factory.hpp" +#include "prereq_event_recorder/prereq_event_recorder.hpp" + +#include + +using namespace launchdarkly; +using namespace launchdarkly::server_side; +using namespace launchdarkly::events; + +TEST(PrereqEventRecorderTest, EmptyByDefault) { + PrereqEventRecorder recorder{"foo"}; + ASSERT_TRUE(recorder.Prerequisites().empty()); + + std::vector prereqs = std::move(recorder).TakePrerequisites(); + ASSERT_TRUE(prereqs.empty()); +} + +TEST(PrereqEventRecorderTest, RecordsPrerequisites) { + std::string const flag = "toplevel"; + + PrereqEventRecorder recorder{flag}; + + auto factory = EventFactory::WithoutReasons(); + + auto const context = ContextBuilder().Kind("cat", "shadow").Build(); + + recorder.SendAsync(factory.Eval("prereq1", context, std::nullopt, + EvaluationReason::Fallthrough(false), false, + flag)); + + recorder.SendAsync(factory.Eval("prereq2", context, std::nullopt, + EvaluationReason::Fallthrough(false), false, + flag)); + + auto const expectedPrereqs = std::vector{"prereq1", "prereq2"}; + ASSERT_EQ(recorder.Prerequisites(), expectedPrereqs); +} + +TEST(PrereqEventRecorderTest, IgnoresIrrelevantEvents) { + PrereqEventRecorder recorder{"foo"}; + + auto factory = EventFactory::WithoutReasons(); + + auto const context = ContextBuilder().Kind("cat", "shadow").Build(); + + recorder.SendAsync(factory.Identify(context)); + recorder.SendAsync(factory.UnknownFlag( + "flag", context, EvaluationReason::Fallthrough(false), true)); + recorder.SendAsync(factory.Custom(context, "event", std::nullopt, 1.0)); + + ASSERT_TRUE(recorder.Prerequisites().empty()); +} + +TEST(PrereqEventRecorderTest, IgnoresEvalEventsWithoutPrereqOf) { + PrereqEventRecorder recorder{"toplevel"}; + + auto factory = EventFactory::WithoutReasons(); + + auto const context = ContextBuilder().Kind("cat", "shadow").Build(); + + // Receiving an eval event without a prereq_of field shouldn't actually + // happen when calling AllFlags, but regardless we should ignore it because + // that would signify that it isn't a prerequisite. + recorder.SendAsync(factory.Eval("not-a-prereq", context, std::nullopt, + EvaluationReason::Fallthrough(false), false, + std::nullopt)); + + ASSERT_TRUE(recorder.Prerequisites().empty()); +} + +TEST(PrereqEventRecorderTest, TakesPrerequisites) { + std::string const flag = "toplevel"; + + PrereqEventRecorder recorder{flag}; + + auto factory = EventFactory::WithoutReasons(); + + auto const context = ContextBuilder().Kind("cat", "shadow").Build(); + + recorder.SendAsync(factory.Eval("prereq1", context, std::nullopt, + EvaluationReason::Fallthrough(false), false, + flag)); + + recorder.SendAsync(factory.Eval("prereq2", context, std::nullopt, + EvaluationReason::Fallthrough(false), false, + flag)); + + auto const expectedPrereqs = std::vector{"prereq1", "prereq2"}; + auto gotPrereqs = std::move(recorder).TakePrerequisites(); + ASSERT_EQ(gotPrereqs, expectedPrereqs); + ASSERT_TRUE(recorder.Prerequisites().empty()); +}