diff --git a/CHANGELOG.md b/CHANGELOG.md index 457f853..3a48075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## X.X.X +- ! Minor breaking change ! Added SDK internal limits enforcement (max key length, value size, segmentation values, breadcrumb count, stack-trace lines per thread, stack-trace line length) across events, views, crashes, and user properties. Limits use config defaults overridable by server SDK Behavior Settings, and can be set via `setMaxKeyLength`, `setMaxValueSize`, `setMaxSegmentationValues`, `setMaxBreadcrumbCount`, `setMaxStackTraceLinesPerThread`, `setMaxStackTraceLineLength` during init. + ## 26.1.1 - Updated CMake minimum required version to use the range format with upper the end of `3.31`. - Hardened mutex handling against exceptions. diff --git a/CMakeLists.txt b/CMakeLists.txt index d1d1786..867b037 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -120,7 +120,9 @@ if(COUNTLY_BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/tests/config.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/immediate_stop.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/mutex_exception_safety.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/tests/sbs.cpp) + ${CMAKE_CURRENT_SOURCE_DIR}/tests/sbs.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/internal_limits.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/internal_limits_integration.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 281bafc..6f9dbc7 100644 --- a/include/countly.hpp +++ b/include/countly.hpp @@ -179,6 +179,13 @@ class Countly : public cly::CountlyDelegates { */ void setEventsToRQThreshold(int value); + void setMaxKeyLength(unsigned int value); + void setMaxValueSize(unsigned int value); + void setMaxSegmentationValues(unsigned int value); + void setMaxBreadcrumbCount(unsigned int value); + void setMaxStackTraceLinesPerThread(unsigned int value); + void setMaxStackTraceLineLength(unsigned int value); + void flushEvents(std::chrono::seconds timeout = std::chrono::seconds(30)); bool beginSession(); diff --git a/include/countly/configuration_module.hpp b/include/countly/configuration_module.hpp index 396ea93..ce1b401 100644 --- a/include/countly/configuration_module.hpp +++ b/include/countly/configuration_module.hpp @@ -41,6 +41,7 @@ class ConfigurationModule : public ConfigurationProvider { bool isCrashReportingEnabled() const override; unsigned int getRequestQueueSizeLimit() const override; + SDKLimits getLimits() const override; unsigned int getEventQueueSizeLimit(); unsigned int getSessionUpdateInterval(); diff --git a/include/countly/configuration_provider.hpp b/include/countly/configuration_provider.hpp index e573de0..943f280 100644 --- a/include/countly/configuration_provider.hpp +++ b/include/countly/configuration_provider.hpp @@ -2,6 +2,15 @@ #define CONFIGURATION_PROVIDER_HPP_ namespace cly { +struct SDKLimits { + unsigned int maxKeyLength; + unsigned int maxValueSize; + unsigned int maxSegmentationValues; + unsigned int maxBreadcrumbCount; + unsigned int maxStackTraceLinesPerThread; + unsigned int maxStackTraceLineLength; +}; + class ConfigurationProvider { public: virtual ~ConfigurationProvider() = default; @@ -11,6 +20,7 @@ class ConfigurationProvider { virtual bool isCrashReportingEnabled() const = 0; virtual bool isViewTrackingEnabled() const = 0; virtual unsigned int getRequestQueueSizeLimit() const = 0; + virtual SDKLimits getLimits() const = 0; }; } // namespace cly #endif \ No newline at end of file diff --git a/include/countly/constants.hpp b/include/countly/constants.hpp index 68490d4..622e723 100644 --- a/include/countly/constants.hpp +++ b/include/countly/constants.hpp @@ -17,6 +17,13 @@ #define COUNTLY_POST_THRESHOLD 2000 #define COUNTLY_KEEPALIVE_INTERVAL 3000 #define COUNTLY_MAX_EVENTS_DEFAULT 200 +#define COUNTLY_MAX_KEY_LENGTH_DEFAULT 128 +#define COUNTLY_MAX_VALUE_SIZE_DEFAULT 256 +#define COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT 100 +#define COUNTLY_MAX_BREADCRUMB_COUNT_DEFAULT 100 +#define COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT 30 +#define COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT 200 +#define COUNTLY_MAX_VALUE_SIZE_PICTURE 4096 namespace cly { struct HTTPResponse { diff --git a/include/countly/countly_configuration.hpp b/include/countly/countly_configuration.hpp index cee2aef..d54bef1 100644 --- a/include/countly/countly_configuration.hpp +++ b/include/countly/countly_configuration.hpp @@ -59,6 +59,15 @@ struct CountlyConfiguration { */ unsigned int breadcrumbsThreshold = 100; + /** + * SDK internal limits (defaults; overridable at runtime by SDK Behavior Settings). + */ + unsigned int maxKeyLength = COUNTLY_MAX_KEY_LENGTH_DEFAULT; + unsigned int maxValueSize = COUNTLY_MAX_VALUE_SIZE_DEFAULT; + unsigned int maxSegmentationValues = COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT; + unsigned int maxStackTraceLinesPerThread = COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT; + unsigned int maxStackTraceLineLength = COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT; + /** * Set to send all requests made to the Countly server using HTTP POST. */ diff --git a/include/countly/event.hpp b/include/countly/event.hpp index 74d700a..b09f60c 100644 --- a/include/countly/event.hpp +++ b/include/countly/event.hpp @@ -35,6 +35,9 @@ class Event { void clearSegmentation(); + // Enforce SDK internal limits on this event's key and developer segmentation. + void applyLimits(unsigned int maxKeyLength, unsigned int maxValueSize, unsigned int maxSegmentationValues); + private: nlohmann::json object; bool timer_running; diff --git a/include/countly/internal_limits.hpp b/include/countly/internal_limits.hpp new file mode 100644 index 0000000..22931e2 --- /dev/null +++ b/include/countly/internal_limits.hpp @@ -0,0 +1,66 @@ +#ifndef COUNTLY_INTERNAL_LIMITS_HPP_ +#define COUNTLY_INTERNAL_LIMITS_HPP_ + +#include "countly/configuration_provider.hpp" // for cly::SDKLimits + +#include +#include +#include + +namespace cly { +namespace limits { + +// Truncate a string to at most maxLen bytes without splitting a multibyte +// UTF-8 sequence at the cut point. +inline std::string truncateString(const std::string &s, unsigned int maxLen) { + if (s.size() <= static_cast(maxLen)) { + return s; + } + size_t cut = maxLen; + // UTF-8 continuation bytes are 0b10xxxxxx. If the cut lands on one, we are + // inside a character; back off until it sits on a code-point boundary. + while (cut > 0 && (static_cast(s[cut]) & 0xC0) == 0x80) { + --cut; + } + return s.substr(0, cut); +} + +// Truncate keys/values of a developer-supplied segmentation map and cap the +// entry count. std::map iterates in sorted key order, so the retained subset +// is deterministic. +inline std::map applySegmentationLimits(const std::map &in, const SDKLimits &lim) { + std::map out; + unsigned int kept = 0; + for (const auto &kv : in) { + if (kept >= lim.maxSegmentationValues) { + break; + } + out[truncateString(kv.first, lim.maxKeyLength)] = truncateString(kv.second, lim.maxValueSize); + ++kept; + } + return out; +} + +// Cap a stack-trace string to maxLines lines and each line to maxLineLength +// bytes. Lines are split on '\n' and rejoined with '\n'. +inline std::string truncateStackTrace(const std::string &trace, unsigned int maxLines, unsigned int maxLineLength) { + std::istringstream stream(trace); + std::string line; + std::string result; + unsigned int count = 0; + bool first = true; + while (count < maxLines && std::getline(stream, line)) { + if (!first) { + result += "\n"; + } + result += truncateString(line, maxLineLength); + first = false; + ++count; + } + return result; +} + +} // namespace limits +} // namespace cly + +#endif diff --git a/src/configuration_module.cpp b/src/configuration_module.cpp index 883106d..e8f70d0 100644 --- a/src/configuration_module.cpp +++ b/src/configuration_module.cpp @@ -85,6 +85,13 @@ class ConfigurationModule::ConfigurationModuleImpl { std::atomic sessionUpdateInterval{0}; std::atomic serverConfigUpdateInterval{4}; + std::atomic maxKeyLength{COUNTLY_MAX_KEY_LENGTH_DEFAULT}; + std::atomic maxValueSize{COUNTLY_MAX_VALUE_SIZE_DEFAULT}; + std::atomic maxSegmentationValues{COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT}; + std::atomic maxBreadcrumbCount{COUNTLY_MAX_BREADCRUMB_COUNT_DEFAULT}; + std::atomic maxStackTraceLinesPerThread{COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT}; + std::atomic maxStackTraceLineLength{COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT}; + mutable std::mutex sbsMutex; std::thread configFetchThread; @@ -184,6 +191,13 @@ class ConfigurationModule::ConfigurationModuleImpl { void _initializeConfigParameters() { requestQueueSizeLimit.store(_configuration->requestQueueThreshold, std::memory_order_release); sessionUpdateInterval.store(_configuration->sessionDuration, std::memory_order_release); + + maxKeyLength.store(_configuration->maxKeyLength, std::memory_order_release); + maxValueSize.store(_configuration->maxValueSize, std::memory_order_release); + maxSegmentationValues.store(_configuration->maxSegmentationValues, std::memory_order_release); + maxBreadcrumbCount.store(_configuration->breadcrumbsThreshold, std::memory_order_release); + maxStackTraceLinesPerThread.store(_configuration->maxStackTraceLinesPerThread, std::memory_order_release); + maxStackTraceLineLength.store(_configuration->maxStackTraceLineLength, std::memory_order_release); } nlohmann::json _processSDKBehaviorSettings(const std::string &settings) { @@ -267,6 +281,17 @@ class ConfigurationModule::ConfigurationModuleImpl { populateListFilter(eventSegmentationFilter, KEY_EVENT_SEGMENTATION_BLACKLIST, KEY_EVENT_SEGMENTATION_WHITELIST, parseMap); } + auto readLimit = [this](const char *key, unsigned int def) { + unsigned int v = getUInt(key, def); + return v < 1 ? def : v; + }; + maxKeyLength.store(readLimit(KEY_LIMIT_KEY_LENGTH, _configuration->maxKeyLength), std::memory_order_release); + maxValueSize.store(readLimit(KEY_LIMIT_VALUE_SIZE, _configuration->maxValueSize), std::memory_order_release); + maxSegmentationValues.store(readLimit(KEY_LIMIT_SEG_VALUES, _configuration->maxSegmentationValues), std::memory_order_release); + maxBreadcrumbCount.store(readLimit(KEY_LIMIT_BREADCRUMB, _configuration->breadcrumbsThreshold), std::memory_order_release); + maxStackTraceLinesPerThread.store(readLimit(KEY_LIMIT_TRACE_LINE, _configuration->maxStackTraceLinesPerThread), std::memory_order_release); + maxStackTraceLineLength.store(readLimit(KEY_LIMIT_TRACE_LENGTH, _configuration->maxStackTraceLineLength), std::memory_order_release); + nlohmann::json changedSettings; if (locationTrackingCurrent != locationTrackingEnabledVal) { changedSettings[KEY_LOCATION_TRACKING] = locationTrackingCurrent; @@ -475,6 +500,17 @@ bool ConfigurationModule::isCrashReportingEnabled() const { return impl->crashRe unsigned int ConfigurationModule::getRequestQueueSizeLimit() const { return impl->requestQueueSizeLimit.load(std::memory_order_acquire); } +SDKLimits ConfigurationModule::getLimits() const { + SDKLimits lim; + lim.maxKeyLength = impl->maxKeyLength.load(std::memory_order_acquire); + lim.maxValueSize = impl->maxValueSize.load(std::memory_order_acquire); + lim.maxSegmentationValues = impl->maxSegmentationValues.load(std::memory_order_acquire); + lim.maxBreadcrumbCount = impl->maxBreadcrumbCount.load(std::memory_order_acquire); + lim.maxStackTraceLinesPerThread = impl->maxStackTraceLinesPerThread.load(std::memory_order_acquire); + lim.maxStackTraceLineLength = impl->maxStackTraceLineLength.load(std::memory_order_acquire); + return lim; +} + unsigned int ConfigurationModule::getEventQueueSizeLimit() { // this is because we permit EQ size to change after initialization unsigned int value = impl->eventQueueThreshold.load(std::memory_order_acquire); diff --git a/src/countly.cpp b/src/countly.cpp index 849ecbb..d5e2a6f 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -1,3 +1,4 @@ +#include "countly/internal_limits.hpp" #include "countly/storage_module_db.hpp" #include "countly/storage_module_memory.hpp" #include @@ -163,6 +164,60 @@ void Countly::enableImmediateRequestOnStop() { configuration->immediateRequestOnStop = true; } +void Countly::setMaxKeyLength(unsigned int value) { + if (is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] setMaxKeyLength, This method can't be called after SDK initialization. Returning."); + return; + } + std::lock_guard lk(*mutex); + configuration->maxKeyLength = value; +} + +void Countly::setMaxValueSize(unsigned int value) { + if (is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] setMaxValueSize, This method can't be called after SDK initialization. Returning."); + return; + } + std::lock_guard lk(*mutex); + configuration->maxValueSize = value; +} + +void Countly::setMaxSegmentationValues(unsigned int value) { + if (is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] setMaxSegmentationValues, This method can't be called after SDK initialization. Returning."); + return; + } + std::lock_guard lk(*mutex); + configuration->maxSegmentationValues = value; +} + +void Countly::setMaxBreadcrumbCount(unsigned int value) { + if (is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] setMaxBreadcrumbCount, This method can't be called after SDK initialization. Returning."); + return; + } + std::lock_guard lk(*mutex); + configuration->breadcrumbsThreshold = value; +} + +void Countly::setMaxStackTraceLinesPerThread(unsigned int value) { + if (is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] setMaxStackTraceLinesPerThread, This method can't be called after SDK initialization. Returning."); + return; + } + std::lock_guard lk(*mutex); + configuration->maxStackTraceLinesPerThread = value; +} + +void Countly::setMaxStackTraceLineLength(unsigned int value) { + if (is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] setMaxStackTraceLineLength, This method can't be called after SDK initialization. Returning."); + return; + } + std::lock_guard lk(*mutex); + configuration->maxStackTraceLineLength = value; +} + void Countly::setMetrics(const std::string &os, const std::string &os_version, const std::string &device, const std::string &resolution, const std::string &carrier, const std::string &app_version) { if (is_sdk_initialized) { log(LogLevel::WARNING, "[Countly] setMetrics, This method can't be called after SDK initialization. Returning."); @@ -197,7 +252,14 @@ void Countly::setUserDetails(const std::map &value) { // unique_lock so the mutex is released on scope exit, including on a throwing // json/map/addRequestToQueue op; unlock/relock around the self-locking flushEvents(). std::unique_lock lk(*mutex); - session_params["user_details"] = value; + SDKLimits lim = configurationModule ? configurationModule->getLimits() + : SDKLimits{COUNTLY_MAX_KEY_LENGTH_DEFAULT, COUNTLY_MAX_VALUE_SIZE_DEFAULT, COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT, COUNTLY_MAX_BREADCRUMB_COUNT_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT}; + std::map limitedDetails; + for (const auto &kv : value) { + unsigned int cap = (kv.first == "picture") ? COUNTLY_MAX_VALUE_SIZE_PICTURE : lim.maxValueSize; + limitedDetails[kv.first] = cly::limits::truncateString(kv.second, cap); + } + session_params["user_details"] = limitedDetails; if (!is_sdk_initialized) { log(LogLevel::ERROR, "[Countly] setUserDetails, This method can't be called before SDK initialization. Returning."); @@ -220,11 +282,13 @@ void Countly::setCustomUserDetails(const std::map &val // json/map/addRequestToQueue op; unlock/re-acquire around the self-locking flushEvents(). std::unique_lock lk(*mutex); - // Apply user property filter + // Determine the post-filter custom property map first, then apply limits. + std::map customValue; + SDKLimits lim{COUNTLY_MAX_KEY_LENGTH_DEFAULT, COUNTLY_MAX_VALUE_SIZE_DEFAULT, COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT, COUNTLY_MAX_BREADCRUMB_COUNT_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT}; if (configurationModule) { + lim = configurationModule->getLimits(); auto upFilter = configurationModule->getUserPropertyFilterList(); if (!upFilter.filterList.empty()) { - std::map filteredValue; for (const auto &kv : value) { bool allowed; if (upFilter.isWhitelist) { @@ -233,22 +297,28 @@ void Countly::setCustomUserDetails(const std::map &val allowed = (upFilter.filterList.find(kv.first) == upFilter.filterList.end()); } if (allowed) { - filteredValue[kv.first] = kv.second; + customValue[kv.first] = kv.second; } } - - if (filteredValue.empty()) { + if (customValue.empty()) { log(LogLevel::DEBUG, "[Countly] setCustomUserDetails, All user properties were filtered out by SBS user property filter."); return; } - session_params["user_details"]["custom"] = filteredValue; } else { - session_params["user_details"]["custom"] = value; + customValue = value; } } else { - session_params["user_details"]["custom"] = value; + customValue = value; } + // Apply SDK internal limits: truncate keys/values. No count cap for user + // properties (that would be the out-of-scope 'upcl'). + std::map limitedCustom; + for (const auto &kv : customValue) { + limitedCustom[cly::limits::truncateString(kv.first, lim.maxKeyLength)] = cly::limits::truncateString(kv.second, lim.maxValueSize); + } + session_params["user_details"]["custom"] = limitedCustom; + if (!is_sdk_initialized) { log(LogLevel::ERROR, "[Countly] setCustomUserDetails, This method can't be called before SDK initialization. Returning."); return; @@ -666,6 +736,13 @@ void Countly::addEvent(const cly::Event &event) { } } + // Apply SDK internal limits to developer-supplied events only. Internal + // [CLY]_* events are limited at their own module boundary (e.g. views). + if (!isInternalEvent) { + SDKLimits limits = configurationModule->getLimits(); + filteredEvent.applyLimits(limits.maxKeyLength, limits.maxValueSize, limits.maxSegmentationValues); + } + std::unique_lock lk(*mutex); #ifndef COUNTLY_USE_SQLITE event_queue.push_back(filteredEvent.serialize()); diff --git a/src/crash_module.cpp b/src/crash_module.cpp index f44e6bb..be45b8c 100644 --- a/src/crash_module.cpp +++ b/src/crash_module.cpp @@ -1,4 +1,5 @@ #include "countly/crash_module.hpp" +#include "countly/internal_limits.hpp" #include "countly/request_module.hpp" #include @@ -34,15 +35,21 @@ CrashModule::CrashModule(std::shared_ptr config, std::shar // function to add breadcrumb void CrashModule::addBreadcrumb(const std::string &value) { - impl->_logger->log(LogLevel::INFO, "[Countly] [CrashModule] addBreadcrumb, value = [" + value + "]"); + SDKLimits lim{COUNTLY_MAX_KEY_LENGTH_DEFAULT, COUNTLY_MAX_VALUE_SIZE_DEFAULT, COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT, COUNTLY_MAX_BREADCRUMB_COUNT_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT}; + if (std::shared_ptr config = impl->_configProvider.lock()) { + lim = config->getLimits(); + } + + std::string limited = cly::limits::truncateString(value, lim.maxValueSize); + impl->_logger->log(LogLevel::INFO, "[Countly] [CrashModule] addBreadcrumb, value = [" + limited + "]"); std::lock_guard lk(*impl->_mutex); - // if breadcrumb threshold is reached, remove oldest breadcrumb - if (impl->_breadCrumbs.size() >= impl->_configuration->breadcrumbsThreshold) { + // if breadcrumb count limit is reached, remove oldest breadcrumb + if (impl->_breadCrumbs.size() >= lim.maxBreadcrumbCount) { impl->_breadCrumbs.pop_front(); } // add new breadcrumb - impl->_breadCrumbs.push_back(value); + impl->_breadCrumbs.push_back(limited); } // function to record exception @@ -50,16 +57,22 @@ void CrashModule::recordException(const std::string &title, const std::string &s impl->_logger->log(LogLevel::INFO, cly::utils::format_string("[Countly] [CrashModule] recordException, title = [%s], stackTrace = [%s]", title.c_str(), stackTrace.c_str())); + SDKLimits lim{COUNTLY_MAX_KEY_LENGTH_DEFAULT, COUNTLY_MAX_VALUE_SIZE_DEFAULT, COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT, COUNTLY_MAX_BREADCRUMB_COUNT_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT}; if (std::shared_ptr config = impl->_configProvider.lock()) { if (config->isCrashReportingEnabled() == false) { impl->_logger->log(LogLevel::DEBUG, "[Countly] [CrashModule] recordException, Crash reporting is disabled. Not recording exception."); return; } + lim = config->getLimits(); } else { impl->_logger->log(LogLevel::WARNING, "[Countly] [CrashModule] recordException, ConfigurationProvider unavailable."); return; } + std::string limitedTitle = cly::limits::truncateString(title, lim.maxStackTraceLineLength); + std::string limitedTrace = cly::limits::truncateStackTrace(stackTrace, lim.maxStackTraceLinesPerThread, lim.maxStackTraceLineLength); + std::map limitedSegmentation = cly::limits::applySegmentationLimits(segmentation, lim); + if (title.empty()) { impl->_logger->log(LogLevel::WARNING, "[Countly] [CrashModule] recordException, The parameter 'title' can't be empty"); } @@ -89,11 +102,11 @@ void CrashModule::recordException(const std::string &title, const std::string &s // create json objects for crash metrics and segmentation nlohmann::json crash(crashMetrics); - nlohmann::json segments(segmentation); + nlohmann::json segments(limitedSegmentation); // add relevant fields to the crash json object - crash["_name"] = title; - crash["_error"] = stackTrace; + crash["_name"] = limitedTitle; + crash["_error"] = limitedTrace; crash["_logs"] = outstream.str(); crash["_custom"] = segments; crash["_nonfatal"] = !fatal; diff --git a/src/event.cpp b/src/event.cpp index 90533e9..252a6a6 100644 --- a/src/event.cpp +++ b/src/event.cpp @@ -1,4 +1,5 @@ #include "countly/event.hpp" +#include "countly/internal_limits.hpp" #include namespace cly { @@ -71,4 +72,30 @@ void Event::removeSegmentation(const std::string &key) { void Event::clearSegmentation() { object.erase("segmentation"); } + +void Event::applyLimits(unsigned int maxKeyLength, unsigned int maxValueSize, unsigned int maxSegmentationValues) { + auto keyIt = object.find("key"); + if (keyIt != object.end() && keyIt->is_string()) { + *keyIt = cly::limits::truncateString(keyIt->get(), maxKeyLength); + } + + auto segIt = object.find("segmentation"); + if (segIt != object.end() && segIt->is_object()) { + nlohmann::json limited = nlohmann::json::object(); + unsigned int kept = 0; + for (auto it = segIt->begin(); it != segIt->end(); ++it) { + if (kept >= maxSegmentationValues) { + break; + } + std::string newKey = cly::limits::truncateString(it.key(), maxKeyLength); + if (it.value().is_string()) { + limited[newKey] = cly::limits::truncateString(it.value().get(), maxValueSize); + } else { + limited[newKey] = it.value(); + } + ++kept; + } + object["segmentation"] = limited; + } +} } // namespace cly diff --git a/src/views_module.cpp b/src/views_module.cpp index 2b833c9..c77c863 100644 --- a/src/views_module.cpp +++ b/src/views_module.cpp @@ -1,5 +1,7 @@ #include "countly/views_module.hpp" +#include "countly/internal_limits.hpp" + #include #define CLY_VIEW_KEY "[CLY]_view" @@ -74,17 +76,25 @@ class ViewsModule::ViewModuleImpl { ~ViewModuleImpl() { _logger.reset(); } std::string _openView(const std::string &name, const std::map &segmentation) { + SDKLimits lim{COUNTLY_MAX_KEY_LENGTH_DEFAULT, COUNTLY_MAX_VALUE_SIZE_DEFAULT, COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT, COUNTLY_MAX_BREADCRUMB_COUNT_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT}; if (std::shared_ptr config = _configProvider.lock()) { if (config->isViewTrackingEnabled() == false) { _logger->log(LogLevel::DEBUG, "[Countly] [ViewsModule] _openView, View tracking is disabled. Not opening view."); return ""; } + lim = config->getLimits(); } else { _logger->log(LogLevel::WARNING, "[Countly] [ViewsModule] _openView, ConfigurationProvider unavailable."); return ""; } + + // A view name is a "key" per the guide -> maxKeyLength. Developer segmentation + // is limited before internal keys (visit/start/_idv/name) are merged. + std::string limitedName = cly::limits::truncateString(name, lim.maxKeyLength); + std::map limitedSeg = cly::limits::applySegmentationLimits(segmentation, lim); + ViewModuleImpl::ViewInfo *v = new ViewModuleImpl::ViewInfo(); - v->name = name; + v->name = limitedName; v->viewId = cly::utils::generateEventID(); v->startTime = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()); @@ -92,7 +102,7 @@ class ViewsModule::ViewModuleImpl { _viewsStartTime[ptr->viewId] = ptr; - _recordView(ptr, segmentation, true); + _recordView(ptr, limitedSeg, true); return ptr->viewId; } diff --git a/tests/internal_limits.cpp b/tests/internal_limits.cpp new file mode 100644 index 0000000..f69d733 --- /dev/null +++ b/tests/internal_limits.cpp @@ -0,0 +1,96 @@ +#include "doctest.h" + +#include "countly/event.hpp" +#include "countly/internal_limits.hpp" +#include "nlohmann/json.hpp" + +#include +#include + +TEST_CASE("Internal Limits helpers") { + SUBCASE("truncateString shorter than limit is unchanged") { + CHECK(cly::limits::truncateString("hello", 10) == "hello"); + CHECK(cly::limits::truncateString("hello", 5) == "hello"); + } + + SUBCASE("truncateString longer than limit is cut") { + CHECK(cly::limits::truncateString("hello world", 5) == "hello"); + } + + SUBCASE("truncateString does not split a UTF-8 multibyte char") { + // bytes: 'a', 0xC3 0xA9 (e-acute), 0xC3 0xA9 (e-acute) => 5 bytes total + std::string s; + s += 'a'; + s += static_cast(0xC3); + s += static_cast(0xA9); + s += static_cast(0xC3); + s += static_cast(0xA9); + + std::string expected; // 'a' + one e-acute = 3 bytes + expected += 'a'; + expected += static_cast(0xC3); + expected += static_cast(0xA9); + + // cut at 3 lands on a lead byte -> kept as-is + CHECK(cly::limits::truncateString(s, 3) == expected); + // cut at 4 lands mid-sequence -> backs off to 3 + CHECK(cly::limits::truncateString(s, 4) == expected); + } + + SUBCASE("applySegmentationLimits truncates keys/values and caps count") { + cly::SDKLimits lim{3, 3, 2, 100, 30, 200}; + std::map in = {{"aaaa", "bbbb"}, {"cccc", "dddd"}, {"eeee", "ffff"}}; + std::map out = cly::limits::applySegmentationLimits(in, lim); + CHECK(out.size() == 2); // capped to maxSegmentationValues=2, first two in sorted order + CHECK(out.count("aaa") == 1); + CHECK(out["aaa"] == "bbb"); + CHECK(out.count("ccc") == 1); + CHECK(out.count("eee") == 0); + } + + SUBCASE("truncateStackTrace caps line count and line length") { + std::string trace = "aaaaaa\nbbbbbb\ncccccc"; + CHECK(cly::limits::truncateStackTrace(trace, 2, 4) == "aaaa\nbbbb"); + } +} + +TEST_CASE("Internal Limits Event applyLimits") { + using json = nlohmann::json; + + SUBCASE("event key is truncated") { + cly::Event e("abcdefghij", 1); + e.applyLimits(5, 256, 100); + CHECK(e.getKey() == "abcde"); + } + + SUBCASE("segmentation key and string value are truncated") { + cly::Event e("k", 1); + e.addSegmentation("longkey", "longvalue"); + e.applyLimits(4, 4, 100); + json o = json::parse(e.serialize()); + CHECK(o["segmentation"].contains("long")); + CHECK(o["segmentation"]["long"].get() == "long"); + } + + SUBCASE("segmentation count is capped in sorted-key order") { + cly::Event e("k", 1); + e.addSegmentation("a", "1"); + e.addSegmentation("b", "2"); + e.addSegmentation("c", "3"); + e.addSegmentation("d", "4"); + e.applyLimits(128, 256, 2); + json o = json::parse(e.serialize()); + CHECK(o["segmentation"].size() == 2); + CHECK(o["segmentation"].contains("a")); + CHECK(o["segmentation"].contains("b")); + } + + SUBCASE("non-string segmentation value is left intact") { + cly::Event e("k", 1); + e.addSegmentation("n", 123456789); + e.applyLimits(128, 2, 100); + json o = json::parse(e.serialize()); + CHECK(o["segmentation"]["n"].is_number()); + CHECK(o["segmentation"]["n"].get() == 123456789); + } +} diff --git a/tests/internal_limits_integration.cpp b/tests/internal_limits_integration.cpp new file mode 100644 index 0000000..ca7ec27 --- /dev/null +++ b/tests/internal_limits_integration.cpp @@ -0,0 +1,323 @@ +#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; + +// Initialize the SDK with a given SBS config, using the fake HTTP client and +// manual session control, then clear the queues so tests start clean. +static void limitsInit(const json &sbs, Countly &countly) { + std::string s = sbs.dump(); + countly.setSDKBehaviorSettings(s); + countly.disableSDKBehaviorSettingsUpdates(); + countly.setHTTPClient(test_utils::fakeSendHTTP); + 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); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + countly.processRQDebug(); + countly.clearRequestQueue(); + http_call_queue.clear(); +} + +static HTTPCall limitsPop() { + CHECK(!http_call_queue.empty()); + HTTPCall c = http_call_queue.front(); + http_call_queue.pop_front(); + return c; +} + +// Records one custom event, flushes, and returns its parsed JSON. +static json recordAndGetEvent(Countly &countly, cly::Event &e) { + countly.addEvent(e); + countly.processRQDebug(); + HTTPCall call = limitsPop(); + json events = json::parse(call.data["events"]); + REQUIRE(events.size() == 1); + return events[0]; +} + +TEST_CASE("Internal Limits Events") { + clearSDK(); + http_call_queue.clear(); + + SUBCASE("SBS lkl truncates event key") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"lkl", 5}, {"eqs", 1}}, countly); + + cly::Event e("abcdefghij", 1); + json ev = recordAndGetEvent(countly, e); + CHECK(ev["key"].get() == "abcde"); + } + + SUBCASE("SBS lvs/lkl truncate segmentation key and value") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"lkl", 5}, {"lvs", 4}, {"eqs", 1}}, countly); + + cly::Event e("evt", 1); + e.addSegmentation("longkey123", "longvalue"); + json ev = recordAndGetEvent(countly, e); + CHECK(ev["segmentation"].contains("longk")); + CHECK(ev["segmentation"]["longk"].get() == "long"); + } + + SUBCASE("SBS lsv caps segmentation entry count") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"lsv", 2}, {"eqs", 1}}, countly); + + cly::Event e("evt", 1); + e.addSegmentation("a", "1"); + e.addSegmentation("b", "2"); + e.addSegmentation("c", "3"); + e.addSegmentation("d", "4"); + json ev = recordAndGetEvent(countly, e); + CHECK(ev["segmentation"].size() == 2); + CHECK(ev["segmentation"].contains("a")); + CHECK(ev["segmentation"].contains("b")); + } + + SUBCASE("default limit truncates long key when no SBS override") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"eqs", 1}}, countly); // no lkl -> default 128 + + cly::Event e(std::string(200, 'x'), 1); + json ev = recordAndGetEvent(countly, e); + CHECK(ev["key"].get().size() == 128); + } + + SUBCASE("internal view event key is not truncated by event limits") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"lkl", 3}, {"eqs", 1}}, countly); + + std::string viewId = countly.views().openView("home"); + CHECK(!viewId.empty()); + countly.processRQDebug(); + HTTPCall call = limitsPop(); + json events = json::parse(call.data["events"]); + REQUIRE(events.size() == 1); + CHECK(events[0]["key"].get() == "[CLY]_view"); // not "[CL" + } +} + +TEST_CASE("Internal Limits Views") { + clearSDK(); + http_call_queue.clear(); + + // Helper: open a view and return the parsed [CLY]_view event. + auto openAndGetViewEvent = [](Countly &countly, const std::string &name, const std::map &seg) -> json { + countly.views().openView(name, seg); + countly.processRQDebug(); + HTTPCall call = limitsPop(); + json events = json::parse(call.data["events"]); + REQUIRE(events.size() == 1); + return events[0]; + }; + + SUBCASE("view name is truncated to maxKeyLength via SBS lkl") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"lkl", 5}, {"vt", true}, {"eqs", 1}}, countly); + + json ev = openAndGetViewEvent(countly, "abcdefghij", {}); + CHECK(ev["segmentation"]["name"].get() == "abcde"); + } + + SUBCASE("developer view segmentation is limited; reserved keys are untouched") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"lkl", 3}, {"lvs", 3}, {"lsv", 2}, {"vt", true}, {"eqs", 1}}, countly); + + json ev = openAndGetViewEvent(countly, "homeview", {{"aaaa", "bbbb"}, {"cccc", "dddd"}, {"eeee", "ffff"}}); + json seg = ev["segmentation"]; + + // reserved keys present and untouched + CHECK(seg["visit"].get() == "1"); + CHECK(seg.contains("_idv")); + CHECK(seg.contains("name")); // internal key name kept + + // developer entries: truncated keys/values, capped to 2 + int devCount = 0; + for (auto it = seg.begin(); it != seg.end(); ++it) { + const std::string &k = it.key(); + if (k == "visit" || k == "start" || k == "_idv" || k == "name") { + continue; + } + devCount++; + CHECK(k.size() <= 3); + CHECK(it.value().get().size() <= 3); + } + CHECK(devCount == 2); + } +} + +TEST_CASE("Internal Limits Crash") { + clearSDK(); + http_call_queue.clear(); + + auto recordAndGetCrash = [](Countly &countly) -> json { + countly.processRQDebug(); + HTTPCall call = limitsPop(); + REQUIRE(call.data.find("crash") != call.data.end()); + return json::parse(call.data["crash"]); + }; + + SUBCASE("crash title is truncated to maxStackTraceLineLength (ltl)") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"ltl", 5}, {"crt", true}}, countly); + + countly.crash().recordException("abcdefghij", "line", false, {{"_os", "OS"}, {"_app_version", "1"}}, {}); + json crash = recordAndGetCrash(countly); + CHECK(crash["_name"].get() == "abcde"); + } + + SUBCASE("stack trace line length and line count are capped") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"ltl", 4}, {"ltlpt", 2}, {"crt", true}}, countly); + + countly.crash().recordException("t", "aaaaaa\nbbbbbb\ncccccc", false, {{"_os", "OS"}, {"_app_version", "1"}}, {}); + json crash = recordAndGetCrash(countly); + CHECK(crash["_error"].get() == "aaaa\nbbbb"); + } + + SUBCASE("breadcrumb text is truncated and count capped (lvs + lbc)") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"lvs", 3}, {"lbc", 2}, {"crt", true}}, countly); + + countly.crash().addBreadcrumb("aaaaaa"); + countly.crash().addBreadcrumb("bbbbbb"); + countly.crash().addBreadcrumb("cccccc"); + countly.crash().recordException("t", "trace", false, {{"_os", "OS"}, {"_app_version", "1"}}, {}); + json crash = recordAndGetCrash(countly); + // oldest ("aaa") dropped by lbc=2; each truncated to 3 chars; newline-joined + CHECK(crash["_logs"].get() == "bbb\nccc\n"); + } + + SUBCASE("crash segmentation is limited but crash metrics are untouched") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"lkl", 3}, {"lvs", 3}, {"lsv", 2}, {"crt", true}}, countly); + + countly.crash().recordException("t", "trace", false, + {{"_os", "AVeryLongOperatingSystemName"}, {"_app_version", "1"}}, + {{"aaaa", "bbbb"}, {"cccc", "dddd"}, {"eeee", "ffff"}}); + json crash = recordAndGetCrash(countly); + + // metrics untouched + CHECK(crash["_os"].get() == "AVeryLongOperatingSystemName"); + + // segmentation limited: 2 entries, keys/values <= 3 chars + json custom = crash["_custom"]; + CHECK(custom.size() == 2); + for (auto it = custom.begin(); it != custom.end(); ++it) { + CHECK(it.key().size() <= 3); + CHECK(it.value().get().size() <= 3); + } + } +} + +TEST_CASE("Internal Limits User Details") { + clearSDK(); + http_call_queue.clear(); + + auto getUserDetails = [](Countly &countly) -> json { + countly.processRQDebug(); + HTTPCall call = limitsPop(); + REQUIRE(call.data.find("user_details") != call.data.end()); + return json::parse(call.data["user_details"]); + }; + + SUBCASE("named user detail value is truncated to maxValueSize") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"lvs", 3}}, countly); + + countly.setUserDetails({{"name", "abcdef"}}); + json ud = getUserDetails(countly); + CHECK(ud["name"].get() == "abc"); + } + + SUBCASE("picture field is allowed up to 4096 and capped beyond it") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"lvs", 3}}, countly); + + countly.setUserDetails({{"picture", std::string(5000, 'x')}}); + json ud = getUserDetails(countly); + CHECK(ud["picture"].get().size() == 4096); + } + + SUBCASE("custom user detail key and value are truncated; no count cap") { + clearSDK(); + Countly &countly = Countly::getInstance(); + limitsInit({{"lkl", 3}, {"lvs", 3}, {"lsv", 1}}, countly); + + countly.setCustomUserDetails({{"longkey", "longval"}, {"another", "value2"}, {"third", "value3"}}); + json ud = getUserDetails(countly); + json custom = ud["custom"]; + // lsv does NOT cap user properties -> all 3 kept + CHECK(custom.size() == 3); + CHECK(custom.contains("lon")); + CHECK(custom["lon"].get() == "lon"); + } +} + +TEST_CASE("Internal Limits Setters") { + SUBCASE("setters write config fields before init") { + clearSDK(); + Countly &countly = Countly::getInstance(); + countly.setMaxKeyLength(50); + countly.setMaxValueSize(60); + countly.setMaxSegmentationValues(7); + countly.setMaxBreadcrumbCount(9); + countly.setMaxStackTraceLinesPerThread(11); + countly.setMaxStackTraceLineLength(13); + + const CountlyConfiguration &cfg = countly.getConfiguration(); + CHECK(cfg.maxKeyLength == 50); + CHECK(cfg.maxValueSize == 60); + CHECK(cfg.maxSegmentationValues == 7); + CHECK(cfg.breadcrumbsThreshold == 9); + CHECK(cfg.maxStackTraceLinesPerThread == 11); + CHECK(cfg.maxStackTraceLineLength == 13); + } + + SUBCASE("setter is ignored after initialization") { + clearSDK(); + Countly &countly = Countly::getInstance(); + countly.setMaxKeyLength(50); + limitsInit({{"eqs", 1}}, countly); // SDK now initialized + + countly.setMaxKeyLength(7); // should be a no-op post-init + CHECK(countly.getConfiguration().maxKeyLength == 50); + } + + SUBCASE("developer-set default limit is enforced when no SBS override") { + clearSDK(); + Countly &countly = Countly::getInstance(); + countly.setMaxKeyLength(4); + limitsInit({{"eqs", 1}}, countly); // no lkl in SBS -> uses config default 4 + + cly::Event e("abcdef", 1); + countly.addEvent(e); + countly.processRQDebug(); + HTTPCall call = limitsPop(); + json events = json::parse(call.data["events"]); + REQUIRE(events.size() == 1); + CHECK(events[0]["key"].get() == "abcd"); + } +}