diff --git a/openfeature/BUILD b/openfeature/BUILD index a905d9b..5539c2b 100644 --- a/openfeature/BUILD +++ b/openfeature/BUILD @@ -104,6 +104,7 @@ cc_library( ":reason", ":resolution_details", ":value", + "//openfeature/exceptions:open_feature_exceptions" ], ) @@ -118,6 +119,10 @@ cc_library( hdrs = ["evaluation_context.h"], srcs = ["evaluation_context.cpp"], include_prefix = "openfeature", + deps = [ + ":value", + "@abseil-cpp//absl/strings", + ], ) cc_library( @@ -368,5 +373,7 @@ cc_library( ":error_code", ":flag_metadata", ":reason", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/time", ], ) diff --git a/openfeature/client_api.cpp b/openfeature/client_api.cpp index 56b72ea..135ce2b 100644 --- a/openfeature/client_api.cpp +++ b/openfeature/client_api.cpp @@ -2,6 +2,7 @@ #include +#include "openfeature/exceptions/open_feature_exceptions.h" #include "openfeature/flag_metadata.h" #include "openfeature/flag_type_value.h" #include "openfeature/global_context_manager.h" @@ -541,7 +542,8 @@ std::unique_ptr ClientAPI::EvaluateFlag( // Error stage if (has_error) { - std::runtime_error error(error_message); + OpenFeatureException error(error_code.value_or(ErrorCode::kGeneral), + error_message); HookSupport::ExecuteErrorHooks( reverse_hooks, flag_key, flag_type, default_value, merged_context, client_metadata, provider_metadata, hints, hook_data_map, error); diff --git a/openfeature/error_code.h b/openfeature/error_code.h index 2194128..ca286fe 100644 --- a/openfeature/error_code.h +++ b/openfeature/error_code.h @@ -1,6 +1,10 @@ #ifndef CPP_SDK_INCLUDE_OPENFEATURE_ERROR_CODE_H_ #define CPP_SDK_INCLUDE_OPENFEATURE_ERROR_CODE_H_ +#include +#include +#include + namespace openfeature { // Represents a standardized error code returned when flag evaluation fails. @@ -8,7 +12,7 @@ namespace openfeature { // The ErrorCode enum provides a set of predefined error types that can be // returned in the Provider Evaluation of a failed flag evaluation. This allows // application authors to programmatically handle specific types of failures. -enum class ErrorCode { +enum class ErrorCode : std::uint8_t { kProviderNotReady, // The value was resolved before the provider was // initialized. kFlagNotFound, // The flag could not be found. @@ -24,8 +28,30 @@ enum class ErrorCode { kGeneral // The error was for a reason not enumerated above. }; -// TODO: Implement a function to convert ErrorCode to string representation if -// needed. +constexpr std::string_view ToString(ErrorCode code) noexcept { + switch (code) { + case ErrorCode::kProviderNotReady: + return "PROVIDER_NOT_READY"; + case ErrorCode::kFlagNotFound: + return "FLAG_NOT_FOUND"; + case ErrorCode::kParseError: + return "PARSE_ERROR"; + case ErrorCode::kTypeMismatch: + return "TYPE_MISMATCH"; + case ErrorCode::kTargetingKeyMissing: + return "TARGETING_KEY_MISSING"; + case ErrorCode::kInvalidContext: + return "INVALID_CONTEXT"; + case ErrorCode::kProviderFatal: + return "PROVIDER_FATAL"; + case ErrorCode::kGeneral: + return "GENERAL"; + } + return "GENERAL"; +} +inline std::ostream& operator<<(std::ostream& output_stream, ErrorCode code) { + return output_stream << ToString(code); +} } // namespace openfeature diff --git a/openfeature/evaluation_context.cpp b/openfeature/evaluation_context.cpp index c62eb6e..122432b 100644 --- a/openfeature/evaluation_context.cpp +++ b/openfeature/evaluation_context.cpp @@ -1,5 +1,11 @@ #include "evaluation_context.h" +#include +#include + +#include "absl/strings/escaping.h" +#include "openfeature/value.h" + namespace openfeature { EvaluationContext::EvaluationContext(std::optional targeting_key, @@ -59,6 +65,49 @@ EvaluationContext EvaluationContext::Merge( return builder.Build(); } +std::string EvaluationContext::ToString() const { + std::ostringstream string_stream; + string_stream << "{"; + bool first = true; + if (auto key = GetTargetingKey(); key.has_value()) { + string_stream << R"("targeting_key": ")" << absl::Utf8SafeCEscape(*key) + << "\""; + first = false; + } + for (const auto& [attr_key, attr_value] : GetAttributes()) { + if (!first) { + string_stream << ", "; + } + first = false; + string_stream << "\"" << absl::Utf8SafeCEscape(attr_key) << "\": "; + if (attr_value.type() == typeid(std::string)) { + string_stream << "\"" + << absl::Utf8SafeCEscape( + std::any_cast(attr_value)) + << "\""; + } else if (attr_value.type() == typeid(bool)) { + string_stream << (std::any_cast(attr_value) ? "true" : "false"); + } else if (attr_value.type() == typeid(int)) { + string_stream << std::any_cast(attr_value); + } else if (attr_value.type() == typeid(int64_t)) { + string_stream << std::any_cast(attr_value); + } else if (attr_value.type() == typeid(double)) { + string_stream << std::any_cast(attr_value); + } else if (attr_value.type() == typeid(Value)) { + string_stream << std::any_cast(attr_value); + } else { + string_stream << "\"\""; + } + } + string_stream << "}"; + return string_stream.str(); +} + +std::ostream& operator<<(std::ostream& output_stream, + const EvaluationContext& ctx) { + return output_stream << ctx.ToString(); +} + EvaluationContext::Builder& EvaluationContext::Builder::WithTargetingKey( std::string key) { this->targeting_key_ = std::move(key); diff --git a/openfeature/evaluation_context.h b/openfeature/evaluation_context.h index c82c80e..d4966dc 100644 --- a/openfeature/evaluation_context.h +++ b/openfeature/evaluation_context.h @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include #include @@ -34,6 +36,9 @@ class EvaluationContext { static EvaluationContext Merge( std::initializer_list contexts); + // Serializes the evaluation context into a human-readable JSON-like string. + std::string ToString() const; + private: EvaluationContext(std::optional targeting_key, std::map attributes); @@ -58,6 +63,9 @@ class EvaluationContext::Builder { std::map attributes_; }; +std::ostream& operator<<(std::ostream& output_stream, + const EvaluationContext& context); + } // namespace openfeature #endif // CPP_SDK_INCLUDE_OPENFEATURE_EVALUATION_CONTEXT_H_ diff --git a/openfeature/exceptions/BUILD b/openfeature/exceptions/BUILD new file mode 100644 index 0000000..6e5fe35 --- /dev/null +++ b/openfeature/exceptions/BUILD @@ -0,0 +1,14 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") + +package( + default_visibility = ["//visibility:public"], +) + +cc_library( + name = "open_feature_exceptions", + hdrs = ["open_feature_exceptions.h"], + include_prefix = "openfeature/exceptions", + deps = [ + "//openfeature:error_code" + ], +) diff --git a/openfeature/exceptions/open_feature_exceptions.h b/openfeature/exceptions/open_feature_exceptions.h new file mode 100644 index 0000000..cd657de --- /dev/null +++ b/openfeature/exceptions/open_feature_exceptions.h @@ -0,0 +1,28 @@ +#ifndef CPP_SDK_INCLUDE_OPENFEATURE_OPENFEATURE_EXCEPTION_H_ +#define CPP_SDK_INCLUDE_OPENFEATURE_OPENFEATURE_EXCEPTION_H_ + +#include +#include +#include + +#include "openfeature/error_code.h" + +namespace openfeature { + +class OpenFeatureException : public std::runtime_error { + public: + explicit OpenFeatureException(ErrorCode error_code, + const std::string& message = "") + : std::runtime_error(message.empty() ? std::string(ToString(error_code)) + : message), + error_code_(error_code) {} + + ErrorCode GetErrorCode() const noexcept { return error_code_; } + + private: + ErrorCode error_code_{ErrorCode::kGeneral}; +}; + +} // namespace openfeature + +#endif // CPP_SDK_INCLUDE_OPENFEATURE_OPENFEATURE_EXCEPTION_H_ diff --git a/openfeature/hooks/BUILD b/openfeature/hooks/BUILD new file mode 100644 index 0000000..d942733 --- /dev/null +++ b/openfeature/hooks/BUILD @@ -0,0 +1,31 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") + +package( + default_visibility = ["//visibility:public"], +) + +cc_library( + name = "log_level", + hdrs = ["log_level.h"], + include_prefix = "openfeature/hooks", +) + +cc_library( + name = "logging_hook", + srcs = ["logging_hook.cpp"], + hdrs = ["logging_hook.h"], + include_prefix = "openfeature/hooks", + deps = [ + ":log_level", + "//openfeature:error_code", + "//openfeature:evaluation_context", + "//openfeature/exceptions:open_feature_exceptions", + "//openfeature:general_flag_evaluation_details", + "//openfeature:general_hook", + "//openfeature:general_hook_context", + "//openfeature:hook_hints", + "//openfeature:reason", + "//openfeature:value", + "@abseil-cpp//absl/strings", + ], +) diff --git a/openfeature/hooks/log_level.h b/openfeature/hooks/log_level.h new file mode 100644 index 0000000..b1a8eea --- /dev/null +++ b/openfeature/hooks/log_level.h @@ -0,0 +1,13 @@ +#ifndef CPP_SDK_INCLUDE_OPENFEATURE_HOOKS_LOG_LEVEL_H_ +#define CPP_SDK_INCLUDE_OPENFEATURE_HOOKS_LOG_LEVEL_H_ + +#include + +namespace openfeature { + +// Represents severity levels for logging flag evaluation events. +enum class LogLevel : std::uint8_t { kDebug, kError }; + +} // namespace openfeature + +#endif // CPP_SDK_INCLUDE_OPENFEATURE_HOOKS_LOG_LEVEL_H_ diff --git a/openfeature/hooks/logging_hook.cpp b/openfeature/hooks/logging_hook.cpp new file mode 100644 index 0000000..20b8bba --- /dev/null +++ b/openfeature/hooks/logging_hook.cpp @@ -0,0 +1,137 @@ +#include "openfeature/hooks/logging_hook.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "absl/strings/escaping.h" +#include "openfeature/evaluation_context.h" +#include "openfeature/exceptions/open_feature_exceptions.h" +#include "openfeature/general_flag_evaluation_details.h" +#include "openfeature/general_hook.h" +#include "openfeature/general_hook_context.h" +#include "openfeature/hook_hints.h" +#include "openfeature/hooks/log_level.h" +#include "openfeature/reason.h" +#include "openfeature/value.h" + +namespace openfeature { + +LoggingHook::LoggingHook(LogCallback logger, bool include_eval_context, + LogLevel default_log_level) + : include_eval_context_(include_eval_context), logger_(std::move(logger)) { + if (!logger_) { + // Default logger prints to stderr/stdout and filters by default_log_level. + logger_ = [default_log_level](LogLevel level, const std::string& msg) { + if (level < default_log_level) { + return; + } + if (level == LogLevel::kError) { + std::cerr << "[ERROR] " << msg << '\n'; + } else { + std::cout << "[DEBUG] " << msg << '\n'; + } + }; + } +} + +void LoggingHook::Log(LogLevel level, const std::string& msg) const { + if (logger_) { + try { + logger_(level, msg); + } catch (const std::exception& e) { + std::cerr << "[ERROR] Exception in logging hook logger: " << e.what() + << '\n'; + } catch (...) { + std::cerr << "[ERROR] Unknown exception in logging hook logger\n"; + } + } +} + +std::optional LoggingHook::Before( + const GeneralHookContext& ctx, const HookHints& hints) { + std::ostringstream log_stream; + log_stream << "stage=before" + << ", domain=\"" + << absl::Utf8SafeCEscape(ctx.GetClientMetadata().name) << "\"" + << ", provider_name=\"" + << absl::Utf8SafeCEscape(ctx.GetProviderMetadata().name) << "\"" + << ", flag_key=\"" << absl::Utf8SafeCEscape(ctx.GetFlagKey()) + << "\"" + << ", default_value=" << ctx.GetDefaultValueAsValue().ToString(); + + if (include_eval_context_) { + log_stream << ", evaluation_context=" + << ctx.GetEvaluationContext().ToString(); + } + + Log(LogLevel::kDebug, log_stream.str()); + return std::nullopt; +} + +void LoggingHook::After(const GeneralHookContext& ctx, + const GeneralFlagEvaluationDetails& details, + const HookHints& hints) { + std::ostringstream log_stream; + log_stream << "stage=after" + << ", domain=\"" + << absl::Utf8SafeCEscape(ctx.GetClientMetadata().name) << "\"" + << ", provider_name=\"" + << absl::Utf8SafeCEscape(ctx.GetProviderMetadata().name) << "\"" + << ", flag_key=\"" << absl::Utf8SafeCEscape(ctx.GetFlagKey()) + << "\"" + << ", default_value=" << ctx.GetDefaultValueAsValue().ToString() + << ", reason=\"" + << absl::Utf8SafeCEscape(ToString(details.GetReason())) << "\"" + << ", variant="; + if (details.GetVariant().has_value()) { + log_stream << "\"" << absl::Utf8SafeCEscape(*details.GetVariant()) << "\""; + } else { + log_stream << "null"; + } + log_stream << ", value=" << details.GetValueAsValue().ToString(); + if (include_eval_context_) { + log_stream << ", evaluation_context=" + << ctx.GetEvaluationContext().ToString(); + } + Log(LogLevel::kDebug, log_stream.str()); +} + +void LoggingHook::Error(const GeneralHookContext& ctx, + const std::exception& error, const HookHints& hints) { + ErrorCode code = ErrorCode::kGeneral; + if (const auto* of_err = dynamic_cast(&error)) { + code = of_err->GetErrorCode(); + } + std::ostringstream log_stream; + log_stream << "stage=error" + << ", domain=\"" + << absl::Utf8SafeCEscape(ctx.GetClientMetadata().name) << "\"" + << ", provider_name=\"" + << absl::Utf8SafeCEscape(ctx.GetProviderMetadata().name) << "\"" + << ", flag_key=\"" << absl::Utf8SafeCEscape(ctx.GetFlagKey()) + << "\"" + << ", default_value=" << ctx.GetDefaultValueAsValue().ToString() + << ", error_code=\"" << absl::Utf8SafeCEscape(ToString(code)) + << "\"" + << ", error_message=\"" << absl::Utf8SafeCEscape(error.what()) + << "\""; + + if (include_eval_context_) { + log_stream << ", evaluation_context=" + << ctx.GetEvaluationContext().ToString(); + } + Log(LogLevel::kError, log_stream.str()); +} + +void LoggingHook::Finally(const GeneralHookContext& ctx, + const GeneralFlagEvaluationDetails& details, + const HookHints& hints) { + // Stage `finally` is a no-op per Appendix A +} + +} // namespace openfeature diff --git a/openfeature/hooks/logging_hook.h b/openfeature/hooks/logging_hook.h new file mode 100644 index 0000000..86856f0 --- /dev/null +++ b/openfeature/hooks/logging_hook.h @@ -0,0 +1,54 @@ +#ifndef CPP_SDK_INCLUDE_OPENFEATURE_HOOKS_LOGGING_HOOK_H_ +#define CPP_SDK_INCLUDE_OPENFEATURE_HOOKS_LOGGING_HOOK_H_ + +#include +#include +#include +#include + +#include "openfeature/evaluation_context.h" +#include "openfeature/general_flag_evaluation_details.h" +#include "openfeature/general_hook.h" +#include "openfeature/general_hook_context.h" +#include "openfeature/hook_hints.h" +#include "openfeature/hooks/log_level.h" + +namespace openfeature { + +using LogCallback = + std::function; + +// A hook for logging flag evaluations. +// Useful for debugging. +// Flag evaluation data is logged at debug and error in before/after stages and +// error stages, respectively. +class LoggingHook : public GeneralHook { + public: + // Constructs a LoggingHook. + // If `logger` is nullptr, a default logger is used that writes error logs to + // std::cerr and debug logs to std::cout (filtered by `default_log_level`). + explicit LoggingHook(LogCallback logger = nullptr, + bool include_eval_context = false, + LogLevel default_log_level = LogLevel::kError); + + std::optional Before(const GeneralHookContext& ctx, + const HookHints& hints) override; + void After(const GeneralHookContext& ctx, + const GeneralFlagEvaluationDetails& details, + const HookHints& hints) override; + void Error(const GeneralHookContext& ctx, const std::exception& error, + const HookHints& hints) override; + void Finally(const GeneralHookContext& ctx, + const GeneralFlagEvaluationDetails& details, + const HookHints& hints) override; + + private: + void Log(LogLevel level, const std::string& msg) const; + + bool include_eval_context_{false}; + LogCallback logger_; +}; + +} // namespace openfeature + +#endif // CPP_SDK_INCLUDE_OPENFEATURE_HOOKS_LOGGING_HOOK_H_ diff --git a/openfeature/reason.h b/openfeature/reason.h index 6b59b00..fbae844 100644 --- a/openfeature/reason.h +++ b/openfeature/reason.h @@ -1,13 +1,17 @@ #ifndef CPP_SDK_INCLUDE_OPENFEATURE_REASON_H_ #define CPP_SDK_INCLUDE_OPENFEATURE_REASON_H_ +#include +#include +#include + namespace openfeature { // Represents the reason for a particular flag evaluation result. // // The Reason enum provides a standardized explanation for the // value returned by a flag evaluation. -enum class Reason { +enum class Reason : std::uint8_t { kStatic, // The resolved value is static (no dynamic evaluation). kDefault, // The resolved value fell back to a pre-configured value (no // dynamic evaluation occurred or dynamic evaluation yielded no @@ -23,8 +27,32 @@ enum class Reason { kError // The resolved value was the result of an error. }; -// TODO: Implement a function to convert Reason to string representation if -// needed. +constexpr std::string_view ToString(Reason reason) noexcept { + switch (reason) { + case Reason::kStatic: + return "STATIC"; + case Reason::kDefault: + return "DEFAULT"; + case Reason::kTargetingMatch: + return "TARGETING_MATCH"; + case Reason::kSplit: + return "SPLIT"; + case Reason::kCached: + return "CACHED"; + case Reason::kDisabled: + return "DISABLED"; + case Reason::kUnknown: + return "UNKNOWN"; + case Reason::kStale: + return "STALE"; + case Reason::kError: + return "ERROR"; + } + return "UNKNOWN"; +} +inline std::ostream& operator<<(std::ostream& output_stream, Reason reason) { + return output_stream << ToString(reason); +} } // namespace openfeature diff --git a/openfeature/value.cpp b/openfeature/value.cpp index 64db15d..9066f3a 100644 --- a/openfeature/value.cpp +++ b/openfeature/value.cpp @@ -1,6 +1,12 @@ #include "openfeature/value.h" #include +#include +#include + +#include "absl/strings/escaping.h" +#include "absl/strings/str_cat.h" +#include "absl/time/time.h" namespace openfeature { @@ -110,8 +116,7 @@ std::optional Value::AsInt() const { return *val; } if (const auto* val = std::get_if(&inner_value_)) { - constexpr double kRoundingOffset = 0.5; - return static_cast(std::floor(*val + kRoundingOffset)); + return static_cast(std::round(*val)); } return std::nullopt; } @@ -194,4 +199,79 @@ bool operator==(const Value& lhs, const Value& rhs) { bool operator!=(const Value& lhs, const Value& rhs) { return !(lhs == rhs); } +namespace { + +void FormatList(std::ostream& stream, const std::vector* list) { + if (list == nullptr) { + stream << "null"; + return; + } + stream << "["; + for (size_t index = 0; index < list->size(); ++index) { + if (index > 0) { + stream << ", "; + } + stream << (*list)[index]; + } + stream << "]"; +} + +void FormatStructure(std::ostream& stream, + const std::map* map) { + if (map == nullptr) { + stream << "null"; + return; + } + stream << "{"; + bool first = true; + for (const auto& [key, value] : *map) { + if (!first) { + stream << ", "; + } + first = false; + stream << "\"" << absl::Utf8SafeCEscape(key) << "\": " << value; + } + stream << "}"; +} + +struct ValueFormatter { + std::ostream& stream; + + void operator()(std::monostate /*unused*/) const { stream << "null"; } + void operator()(bool val) const { stream << (val ? "true" : "false"); } + void operator()(int64_t val) const { stream << absl::StrCat(val); } + void operator()(double val) const { stream << absl::StrCat(val); } + void operator()(const std::string& val) const { + stream << "\"" << absl::Utf8SafeCEscape(val) << "\""; + } + void operator()(std::chrono::system_clock::time_point val) const { + stream << "\"" + << absl::FormatTime(absl::RFC3339_full, absl::FromChrono(val), + absl::UTCTimeZone()) + << "\""; + } + void operator()(const std::unique_ptr>& val) const { + FormatList(stream, val.get()); + } + void operator()( + const std::unique_ptr>& val) const { + FormatStructure(stream, val.get()); + } + // Fallback for any other type not explicitly listed above: + template + void operator()(const T& /*unused*/) const = delete; +}; + +} // namespace + +std::ostream& operator<<(std::ostream& output_stream, const Value& value) { + std::visit(ValueFormatter{output_stream}, value.inner_value_); + return output_stream; +} +std::string Value::ToString() const { + std::ostringstream stream; + stream << *this; + return stream.str(); +} + } // namespace openfeature diff --git a/openfeature/value.h b/openfeature/value.h index 4c8970f..4bda445 100644 --- a/openfeature/value.h +++ b/openfeature/value.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -53,6 +54,10 @@ class Value { const std::map* AsStructure() const; const std::vector* AsList() const; + // Returns a JSON-formatted or human-readable string representation of the + // value. + std::string ToString() const; + private: using InternalVariant = std::variant>>; InternalVariant inner_value_; + + friend std::ostream& operator<<(std::ostream& output_stream, + const Value& value); }; bool operator==(const Value& lhs, const Value& rhs); bool operator!=(const Value& lhs, const Value& rhs); +std::ostream& operator<<(std::ostream& output_stream, const Value& value); } // namespace openfeature diff --git a/test/evaluation_context_test.cpp b/test/evaluation_context_test.cpp index f8b3646..9a160e3 100644 --- a/test/evaluation_context_test.cpp +++ b/test/evaluation_context_test.cpp @@ -17,7 +17,6 @@ class EvaluationContextTest : public ::testing::Test { } }; -// Test that a context built with no properties returns expected defaults. TEST_F(EvaluationContextTest, DefaultBuilderCreatesEmptyContext) { EvaluationContext ctx = EvaluationContext::Builder().Build(); @@ -29,7 +28,6 @@ TEST_F(EvaluationContextTest, DefaultBuilderCreatesEmptyContext) { EXPECT_TRUE(ctx.GetAttributes().empty()); } -// Test setting and retrieving the targeting key. TEST_F(EvaluationContextTest, BuilderSetsTargetingKey) { std::string expected_key = "user-12345"; EvaluationContext ctx = @@ -40,7 +38,6 @@ TEST_F(EvaluationContextTest, BuilderSetsTargetingKey) { EXPECT_EQ(key.value(), expected_key); } -// Test setting and retrieving various attribute types. TEST_F(EvaluationContextTest, BuilderSetsAttributesOfVariousTypes) { constexpr int kIntValue = 42; constexpr double kDoubleValue = 3.14; @@ -72,7 +69,6 @@ TEST_F(EvaluationContextTest, BuilderSetsAttributesOfVariousTypes) { EXPECT_DOUBLE_EQ(std::any_cast(*double_val), kDoubleValue); } -// Test behavior when requesting a non-existent attribute. TEST_F(EvaluationContextTest, GetValueReturnsNullForMissingKey) { EvaluationContext ctx = EvaluationContext::Builder().WithAttribute("exists", 1).Build(); @@ -81,8 +77,6 @@ TEST_F(EvaluationContextTest, GetValueReturnsNullForMissingKey) { EXPECT_EQ(ctx.GetValue("does_not_exist"), nullptr); } -// Test that setting the same attribute key twice overwrites the previous value -// within the same builder chain. TEST_F(EvaluationContextTest, BuilderOverwritesDuplicateKeys) { constexpr int kInitialValue = 100; constexpr int kOverwrittenValue = 200; @@ -96,7 +90,6 @@ TEST_F(EvaluationContextTest, BuilderOverwritesDuplicateKeys) { EXPECT_EQ(std::any_cast(*val), kOverwrittenValue); } -// Test merging attributes with precedence. TEST_F(EvaluationContextTest, MergeAttributesWithPrecedence) { constexpr int kCommonVal1 = 1; constexpr int kCommonVal2 = 2; @@ -120,8 +113,6 @@ TEST_F(EvaluationContextTest, MergeAttributesWithPrecedence) { EXPECT_EQ(std::any_cast(*merged.GetValue("ctx2")), "B"); } -// Test that the last context in the list with a valid and non-empty targeting -// key remains as the final result. TEST_F(EvaluationContextTest, MergeTargetingKeyWithPrecedence) { EvaluationContext ctx_no_key = EvaluationContext::Builder().Build(); EvaluationContext ctx_key_a = @@ -141,7 +132,6 @@ TEST_F(EvaluationContextTest, MergeTargetingKeyWithPrecedence) { EXPECT_EQ(res3.GetTargetingKey().value(), "KeyB"); } -// Test Merging: Complex scenario with attributes and keys. TEST_F(EvaluationContextTest, MergeComplexScenario) { constexpr int kRequestId = 123; EvaluationContext base = EvaluationContext::Builder() @@ -166,7 +156,6 @@ TEST_F(EvaluationContextTest, MergeComplexScenario) { EXPECT_EQ(std::any_cast(*merged.GetValue("request_id")), kRequestId); } -// The merged context should only reflect non-null inputs. TEST_F(EvaluationContextTest, MergeIgnoresNullPointers) { EvaluationContext ctx = EvaluationContext::Builder().WithTargetingKey("valid").Build(); @@ -176,7 +165,6 @@ TEST_F(EvaluationContextTest, MergeIgnoresNullPointers) { EXPECT_EQ(merged.GetTargetingKey().value(), "valid"); } -// Test that string literals and std::string are stored and retrieved correctly. TEST_F(EvaluationContextTest, StoresStringCorrectly) { EvaluationContext ctx_char = EvaluationContext::Builder().WithAttribute("k", "v").Build(); @@ -194,4 +182,50 @@ TEST_F(EvaluationContextTest, StoresStringCorrectly) { EXPECT_EQ(std::any_cast(*val_str), "v"); } +TEST_F(EvaluationContextTest, ToStringEmptyContext) { + EvaluationContext empty_ctx = EvaluationContext::Builder().Build(); + EXPECT_EQ(empty_ctx.ToString(), "{}"); +} + +TEST_F(EvaluationContextTest, ToStringWithTargetingKeyOnly) { + EvaluationContext key_ctx = + EvaluationContext::Builder().WithTargetingKey("user-123").Build(); + EXPECT_EQ(key_ctx.ToString(), "{\"targeting_key\": \"user-123\"}"); +} + +TEST_F(EvaluationContextTest, ToStringWithTargetingKeyAndAttributes) { + constexpr int kAttempts = 3; + EvaluationContext full_ctx = EvaluationContext::Builder() + .WithTargetingKey("user-123") + .WithAttribute("env", std::string("prod")) + .WithAttribute("authenticated", true) + .WithAttribute("attempts", kAttempts) + .Build(); + + std::string full_str = full_ctx.ToString(); + EXPECT_NE(full_str.find("\"targeting_key\": \"user-123\""), + std::string::npos); + EXPECT_NE(full_str.find("\"env\": \"prod\""), std::string::npos); + EXPECT_NE(full_str.find("\"authenticated\": true"), std::string::npos); + EXPECT_NE(full_str.find("\"attempts\": 3"), std::string::npos); +} + +TEST_F(EvaluationContextTest, StreamOperatorFormatsContext) { + EvaluationContext key_ctx = + EvaluationContext::Builder().WithTargetingKey("user-123").Build(); + std::ostringstream stream; + stream << key_ctx; + EXPECT_EQ(stream.str(), "{\"targeting_key\": \"user-123\"}"); +} + +TEST_F(EvaluationContextTest, ToStringEscapesSpecialCharacters) { + EvaluationContext escaped_ctx = + EvaluationContext::Builder() + .WithTargetingKey("user\n123") + .WithAttribute("key\n1", std::string("val\n\"1\"")) + .Build(); + EXPECT_EQ( + escaped_ctx.ToString(), + "{\"targeting_key\": \"user\\n123\", \"key\\n1\": \"val\\n\\\"1\\\"\"}"); +} } // namespace openfeature diff --git a/test/hooks/BUILD b/test/hooks/BUILD new file mode 100644 index 0000000..4b9ecf2 --- /dev/null +++ b/test/hooks/BUILD @@ -0,0 +1,21 @@ +load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test") + +cc_test( + name = "logging_hook_test", + srcs = ["logging_hook_test.cpp"], + deps = [ + "//openfeature:evaluation_context", + "//openfeature:flag_evaluation_details", + "//openfeature:flag_metadata", + "//openfeature:flag_type_value", + "//openfeature:hook_context", + "//openfeature:hook_data", + "//openfeature:hook_hints", + "//openfeature:reason", + "//openfeature:value", + "//openfeature/exceptions:open_feature_exceptions", + "//openfeature/hooks:log_level", + "//openfeature/hooks:logging_hook", + "@googletest//:gtest_main", + ], +) diff --git a/test/hooks/logging_hook_test.cpp b/test/hooks/logging_hook_test.cpp new file mode 100644 index 0000000..462e38f --- /dev/null +++ b/test/hooks/logging_hook_test.cpp @@ -0,0 +1,307 @@ +#include "openfeature/hooks/logging_hook.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include "openfeature/evaluation_context.h" +#include "openfeature/exceptions/open_feature_exceptions.h" +#include "openfeature/flag_evaluation_details.h" +#include "openfeature/flag_metadata.h" +#include "openfeature/flag_type_value.h" +#include "openfeature/hook_context.h" +#include "openfeature/hook_data.h" +#include "openfeature/hook_hints.h" +#include "openfeature/hooks/log_level.h" +#include "openfeature/reason.h" +#include "openfeature/value.h" + +namespace openfeature { + +constexpr std::string_view kClientName = "test-client"; +constexpr std::string_view kProviderName = "test-provider"; +constexpr std::string_view kTargetingKey = "user-42"; +constexpr std::string_view kAttrKey = "tier"; +constexpr std::string_view kAttrValue = "gold"; +constexpr std::string_view kAttrKeyEmail = "email"; +constexpr std::string_view kAttrValueEmail = "alice@example.com"; +constexpr std::string_view kAttrKeyAge = "age"; +constexpr int64_t kAttrValueAge = 25; +constexpr std::string_view kAttrKeyVersion = "is_beta"; +constexpr bool kAttrValueVersion = true; + +constexpr std::string_view kBoolFlagKey = "bool-flag"; +constexpr bool kDefaultBoolValue = true; + +constexpr std::string_view kStringFlagKey = "theme-flag"; +constexpr std::string_view kDefaultStringValue = "dark"; +constexpr std::string_view kEvaluatedStringValue = "light"; +constexpr std::string_view kVariant = "variant-b"; + +constexpr std::string_view kIntFlagKey = "int-flag"; +constexpr int64_t kDefaultIntValue = 100; +constexpr std::string_view kErrorMessage = "flag not found"; + +struct LogEntry { + LogLevel level; + std::string message; +}; + +class LoggingHookTest : public ::testing::Test { + protected: + LoggingHookTest() + : context_( + EvaluationContext::Builder() + .WithTargetingKey(std::string(kTargetingKey)) + .WithAttribute(std::string(kAttrKey), std::string(kAttrValue)) + .Build()), + client_metadata_{std::string(kClientName)}, + provider_metadata_{std::string(kProviderName)}, + hook_data_(std::make_shared()) {} + + LogCallback CreateCapturingLogger(std::vector& logs) { + return [&logs](LogLevel level, const std::string& msg) { + logs.push_back({level, msg}); + }; + } + + EvaluationContext context_; + Metadata client_metadata_; + Metadata provider_metadata_; + std::shared_ptr hook_data_; + HookHints hints_; +}; + +TEST_F(LoggingHookTest, BeforeStageLogsWithoutContextByDefault) { + std::vector logs; + LoggingHook hook(CreateCapturingLogger(logs), false, LogLevel::kError); + + BoolHookContext ctx(std::string(kBoolFlagKey), FlagValueType::kBoolean, + kDefaultBoolValue, context_, client_metadata_, + provider_metadata_, hook_data_); + + auto result = hook.Before(ctx, hints_); + + EXPECT_FALSE(result.has_value()); + ASSERT_EQ(logs.size(), 1); + EXPECT_EQ(logs[0].level, LogLevel::kDebug); + EXPECT_NE(logs[0].message.find("stage=before"), std::string::npos); + EXPECT_NE(logs[0].message.find("domain=\"" + std::string(kClientName) + "\""), + std::string::npos); + EXPECT_NE(logs[0].message.find("provider_name=\"" + + std::string(kProviderName) + "\""), + std::string::npos); + EXPECT_NE( + logs[0].message.find("flag_key=\"" + std::string(kBoolFlagKey) + "\""), + std::string::npos); + EXPECT_NE(logs[0].message.find("default_value=true"), std::string::npos); + EXPECT_EQ(logs[0].message.find("evaluation_context="), std::string::npos); +} + +TEST_F(LoggingHookTest, BeforeStageLogsWithContextWhenOptedIn) { + std::vector logs; + LoggingHook hook(CreateCapturingLogger(logs), true, LogLevel::kError); + + BoolHookContext ctx(std::string(kBoolFlagKey), FlagValueType::kBoolean, + kDefaultBoolValue, context_, client_metadata_, + provider_metadata_, hook_data_); + + hook.Before(ctx, hints_); + + ASSERT_EQ(logs.size(), 1); + EXPECT_NE(logs[0].message.find("evaluation_context="), std::string::npos); + EXPECT_NE(logs[0].message.find(std::string(kTargetingKey)), + std::string::npos); + EXPECT_NE(logs[0].message.find(std::string(kAttrValue)), std::string::npos); +} + +TEST_F(LoggingHookTest, AfterStageLogsEvaluationOutcome) { + std::vector logs; + LoggingHook hook(CreateCapturingLogger(logs), false, LogLevel::kError); + + StringHookContext ctx(std::string(kStringFlagKey), FlagValueType::kString, + std::string(kDefaultStringValue), context_, + client_metadata_, provider_metadata_, hook_data_); + StringFlagEvaluationDetails details( + std::string(kStringFlagKey), std::string(kEvaluatedStringValue), + Reason::kTargetingMatch, std::string(kVariant), FlagMetadata()); + + hook.After(ctx, details, hints_); + + ASSERT_EQ(logs.size(), 1); + EXPECT_EQ(logs[0].level, LogLevel::kDebug); + EXPECT_NE(logs[0].message.find("stage=after"), std::string::npos); + EXPECT_NE( + logs[0].message.find("flag_key=\"" + std::string(kStringFlagKey) + "\""), + std::string::npos); + EXPECT_NE(logs[0].message.find("default_value=\"" + + std::string(kDefaultStringValue) + "\""), + std::string::npos); + EXPECT_NE(logs[0].message.find("reason=\"TARGETING_MATCH\""), + std::string::npos); + EXPECT_NE(logs[0].message.find("variant=\"" + std::string(kVariant) + "\""), + std::string::npos); + EXPECT_NE(logs[0].message.find("value=\"" + + std::string(kEvaluatedStringValue) + "\""), + std::string::npos); +} + +TEST_F(LoggingHookTest, AfterStageLogsWithContextWhenOptedIn) { + std::vector logs; + LoggingHook hook(CreateCapturingLogger(logs), true, LogLevel::kError); + + StringHookContext ctx(std::string(kStringFlagKey), FlagValueType::kString, + std::string(kDefaultStringValue), context_, + client_metadata_, provider_metadata_, hook_data_); + StringFlagEvaluationDetails details( + std::string(kStringFlagKey), std::string(kEvaluatedStringValue), + Reason::kTargetingMatch, std::string(kVariant), FlagMetadata()); + + hook.After(ctx, details, hints_); + + ASSERT_EQ(logs.size(), 1); + EXPECT_NE(logs[0].message.find("evaluation_context="), std::string::npos); + EXPECT_NE(logs[0].message.find(std::string(kTargetingKey)), + std::string::npos); + EXPECT_NE(logs[0].message.find(std::string(kAttrValue)), std::string::npos); +} + +TEST_F(LoggingHookTest, AfterStageLogsNullVariantWhenNotSet) { + std::vector logs; + LoggingHook hook(CreateCapturingLogger(logs), false, LogLevel::kError); + StringHookContext ctx(std::string(kStringFlagKey), FlagValueType::kString, + std::string(kDefaultStringValue), context_, + client_metadata_, provider_metadata_, hook_data_); + StringFlagEvaluationDetails details( + std::string(kStringFlagKey), std::string(kEvaluatedStringValue), + Reason::kDefault, std::nullopt, FlagMetadata()); + + hook.After(ctx, details, hints_); + + ASSERT_EQ(logs.size(), 1); + EXPECT_NE(logs[0].message.find("variant=null"), std::string::npos); +} + +TEST_F(LoggingHookTest, ErrorStageLogsExceptionAtErrorLevel) { + std::vector logs; + LoggingHook hook(CreateCapturingLogger(logs), false, LogLevel::kError); + + IntHookContext ctx(std::string(kIntFlagKey), FlagValueType::kInteger, + kDefaultIntValue, context_, client_metadata_, + provider_metadata_, hook_data_); + OpenFeatureException error(ErrorCode::kFlagNotFound, + std::string(kErrorMessage)); + + hook.Error(ctx, error, hints_); + + ASSERT_EQ(logs.size(), 1); + EXPECT_EQ(logs[0].level, LogLevel::kError); + EXPECT_NE(logs[0].message.find("stage=error"), std::string::npos); + EXPECT_NE(logs[0].message.find("error_code=\"FLAG_NOT_FOUND\""), + std::string::npos); + EXPECT_NE(logs[0].message.find("error_message=\"" + + std::string(kErrorMessage) + "\""), + std::string::npos); +} + +TEST_F(LoggingHookTest, ErrorStageLogsWithContextWhenOptedIn) { + std::vector logs; + LoggingHook hook(CreateCapturingLogger(logs), true, LogLevel::kError); + + EvaluationContext custom_context = + EvaluationContext::Builder() + .WithTargetingKey(std::string(kTargetingKey)) + .WithAttribute(std::string(kAttrKeyEmail), + std::string(kAttrValueEmail)) + .WithAttribute(std::string(kAttrKeyAge), kAttrValueAge) + .WithAttribute(std::string(kAttrKeyVersion), kAttrValueVersion) + .Build(); + + IntHookContext ctx(std::string(kIntFlagKey), FlagValueType::kInteger, + kDefaultIntValue, custom_context, client_metadata_, + provider_metadata_, hook_data_); + OpenFeatureException error(ErrorCode::kFlagNotFound, + std::string(kErrorMessage)); + + hook.Error(ctx, error, hints_); + + ASSERT_EQ(logs.size(), 1); + EXPECT_NE(logs[0].message.find("evaluation_context="), std::string::npos); + EXPECT_NE(logs[0].message.find(std::string(kTargetingKey)), + std::string::npos); + EXPECT_NE(logs[0].message.find(std::string(kAttrKeyEmail)), + std::string::npos); + EXPECT_NE(logs[0].message.find(std::string(kAttrValueEmail)), + std::string::npos); + EXPECT_NE(logs[0].message.find(std::string(kAttrKeyAge)), std::string::npos); + EXPECT_NE(logs[0].message.find(std::to_string(kAttrValueAge)), + std::string::npos); + EXPECT_NE(logs[0].message.find(std::string(kAttrKeyVersion)), + std::string::npos); + EXPECT_NE(logs[0].message.find(kAttrValueVersion ? "true" : "false"), + std::string::npos); +} + +TEST_F(LoggingHookTest, ErrorStageLogsGeneralErrorForStandardException) { + std::vector logs; + LoggingHook hook(CreateCapturingLogger(logs), false, LogLevel::kError); + IntHookContext ctx(std::string(kIntFlagKey), FlagValueType::kInteger, + kDefaultIntValue, context_, client_metadata_, + provider_metadata_, hook_data_); + std::runtime_error error("unexpected network failure"); + hook.Error(ctx, error, hints_); + + ASSERT_EQ(logs.size(), 1); + EXPECT_EQ(logs[0].level, LogLevel::kError); + EXPECT_NE(logs[0].message.find("stage=error"), std::string::npos); + EXPECT_NE(logs[0].message.find("error_code=\"GENERAL\""), std::string::npos); + EXPECT_NE( + logs[0].message.find("error_message=\"unexpected network failure\""), + std::string::npos); +} + +TEST_F(LoggingHookTest, FinallyStageIsNoOp) { + std::vector logs; + LoggingHook hook(CreateCapturingLogger(logs), true, LogLevel::kError); + + BoolHookContext ctx(std::string(kBoolFlagKey), FlagValueType::kBoolean, + kDefaultBoolValue, context_, client_metadata_, + provider_metadata_, hook_data_); + BoolFlagEvaluationDetails details(std::string(kBoolFlagKey), + kDefaultBoolValue, Reason::kDefault, + std::nullopt, FlagMetadata()); + + hook.Finally(ctx, details, hints_); + + EXPECT_TRUE(logs.empty()); +} + +TEST_F(LoggingHookTest, DefaultConstructorInitializesSuccessfully) { + EXPECT_NO_THROW({ + LoggingHook default_hook; + BoolHookContext ctx(std::string(kBoolFlagKey), FlagValueType::kBoolean, + kDefaultBoolValue, context_, client_metadata_, + provider_metadata_, hook_data_); + default_hook.Before(ctx, hints_); + }); +} + +TEST_F(LoggingHookTest, LogLevelFiltering) { + LoggingHook debug_hook(nullptr, true, LogLevel::kDebug); + BoolHookContext ctx(std::string(kBoolFlagKey), FlagValueType::kBoolean, + kDefaultBoolValue, context_, client_metadata_, + provider_metadata_, hook_data_); + + testing::internal::CaptureStdout(); + debug_hook.Before(ctx, hints_); + std::string stdout_output = testing::internal::GetCapturedStdout(); + EXPECT_NE(stdout_output.find("[DEBUG]"), std::string::npos); + EXPECT_NE(stdout_output.find("stage=before"), std::string::npos); +} + +} // namespace openfeature diff --git a/test/value_test.cpp b/test/value_test.cpp index e4524f1..086e41f 100644 --- a/test/value_test.cpp +++ b/test/value_test.cpp @@ -15,8 +15,6 @@ namespace openfeature { using namespace std::chrono_literals; namespace { - -// Test constants to avoid magic numbers constexpr int kTestInt = 456; constexpr int64_t kTestInt64 = 123LL; constexpr int64_t kExpectedInt64 = 456LL; @@ -36,6 +34,8 @@ constexpr int64_t kRoundUpExpected = 124LL; constexpr int64_t kRoundDownExpected = 123LL; constexpr int64_t kExpectedRoundedUp6 = 6LL; constexpr int64_t kExpectedNegative5 = -5LL; +constexpr int64_t kExpectedNegative6 = -6LL; +constexpr int kListItem42 = 42; constexpr double kTestDouble = 123.45; constexpr double kTestDouble123 = 123.0; @@ -50,9 +50,9 @@ constexpr double kTestDouble51 = 5.1; constexpr double kTestDouble57 = 5.7; constexpr double kNegative53 = -5.3; constexpr double kNegative55 = -5.5; +constexpr double kTestDouble1e9 = 1e-9; constexpr size_t kExpectedListSize = 3; - } // namespace TEST(ValueTest, DefaultConstructorIsNull) { @@ -106,7 +106,7 @@ TEST(ValueTest, DoubleConstructorAndAccessors) { EXPECT_FALSE(double_val.IsNull()); EXPECT_TRUE(double_val.IsNumber()); EXPECT_EQ(double_val.AsDouble(), kTestDouble); - EXPECT_EQ(double_val.AsInt(), kExpectedRoundedInt); // Should round + EXPECT_EQ(double_val.AsInt(), kExpectedRoundedInt); EXPECT_FALSE(double_val.AsBool().has_value()); Value double_round_up(kRoundUpInput); @@ -302,14 +302,11 @@ TEST(ValueTest, AsNumberConversions) { EXPECT_EQ(double_val.AsDouble(), kTestDouble57); Value double_val_negative(kNegative53); - EXPECT_EQ(double_val_negative.AsInt(), - kExpectedNegative5); // Rounds to nearest even on .5, otherwise - // standard rounding + EXPECT_EQ(double_val_negative.AsInt(), kExpectedNegative5); EXPECT_EQ(double_val_negative.AsDouble(), kNegative53); Value double_val_negative_half(kNegative55); - EXPECT_EQ(double_val_negative_half.AsInt(), - kExpectedNegative5); // Rounds to nearest even + EXPECT_EQ(double_val_negative_half.AsInt(), kExpectedNegative6); } TEST(ValueTest, EqualityOperatorBasicTypes) { @@ -416,4 +413,32 @@ TEST(ValueTest, MoveConstructorAndAssignmentDefaulted) { EXPECT_EQ(target_val.AsStructure()->at("key").AsInt(), kExpectedInt100); } +TEST(ValueTest, ToStringAndStreamOperator) { + EXPECT_EQ(Value().ToString(), "null"); + + EXPECT_EQ(Value(true).ToString(), "true"); + EXPECT_EQ(Value(false).ToString(), "false"); + + EXPECT_EQ(Value(static_cast(kTestInt64)).ToString(), "123"); + EXPECT_EQ(Value(kExpectedInt64).ToString(), "456"); + + EXPECT_EQ(Value(kTestDouble57).ToString(), "5.7"); + EXPECT_EQ(Value(kTestDouble1e9).ToString(), "1e-09"); + + EXPECT_EQ(Value("hello").ToString(), "\"hello\""); + EXPECT_EQ(Value("hello\n\"world\"").ToString(), "\"hello\\n\\\"world\\\"\""); + + std::vector list = {Value("item1"), Value(kListItem42), Value(true)}; + EXPECT_EQ(Value(list).ToString(), "[\"item1\", 42, true]"); + + std::map map = {{"key", Value("value")}}; + EXPECT_EQ(Value(map).ToString(), "{\"key\": \"value\"}"); + std::map escaped_map = {{"key\n1", Value("val\n1")}}; + EXPECT_EQ(Value(escaped_map).ToString(), "{\"key\\n1\": \"val\\n1\"}"); + + std::ostringstream stream; + stream << Value("stream_test"); + EXPECT_EQ(stream.str(), "\"stream_test\""); +} + } // namespace openfeature