diff --git a/CHANGELOG.md b/CHANGELOG.md index 03ad254..2d8510b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +- Added `multiplyCustomUserDetail` for multiplying the numeric value of a custom user property on the server. + ## 26.8.1 - ! Minor breaking change ! Builds now stop with a clear error when the SDK headers find a nlohmann/json older than 3.11 first on the include path, instead of mixing two versions of the library. diff --git a/CMakeLists.txt b/CMakeLists.txt index 667bb66..9eabe57 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -152,7 +152,8 @@ if(COUNTLY_BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/tests/sbs.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/internal_limits.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/internal_limits_integration.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/tests/multi_instance.cpp) + ${CMAKE_CURRENT_SOURCE_DIR}/tests/multi_instance.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/user_details.cpp) target_compile_options(countly-tests PRIVATE $<$>:-g>) target_compile_definitions(countly-tests PRIVATE COUNTLY_BUILD_TESTS) diff --git a/include/countly.hpp b/include/countly.hpp index 04baadf..6750b11 100644 --- a/include/countly.hpp +++ b/include/countly.hpp @@ -144,6 +144,14 @@ class Countly : public cly::CountlyDelegates { */ void setCustomUserDetails(const std::map &value); + /** + * Multiplies the numeric value of the user's custom property 'key' by 'value' on the server. + * Call it after start(): each call queues its own request, after any user details still waiting + * for the first session. A call before start(), an empty key, or a value that is not a finite + * number is ignored and logged. + */ + void multiplyCustomUserDetail(const std::string &key, double value); + /* setCountry is deprecated, please use void `setLocation(const std::string& countryCode, const std::string& city, const std::string& gpsCoordinates, const std::string& ipAddress)` function instead. */ @@ -477,6 +485,11 @@ class Countly : public cly::CountlyDelegates { */ void queueUserDetails(); + /** + * Queues a request with the given user details for the current device ID. The caller must hold the instance mutex. + */ + void queueUserDetails(const nlohmann::json &userDetails); + std::chrono::steady_clock::duration getSessionDuration(std::chrono::steady_clock::time_point now); std::chrono::steady_clock::duration getSessionDuration(); diff --git a/src/countly.cpp b/src/countly.cpp index b7ce9e4..9e2d487 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -3,6 +3,7 @@ #include "countly/storage_module_db.hpp" #include "countly/storage_module_memory.hpp" #include +#include #include #include #include @@ -104,6 +105,20 @@ void runRemoteConfigFetch(std::shared_ptr fetch) { fetch->store->values = response.data; } } + +/** + * Whether an SDK behavior settings blacklist or whitelist lets 'key' through. An empty list lets every key through. + */ +bool isAllowedByFilter(const FilterList> &filter, const std::string &key) { + if (filter.filterList.empty()) { + return true; + } + bool listed = filter.filterList.find(key) != filter.filterList.end(); + if (filter.isWhitelist) { + return listed; + } + return !listed; +} } // namespace Countly::Countly() { @@ -625,24 +640,14 @@ void Countly::setCustomUserDetails(const std::map &val if (configurationModule) { lim = configurationModule->getLimits(); auto upFilter = configurationModule->getUserPropertyFilterList(); - if (!upFilter.filterList.empty()) { - for (const auto &kv : value) { - bool allowed; - if (upFilter.isWhitelist) { - allowed = (upFilter.filterList.find(kv.first) != upFilter.filterList.end()); - } else { - allowed = (upFilter.filterList.find(kv.first) == upFilter.filterList.end()); - } - if (allowed) { - customValue[kv.first] = kv.second; - } - } - if (customValue.empty()) { - log(LogLevel::DEBUG, "[Countly] setCustomUserDetails, All user properties were filtered out by SBS user property filter."); - return; + for (const auto &kv : value) { + if (isAllowedByFilter(upFilter, kv.first)) { + customValue[kv.first] = kv.second; } - } else { - customValue = value; + } + if (customValue.empty() && !upFilter.filterList.empty()) { + log(LogLevel::DEBUG, "[Countly] setCustomUserDetails, All user properties were filtered out by SBS user property filter."); + return; } } else { customValue = value; @@ -670,12 +675,47 @@ void Countly::setCustomUserDetails(const std::map &val queueUserDetails(); } +void Countly::multiplyCustomUserDetail(const std::string &key, double value) { + if (!is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] multiplyCustomUserDetail, SDK is not initialized."); + return; + } + if (!std::isfinite(value)) { + log(LogLevel::WARNING, "[Countly] multiplyCustomUserDetail, The parameter 'value' must be a finite number. Returning."); + return; + } + const std::string truncatedKey = cly::limits::truncateString(key, configurationModule->getLimits().maxKeyLength); + if (truncatedKey.empty()) { + log(LogLevel::WARNING, "[Countly] multiplyCustomUserDetail, The parameter 'key' can't be empty. Returning."); + return; + } + if (!isAllowedByFilter(configurationModule->getUserPropertyFilterList(), key)) { + log(LogLevel::DEBUG, "[Countly] multiplyCustomUserDetail, User property filtered out by SBS user property filter: [" + key + "]"); + return; + } + + if (configuration->autoEventsOnUserProperties == true) { + flushEvents(); + } + + std::lock_guard lk(*mutex); + // Details set before start() and still waiting for the first session go first, so the server applies them before the multiplication. + if (session_params.contains("user_details")) { + queueUserDetails(); + } + queueUserDetails({{"custom", {{truncatedKey, {{"$mul", value}}}}}}); +} + void Countly::queueUserDetails() { - std::map data = {{"app_key", session_params["app_key"].get()}, {"device_id", session_params["device_id"].get()}, {"user_details", session_params["user_details"].dump()}}; - requestModule->addRequestToQueue(data); + queueUserDetails(session_params["user_details"]); session_params.erase("user_details"); } +void Countly::queueUserDetails(const nlohmann::json &userDetails) { + std::map data = {{"app_key", session_params["app_key"].get()}, {"device_id", session_params["device_id"].get()}, {"user_details", userDetails.dump()}}; + requestModule->addRequestToQueue(data); +} + #pragma region User location void Countly::setCountry(const std::string &country_code) { @@ -1072,20 +1112,9 @@ void Countly::addEvent(const cly::Event &event) { } // Apply event filter (only for custom events) - if (!isInternalEvent) { - auto filter = configurationModule->getEventFilterList(); - if (!filter.filterList.empty()) { - bool blocked = false; - if (filter.isWhitelist) { - blocked = (filter.filterList.find(eventKey) == filter.filterList.end()); - } else { - blocked = (filter.filterList.find(eventKey) != filter.filterList.end()); - } - if (blocked) { - log(LogLevel::DEBUG, "[Countly] addEvent, event filtered out by SBS event filter: [" + eventKey + "]"); - return; - } - } + if (!isInternalEvent && !isAllowedByFilter(configurationModule->getEventFilterList(), eventKey)) { + log(LogLevel::DEBUG, "[Countly] addEvent, event filtered out by SBS event filter: [" + eventKey + "]"); + return; } // Copy the event so we can apply segmentation filters without modifying the caller's object diff --git a/tests/user_details.cpp b/tests/user_details.cpp new file mode 100644 index 0000000..61447dc --- /dev/null +++ b/tests/user_details.cpp @@ -0,0 +1,309 @@ +#include +#include +#include +#include +#include + +#include "doctest.h" +#include "nlohmann/json.hpp" +#include "test_utils.hpp" + +using namespace cly; +using namespace test_utils; +using json = nlohmann::json; + +/** + * Waits until start() has sent the SDK behavior settings request from its own thread, + * so it cannot land among the requests a test asserts on. + */ +static void waitForConfigFetch() { + const std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (!httpQueueContains("method", "sc")) { + if (std::chrono::steady_clock::now() >= deadline) { + FAIL("start() did not send the SDK behavior settings request"); + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } +} + +/** + * Starts the SDK with manual session control and the given SDK behavior settings and HTTP client, + * then drops what start() sent, so a test sees only the requests it causes. + */ +static void startForUserDetails(Countly &countly, const json &sbs = json::object(), HTTPClientFunction client = test_utils::fakeSendHTTP) { + if (!sbs.empty()) { + std::string settings = sbs.dump(); + countly.setSDKBehaviorSettings(settings); + } + countly.disableSDKBehaviorSettingsUpdates(); + countly.setHTTPClient(client); + countly.setDeviceID(COUNTLY_TEST_DEVICE_ID); + countly.SetPath(TEST_DATABASE_NAME); + countly.enableManualSessionControl(); + countly.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + waitForConfigFetch(); + countly.clearRequestQueue(); + http_call_queue.clear(); +} + +/** + * Removes the oldest captured request and returns it. + */ +static HTTPCall popRequest() { + REQUIRE(!http_call_queue.empty()); + HTTPCall call = http_call_queue.front(); + http_call_queue.pop_front(); + return call; +} + +/** + * Returns the captured requests that carry the given parameter, oldest first. + */ +static std::vector requestsWith(const std::string ¶meter) { + std::vector calls; + for (size_t index = 0; index < http_call_queue.size(); index++) { + HTTPCall call = http_call_queue.at(index); + if (call.data.find(parameter) != call.data.end()) { + calls.push_back(call); + } + } + return calls; +} + +/** + * The user_details a request carries when it multiplies the custom property 'key' by 'value'. + */ +static json multiplied(const std::string &key, double value) { return json{{"custom", {{key, {{"$mul", value}}}}}}; } + +/** + * HTTP client that captures each request like fakeSendHTTP, but reports that the server did not accept it. + */ +static HTTPResponse rejectingSendHTTP(bool use_post, const std::string &url, const std::string &data) { + fakeSendHTTP(use_post, url, data); + return HTTPResponse{false, json::object()}; +} + +TEST_CASE("multiplyCustomUserDetail sends the $mul modifier") { + clearSDK(); + Countly &countly = Countly::getInstance(); + startForUserDetails(countly); + + SUBCASE("a decimal multiplier is sent for the current device ID") { + countly.multiplyCustomUserDetail("score", 2.5); + countly.processRQDebug(); + + REQUIRE(http_call_queue.size() == 1); + HTTPCall call = popRequest(); + CHECK(call.url == "/i"); + CHECK(call.data["app_key"] == COUNTLY_TEST_APP_KEY); + CHECK(call.data["device_id"] == COUNTLY_TEST_DEVICE_ID); + CHECK(json::parse(call.data["user_details"]) == multiplied("score", 2.5)); + CHECK(countly.checkRQSize() == 0); + } + + SUBCASE("integer, zero and negative multipliers are each sent as their own request, in call order") { + countly.multiplyCustomUserDetail("level", 3); + countly.multiplyCustomUserDetail("level", 0); + countly.multiplyCustomUserDetail("balance", -1.5); + countly.processRQDebug(); + + REQUIRE(http_call_queue.size() == 3); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("level", 3)); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("level", 0)); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("balance", -1.5)); + } +} + +TEST_CASE("multiplyCustomUserDetail and recorded events") { + SUBCASE("recorded events are sent before the modifier") { + clearSDK(); + Countly &countly = Countly::getInstance(); + startForUserDetails(countly); + + generateEvents(2, countly); + REQUIRE(countly.checkEQSize() == 2); + countly.multiplyCustomUserDetail("score", 2); + CHECK(countly.checkEQSize() == 0); + countly.processRQDebug(); + + REQUIRE(http_call_queue.size() == 2); + HTTPCall events = popRequest(); + CHECK(json::parse(events.data["events"]).size() == 2); + CHECK(events.data.find("user_details") == events.data.end()); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("score", 2)); + } + + SUBCASE("recorded events stay queued after disableAutoEventsOnUserProperties") { + clearSDK(); + Countly &countly = Countly::getInstance(); + countly.disableAutoEventsOnUserProperties(); + startForUserDetails(countly); + + generateEvents(2, countly); + countly.multiplyCustomUserDetail("score", 2); + CHECK(countly.checkEQSize() == 2); + countly.processRQDebug(); + + REQUIRE(http_call_queue.size() == 1); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("score", 2)); + } +} + +TEST_CASE("multiplyCustomUserDetail ignores what it cannot send") { + SUBCASE("a call before start is neither sent nor kept for the first session") { + clearSDK(); + clearCapturedLogs(); + Countly &countly = Countly::getInstance(); + countly.setLogger(captureLog); + countly.setHTTPClient(test_utils::fakeSendHTTP); + countly.setDeviceID(COUNTLY_TEST_DEVICE_ID); + countly.SetPath(TEST_DATABASE_NAME); + + countly.multiplyCustomUserDetail("score", 2); + countly.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + waitForConfigFetch(); + countly.multiplyCustomUserDetail("score", 3); + countly.processRQDebug(); + + CHECK(capturedLogContains("multiplyCustomUserDetail, SDK is not initialized")); + std::vector sessions = requestsWith("begin_session"); + REQUIRE(sessions.size() == 1); + CHECK(sessions[0].data.find("user_details") == sessions[0].data.end()); + std::vector userDetails = requestsWith("user_details"); + REQUIRE(userDetails.size() == 1); + CHECK(json::parse(userDetails[0].data["user_details"]) == multiplied("score", 3)); + } + + SUBCASE("an empty key is not sent") { + clearSDK(); + clearCapturedLogs(); + Countly &countly = Countly::getInstance(); + countly.setLogger(captureLog); + startForUserDetails(countly); + + countly.multiplyCustomUserDetail("", 2); + countly.multiplyCustomUserDetail("score", 3); + countly.processRQDebug(); + + CHECK(capturedLogContains("multiplyCustomUserDetail, The parameter 'key' can't be empty")); + REQUIRE(http_call_queue.size() == 1); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("score", 3)); + } + + SUBCASE("a multiplier that is not a finite number is not sent") { + clearSDK(); + clearCapturedLogs(); + Countly &countly = Countly::getInstance(); + countly.setLogger(captureLog); + startForUserDetails(countly); + + countly.multiplyCustomUserDetail("nan", std::numeric_limits::quiet_NaN()); + countly.multiplyCustomUserDetail("infinity", std::numeric_limits::infinity()); + countly.multiplyCustomUserDetail("negative_infinity", -std::numeric_limits::infinity()); + countly.multiplyCustomUserDetail("score", 3); + countly.processRQDebug(); + + CHECK(capturedLogContains("multiplyCustomUserDetail, The parameter 'value' must be a finite number")); + REQUIRE(http_call_queue.size() == 1); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("score", 3)); + } +} + +TEST_CASE("multiplyCustomUserDetail follows the SDK behavior settings") { + SUBCASE("a key on the user property blacklist is not sent") { + clearSDK(); + Countly &countly = Countly::getInstance(); + startForUserDetails(countly, {{"upb", json::array({"blocked"})}}); + + countly.multiplyCustomUserDetail("blocked", 2); + countly.multiplyCustomUserDetail("allowed", 3); + countly.processRQDebug(); + + REQUIRE(http_call_queue.size() == 1); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("allowed", 3)); + } + + SUBCASE("only a key on the user property whitelist is sent") { + clearSDK(); + Countly &countly = Countly::getInstance(); + startForUserDetails(countly, {{"upw", json::array({"allowed"})}}); + + countly.multiplyCustomUserDetail("other", 2); + countly.multiplyCustomUserDetail("allowed", 3); + countly.processRQDebug(); + + REQUIRE(http_call_queue.size() == 1); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("allowed", 3)); + } + + SUBCASE("the key is truncated to the maximum key length") { + clearSDK(); + Countly &countly = Countly::getInstance(); + startForUserDetails(countly, {{"lkl", 3}}); + + countly.multiplyCustomUserDetail("longkey", 2); + countly.processRQDebug(); + + REQUIRE(http_call_queue.size() == 1); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("lon", 2)); + } + + SUBCASE("the user property filter matches the key as given, before it is truncated") { + clearSDK(); + Countly &countly = Countly::getInstance(); + startForUserDetails(countly, {{"upw", json::array({"longkey"})}, {"lkl", 3}}); + + countly.multiplyCustomUserDetail("longkey", 2); + countly.processRQDebug(); + + REQUIRE(http_call_queue.size() == 1); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("lon", 2)); + } + + SUBCASE("a key the maximum key length cuts down to nothing is not sent") { + clearSDK(); + Countly &countly = Countly::getInstance(); + startForUserDetails(countly, {{"lkl", 1}}); + + countly.multiplyCustomUserDetail("\xC3\xA9score", 2); + countly.multiplyCustomUserDetail("s", 3); + countly.processRQDebug(); + + REQUIRE(http_call_queue.size() == 1); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("s", 3)); + } +} + +TEST_CASE("multiplyCustomUserDetail sends details still waiting from before start ahead of the modifier") { + clearSDK(); + Countly &countly = Countly::getInstance(); + countly.setUserDetails({{"name", "First User"}}); + countly.setCustomUserDetails({{"score", "10"}}); + startForUserDetails(countly); + + countly.multiplyCustomUserDetail("score", 2); + countly.beginSession(); + countly.processRQDebug(); + + REQUIRE(http_call_queue.size() == 3); + HTTPCall waiting = popRequest(); + CHECK(waiting.data.find("begin_session") == waiting.data.end()); + CHECK(json::parse(waiting.data["user_details"]) == json({{"name", "First User"}, {"custom", {{"score", "10"}}}})); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("score", 2)); + HTTPCall session = popRequest(); + CHECK(session.data["begin_session"] == "1"); + CHECK(session.data.find("user_details") == session.data.end()); +} + +TEST_CASE("multiplyCustomUserDetail keeps its request for a retry when the server does not accept it") { + clearSDK(); + Countly &countly = Countly::getInstance(); + startForUserDetails(countly, json::object(), rejectingSendHTTP); + + countly.multiplyCustomUserDetail("score", 2); + countly.processRQDebug(); + + REQUIRE(http_call_queue.size() == 1); + CHECK(json::parse(popRequest().data["user_details"]) == multiplied("score", 2)); + CHECK(countly.checkRQSize() == 1); +}