From 5b767257e2a91ac9bb19edf7fa411defcd5f67ef Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:02:28 +0900 Subject: [PATCH] various fixes --- CHANGELOG.md | 11 +- include/countly.hpp | 48 +++++-- include/countly/configuration_module.hpp | 6 + include/countly/constants.hpp | 6 + include/countly/countly_configuration.hpp | 6 + include/countly/request_module.hpp | 12 +- src/configuration_module.cpp | 23 ++- src/countly.cpp | 166 +++++++++++++++++----- src/request_module.cpp | 22 +-- tests/behavior_settings.cpp | 100 +++++++++++++ tests/consent.cpp | 11 +- tests/event_recording.cpp | 23 +++ tests/init.cpp | 120 ++++++++++++++++ tests/request.cpp | 75 ++++++++++ tests/request_pipeline.cpp | 43 ++++++ 15 files changed, 602 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4a76ab..4d18991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ ## XX.XX.XX - ! Minor breaking change ! The "Consent required" option of the SDK Behavior Settings now applies. While it is on, the SDK collects nothing until the app gives consent; while it is off, the SDK works as if every consent were given, also in apps that require consent. +- ! Minor breaking change ! The "Automatic session tracking" option of the SDK Behavior Settings now applies. While it is off, the SDK tracks no session by itself and the app's session calls work, as with `enableManualSessionControl()`; while it is on, the SDK tracks sessions also in apps that called `enableManualSessionControl()`. A change while the SDK runs ends or begins the session. - ! Minor breaking change ! Every request is now sent as POST, with its data in the request body. A client set with `setHTTPClient` gets `usePost` true for every request and must support POST. `alwaysUsePost` is deprecated and has no effect. -- ! Minor breaking change ! `start()` now refuses a server URL without `http://` or `https://` or without a host, an empty app key and the placeholder `YOUR_APP_KEY`: it logs an error and the SDK stays off. Before, a URL without a scheme was used over plain HTTP. -- ! Minor breaking change ! Recorded events are now sent on the session update interval (60 seconds by default) or when the event queue threshold is reached, instead of every 3 seconds. `flushEvents()` moves them into a request right away. +- ! Minor breaking change ! `start()` now refuses a server URL without `http://` or `https://`, without a host, with a port that is not a number from 1 to 65535, or with a query or a fragment, an empty app key and the placeholder `YOUR_APP_KEY`: it logs an error and the SDK stays off. Before, a URL without a scheme was used over plain HTTP. +- ! Minor breaking change ! Recorded events are now sent on the session update interval (60 seconds by default) or when the event queue threshold is reached, instead of every 3 seconds. `flushEvents()` moves them into a request right away. Feedback answers (`[CLY]_nps`, `[CLY]_survey`, `[CLY]_star_rating`) and `[CLY]_push_action` events are moved into a request as soon as they are recorded. - ! Minor breaking change ! `beginSession`, `updateSession` and `endSession` now work only with `enableManualSessionControl()`; with automatic sessions they are ignored with a warning. A manual `updateSession()` now always reports the session duration, also when called sooner than the session update interval. - ! Minor breaking change ! Remote config downloads now use the current remote config request and no longer enroll the user in A/B tests; call `enrollABOnRCDownload()` before `start()` to keep enrolling. With `enableRemoteConfig()`, values are downloaded at the end of `start()`, after a device ID change and when the consent is given, instead of at every session start. - ! Minor breaking change ! Config values below their minimum are now ignored with a warning instead of being raised: 0 for the internal limits, `setMaxRequestQueueSize` and `setAutomaticSessionUpdateInterval`, and values below 1 for `setEventsToRQThreshold`. `setEventsToRQThreshold` also keeps values above 10000; the event queue still sends at 1000 events. @@ -23,14 +24,14 @@ - Added `disableHealthCheck()` for turning off the health check request. - Added `status_code` and `body` to `HTTPResponse`. A custom HTTP client can fill them so that the health check reports the last request the server refused; a client that leaves them empty works as before. - Calls made before `start()`, a second `start()`, beginning a session that already runs and ending one that does not are now logged at warning level. Failed and non-JSON server answers, and session calls ignored because the SDK Behavior Settings turned session tracking off, are now logged at debug level. -- Added device ID generation: when no device ID is stored and the app gives none before `start()`, the SDK generates one. `getDeviceID()` and `getDeviceIDType()` return the device ID in use and where it came from. Every request carries the device ID type (`t`). +- Added device ID generation: when no device ID is stored and the app gives none before `start()`, the SDK generates one. `getDeviceID()` and `getDeviceIDType()` return the device ID in use and where it came from. Every request carries the device ID type (`t`). After `start()`, `setDeviceID(id)` merges a generated device ID into the new one, as for a user who logs in, and changes a device ID the app gave without merge; `setDeviceID(id, same_user)` still decides explicitly. - Added `enableClearStoredDeviceID()` for replacing the stored device ID at `start()` with the given or a newly generated one. The events the stored user left are first sent under the stored device ID. It is meant for testing and forced user resets: left on, every start creates a new user. - Added the app version (`av`) and the number of waiting requests (`rr`) to every request. - Added the operating system name and version (`_os`, `_os_version`) to the device metrics the SDK sends; values given with `setMetrics` still win. - Added event IDs: every event carries its own ID and the current view (`cvid`), custom events the previous custom event (`peid`), view events the previous view (`pvid`) and the platform (`segment`), and crash reports the current view (`_cvid`). - Added `setFailedRequestCooldown`: a failed request is tried again after this cool-down, 60 seconds by default, instead of on every update. - Added `setRequestDropAgeHours` and the server's "dort" setting for dropping stored requests older than the given age. -- Added the backoff mechanism: when the server answers slowly, sending pauses for a while. `disableBackoffMechanism()` turns it off, and the SDK Behavior Settings can configure it. +- Added the backoff mechanism: when the server answers slowly, sending pauses for a while. `disableBackoffMechanism()` turns it off, and the SDK Behavior Settings can configure it. `stop()` doesn't wait for a pause: it sends the waiting requests before the SDK stops. - Added gradual trimming of a request queue far over its size limit, at most 100 requests at a time; `disableGradualRequestCleaner()` drops the whole overflow at once. - Added `setCrashFilterCallback` for changing or dropping crash reports before they are queued, and `setCustomCrashSegmentation` for segmentation added to every crash report. - Added `crash().clearBreadcrumbs()`, `views().stopAllViews()`, and segmentation for `closeViewWithName` and `closeViewWithID`. @@ -65,6 +66,8 @@ - Mitigated an issue where values cut to the internal limits were not logged. - Mitigated an issue where `setUserDetails` and `setCustomUserDetails` called before `start()` logged that the call was ignored, although the details are sent with the first session. - Mitigated an issue where a C++ standard set with `CMAKE_CXX_STANDARD`, for example by a Conan profile, was ignored and the SDK was always compiled as C++14. C++14 stays the default and the minimum. +- Mitigated an issue where a server URL with a port or a path, such as `https://example.com:8443` or `https://example.com/countly`, was accepted but no request reached the server. A port in the URL now wins over the port argument, with a warning when they differ. +- Mitigated an issue where user details given before `start()` replaced each other, so that for example custom properties set with `setCustomUserDetails` were lost after a `setUserDetails` call. ## 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/include/countly.hpp b/include/countly.hpp index 7daa0c1..e277940 100644 --- a/include/countly.hpp +++ b/include/countly.hpp @@ -76,8 +76,9 @@ class Countly : public cly::CountlyDelegates { static bool hasInstance(const std::string &name); /** - * Destroys the instance registered under 'name'. Joins its threads, ends its - * session and frees its database path claim. A no-op if the name is not + * Destroys the instance registered under 'name'. Joins its threads and frees + * its database path claim. It neither ends the session nor moves the waiting + * events into a request: call stop() first for that. A no-op if the name is not * registered. Any reference or pointer to that instance dangles afterwards. */ static void destroyInstance(const std::string &name); @@ -152,6 +153,11 @@ class Countly : public cly::CountlyDelegates { void setSha256(cly::SHA256Function fun); + /** + * Makes the app begin, update and end sessions with beginSession(), updateSession() and endSession() instead of the + * SDK. While the server's SDK behavior settings contain "Automatic session tracking", that setting decides instead, + * also while the SDK runs. Call it before start(). + */ void enableManualSessionControl(); void disableAutoEventsOnUserProperties(); @@ -212,11 +218,18 @@ class Countly : public cly::CountlyDelegates { * Before start(): the device ID to use when no device ID is stored from an earlier start (SQLite builds keep the * device ID in use between starts). A stored device ID wins, and a different ID given here is ignored with a * warning, unless enableClearStoredDeviceID() is called too. Without a device ID, start() generates one. - * After start(): changes the device ID. With same_user the server merges the old user's data into the new device - * ID; without it the new device ID is a new user: the waiting events, and with automatic sessions the session, end - * under the old device ID first. + * After start(): changes the device ID and chooses by where the device ID in use came from: a device ID the SDK + * generated is merged into the new one on the server, as for a user who logs in; a device ID the app gave is changed + * without merge, see setDeviceID(value, same_user). */ - void setDeviceID(const std::string &value, bool same_user = false); + void setDeviceID(const std::string &value); + + /** + * Like setDeviceID(value), but after start() 'same_user' decides: true merges the old user's data into the new device + * ID on the server; false makes the new device ID a new user: the waiting events, and with automatic sessions the + * session, end under the old device ID first. + */ + void setDeviceID(const std::string &value, bool same_user); /** * Makes start() forget the device ID stored by an earlier start. The events that user left in the database are @@ -264,8 +277,9 @@ class Countly : public cly::CountlyDelegates { * at stop(). * * @param app_key: the app key of your Countly app - * @param host: the server URL, an absolute http or https URL such as "https://your.server.ly" - * @param port: the server port, 0 for the scheme's default + * @param host: the server URL, an absolute http or https URL such as "https://your.server.ly". It may contain a port + * and a path, such as "https://your.server.ly:8443/countly" + * @param port: the server port, 0 for the port in the URL or the scheme's default. A port in the URL wins * @param start_thread: has no effect, the SDK always starts its update thread */ void start(const std::string &app_key, const std::string &host, int port = 0, bool start_thread = false); @@ -323,6 +337,12 @@ class Countly : public cly::CountlyDelegates { */ void LocationSettingChanged() override; + /** + * Applies a change of the server's "Automatic session tracking" setting: turning it off ends the session the SDK + * runs, and the app's session calls work from then on; turning it on begins a session. + */ + void AutomaticSessionTrackingChanged() override; + /* * Checks and returns the size of the event queue in memory or persistent storage. */ @@ -914,6 +934,18 @@ class Countly : public cly::CountlyDelegates { */ bool isLocationOn(); + /** + * Whether the SDK tracks sessions by itself: the server's "Automatic session tracking" setting while the settings + * contain it, otherwise the app's choice with enableManualSessionControl(). + */ + bool isAutomaticSessionTracking(); + + /** + * The port requests go to: the port written in the server URL when it has one, otherwise the port argument. A port + * argument that differs from the port in the URL is ignored with a warning, counted as log code w121. + */ + unsigned int serverPort(unsigned int url_port, int port, const char *caller); + /** * The shared part of the public calls that give or remove consent: checks the call, expands groups, records w402 * for unknown names and runs the change for the features whose consent changes. diff --git a/include/countly/configuration_module.hpp b/include/countly/configuration_module.hpp index f9f69a8..f86156c 100644 --- a/include/countly/configuration_module.hpp +++ b/include/countly/configuration_module.hpp @@ -50,6 +50,12 @@ class ConfigurationModule : public ConfigurationProvider { */ int getConsentRequired() const; + /** + * The server's "Automatic session tracking" setting ("ast"): -1 while the settings don't contain it, 0 when it is + * off, 1 when it is on. + */ + int getAutomaticSessionTracking() const; + unsigned int getRequestQueueSizeLimit() const override; unsigned int getRequestDropAgeHours() const override; bool isBackoffEnabled() const override; diff --git a/include/countly/constants.hpp b/include/countly/constants.hpp index 6841a81..5bdf6a7 100644 --- a/include/countly/constants.hpp +++ b/include/countly/constants.hpp @@ -238,6 +238,12 @@ class CountlyDelegates { * Called by the SDK behavior settings when the server's "cr" setting changes whether consent is required. */ virtual void ConsentRequirementChanged() {} + + /** + * Called by the SDK behavior settings when the server's "ast" setting changes whether the SDK tracks sessions by + * itself. + */ + virtual void AutomaticSessionTrackingChanged() {} }; } // namespace cly diff --git a/include/countly/countly_configuration.hpp b/include/countly/countly_configuration.hpp index 816dd0b..a32a735 100644 --- a/include/countly/countly_configuration.hpp +++ b/include/countly/countly_configuration.hpp @@ -42,6 +42,12 @@ struct CountlyConfiguration { */ std::string serverUrl; + /** + * The path of the server URL, such as "/countly" for "https://example.com/countly", which the built-in HTTP client + * puts in front of every endpoint. Empty when the URL has none. + */ + std::string serverPath; + /** * App key for the application being tracked. * Mandatory field. diff --git a/include/countly/request_module.hpp b/include/countly/request_module.hpp index f04368a..e1e716b 100644 --- a/include/countly/request_module.hpp +++ b/include/countly/request_module.hpp @@ -34,8 +34,11 @@ class RequestModule { /** * SDK central execution call for processing requests in the request queue. * Only one sender is active at a time. Requests are processed in order. + * + * @param closing: true for the last send of stop(), which closes by sending: it doesn't wait for a backoff pause and + * starts none, so that the waiting requests go out before the SDK stops */ - void processQueue(std::shared_ptr mutex); + void processQueue(std::shared_ptr mutex, bool closing = false); void addRequestToQueue(const std::map &data); @@ -46,10 +49,11 @@ class RequestModule { void clearRequestQueue(); /** - * Replaces the server URL and the port that the following requests go to, the way start() sets them: a URL without - * a scheme gets "http://" in front, trailing slashes are removed, and a port of 0 becomes the scheme's default port. + * Replaces the server URL, the port and the URL path that the following requests go to, the way start() sets them: + * a URL without a scheme gets "http://" in front, trailing slashes are removed, and a port of 0 becomes the scheme's + * default port. The built-in HTTP client puts the path, such as "/countly", in front of every endpoint. */ - void setServerUrl(const std::string &url, unsigned int port); + void setServerUrl(const std::string &url, unsigned int port, const std::string &path = std::string()); /** * Gives every queued request the app key. The caller holds the mutex it passes to processQueue(). diff --git a/src/configuration_module.cpp b/src/configuration_module.cpp index 74fe035..979d410 100644 --- a/src/configuration_module.cpp +++ b/src/configuration_module.cpp @@ -23,6 +23,7 @@ static constexpr const char *KEY_LOCATION_TRACKING = "lt"; static constexpr const char *KEY_CUSTOM_EVENT_TRACKING = "cet"; static constexpr const char *KEY_CRASH_REPORTING = "crt"; static constexpr const char *KEY_CONSENT_REQUIRED = "cr"; +static constexpr const char *KEY_AUTOMATIC_SESSION_TRACKING = "ast"; static constexpr const char *KEY_SERVER_CONFIG_UPDATE_INTERVAL = "scui"; static constexpr const char *KEY_LOGGING = "log"; // not used and implemented yet @@ -84,6 +85,8 @@ class ConfigurationModule::ConfigurationModuleImpl { std::atomic crashReportingEnabled{true}; // -1 while the settings don't contain "cr", otherwise 0 or 1 std::atomic consentRequired{-1}; + // -1 while the settings don't contain "ast", otherwise 0 or 1 + std::atomic automaticSessionTracking{-1}; std::atomic eventQueueThreshold{0}; std::atomic requestQueueSizeLimit{0}; std::atomic sessionUpdateInterval{0}; @@ -274,6 +277,10 @@ class ConfigurationModule::ConfigurationModuleImpl { // safe because _onSBSChanged is called outside of _mutex _cly->LocationSettingChanged(); } + + if (changedSettings.contains(KEY_AUTOMATIC_SESSION_TRACKING)) { + _cly->AutomaticSessionTrackingChanged(); + } } nlohmann::json _populateConfigValues() { @@ -299,6 +306,15 @@ class ConfigurationModule::ConfigurationModuleImpl { } } consentRequired.store(consentRequiredCurrent, std::memory_order_release); + const int automaticSessionTrackingVal = automaticSessionTracking.load(std::memory_order_acquire); + int automaticSessionTrackingCurrent = -1; + if (sdk_behavior_settings.is_object()) { + nlohmann::json::const_iterator automaticSessionSetting = sdk_behavior_settings.find(KEY_AUTOMATIC_SESSION_TRACKING); + if (automaticSessionSetting != sdk_behavior_settings.end() && automaticSessionSetting->is_boolean()) { + automaticSessionTrackingCurrent = automaticSessionSetting->get() ? 1 : 0; + } + } + automaticSessionTracking.store(automaticSessionTrackingCurrent, std::memory_order_release); trackingEnabled.store(getBool(KEY_TRACKING, true), std::memory_order_release); networkingEnabled.store(getBool(KEY_NETWORKING, true), std::memory_order_release); @@ -373,6 +389,9 @@ class ConfigurationModule::ConfigurationModuleImpl { if (consentRequiredCurrent != consentRequiredVal) { changedSettings[KEY_CONSENT_REQUIRED] = consentRequiredCurrent; } + if (automaticSessionTrackingCurrent != automaticSessionTrackingVal) { + changedSettings[KEY_AUTOMATIC_SESSION_TRACKING] = automaticSessionTrackingCurrent; + } return changedSettings; } @@ -394,7 +413,7 @@ class ConfigurationModule::ConfigurationModuleImpl { valid = value.is_number_unsigned() && value.get() <= UINT_MAX; } else if (key == KEY_BOM_RQ_PERCENTAGE) { valid = value.is_number() && value.get() > 0 && value.get() <= 1; - } else if (key == KEY_TRACKING || key == KEY_NETWORKING || key == KEY_LOGGING || key == KEY_SESSION_TRACKING || key == KEY_VIEW_TRACKING || key == KEY_LOCATION_TRACKING || key == KEY_CUSTOM_EVENT_TRACKING || key == KEY_CONSENT_REQUIRED || key == KEY_CRASH_REPORTING || key == KEY_BACKOFF_MECHANISM) { + } else if (key == KEY_TRACKING || key == KEY_NETWORKING || key == KEY_LOGGING || key == KEY_SESSION_TRACKING || key == KEY_VIEW_TRACKING || key == KEY_LOCATION_TRACKING || key == KEY_CUSTOM_EVENT_TRACKING || key == KEY_CONSENT_REQUIRED || key == KEY_CRASH_REPORTING || key == KEY_BACKOFF_MECHANISM || key == KEY_AUTOMATIC_SESSION_TRACKING) { valid = value.is_boolean(); } else if (key == KEY_EVENT_BLACKLIST || key == KEY_USER_PROPERTY_BLACKLIST || key == KEY_SEGMENTATION_BLACKLIST || key == KEY_EVENT_WHITELIST || key == KEY_USER_PROPERTY_WHITELIST || key == KEY_SEGMENTATION_WHITELIST) { valid = value.is_array(); @@ -587,6 +606,8 @@ bool ConfigurationModule::isCrashReportingEnabled() const { return impl->crashRe int ConfigurationModule::getConsentRequired() const { return impl->consentRequired.load(std::memory_order_acquire); } +int ConfigurationModule::getAutomaticSessionTracking() const { return impl->automaticSessionTracking.load(std::memory_order_acquire); } + unsigned int ConfigurationModule::getRequestQueueSizeLimit() const { return impl->requestQueueSizeLimit.load(std::memory_order_acquire); } unsigned int ConfigurationModule::getRequestDropAgeHours() const { return impl->requestDropAgeHours.load(std::memory_order_acquire); } diff --git a/src/countly.cpp b/src/countly.cpp index 85bd710..658d035 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -35,15 +35,18 @@ const char *const HEALTH_CHECK_AREA_SESSIONS = "sessions"; const char *const HEALTH_CHECK_AREA_EVENTS = "events"; /** - * Checks that the server URL is an absolute http or https URL with a host, and gives it back with a lower case scheme - * and without trailing '/' characters. + * Splits an absolute http or https server URL into its origin, the lower case scheme with the host, the port written in + * it and its path, without trailing '/' characters. * * @param url: the URL the app gave - * @param normalized: receives the URL to use + * @param origin: receives the scheme and the host, for example "https://example.com" + * @param port: receives the port written in the URL, 0 when it has none + * @param path: receives the path, for example "/countly", empty when the URL has none * @param trimmed: set to true when trailing '/' characters were removed - * @return false when the URL can't be used + * @return false when the URL can't be used: another scheme, no host, a port that is not a number from 1 to 65535, a + * query, a fragment or white space */ -bool normalizeServerUrl(const std::string &url, std::string &normalized, bool &trimmed) { +bool parseServerUrl(const std::string &url, std::string &origin, unsigned int &port, std::string &path, bool &trimmed) { const size_t separator = url.find("://"); if (separator == std::string::npos) { return false; @@ -59,10 +62,31 @@ bool normalizeServerUrl(const std::string &url, std::string &normalized, bool &t rest.pop_back(); } trimmed = rest.size() != length; - if (rest.empty() || rest.front() == '/' || rest.find_first_of(" \t\r\n") != std::string::npos) { + if (rest.empty() || rest.find_first_of(" \t\r\n?#") != std::string::npos) { return false; } - normalized = scheme + "://" + rest; + const size_t path_start = rest.find('/'); + std::string authority = rest.substr(0, path_start); + // the colons of an IPv6 address are inside its brackets, so only a colon after them starts the port + const size_t port_separator = authority.find(':', authority.empty() || authority[0] != '[' ? 0 : authority.find(']')); + unsigned int url_port = 0; + if (port_separator != std::string::npos) { + const std::string digits = authority.substr(port_separator + 1); + if (digits.empty() || digits.size() > 5 || digits.find_first_not_of("0123456789") != std::string::npos) { + return false; + } + url_port = static_cast(std::stoul(digits)); + if (url_port < 1 || url_port > 65535) { + return false; + } + authority.erase(port_separator); + } + if (authority.empty() || (authority[0] == '[' && authority.back() != ']')) { + return false; + } + origin = scheme + "://" + authority; + port = url_port; + path = path_start == std::string::npos ? std::string() : rest.substr(path_start); return true; } @@ -130,6 +154,11 @@ bool isInternalEventKey(const std::string &key) { return false; } +/** + * Whether the key is one of the internal events that go to the server right away: feedback answers and push actions. + */ +bool isImmediateFlushEventKey(const std::string &key) { return key == "[CLY]_nps" || key == "[CLY]_survey" || key == "[CLY]_star_rating" || key == "[CLY]_push_action"; } + /** * Replaces a count that is not a whole number from 1 to INT_MAX with 1 and removes a negative duration, logging * each as a warning counted as log code w750. @@ -176,20 +205,6 @@ void appendStoredEvents(const std::deque &queue, nlohmann::json &ev */ bool containsFeature(const std::vector &features, const char *feature) { return std::find(features.begin(), features.end(), feature) != features.end(); } -/** - * Whether the URL is absolute, with the http or https scheme and a host. - */ -bool isValidServerUrl(const std::string &url) { - std::string::size_type hostStart; - if (url.compare(0, 7, "http://") == 0) { - hostStart = 7; - } else if (url.compare(0, 8, "https://") == 0) { - hostStart = 8; - } else { - return false; - } - return hostStart < url.size() && std::string(":/?#").find(url[hostStart]) == std::string::npos; -} } // namespace @@ -906,7 +921,13 @@ void Countly::setUserDetails(const std::map &value) { unsigned int cap = (kv.first == "picture") ? COUNTLY_MAX_VALUE_SIZE_PICTURE : lim.maxValueSize; limitedDetails[kv.first] = cly::limits::limitValue(kv.second, cap, logger.get(), "setUserDetails", kv.first); } - session_params["user_details"] = limitedDetails; + nlohmann::json &details = session_params["user_details"]; + if (!details.is_object()) { + details = nlohmann::json::object(); + } + for (const auto &kv : limitedDetails) { + details[kv.first] = kv.second; + } if (!is_sdk_initialized) { log(LogLevel::INFO, "[Countly] setUserDetails, The SDK is not started yet, the details are sent with the first session."); @@ -956,7 +977,17 @@ void Countly::setCustomUserDetails(const std::map &val for (const auto &kv : customValue) { limitedCustom[cly::limits::limitKey(kv.first, lim.maxKeyLength, logger.get(), "setCustomUserDetails")] = cly::limits::limitValue(kv.second, lim.maxValueSize, logger.get(), "setCustomUserDetails", kv.first); } - session_params["user_details"]["custom"] = limitedCustom; + nlohmann::json &details = session_params["user_details"]; + if (!details.is_object()) { + details = nlohmann::json::object(); + } + nlohmann::json &custom = details["custom"]; + if (!custom.is_object()) { + custom = nlohmann::json::object(); + } + for (const auto &kv : limitedCustom) { + custom[kv.first] = kv.second; + } if (!is_sdk_initialized) { log(LogLevel::INFO, "[Countly] setCustomUserDetails, The SDK is not started yet, the properties are sent with the first session."); @@ -1143,6 +1174,15 @@ void Countly::sendLocationErase() { #pragma endregion User location #pragma region Device Id +void Countly::setDeviceID(const std::string &value) { + bool generated = false; + { + std::lock_guard lk(*mutex); + generated = is_sdk_initialized && configuration->deviceIdType == DeviceIdType::SDK_GENERATED; + } + setDeviceID(value, generated); +} + void Countly::setDeviceID(const std::string &value, bool same_user) { if (value.empty()) { log(LogLevel::WARNING, "[Countly] setDeviceID, The device ID can't be empty, the call is ignored."); @@ -1291,6 +1331,36 @@ void Countly::LocationSettingChanged() { } } +void Countly::AutomaticSessionTrackingChanged() { + std::lock_guard change(consent_change_mutex); + if (!start_finished || is_being_disposed || stopping) { + return; + } + if (isAutomaticSessionTracking()) { + log(LogLevel::INFO, "[Countly] AutomaticSessionTrackingChanged, the server turned automatic session tracking on."); + beginSessionInternal(true); + } else { + log(LogLevel::INFO, "[Countly] AutomaticSessionTrackingChanged, the server turned automatic session tracking off, the session the SDK runs ends."); + endSessionInternal(true); + } +} + +bool Countly::isAutomaticSessionTracking() { + const int server = configurationModule ? configurationModule->getAutomaticSessionTracking() : -1; + return server >= 0 ? server == 1 : !configuration->manualSessionControl; +} + +unsigned int Countly::serverPort(unsigned int url_port, int port, const char *caller) { + if (url_port == 0) { + return static_cast(port); + } + if (port != 0 && static_cast(port) != url_port) { + log(LogLevel::WARNING, std::string("[Countly] ") + caller + ", The port [" + std::to_string(port) + "] differs from the port in the server URL [" + std::to_string(url_port) + "], so the port in the URL is used."); + logger->recordLogCode("w121"); + } + return url_port; +} + std::string Countly::getDeviceID() { if (!is_sdk_initialized) { log(LogLevel::WARNING, "[Countly] getDeviceID, SDK is not initialized."); @@ -1344,7 +1414,7 @@ void Countly::_changeDeviceIdWithoutMerge(const std::string &value) { views_module->stopRunningViews(); // send all event to server and end current session of old user flushEvents(); - if (configuration->manualSessionControl == false) { + if (isAutomaticSessionTracking()) { endSessionInternal(true); } @@ -1374,7 +1444,7 @@ void Countly::_changeDeviceIdWithoutMerge(const std::string &value) { clearRemoteConfigForNewUser(); // start a new session for new user - if (configuration->manualSessionControl == false) { + if (isAutomaticSessionTracking()) { beginSessionInternal(true); } @@ -1407,7 +1477,11 @@ void Countly::changeServerUrl(const std::string &host, int port) { if (!isCallAllowed("changeServerUrl")) { return; } - if (!isValidServerUrl(host)) { + std::string server_url; + std::string server_path; + unsigned int url_port = 0; + bool trimmed_server_url = false; + if (!parseServerUrl(host, server_url, url_port, server_path, trimmed_server_url)) { log(LogLevel::ERROR, "[Countly] changeServerUrl, The server URL [" + host + "] is not an absolute http:// or https:// URL, the current server URL stays."); logger->recordLogCode("e102"); return; @@ -1417,13 +1491,14 @@ void Countly::changeServerUrl(const std::string &host, int port) { logger->recordLogCode("w121"); port = 0; } + const unsigned int server_port = serverPort(url_port, port, "changeServerUrl"); std::lock_guard change(consent_change_mutex); if (is_being_disposed) { return; } - log(LogLevel::INFO, "[Countly] changeServerUrl, Changing the server URL to [" + host + "], port = [" + std::to_string(port) + "]"); - requestModule->setServerUrl(host, static_cast(port)); + log(LogLevel::INFO, "[Countly] changeServerUrl, Changing the server URL to [" + host + "], port = [" + std::to_string(server_port) + "]"); + requestModule->setServerUrl(server_url, server_port, server_path); if (consent_module->isRequired()) { queueConsentState(); } @@ -1558,13 +1633,15 @@ void Countly::start(const std::string &app_key, const std::string &host, int por } std::string server_url; + std::string server_path; + unsigned int url_port = 0; bool trimmed_server_url = false; - if (!normalizeServerUrl(host, server_url, trimmed_server_url)) { + if (!parseServerUrl(host, server_url, url_port, server_path, trimmed_server_url)) { log(LogLevel::ERROR, "[Countly] start, The server URL [" + host + "] is not an absolute http or https URL, for example 'https://your.server.ly'. SDK will not be initialized."); return; } if (trimmed_server_url) { - log(LogLevel::WARNING, "[Countly] start, The trailing '/' characters of the server URL are removed: [" + server_url + "]"); + log(LogLevel::WARNING, "[Countly] start, The trailing '/' characters of the server URL are removed: [" + server_url + server_path + "]"); } #ifdef COUNTLY_USE_SQLITE @@ -1620,9 +1697,10 @@ void Countly::start(const std::string &app_key, const std::string &host, int por port = 0; } - configuration->port = port; + configuration->port = serverPort(url_port, port, "start"); configuration->appKey = app_key; configuration->serverUrl = server_url; + configuration->serverPath = server_path; session_params["app_key"] = app_key; { @@ -1670,9 +1748,10 @@ void Countly::start(const std::string &app_key, const std::string &host, int por activateConsent(); lk.lock(); + const bool automatic_sessions = isAutomaticSessionTracking(); if (!running) { - if (configuration->manualSessionControl == false) { + if (automatic_sessions) { lk.unlock(); beginSessionInternal(true); lk.lock(); @@ -1695,6 +1774,10 @@ void Countly::start(const std::string &app_key, const std::string &host, int por sendLocationErase(); } start_finished = true; + if (isAutomaticSessionTracking() != automatic_sessions) { + // the server's automatic session tracking setting arrived while start() ran + AutomaticSessionTrackingChanged(); + } downloadRemoteConfigAutomatically(); } @@ -1799,7 +1882,7 @@ void Countly::stop() { if (is_sdk_initialized && !is_being_disposed) { // queued while the update loop still runs, so that its last pass can send them packEvents(); - if (configuration->manualSessionControl == false) { + if (isAutomaticSessionTracking()) { endSessionInternal(true); } } @@ -1961,7 +2044,11 @@ void Countly::addEvent(const cly::Event &event) { event_queue.push_back(filteredEvent.serialize()); } lk.unlock(); - checkAndSendEventToRQ(); + if (isImmediateFlushEventKey(eventKey)) { + packEvents(); + } else { + checkAndSendEventToRQ(); + } } bool Countly::IsSessionRunning() { @@ -2072,7 +2159,7 @@ bool Countly::attemptSessionUpdateEQ() { return false; } } - if (configuration->manualSessionControl == false) { + if (isAutomaticSessionTracking()) { return !updateSessionInternal(true); } else { packEvents(); @@ -2158,7 +2245,7 @@ bool Countly::beginSession() { } bool Countly::rejectSessionCallInAutomaticMode(const char *caller, const char *usage) { - if (!is_sdk_initialized || configuration->manualSessionControl) { + if (!is_sdk_initialized || !isAutomaticSessionTracking()) { return false; } if (hasConsent(consent::SESSIONS)) { @@ -2944,14 +3031,15 @@ void Countly::updateTick(std::chrono::steady_clock::time_point &last_update) { const std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if (now - last_update >= std::chrono::seconds(configurationModule->getSessionUpdateInterval())) { last_update = now; - if (configuration->manualSessionControl == false) { + if (isAutomaticSessionTracking()) { updateSessionInternal(true); } else { packEvents(); } } } - requestModule->processQueue(mutex); + // after stop() this is the last pass, which closes by sending + requestModule->processQueue(mutex, stopping); saveHealthCheckOnTimer(); } @@ -3369,7 +3457,7 @@ void Countly::consentChanged(const std::vector &features, bool give } bool sessionBegan = false; - if (containsFeature(features, consent::SESSIONS) && configuration->manualSessionControl == false) { + if (containsFeature(features, consent::SESSIONS) && isAutomaticSessionTracking()) { bool sessionRunning = false; { std::lock_guard lk(*mutex); diff --git a/src/request_module.cpp b/src/request_module.cpp index e344ccd..e7ea0ba 100644 --- a/src/request_module.cpp +++ b/src/request_module.cpp @@ -53,13 +53,13 @@ class RequestModule::RequestModuleImpl { std::shared_ptr _storageModule; RequestModuleImpl(std::shared_ptr config, std::shared_ptr logger, std::shared_ptr requestBuilder, std::shared_ptr storageModule) : _configuration(config), _logger(logger), _requestBuilder(requestBuilder), _storageModule(storageModule) { - setServerUrl(_configuration->serverUrl, _configuration->port); + setServerUrl(_configuration->serverUrl, _configuration->port, _configuration->serverPath); } /** * Normalizes and stores the server URL and the port, see RequestModule::setServerUrl(). */ - void setServerUrl(std::string url, unsigned int port) { + void setServerUrl(std::string url, unsigned int port, std::string path) { const bool use_https = url.find("https://") == 0; if (!use_https && url.find("http://") != 0) { url.insert(0, "http://"); @@ -77,6 +77,7 @@ class RequestModule::RequestModuleImpl { std::lock_guard lk(server_mutex); _configuration->serverUrl = url; _configuration->port = port; + _configuration->serverPath = path; } ~RequestModuleImpl() { _logger.reset(); } @@ -299,7 +300,7 @@ void RequestModule::clearRequestQueue() { impl->sending_removed = true; } -void RequestModule::setServerUrl(const std::string &url, unsigned int port) { impl->setServerUrl(url, port); } +void RequestModule::setServerUrl(const std::string &url, unsigned int port, const std::string &path) { impl->setServerUrl(url, port, path); } void RequestModule::replaceAppKeysInQueue(const std::string &appKey) { const std::string encodedKey = RequestBuilder::encodeURL(appKey); @@ -331,7 +332,7 @@ long long RequestModule::removeOtherAppKeysFromQueue(const std::string &appKey) return removed; } -void RequestModule::processQueue(std::shared_ptr mutex) { +void RequestModule::processQueue(std::shared_ptr mutex, bool closing) { std::shared_ptr config; { // lock_guard so the mutex is released on every exit path of this block, @@ -358,7 +359,7 @@ void RequestModule::processQueue(std::shared_ptr mutex) { impl->_logger->log(LogLevel::DEBUG, "[Countly] [RequestModule] processQueue: Waiting for the cool-down after a failed request."); return; } - if (now < impl->backoff_ends) { + if (!closing && now < impl->backoff_ends) { impl->_logger->log(LogLevel::DEBUG, "[Countly] [RequestModule] processQueue: Waiting for the backoff to end."); return; } @@ -430,7 +431,7 @@ void RequestModule::processQueue(std::shared_ptr mutex) { processedRequestsCounter++; mergeDelivered = data->getData().find("old_device_id=") != std::string::npos; - if (config->isBackoffEnabled()) { + if (!closing && config->isBackoffEnabled()) { const long long timestamp = requestTimestamp(data->getData()); const bool slow = responseTime >= std::chrono::seconds(config->getBackoffAcceptedTimeout()); const bool queueNotFull = impl->_storageModule->RQCount() <= config->getBackoffRequestQueuePercentage() * config->getRequestQueueSizeLimit(); @@ -501,12 +502,15 @@ HTTPResponse RequestModule::sendHTTP(std::string path, std::string data) { } std::string server_url; + std::string server_path; unsigned int port; { std::lock_guard lk(impl->server_mutex); server_url = impl->_configuration->serverUrl; + server_path = impl->_configuration->serverPath; port = impl->_configuration->port; } + const std::string request_path = server_path + path; #ifdef _WIN32 const bool use_https = server_url.find("https://") == 0; HINTERNET hSession = nullptr; @@ -533,9 +537,9 @@ HTTPResponse RequestModule::sendHTTP(std::string path, std::string data) { } if (hConnect) { - size_t buffer_size = MultiByteToWideChar(CP_ACP, 0, path.c_str(), -1, nullptr, 0); + size_t buffer_size = MultiByteToWideChar(CP_ACP, 0, request_path.c_str(), -1, nullptr, 0); wchar_t *wide_path = new wchar_t[buffer_size]; - MultiByteToWideChar(CP_ACP, 0, path.c_str(), -1, wide_path, static_cast(buffer_size)); + MultiByteToWideChar(CP_ACP, 0, request_path.c_str(), -1, wide_path, static_cast(buffer_size)); hRequest = WinHttpOpenRequest(hConnect, L"POST", wide_path, NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, use_https ? WINHTTP_FLAG_SECURE : 0); delete[] wide_path; @@ -610,7 +614,7 @@ HTTPResponse RequestModule::sendHTTP(std::string path, std::string data) { curl = curl_easy_init(); if (curl) { std::ostringstream full_url_stream; - full_url_stream << server_url << ':' << std::dec << port << path; + full_url_stream << server_url << ':' << std::dec << port << request_path; curl_easy_setopt(curl, CURLOPT_POSTFIELDS, data.c_str()); diff --git a/tests/behavior_settings.cpp b/tests/behavior_settings.cpp index 1202d28..a0ce4b4 100644 --- a/tests/behavior_settings.cpp +++ b/tests/behavior_settings.cpp @@ -224,3 +224,103 @@ TEST_CASE("Server settings with an invalid value are ignored with a warning") { CHECK(settingsLogCode(countly, "w121") == 5); CHECK(recorded(countly, "still_tracked")); } + +namespace { +/** + * Prepares the default instance like prepareSettings(), with automatic sessions unless 'manual' is set. + */ +Countly &prepareSessionSettings(const std::string &provided, bool manual) { + clearSDK(); + Countly &countly = Countly::getInstance(); + countly.setHTTPClient(settingsClient); + countly.disableHealthCheck(); + if (manual) { + countly.enableManualSessionControl(); + } + countly.enableImmediateRequestOnStop(); + countly.setUpdateInterval(600000); + countly.setDeviceID(COUNTLY_TEST_DEVICE_ID); + countly.SetPath(TEST_DATABASE_NAME); + if (!provided.empty()) { + countly.setSDKBehaviorSettings(provided); + } + return countly; +} + +/** + * The session requests the fake server received, in order: "begin", "update" or "end". + */ +std::vector sessionRequests() { + std::vector kinds; + for (size_t index = 0; index < http_call_queue.size(); index++) { + HTTPCall call = http_call_queue.at(index); + if (call.data.count("begin_session")) { + kinds.push_back("begin"); + } else if (call.data.count("end_session")) { + kinds.push_back("end"); + } else if (call.data.count("session_duration")) { + kinds.push_back("update"); + } + } + return kinds; +} + +/** + * A behavior settings answer of the server with the given settings. + */ +json settingsAnswer(const json &settings) { return json({{"v", 2}, {"t", 1}, {"c", settings}}); } +} // namespace + +TEST_CASE("The server's automatic session tracking setting") { + SUBCASE("ast false: no session begins by itself, and the app's session calls work") { + settings_answers.reset({}); + Countly &countly = prepareSessionSettings("{\"ast\":false}", false); + startSettings(countly); + countly.processRQDebug(); + CHECK(sessionRequests().empty()); + + CHECK(countly.beginSession()); + countly.processRQDebug(); + CHECK(sessionRequests() == std::vector{"begin"}); + } + + SUBCASE("ast true: the SDK tracks sessions also when the app chose manual session control") { + settings_answers.reset({}); + Countly &countly = prepareSessionSettings("{\"ast\":true}", true); + startSettings(countly); + + CHECK_FALSE(countly.beginSession()); + countly.processRQDebug(); + CHECK(sessionRequests() == std::vector{"begin"}); + CHECK(settingsLogCode(countly, "w740") == 1); + } + + SUBCASE("ast false from the server ends the session the SDK runs") { + settings_answers.reset({settingsAnswer(json::object()), settingsAnswer({{"ast", false}})}); + Countly &countly = prepareSessionSettings("", false); + startSettings(countly); + REQUIRE(waitFor([] { return settings_answers.fetches() == 1; })); + countly.changeServerUrl(COUNTLY_TEST_HOST, COUNTLY_TEST_PORT); + + CHECK(waitFor([&countly] { + countly.processRQDebug(); + return sessionRequests() == std::vector{"begin", "end"}; + })); + CHECK(countly.beginSession()); + } + + SUBCASE("ast true from the server begins a session for an app with manual session control") { + settings_answers.reset({settingsAnswer(json::object()), settingsAnswer({{"ast", true}})}); + Countly &countly = prepareSessionSettings("", true); + startSettings(countly); + REQUIRE(waitFor([] { return settings_answers.fetches() == 1; })); + countly.processRQDebug(); + REQUIRE(sessionRequests().empty()); + countly.changeServerUrl(COUNTLY_TEST_HOST, COUNTLY_TEST_PORT); + + CHECK(waitFor([&countly] { + countly.processRQDebug(); + return sessionRequests() == std::vector{"begin"}; + })); + } +} diff --git a/tests/consent.cpp b/tests/consent.cpp index 4bc639d..6d8c813 100644 --- a/tests/consent.cpp +++ b/tests/consent.cpp @@ -191,12 +191,13 @@ std::vector eventsIn(const std::vector &requests) { } /** - * The keys of the events waiting in the event queue, in order. + * The keys of the recorded events, in order: the waiting events are moved into a request and every request is sent. */ -std::vector queuedEventKeys(Countly &countly) { +std::vector recordedEventKeys(Countly &countly) { + countly.flushEvents(); std::vector keys; - for (const std::string &event : countly.debugReturnStateOfEQ()) { - keys.push_back(json::parse(event)["key"].get()); + for (const json &event : eventsIn(takeRequests(countly))) { + keys.push_back(event["key"].get()); } return keys; } @@ -866,7 +867,7 @@ TEST_CASE("consent: internal events need the consent of their own feature") { for (const std::string &key : keys) { countly.RecordEvent(key, 1); } - CHECK(queuedEventKeys(countly) == testCase.recorded); + CHECK(recordedEventKeys(countly) == testCase.recorded); clearSDK(); } } diff --git a/tests/event_recording.cpp b/tests/event_recording.cpp index 3c0f51e..edcdf83 100644 --- a/tests/event_recording.cpp +++ b/tests/event_recording.cpp @@ -268,3 +268,26 @@ TEST_CASE("Events are not recorded while the server turns tracking off") { CHECK(countly.checkEQSize() == 0); CHECK(eventLogCode(countly, "w505") == 1); } + +TEST_CASE("Feedback answers and push actions are moved into a request right away") { + const std::vector keys = {"[CLY]_nps", "[CLY]_survey", "[CLY]_star_rating", "[CLY]_push_action"}; + for (const std::string &key : keys) { + CAPTURE(key); + Countly &countly = prepareEvents(); + startEvents(countly); + countly.RecordEvent(key, {{"widget_id", "5f1a2b3c4d5e6f7a8b9c0d1e"}}, 1); + + CHECK(countly.checkEQSize() == 0); + countly.processRQDebug(); + CHECK(httpQueueContainsEvent(key)); + } +} + +TEST_CASE("Other events wait in the event queue for the update timer") { + Countly &countly = prepareEvents(); + startEvents(countly); + countly.RecordEvent("[CLY]_orientation", {{"mode", "landscape"}}, 1); + countly.RecordEvent("purchase", 1); + + CHECK(countly.checkEQSize() == 2); +} diff --git a/tests/init.cpp b/tests/init.cpp index 86bbcf0..65fb378 100644 --- a/tests/init.cpp +++ b/tests/init.cpp @@ -485,3 +485,123 @@ TEST_CASE("Cut user detail values are logged as warnings") { CHECK(logCodeCount(countly, "w202") == 2); CHECK(logCodeCount(countly, "w201") == 1); } + +TEST_CASE("A port or a path in the server URL is used") { + SUBCASE("start() takes the port and the path from the URL") { + clearSDK(); + Countly &countly = prepareInstance(); + countly.setDeviceID("developer-id"); + countly.start(COUNTLY_TEST_APP_KEY, "https://analytics.example.com:8443/countly/", 0, false); + + CHECK(countly.checkEQSize() == 0); + CHECK(countly.getConfiguration().serverUrl == "https://analytics.example.com"); + CHECK(countly.getConfiguration().port == 8443); + CHECK(countly.getConfiguration().serverPath == "/countly"); + } + + SUBCASE("a port argument that differs from the port in the URL is ignored with a warning") { + clearSDK(); + Countly &countly = prepareInstance(); + countly.setDeviceID("developer-id"); + countly.start(COUNTLY_TEST_APP_KEY, "https://analytics.example.com:8443", 443, false); + + CHECK(countly.getConfiguration().port == 8443); + CHECK(logCodeCount(countly, "w121") == 1); + } + + SUBCASE("changeServerUrl takes the port and the path from the URL") { + clearSDK(); + Countly &countly = prepareInstance(); + countly.setDeviceID("developer-id"); + countly.start(COUNTLY_TEST_APP_KEY, "https://first.example.com", 0, false); + countly.changeServerUrl("https://second.example.com:9443/analytics", 0); + + CHECK(countly.getConfiguration().serverUrl == "https://second.example.com"); + CHECK(countly.getConfiguration().port == 9443); + CHECK(countly.getConfiguration().serverPath == "/analytics"); + } +} + +TEST_CASE("start() refuses a server URL with an invalid port, a query or a fragment") { + const std::vector urls = {"https://example.com:0", "https://example.com:70000", "https://example.com:http", "https://example.com:", "https://example.com/?app=1", "https://example.com/#top"}; + for (const std::string &url : urls) { + CAPTURE(url); + clearSDK(); + Countly &countly = prepareInstance(); + countly.setDeviceID("developer-id"); + + CHECK_NOTHROW(countly.start(COUNTLY_TEST_APP_KEY, url, 0, false)); + CHECK(countly.checkEQSize() == -1); + } +} + +TEST_CASE("setDeviceID with one argument merges only a device ID the SDK generated") { + SUBCASE("a generated device ID is merged into the new one") { + clearSDK(); + Countly &countly = prepareInstance(); + countly.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + const std::string generated = countly.getDeviceID(); + countly.processRQDebug(); + http_call_queue.clear(); + + countly.setDeviceID("user-42"); + countly.processRQDebug(); + + CHECK(httpQueueContains("old_device_id", generated)); + CHECK_FALSE(httpQueueContains("end_session", "1")); + CHECK(countly.getDeviceID() == "user-42"); + CHECK(countly.getDeviceIDType() == DeviceIdType::DEVELOPER_SUPPLIED); + } + + SUBCASE("a device ID the app gave is changed without merge") { + clearSDK(); + Countly &countly = prepareInstance(); + countly.setDeviceID("developer-id"); + countly.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + countly.processRQDebug(); + http_call_queue.clear(); + + countly.setDeviceID("user-42"); + countly.processRQDebug(); + + CHECK_FALSE(httpQueueContains("old_device_id", "developer-id")); + CHECK(httpQueueContains("end_session", "1")); + } + + SUBCASE("same_user false changes a generated device ID without merge") { + clearSDK(); + Countly &countly = prepareInstance(); + countly.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + const std::string generated = countly.getDeviceID(); + countly.processRQDebug(); + http_call_queue.clear(); + + countly.setDeviceID("user-42", false); + countly.processRQDebug(); + + CHECK_FALSE(httpQueueContains("old_device_id", generated)); + CHECK(httpQueueContains("end_session", "1")); + } +} + +TEST_CASE("User details given before start() in several calls are all sent with the first session") { + clearSDK(); + Countly &countly = prepareInstance(); + countly.setCustomUserDetails({{"plan", "gold"}}); + countly.setUserDetails({{"name", "Alice"}}); + countly.setUserDetails({{"email", "alice@example.com"}}); + countly.setCustomUserDetails({{"team", "blue"}}); + + countly.setDeviceID("developer-id"); + countly.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + countly.processRQDebug(); + bool sent = false; + for (size_t index = 0; index < http_call_queue.size(); index++) { + HTTPCall call = http_call_queue.at(index); + if (call.data.count("begin_session")) { + CHECK(json::parse(call.data["user_details"]) == json({{"name", "Alice"}, {"email", "alice@example.com"}, {"custom", {{"plan", "gold"}, {"team", "blue"}}}})); + sent = true; + } + } + CHECK(sent); +} diff --git a/tests/request.cpp b/tests/request.cpp index 922b8a3..f07a287 100644 --- a/tests/request.cpp +++ b/tests/request.cpp @@ -760,4 +760,79 @@ TEST_CASE("Changing the server URL with the built-in HTTP client") { CHECK((request_body.find("method=sc") != std::string::npos || request_body.find("hc=") != std::string::npos)); } } +/** + * Sends the queue until the server received a request with the text, for at most 10 seconds. + */ +static void sendUntilReceived(cly::Countly &countly, const LocalHTTPServer &server, const std::string &text) { + const std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (std::chrono::steady_clock::now() < deadline && !receivedRequestWith(server, text)) { + countly.processRQDebug(); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } +} + +TEST_CASE("Built-in HTTP client with a port or a path in the server URL") { + const std::string database_path = "test-countly-http.db"; + remove(database_path.c_str()); + + SUBCASE("the port in the URL is used") { + LocalHTTPServer server(true); + std::shared_ptr countly = std::make_shared(); + countly->setDeviceID(COUNTLY_TEST_DEVICE_ID); + countly->SetPath(database_path); + countly->enableImmediateRequestOnStop(); + countly->setUpdateInterval(60000); + countly->start(COUNTLY_TEST_APP_KEY, "http://127.0.0.1:" + std::to_string(server.port()), 0, false); + sendUntilReceived(*countly, server, "begin_session=1"); + + countly.reset(); + server.stop(); + remove(database_path.c_str()); + CHECK(receivedRequestWith(server, "begin_session=1")); + } + + SUBCASE("a path in the URL goes in front of every endpoint") { + LocalHTTPServer server(true); + std::shared_ptr countly = std::make_shared(); + countly->setDeviceID(COUNTLY_TEST_DEVICE_ID); + countly->SetPath(database_path); + countly->enableImmediateRequestOnStop(); + countly->setUpdateInterval(60000); + countly->start(COUNTLY_TEST_APP_KEY, "http://127.0.0.1:" + std::to_string(server.port()) + "/countly/", 0, false); + sendUntilReceived(*countly, server, "begin_session=1"); + sendUntilReceived(*countly, server, "method=sc"); + + countly.reset(); + server.stop(); + remove(database_path.c_str()); + CHECK(receivedRequestWith(server, "begin_session=1")); + CHECK(receivedRequestWith(server, "method=sc")); + for (const std::string &request_line : server.requestLines()) { + CHECK((request_line.rfind("POST /countly/i ", 0) == 0 || request_line.rfind("POST /countly/o/sdk ", 0) == 0)); + } + } + + SUBCASE("changeServerUrl takes the port and the path from the URL") { + LocalHTTPServer first_server(true); + LocalHTTPServer second_server(true); + std::shared_ptr countly = std::make_shared(); + countly->setDeviceID(COUNTLY_TEST_DEVICE_ID); + countly->SetPath(database_path); + countly->enableImmediateRequestOnStop(); + countly->setUpdateInterval(60000); + countly->start(COUNTLY_TEST_APP_KEY, "http://127.0.0.1", first_server.port(), false); + countly->changeServerUrl("http://127.0.0.1:" + std::to_string(second_server.port()) + "/analytics", 0); + sendUntilReceived(*countly, second_server, "begin_session=1"); + + countly.reset(); + first_server.stop(); + second_server.stop(); + remove(database_path.c_str()); + CHECK(receivedRequestWith(second_server, "begin_session=1")); + for (const std::string &request_line : second_server.requestLines()) { + CHECK((request_line.rfind("POST /analytics/i ", 0) == 0 || request_line.rfind("POST /analytics/o/sdk ", 0) == 0)); + } + } +} + #endif diff --git a/tests/request_pipeline.cpp b/tests/request_pipeline.cpp index 36f9dae..4b2f056 100644 --- a/tests/request_pipeline.cpp +++ b/tests/request_pipeline.cpp @@ -552,3 +552,46 @@ TEST_CASE("A slow answer pauses sending for the backoff duration") { CHECK(countly.debugHealthCheck()->snapshot()["bom"] == 0); } } + +TEST_CASE("stop() sends the waiting requests also while a backoff pause runs") { + clearSDK(); + server.reset(); + Countly &countly = Countly::getInstance(); + countly.setHTTPClient(scriptedClient); + countly.setSha256(fakeSha); + countly.disableHealthCheck(); + countly.disableSDKBehaviorSettingsUpdates(); + countly.enableManualSessionControl(); + countly.setDeviceID(COUNTLY_TEST_DEVICE_ID); + countly.SetPath(TEST_DATABASE_NAME); + // Without enableImmediateRequestOnStop, stop() closes by sending: the update loop sends the queue once more + countly.setUpdateInterval(100); + countly.setSDKBehaviorSettings("{\"bom_at\":1,\"bom_d\":60}"); + start(countly); + + server.setAnswer([](const ReceivedRequest &) { + std::this_thread::sleep_for(std::chrono::milliseconds(1100)); + return HTTPResponse{true, json({{"result", "Success"}}), 200}; + }); + countly.setUserDetails({{"name", "answered slowly"}}); + REQUIRE(eventually([&countly] { return logCode(countly, "w320") == 1; }, std::chrono::milliseconds(5000))); + + server.reset(); + const auto eventSent = [] { + for (const ReceivedRequest &request : server.queued()) { + if (request.params.count("events") && request.params.at("events").find("recorded_during_the_pause") != std::string::npos) { + return true; + } + } + return false; + }; + countly.RecordEvent("recorded_during_the_pause", 1); + // A call that asks to send the stored requests waits for the pause to end + countly.attemptToSendStoredRequests(); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + REQUIRE_FALSE(eventSent()); + + countly.stop(); + CHECK(eventSent()); + CHECK(countly.checkRQSize() == 0); +}