From c9455adcc989883888fe7aca0314d0f8e2b01665 Mon Sep 17 00:00:00 2001 From: turtledreams Date: Thu, 20 Aug 2026 22:22:49 +0900 Subject: [PATCH 1/4] Issue Fixes --- CHANGELOG.md | 8 ++++++ CMakeLists.txt | 51 +++++++++++++++++++++++++++-------- include/countly.hpp | 9 ++++--- include/countly/constants.hpp | 11 ++++++++ include/countly/event.hpp | 3 +++ src/countly.cpp | 12 ++++----- src/event.cpp | 5 ++-- src/views_module.cpp | 11 ++++---- 8 files changed, 83 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b6e36f..a232cd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## Next release +- Static library builds on non-Windows platforms no longer require a custom HTTP client; the built-in libcurl client can now be used there as well. + +- Added the `COUNTLY_USE_SYSTEM_JSON` CMake option to compile the SDK against the application's own nlohmann/json (3.11 or newer) instead of the bundled copy, for applications that already use the library. The SDK headers now reject unsupported nlohmann/json versions at compile time with a clear error. + +- Fixed session, view and timed-event durations being distorted when the system clock changed while the application was running; elapsed time is now measured with a monotonic clock. +- Fixed `cmake --install` producing an unusable include layout: `countly.hpp` is now installed into the include root, all public headers (several were missing, including `countly_configuration.hpp`) are installed under `include/countly`, and the bundled nlohmann/json headers are installed alongside them, so the installed tree compiles on its own. + ## 26.8.0 - ! Minor breaking change ! The SDK now enforces internal limits on recorded data (key length, value size, segmentation entry count, breadcrumb count, stack trace lines per thread and line length) across events, views, crashes, and user properties. Defaults can be overridden by server-side SDK Behavior Settings, or during init via `setMaxKeyLength`, `setMaxValueSize`, `setMaxSegmentationValues`, `setMaxBreadcrumbCount`, `setMaxStackTraceLinesPerThread` and `setMaxStackTraceLineLength`. - ! Minor breaking change ! Updated the bundled nlohmann/json from v3.7.0 to v3.12.0. JSON types are part of the public API, so applications must be recompiled against the new headers; binaries built against the old headers will not link against a library built with the new ones. diff --git a/CMakeLists.txt b/CMakeLists.txt index b90cc77..162efa2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,7 @@ option(COUNTLY_USE_CUSTOM_HTTP "Use a custom HTTP library" OFF) option(COUNTLY_USE_CUSTOM_SHA256 "Use a custom SHA 256 library" OFF) option(COUNTLY_USE_SQLITE "Use SQLite" OFF) option(COUNTLY_USE_SYSTEM_SQLITE "Link the system SQLite instead of compiling the vendored amalgamation" OFF) +option(COUNTLY_USE_SYSTEM_JSON "Compile against the system nlohmann_json instead of the vendored copy" OFF) option(COUNTLY_BUILD_TESTS "Build test programs" OFF) option(COUNTLY_BUILD_SAMPLE "Build Sample programs" OFF) @@ -23,25 +24,30 @@ message("Use a custom HTTP library:" ${COUNTLY_USE_CUSTOM_HTTP}) message("Use a custom SHA 256 library:" ${COUNTLY_USE_CUSTOM_SHA256}) message("Use SQLite:" ${COUNTLY_USE_SQLITE}) message("Use system SQLite:" ${COUNTLY_USE_SYSTEM_SQLITE}) +message("Use system nlohmann_json:" ${COUNTLY_USE_SYSTEM_JSON}) message("Build test programs:" ${COUNTLY_BUILD_TESTS}) message("Build Sample programs:" ${COUNTLY_BUILD_SAMPLE}) -if (NOT WIN32 AND NOT BUILD_SHARED_LIBS AND NOT COUNTLY_USE_CUSTOM_HTTP) - message(FATAL_ERROR "You must provide a custom HTTP function when compiling statically.") -endif() - -# Compile countly public headers +# Compile countly public headers: everything countly.hpp includes, so an +# installed copy of the SDK is self-contained. countly.hpp itself is installed +# separately into the include root (see the install rules at the bottom). set(COUNTLY_PUBLIC_HEADERS - ${CMAKE_CURRENT_SOURCE_DIR}/include/countly.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/configuration_module.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/configuration_provider.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/constants.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/countly_configuration.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/crash_module.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/event.hpp - ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/logger_module.hpp - ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/crash_module.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/remote_config_store.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/request_builder.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/request_module.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/storage_module_base.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/views_module.hpp) add_library(countly ${COUNTLY_PUBLIC_HEADERS} + ${CMAKE_CURRENT_SOURCE_DIR}/include/countly.hpp ${CMAKE_CURRENT_SOURCE_DIR}/src/countly.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/views_module.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/logger_module.cpp @@ -66,7 +72,21 @@ set_target_properties(countly PROPERTIES find_package(Threads) target_link_libraries(countly Threads::Threads) -target_include_directories(countly PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/json/include) +# nlohmann/json is part of the public API, so its headers are a usage +# requirement of the countly target. By default the vendored copy is used and +# later installed next to the SDK headers, so the installed tree is +# self-contained. An application that already uses nlohmann/json should set +# COUNTLY_USE_SYSTEM_JSON to compile the SDK against that same copy instead, +# so only one version of the library exists in the build (the SDK headers +# reject unsupported versions at compile time). +if(COUNTLY_USE_SYSTEM_JSON) + find_package(nlohmann_json 3.11 REQUIRED) + target_link_libraries(countly nlohmann_json::nlohmann_json) +else() + target_include_directories(countly PUBLIC + $ + $) +endif() if (COUNTLY_BUILD_TESTS) message("Compiling definitions for tests") target_compile_definitions(countly PRIVATE COUNTLY_BUILD_TESTS) @@ -154,7 +174,7 @@ if(COUNTLY_BUILD_TESTS) target_compile_definitions(countly-tests PRIVATE COUNTLY_USE_CUSTOM_SHA256) endif() target_include_directories(countly-tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/vendor/doctest/doctest) - target_include_directories(countly-tests PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/json/include) + # nlohmann/json comes in as a usage requirement of the countly target. target_link_libraries(countly-tests countly) set_target_properties(countly-tests PROPERTIES CXX_STANDARD 14 @@ -177,7 +197,7 @@ if(COUNTLY_USE_SQLITE) endif() endif() target_include_directories(countly-sample PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/vendor/doctest/doctest) - target_include_directories(countly-sample PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/json/include) + # nlohmann/json comes in as a usage requirement of the countly target. target_link_libraries(countly-sample countly) set_target_properties(countly-sample PROPERTIES CXX_STANDARD 14 @@ -190,3 +210,12 @@ install(TARGETS countly LIBRARY DESTINATION lib RUNTIME DESTINATION bin PUBLIC_HEADER DESTINATION include/countly) +# countly.hpp lives in the include root, so `#include "countly.hpp"` works +# against the installed tree just like against the source tree. +install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/include/countly.hpp DESTINATION include) +# The SDK headers include "nlohmann/json.hpp", so ship the vendored copy with +# them; without it an installed tree does not compile on its own. Skipped when +# building against the system copy, which the application provides itself. +if(NOT COUNTLY_USE_SYSTEM_JSON) + install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/vendor/json/include/nlohmann DESTINATION include) +endif() diff --git a/include/countly.hpp b/include/countly.hpp index 335e077..1ab79e3 100644 --- a/include/countly.hpp +++ b/include/countly.hpp @@ -464,9 +464,9 @@ class Countly : public cly::CountlyDelegates { void _changeDeviceIdWithoutMerge(const std::string &value); - std::chrono::system_clock::duration getSessionDuration(std::chrono::system_clock::time_point now); + std::chrono::steady_clock::duration getSessionDuration(std::chrono::steady_clock::time_point now); - std::chrono::system_clock::duration getSessionDuration(); + std::chrono::steady_clock::duration getSessionDuration(); void updateLoop(); void packEvents(); @@ -474,7 +474,10 @@ class Countly : public cly::CountlyDelegates { std::atomic is_being_disposed{false}; std::atomic is_sdk_initialized{false}; - std::chrono::system_clock::time_point last_sent_session_request; + // Monotonic, not system_clock: session duration is elapsed time, and a + // system clock change mid-session must not shrink it or turn it negative + // (issue #100). Wall-clock timestamps in requests still use system_clock. + std::chrono::steady_clock::time_point last_sent_session_request; nlohmann::json session_params; std::unique_ptr thread; diff --git a/include/countly/constants.hpp b/include/countly/constants.hpp index dccde59..3163203 100644 --- a/include/countly/constants.hpp +++ b/include/countly/constants.hpp @@ -2,6 +2,17 @@ #define COUNTLY_CONSTANTS_HPP_ #include "nlohmann/json.hpp" + +// nlohmann/json is part of the SDK's public API, so the copy these headers are +// compiled against must be the one the library was built with. If another copy +// of the library wins the include-path race (both use the same include guard, +// so only the first one found is ever seen), fail here with a clear message +// instead of failing at link time. Versions from 3.11 on carry an ABI-tagged +// inline namespace, which is what makes a mismatch a linker error rather than +// silent memory corruption. To build against your application's own copy, use +// the COUNTLY_USE_SYSTEM_JSON CMake option. +static_assert(NLOHMANN_JSON_VERSION_MAJOR == 3 && NLOHMANN_JSON_VERSION_MINOR >= 11, "Countly SDK requires nlohmann/json 3.11 or newer (3.x). An incompatible copy of nlohmann/json was found first on the include path."); + #include #include #include diff --git a/include/countly/event.hpp b/include/countly/event.hpp index b09f60c..5a5e58c 100644 --- a/include/countly/event.hpp +++ b/include/countly/event.hpp @@ -42,6 +42,9 @@ class Event { nlohmann::json object; bool timer_running; std::chrono::system_clock::time_point timestamp; + // Durations are measured on the monotonic clock so a system clock change + // while a timed event runs cannot produce a wrong or negative duration. + std::chrono::steady_clock::time_point timer_start; }; } // namespace cly #endif diff --git a/src/countly.cpp b/src/countly.cpp index 1ed69b8..45c0a5a 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -1389,7 +1389,7 @@ bool Countly::beginSession() { requestModule->addRequestToQueue(data); session_params.erase("user_details"); - last_sent_session_request = Countly::getTimestamp(); + last_sent_session_request = std::chrono::steady_clock::now(); began_session = true; // snapshot guarded state before releasing the lock bool shouldUpdateRemoteConfig = remote_config_enabled; @@ -1568,8 +1568,8 @@ bool Countly::endSession() { log(LogLevel::ERROR, "[Countly] endSession, Session tracking is disabled in server configuration, can not end session."); return false; } - const std::chrono::system_clock::time_point now = Countly::getTimestamp(); - const auto timestamp = std::chrono::duration_cast(now.time_since_epoch()); + const auto timestamp = std::chrono::duration_cast(Countly::getTimestamp().time_since_epoch()); + const std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); // lock_guard so the mutex is released on scope exit, including the early // returns below and any exception (e.g. a json type_error from a session_params @@ -1933,13 +1933,13 @@ std::string Countly::calculateChecksum(const std::string &salt, const std::strin #endif } -std::chrono::system_clock::duration Countly::getSessionDuration(std::chrono::system_clock::time_point now) { +std::chrono::steady_clock::duration Countly::getSessionDuration(std::chrono::steady_clock::time_point now) { std::lock_guard lk(*mutex); - std::chrono::system_clock::duration duration = now - last_sent_session_request; + std::chrono::steady_clock::duration duration = now - last_sent_session_request; return duration; } -std::chrono::system_clock::duration Countly::getSessionDuration() { return Countly::getSessionDuration(Countly::getTimestamp()); } +std::chrono::steady_clock::duration Countly::getSessionDuration() { return Countly::getSessionDuration(std::chrono::steady_clock::now()); } void Countly::updateLoop() { log(LogLevel::DEBUG, "[Countly][updateLoop]"); diff --git a/src/event.cpp b/src/event.cpp index 5f87be0..c9e6c6e 100644 --- a/src/event.cpp +++ b/src/event.cpp @@ -28,6 +28,7 @@ Event::Event(const std::string &key, size_t count, double sum, double duration) void Event::setTimestamp() { timestamp = std::chrono::system_clock::now(); + timer_start = std::chrono::steady_clock::now(); object["timestamp"] = std::chrono::duration_cast(timestamp.time_since_epoch()).count(); std::time_t time = std::chrono::system_clock::to_time_t(timestamp); @@ -45,8 +46,8 @@ void Event::startTimer() { void Event::stopTimer() { if (timer_running) { - auto now = std::chrono::system_clock::now(); - object["dur"] = std::chrono::duration_cast(now - timestamp).count(); + auto now = std::chrono::steady_clock::now(); + object["dur"] = std::chrono::duration_cast(now - timer_start).count(); timer_running = false; } } diff --git a/src/views_module.cpp b/src/views_module.cpp index 9302bc9..bcfcfba 100644 --- a/src/views_module.cpp +++ b/src/views_module.cpp @@ -13,7 +13,9 @@ class ViewsModule::ViewModuleImpl { public: std::string name; std::string viewId; - std::chrono::seconds startTime; + // Monotonic, not system_clock: the view duration is elapsed time, and a + // system clock change while the view is open must not distort it (issue #100). + std::chrono::steady_clock::time_point startTime; }; private: @@ -67,9 +69,8 @@ class ViewsModule::ViewModuleImpl { } } else { - const std::chrono::system_clock::time_point now = std::chrono::system_clock::now(); - const auto timestamp = std::chrono::duration_cast(now.time_since_epoch()); - std::chrono::seconds dur = timestamp - v->startTime; + const std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + const auto dur = std::chrono::duration_cast(now - v->startTime); duration = dur.count(); } @@ -107,7 +108,7 @@ class ViewsModule::ViewModuleImpl { ViewModuleImpl::ViewInfo *v = new ViewModuleImpl::ViewInfo(); v->name = limitedName; v->viewId = cly::utils::generateEventID(); - v->startTime = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()); + v->startTime = std::chrono::steady_clock::now(); std::shared_ptr ptr(v); From 59c6ce7d006929318d890dda81b5dc56325e0281 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:37:13 +0900 Subject: [PATCH 2/4] Tidy changelog and comments, add CI for install, static and system JSON builds - CHANGELOG section as XX.XX.XX with one line per entry, and a minor breaking line for the nlohmann/json version check - comments cut to single-line notes - the version check's message names COUNTLY_USE_SYSTEM_JSON - CI: static-builtin-http and system-json flavors, and an install job that compiles and runs an app against the installed SDK only Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017YYaqXRqRFW3PxHyzgwNa9 --- .github/workflows/tests.yml | 61 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 11 ++++--- CMakeLists.txt | 20 ++---------- include/countly.hpp | 4 +-- include/countly/constants.hpp | 11 ++----- include/countly/event.hpp | 3 +- src/views_module.cpp | 3 +- 7 files changed, 75 insertions(+), 38 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 44cd7bc..eec3317 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -83,6 +83,11 @@ jobs: experimental: true - name: static-custom-http flags: -DCOUNTLY_USE_SQLITE=ON -DBUILD_SHARED_LIBS=OFF -DCOUNTLY_USE_CUSTOM_HTTP=ON + - name: static-builtin-http + flags: -DCOUNTLY_USE_SQLITE=ON -DBUILD_SHARED_LIBS=OFF + - name: system-json + flags: -DCOUNTLY_USE_SQLITE=ON -DCOUNTLY_USE_SYSTEM_JSON=ON + apt: nlohmann-json3-dev - name: custom-sha256 flags: -DCOUNTLY_USE_SQLITE=ON -DCOUNTLY_USE_CUSTOM_SHA256=ON - name: system-sqlite @@ -157,6 +162,62 @@ jobs: - name: Run tests run: ./build/countly-tests + # ────────────────────────────────────────────── + # Installed SDK: an app that sees only the install + # prefix must compile, link and run against it. + # ────────────────────────────────────────────── + install: + name: install-${{ matrix.libs }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - libs: shared + shared: ON + - libs: static + shared: OFF + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: "recursive" + + - name: Install dependencies + run: | + sudo apt-get $APT_OPTS update + sudo apt-get $APT_OPTS install -y --no-install-recommends \ + libcurl4-openssl-dev libssl-dev + + - name: Build and install + run: | + cmake -DBUILD_SHARED_LIBS=${{ matrix.shared }} -DCMAKE_BUILD_TYPE=Release -B build . + cmake --build build -j 4 + cmake --install build --prefix "$RUNNER_TEMP/countly" + + - name: Compile and run an app against the installed SDK + working-directory: ${{ runner.temp }} + run: | + cat > app.cpp << 'EOF' + #include "countly.hpp" + #include + + int main() { + (void)cly::Countly::getInstance(); + cly::Event event("install_check", 1); + event.addSegmentation("source", "installed tree"); + std::cout << event.serialize() << std::endl; + return 0; + } + EOF + if [ "${{ matrix.libs }}" = "static" ]; then + g++ -std=c++14 -I countly/include app.cpp countly/lib/libcountly.a -lcurl -lssl -lcrypto -lpthread -o app + else + g++ -std=c++14 -I countly/include app.cpp -L countly/lib -lcountly -Wl,-rpath,"$PWD/countly/lib" -o app + fi + ./app + # ────────────────────────────────────────────── # Release tooling smoke test: the SBOM generator must # not be discovered broken on release day. diff --git a/CHANGELOG.md b/CHANGELOG.md index a232cd6..e5583ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ -## Next release -- Static library builds on non-Windows platforms no longer require a custom HTTP client; the built-in libcurl client can now be used there as well. +## XX.XX.XX +- ! 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. -- Added the `COUNTLY_USE_SYSTEM_JSON` CMake option to compile the SDK against the application's own nlohmann/json (3.11 or newer) instead of the bundled copy, for applications that already use the library. The SDK headers now reject unsupported nlohmann/json versions at compile time with a clear error. +- Added the `COUNTLY_USE_SYSTEM_JSON` CMake option for building against the application's own nlohmann/json (3.11 or newer) instead of the bundled copy (default `OFF`). +- Added support for static library builds with the built-in HTTP client (libcurl) on Linux and macOS. -- Fixed session, view and timed-event durations being distorted when the system clock changed while the application was running; elapsed time is now measured with a monotonic clock. -- Fixed `cmake --install` producing an unusable include layout: `countly.hpp` is now installed into the include root, all public headers (several were missing, including `countly_configuration.hpp`) are installed under `include/countly`, and the bundled nlohmann/json headers are installed alongside them, so the installed tree compiles on its own. +- Fixed session, view and timed event durations changing when the system clock was adjusted while the app was running. +- Fixed `cmake --install` producing an include folder that could not be compiled against. ## 26.8.0 - ! Minor breaking change ! The SDK now enforces internal limits on recorded data (key length, value size, segmentation entry count, breadcrumb count, stack trace lines per thread and line length) across events, views, crashes, and user properties. Defaults can be overridden by server-side SDK Behavior Settings, or during init via `setMaxKeyLength`, `setMaxValueSize`, `setMaxSegmentationValues`, `setMaxBreadcrumbCount`, `setMaxStackTraceLinesPerThread` and `setMaxStackTraceLineLength`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 162efa2..4332523 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,9 +28,7 @@ message("Use system nlohmann_json:" ${COUNTLY_USE_SYSTEM_JSON}) message("Build test programs:" ${COUNTLY_BUILD_TESTS}) message("Build Sample programs:" ${COUNTLY_BUILD_SAMPLE}) -# Compile countly public headers: everything countly.hpp includes, so an -# installed copy of the SDK is self-contained. countly.hpp itself is installed -# separately into the include root (see the install rules at the bottom). +# Public headers installed under include/countly; countly.hpp goes to the include root, see the install rules set(COUNTLY_PUBLIC_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/configuration_module.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/configuration_provider.hpp @@ -72,13 +70,7 @@ set_target_properties(countly PROPERTIES find_package(Threads) target_link_libraries(countly Threads::Threads) -# nlohmann/json is part of the public API, so its headers are a usage -# requirement of the countly target. By default the vendored copy is used and -# later installed next to the SDK headers, so the installed tree is -# self-contained. An application that already uses nlohmann/json should set -# COUNTLY_USE_SYSTEM_JSON to compile the SDK against that same copy instead, -# so only one version of the library exists in the build (the SDK headers -# reject unsupported versions at compile time). +# nlohmann/json is part of the public API, so it is a usage requirement of the countly target if(COUNTLY_USE_SYSTEM_JSON) find_package(nlohmann_json 3.11 REQUIRED) target_link_libraries(countly nlohmann_json::nlohmann_json) @@ -174,7 +166,6 @@ if(COUNTLY_BUILD_TESTS) target_compile_definitions(countly-tests PRIVATE COUNTLY_USE_CUSTOM_SHA256) endif() target_include_directories(countly-tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/vendor/doctest/doctest) - # nlohmann/json comes in as a usage requirement of the countly target. target_link_libraries(countly-tests countly) set_target_properties(countly-tests PROPERTIES CXX_STANDARD 14 @@ -197,7 +188,6 @@ if(COUNTLY_USE_SQLITE) endif() endif() target_include_directories(countly-sample PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/vendor/doctest/doctest) - # nlohmann/json comes in as a usage requirement of the countly target. target_link_libraries(countly-sample countly) set_target_properties(countly-sample PROPERTIES CXX_STANDARD 14 @@ -210,12 +200,8 @@ install(TARGETS countly LIBRARY DESTINATION lib RUNTIME DESTINATION bin PUBLIC_HEADER DESTINATION include/countly) -# countly.hpp lives in the include root, so `#include "countly.hpp"` works -# against the installed tree just like against the source tree. install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/include/countly.hpp DESTINATION include) -# The SDK headers include "nlohmann/json.hpp", so ship the vendored copy with -# them; without it an installed tree does not compile on its own. Skipped when -# building against the system copy, which the application provides itself. +# The installed headers include nlohmann/json.hpp, so the bundled copy ships with them if(NOT COUNTLY_USE_SYSTEM_JSON) install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/vendor/json/include/nlohmann DESTINATION include) endif() diff --git a/include/countly.hpp b/include/countly.hpp index 1ab79e3..64d285b 100644 --- a/include/countly.hpp +++ b/include/countly.hpp @@ -474,9 +474,7 @@ class Countly : public cly::CountlyDelegates { std::atomic is_being_disposed{false}; std::atomic is_sdk_initialized{false}; - // Monotonic, not system_clock: session duration is elapsed time, and a - // system clock change mid-session must not shrink it or turn it negative - // (issue #100). Wall-clock timestamps in requests still use system_clock. + // steady_clock, so a system clock change can't distort the session duration std::chrono::steady_clock::time_point last_sent_session_request; nlohmann::json session_params; diff --git a/include/countly/constants.hpp b/include/countly/constants.hpp index 3163203..bf06f79 100644 --- a/include/countly/constants.hpp +++ b/include/countly/constants.hpp @@ -3,15 +3,8 @@ #include "nlohmann/json.hpp" -// nlohmann/json is part of the SDK's public API, so the copy these headers are -// compiled against must be the one the library was built with. If another copy -// of the library wins the include-path race (both use the same include guard, -// so only the first one found is ever seen), fail here with a clear message -// instead of failing at link time. Versions from 3.11 on carry an ABI-tagged -// inline namespace, which is what makes a mismatch a linker error rather than -// silent memory corruption. To build against your application's own copy, use -// the COUNTLY_USE_SYSTEM_JSON CMake option. -static_assert(NLOHMANN_JSON_VERSION_MAJOR == 3 && NLOHMANN_JSON_VERSION_MINOR >= 11, "Countly SDK requires nlohmann/json 3.11 or newer (3.x). An incompatible copy of nlohmann/json was found first on the include path."); +// From 3.11 on nlohmann/json tags its namespace with its version, so a copy other than the library's fails to link instead of corrupting memory +static_assert(NLOHMANN_JSON_VERSION_MAJOR == 3 && NLOHMANN_JSON_VERSION_MINOR >= 11, "Countly SDK requires nlohmann/json 3.11 or newer (3.x). An incompatible copy of nlohmann/json was found first on the include path. To use your own copy, build the SDK with COUNTLY_USE_SYSTEM_JSON."); #include #include diff --git a/include/countly/event.hpp b/include/countly/event.hpp index 5a5e58c..7495c74 100644 --- a/include/countly/event.hpp +++ b/include/countly/event.hpp @@ -42,8 +42,7 @@ class Event { nlohmann::json object; bool timer_running; std::chrono::system_clock::time_point timestamp; - // Durations are measured on the monotonic clock so a system clock change - // while a timed event runs cannot produce a wrong or negative duration. + // steady_clock, so a system clock change can't distort the timed duration std::chrono::steady_clock::time_point timer_start; }; } // namespace cly diff --git a/src/views_module.cpp b/src/views_module.cpp index bcfcfba..a06f5b8 100644 --- a/src/views_module.cpp +++ b/src/views_module.cpp @@ -13,8 +13,7 @@ class ViewsModule::ViewModuleImpl { public: std::string name; std::string viewId; - // Monotonic, not system_clock: the view duration is elapsed time, and a - // system clock change while the view is open must not distort it (issue #100). + // steady_clock, so a system clock change can't distort the view duration std::chrono::steady_clock::time_point startTime; }; From 7e741e90361d35cea3bf3bcd5075971954c8fd59 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:37:14 +0900 Subject: [PATCH 3/4] cmake and package manager fixes --- .github/package-check/CMakeLists.txt | 10 + .github/package-check/app.cpp | 30 ++ .github/workflows/packaging.yml | 258 +++++++++++++ .github/workflows/release_notice.yml | 34 +- .github/workflows/sbom.yml | 4 +- .github/workflows/tests.yml | 115 ++++-- CHANGELOG.md | 18 +- CMakeLists.txt | 50 ++- cmake/countlyConfig.cmake.in | 7 + include/countly.hpp | 37 +- include/countly/constants.hpp | 12 +- include/countly/countly_configuration.hpp | 4 +- packaging/README.md | 45 +++ .../conan/countly-sdk-cpp/all/conandata.yml | 4 + .../conan/countly-sdk-cpp/all/conanfile.py | 88 +++++ .../all/test_package/CMakeLists.txt | 8 + .../all/test_package/conanfile.py | 26 ++ .../all/test_package/test_package.cpp | 11 + packaging/conan/countly-sdk-cpp/config.yml | 3 + .../ports/countly-sdk-cpp/portfile.cmake | 32 ++ .../vcpkg/ports/countly-sdk-cpp/vcpkg.json | 32 ++ src/countly.cpp | 345 +++++++++--------- src/request_module.cpp | 8 + src/storage_module_db.cpp | 17 +- tests/config.cpp | 40 ++ tests/multi_instance.cpp | 12 +- tests/request.cpp | 246 ++++++++++++- tests/session.cpp | 124 +++++++ tests/storage.cpp | 23 ++ tests/storage_db.cpp | 105 ++++++ tests/test_utils.hpp | 36 ++ 31 files changed, 1532 insertions(+), 252 deletions(-) create mode 100644 .github/package-check/CMakeLists.txt create mode 100644 .github/package-check/app.cpp create mode 100644 .github/workflows/packaging.yml create mode 100644 cmake/countlyConfig.cmake.in create mode 100644 packaging/README.md create mode 100644 packaging/conan/countly-sdk-cpp/all/conandata.yml create mode 100644 packaging/conan/countly-sdk-cpp/all/conanfile.py create mode 100644 packaging/conan/countly-sdk-cpp/all/test_package/CMakeLists.txt create mode 100644 packaging/conan/countly-sdk-cpp/all/test_package/conanfile.py create mode 100644 packaging/conan/countly-sdk-cpp/all/test_package/test_package.cpp create mode 100644 packaging/conan/countly-sdk-cpp/config.yml create mode 100644 packaging/vcpkg/ports/countly-sdk-cpp/portfile.cmake create mode 100644 packaging/vcpkg/ports/countly-sdk-cpp/vcpkg.json diff --git a/.github/package-check/CMakeLists.txt b/.github/package-check/CMakeLists.txt new file mode 100644 index 0000000..a799102 --- /dev/null +++ b/.github/package-check/CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.13) +project(package_check CXX) + +# Uses the SDK only through find_package, the way an app that installed it or got it from vcpkg or Conan does +find_package(countly CONFIG REQUIRED) +add_executable(app app.cpp) +target_link_libraries(app PRIVATE countly::countly) +if(EXPECT_COUNTLY_SQLITE) + target_compile_definitions(app PRIVATE EXPECT_COUNTLY_SQLITE) +endif() diff --git a/.github/package-check/app.cpp b/.github/package-check/app.cpp new file mode 100644 index 0000000..d56ff0d --- /dev/null +++ b/.github/package-check/app.cpp @@ -0,0 +1,30 @@ +#include "countly.hpp" +#include + +#if defined(EXPECT_COUNTLY_SQLITE) && !defined(COUNTLY_USE_SQLITE) +#error "COUNTLY_USE_SQLITE did not reach the app through find_package" +#endif + +int main() { + cly::Countly &countly = cly::Countly::getInstance(); + countly.setHTTPClient([](bool use_post, const std::string &path, const std::string &data) { + (void)use_post; + (void)path; + (void)data; + return cly::HTTPResponse{true, nlohmann::json::object()}; + }); +#ifdef EXPECT_COUNTLY_SQLITE + countly.setDatabasePath("install_check.db"); +#endif + countly.setDeviceID("install-check-device"); + countly.start("install-check-app-key", "https://example.invalid", 443, false); + + cly::Event event("install_check", 1); + event.addSegmentation("source", "installed package"); + countly.addEvent(event); + const bool started = countly.checkEQSize() >= 0; + std::cout << event.serialize() << (started ? " recorded" : " not recorded: the SDK did not start") << std::endl; + + countly.stop(); + return started ? 0 : 1; +} diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml new file mode 100644 index 0000000..e057202 --- /dev/null +++ b/.github/workflows/packaging.yml @@ -0,0 +1,258 @@ +name: Package Managers + +on: + push: + branches: [master, staging] + paths: + - "CMakeLists.txt" + - "cmake/**" + - "include/**" + - "src/**" + - "packaging/**" + - ".github/package-check/**" + - ".github/workflows/packaging.yml" + pull_request: + branches: [master, staging] + paths: + - "CMakeLists.txt" + - "cmake/**" + - "include/**" + - "src/**" + - "packaging/**" + - ".github/package-check/**" + - ".github/workflows/packaging.yml" + workflow_dispatch: + +# A new push to the same branch/PR cancels the still-running previous run. +concurrency: + group: packaging-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # ────────────────────────────────────────────── + # The vcpkg port and the Conan recipe carry the + # SDK version, so they cannot ship with an old one. + # ────────────────────────────────────────────── + versions: + name: package-versions + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Compare the package versions with COUNTLY_SDK_VERSION + run: | + version=$(sed -n 's/^#define COUNTLY_SDK_VERSION "\(.*\)"$/\1/p' include/countly/constants.hpp) + echo "SDK version: $version" + status=0 + if ! grep -qF "\"version\": \"$version\"" packaging/vcpkg/ports/countly-sdk-cpp/vcpkg.json; then + echo "::error file=packaging/vcpkg/ports/countly-sdk-cpp/vcpkg.json::version is not $version" + status=1 + fi + for file in packaging/conan/countly-sdk-cpp/config.yml packaging/conan/countly-sdk-cpp/all/conandata.yml; do + if ! grep -qxF " \"$version\":" "$file"; then + echo "::error file=$file::no entry for $version" + status=1 + fi + done + exit $status + + # ────────────────────────────────────────────── + # vcpkg: the port builds from an archive of this + # commit, then an app uses it through vcpkg's CMake + # toolchain, without and with the sqlite feature. + # Android only builds: its binaries cannot run here. + # ────────────────────────────────────────────── + vcpkg: + name: vcpkg-${{ matrix.triplet }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + triplet: x64-linux + run-app: true + - os: windows-2022 + triplet: x64-windows + run-app: true + - os: macos-15 + triplet: arm64-osx + run-app: true + - os: ubuntu-24.04 + triplet: arm64-android + run-app: false + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Pack this commit like a release and point the port at it + run: | + work=$RUNNER_TEMP + if command -v cygpath > /dev/null; then + work=$(cygpath -m "$work") + fi + echo "WORK=$work" >> "$GITHUB_ENV" + echo "VCPKG_ROOT=$work/vcpkg" >> "$GITHUB_ENV" + echo "VCPKG_BINARY_SOURCES=clear;files,$work/vcpkg-cache,readwrite" >> "$GITHUB_ENV" + mkdir -p "$work/vcpkg-cache" "$work/ports" + + # git archive leaves the submodules out, as GitHub's release archives do + git archive --format=tar.gz --prefix=countly-sdk-cpp/ -o "$work/countly-sdk-cpp.tar.gz" HEAD + cp -r packaging/vcpkg/ports/countly-sdk-cpp "$work/ports/" + portfile="$work/ports/countly-sdk-cpp/portfile.cmake" + ARCHIVE="$work/countly-sdk-cpp.tar.gz" perl -0pi -e 's/vcpkg_from_github\(.*?\n\)\n/vcpkg_extract_source_archive(SOURCE_PATH ARCHIVE "$ENV{ARCHIVE}")\n/s' "$portfile" + if grep -q "vcpkg_from_github" "$portfile" || ! grep -q "vcpkg_extract_source_archive" "$portfile"; then + echo "::error file=packaging/vcpkg/ports/countly-sdk-cpp/portfile.cmake::could not point the port at the archive" + exit 1 + fi + + - name: Restore the vcpkg binary cache + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/vcpkg-cache + key: vcpkg-${{ matrix.triplet }}-${{ github.run_id }} + restore-keys: vcpkg-${{ matrix.triplet }}- + + - name: Point vcpkg at the Android NDK + if: contains(matrix.triplet, 'android') + run: echo "ANDROID_NDK_HOME=${ANDROID_NDK_LATEST_HOME:-$ANDROID_NDK_HOME}" >> "$GITHUB_ENV" + + - name: Set up vcpkg + run: git clone --depth 1 https://github.com/microsoft/vcpkg "$VCPKG_ROOT" + + - name: Bootstrap vcpkg (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: '& "$env:VCPKG_ROOT/bootstrap-vcpkg.bat" -disableMetrics' + + - name: Bootstrap vcpkg (Linux and macOS) + if: runner.os != 'Windows' + run: '"$VCPKG_ROOT/bootstrap-vcpkg.sh" -disableMetrics' + + - name: Install the port + run: '"$VCPKG_ROOT/vcpkg" install countly-sdk-cpp --overlay-ports="$WORK/ports" --triplet ${{ matrix.triplet }}' + + - name: Build and run an app with the port + if: matrix.run-app + run: | + cmake -S .github/package-check -B "$WORK/app" -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=${{ matrix.triplet }} -DCMAKE_BUILD_TYPE=Release + cmake --build "$WORK/app" --config Release + if [ "$RUNNER_OS" = "Windows" ]; then + "$WORK/app/Release/app.exe" + else + "$WORK/app/app" + fi + + - name: Install the port with the sqlite feature + run: '"$VCPKG_ROOT/vcpkg" install "countly-sdk-cpp[sqlite]" --recurse --overlay-ports="$WORK/ports" --triplet ${{ matrix.triplet }}' + + - name: Build and run an app with the sqlite feature + if: matrix.run-app + run: | + cmake -S .github/package-check -B "$WORK/app-sqlite" -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=${{ matrix.triplet }} -DCMAKE_BUILD_TYPE=Release -DEXPECT_COUNTLY_SQLITE=ON + cmake --build "$WORK/app-sqlite" --config Release + if [ "$RUNNER_OS" = "Windows" ]; then + "$WORK/app-sqlite/Release/app.exe" + else + "$WORK/app-sqlite/app" + fi + + # vcpkg features must be additive: turning on sqlite must not break an app that never sets a database path + - name: Build and run an app that sets no database path with the sqlite feature + if: matrix.run-app + run: | + cmake -S .github/package-check -B "$WORK/app-sqlite-no-path" -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=${{ matrix.triplet }} -DCMAKE_BUILD_TYPE=Release + cmake --build "$WORK/app-sqlite-no-path" --config Release + if [ "$RUNNER_OS" = "Windows" ]; then + "$WORK/app-sqlite-no-path/Release/app.exe" + else + "$WORK/app-sqlite-no-path/app" + fi + + # ────────────────────────────────────────────── + # Conan: the recipe builds from an archive of this + # commit and its test_package runs, without and with + # SQLite and as shared libraries, like ConanCenter's + # own builds; an app then uses the SQLite package. + # ────────────────────────────────────────────── + conan: + name: conan-${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022, macos-15] + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Conan + run: | + pip install "conan>=2,<3" + conan profile detect --force + + - name: Restore the Conan package cache + uses: actions/cache@v4 + with: + path: ~/.conan2/p + key: conan-${{ matrix.os }}-${{ github.run_id }} + restore-keys: conan-${{ matrix.os }}- + + - name: Pack this commit like a release and point the recipe at it + run: | + work=$RUNNER_TEMP + if command -v cygpath > /dev/null; then + work=$(cygpath -m "$work") + fi + version=$(sed -n 's/^#define COUNTLY_SDK_VERSION "\(.*\)"$/\1/p' include/countly/constants.hpp) + echo "WORK=$work" >> "$GITHUB_ENV" + echo "COUNTLY_VERSION=$version" >> "$GITHUB_ENV" + + # git archive leaves the submodules out, as GitHub's release archives do + git archive --format=tar.gz --prefix=countly-sdk-cpp/ -o "$work/countly-sdk-cpp.tar.gz" HEAD + sha256=$(sha256sum "$work/countly-sdk-cpp.tar.gz" | cut -d ' ' -f 1) + cp -r packaging/conan/countly-sdk-cpp/all "$work/recipe" + printf 'sources:\n "%s":\n url: "file:///%s"\n sha256: "%s"\n' "$version" "${work#/}/countly-sdk-cpp.tar.gz" "$sha256" > "$work/recipe/conandata.yml" + cat "$work/recipe/conandata.yml" + + - name: Create the package + run: conan create "$WORK/recipe" --version "$COUNTLY_VERSION" --build=missing + + - name: Create the package with SQLite + run: conan create "$WORK/recipe" --version "$COUNTLY_VERSION" --build=missing -o "countly-sdk-cpp/*:with_sqlite=True" + + - name: Create the package as shared libraries + run: conan create "$WORK/recipe" --version "$COUNTLY_VERSION" --build=missing -o "*:shared=True" + + - name: Build and run an app with the SQLite package + run: | + conan install --requires="countly-sdk-cpp/$COUNTLY_VERSION" -o "countly-sdk-cpp/*:with_sqlite=True" -g CMakeDeps -g CMakeToolchain --output-folder="$WORK/app-deps" + cmake -S .github/package-check -B "$WORK/app" -DCMAKE_TOOLCHAIN_FILE="$WORK/app-deps/conan_toolchain.cmake" -DCMAKE_BUILD_TYPE=Release -DEXPECT_COUNTLY_SQLITE=ON + cmake --build "$WORK/app" --config Release + if [ "$RUNNER_OS" = "Windows" ]; then + "$WORK/app/Release/app.exe" + else + "$WORK/app/app" + fi + + # Keeps the saved cache to built packages, without sources and build folders + - name: Trim the Conan cache + run: conan cache clean "*" diff --git a/.github/workflows/release_notice.yml b/.github/workflows/release_notice.yml index e17315a..2e97659 100644 --- a/.github/workflows/release_notice.yml +++ b/.github/workflows/release_notice.yml @@ -3,15 +3,25 @@ on: release: types: [published] workflow_dispatch: + inputs: + tag_name: + description: "Version to announce, for example 26.8.1" + type: string + required: false + html_url: + description: "Link to the GitHub release" + type: string + required: false + body: + description: "Release notes" + type: string + required: false +permissions: + contents: read jobs: build: runs-on: ubuntu-latest steps: - # To check the github context - - name: Dump Github context - env: - GITHUB_CONTEXT: ${{ toJSON(github) }} - run: echo "$GITHUB_CONTEXT" - name: Send custom JSON data to Slack workflow id: slack uses: slackapi/slack-github-action@v1.23.0 @@ -20,10 +30,10 @@ jobs: payload: | { "repository": "${{ github.repository }}", - "tag_name": "${{ github.event.release.tag_name }}", + "tag_name": "${{ inputs.tag_name || github.event.release.tag_name }}", "actor": "${{ github.actor }}", - "body": ${{ toJSON(github.event.release.body) }}, - "html_url": "${{ github.event.release.html_url }}" + "body": ${{ toJSON(inputs.body || github.event.release.body) }}, + "html_url": "${{ inputs.html_url || github.event.release.html_url }}" } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_RELEASE }} @@ -32,8 +42,8 @@ jobs: with: webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} nodetail: true - title: New ${{ github.repository }} version ${{ github.event.release.tag_name }} published by ${{ github.actor }} + title: New ${{ github.repository }} version ${{ inputs.tag_name || github.event.release.tag_name }} published by ${{ github.actor }} description: | - Release URL: ${{ github.event.release.html_url }} - Click [here](https://github.com/Countly/countly-server/blob/master/CHANGELOG.md) to view the change log. - `${{ github.event.release.body }}` + Release URL: ${{ inputs.html_url || github.event.release.html_url }} + Click [here](https://github.com/${{ github.repository }}/blob/master/CHANGELOG.md) to view the change log. + `${{ inputs.body || github.event.release.body }}` diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 4e9c48f..9f64bc6 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -46,13 +46,13 @@ jobs: subject-path: countly-sdk-cpp-${{ steps.generate.outputs.version }}.tar.gz sbom-path: ${{ steps.generate.outputs.sbom_file }} + # No --clobber: the Conan recipe pins this archive's checksum, so a released archive must never be replaced - name: Attach to release if: github.event_name == 'release' run: | gh release upload "${{ github.event.release.tag_name }}" \ "${{ steps.generate.outputs.sbom_file }}" \ - "countly-sdk-cpp-${{ steps.generate.outputs.version }}.tar.gz" \ - --clobber + "countly-sdk-cpp-${{ steps.generate.outputs.version }}.tar.gz" env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index eec3317..dab326b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -164,27 +164,59 @@ jobs: # ────────────────────────────────────────────── # Installed SDK: an app that sees only the install - # prefix must compile, link and run against it. + # prefix finds it with find_package and runs. # ────────────────────────────────────────────── install: - name: install-${{ matrix.libs }} - runs-on: ubuntu-24.04 + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} timeout-minutes: 20 strategy: fail-fast: false matrix: include: - - libs: shared + - name: install-shared + os: ubuntu-24.04 + shared: ON + sqlite: OFF + - name: install-static + os: ubuntu-24.04 + shared: OFF + sqlite: OFF + - name: install-static-sqlite + os: ubuntu-24.04 + shared: OFF + sqlite: ON + - name: install-shared-macos + os: macos-15 + shared: ON + sqlite: OFF + - name: install-static-macos + os: macos-15 + shared: OFF + sqlite: OFF + - name: install-shared-windows + os: windows-2022 + shared: ON + sqlite: OFF + - name: install-shared-sqlite-windows + os: windows-2022 shared: ON - - libs: static + sqlite: ON + - name: install-static-windows + os: windows-2022 shared: OFF + sqlite: OFF + defaults: + run: + shell: bash steps: - name: Checkout code uses: actions/checkout@v4 with: submodules: "recursive" - - name: Install dependencies + - name: Install dependencies (Ubuntu) + if: startsWith(matrix.os, 'ubuntu') run: | sudo apt-get $APT_OPTS update sudo apt-get $APT_OPTS install -y --no-install-recommends \ @@ -192,31 +224,62 @@ jobs: - name: Build and install run: | - cmake -DBUILD_SHARED_LIBS=${{ matrix.shared }} -DCMAKE_BUILD_TYPE=Release -B build . - cmake --build build -j 4 - cmake --install build --prefix "$RUNNER_TEMP/countly" + cmake -DBUILD_SHARED_LIBS=${{ matrix.shared }} -DCOUNTLY_USE_SQLITE=${{ matrix.sqlite }} -DCMAKE_BUILD_TYPE=Release -B build . + cmake --build build --config Release -j 4 + cmake --install build --config Release --prefix "$RUNNER_TEMP/countly" - - name: Compile and run an app against the installed SDK + # Apps built without CMake (vcpkg's Visual Studio integration, plain makefiles) never see the SDK's definitions + - name: Check that the installed headers do not depend on COUNTLY_USE_SQLITE + run: | + if grep -rnE '^[[:space:]]*#[[:space:]]*(if|ifdef|ifndef|elif).*COUNTLY_USE_SQLITE' "$RUNNER_TEMP/countly/include/countly.hpp" "$RUNNER_TEMP/countly/include/countly"; then + echo "::error::the installed headers change with COUNTLY_USE_SQLITE" + exit 1 + fi + + - name: Build and run an app with find_package working-directory: ${{ runner.temp }} run: | - cat > app.cpp << 'EOF' - #include "countly.hpp" - #include - - int main() { - (void)cly::Countly::getInstance(); - cly::Event event("install_check", 1); - event.addSegmentation("source", "installed tree"); - std::cout << event.serialize() << std::endl; - return 0; - } - EOF - if [ "${{ matrix.libs }}" = "static" ]; then - g++ -std=c++14 -I countly/include app.cpp countly/lib/libcountly.a -lcurl -lssl -lcrypto -lpthread -o app + openssl_root="" + if [ "$RUNNER_OS" = "macOS" ]; then + openssl_root="-DOPENSSL_ROOT_DIR=$(brew --prefix openssl)" + fi + cmake -S "$GITHUB_WORKSPACE/.github/package-check" -B app/build -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$RUNNER_TEMP/countly" -DEXPECT_COUNTLY_SQLITE=${{ matrix.sqlite }} $openssl_root + cmake --build app/build --config Release + if [ "$RUNNER_OS" = "Windows" ]; then + if [ "${{ matrix.shared }}" = "ON" ]; then + cp countly/bin/countly.dll app/build/Release/ + fi + ./app/build/Release/app.exe else - g++ -std=c++14 -I countly/include app.cpp -L countly/lib -lcountly -Wl,-rpath,"$PWD/countly/lib" -o app + ./app/build/app fi - ./app + + # ────────────────────────────────────────────── + # Android: the SDK compiles with the NDK's default + # flags, which make format-security warnings errors. + # ────────────────────────────────────────────── + android: + name: android-${{ matrix.abi }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, x86_64] + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: "recursive" + + - name: Build with the Android NDK + run: | + ndk="${ANDROID_NDK_LATEST_HOME:-$ANDROID_NDK_HOME}" + cmake -B build -DCMAKE_TOOLCHAIN_FILE="$ndk/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=${{ matrix.abi }} -DANDROID_PLATFORM=android-28 -DCMAKE_BUILD_TYPE=Release \ + -DCOUNTLY_USE_CUSTOM_HTTP=ON -DCOUNTLY_USE_CUSTOM_SHA256=ON -DCOUNTLY_USE_SQLITE=ON \ + -DCOUNTLY_BUILD_TESTS=1 . + cmake --build build -j 4 # ────────────────────────────────────────────── # Release tooling smoke test: the SBOM generator must diff --git a/CHANGELOG.md b/CHANGELOG.md index e5583ef..03ad254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,21 @@ -## XX.XX.XX +## 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. - Added the `COUNTLY_USE_SYSTEM_JSON` CMake option for building against the application's own nlohmann/json (3.11 or newer) instead of the bundled copy (default `OFF`). - Added support for static library builds with the built-in HTTP client (libcurl) on Linux and macOS. - -- Fixed session, view and timed event durations changing when the system clock was adjusted while the app was running. -- Fixed `cmake --install` producing an include folder that could not be compiled against. +- Added CMake package files: an installed SDK is found with `find_package(countly CONFIG)` and linked as `countly::countly`, the same target name as with `add_subdirectory`. +- Added an in-memory fallback to SQLite builds: when no database path is set or the database cannot be opened, the SDK logs it and keeps events and requests in memory instead of not starting. +- Added `setDatabasePath` to builds without SQLite; it logs a warning and ignores the path, so the same code works with both builds. + +- Mitigated an issue where session, view and timed event durations changed when the system clock was adjusted while the app was running. +- Mitigated an issue where `cmake --install` produced an include folder that could not be compiled against. +- Mitigated an issue where macOS builds replaced an `OPENSSL_ROOT_DIR` passed to CMake with Homebrew's OpenSSL. +- Mitigated an issue where Android NDK builds failed with `-Werror=format-security` errors in `constants.hpp`. +- Mitigated an issue where SQLite builds did not store events and requests that contained a single quote. +- Mitigated an issue where the previous user's details were sent with the new user's session after a device ID change without merge. +- Mitigated an issue where no requests were delivered when the server URL ended with a slash. +- Mitigated an issue where, on Linux and macOS, a server that stopped answering held back all further requests; requests now time out after 30 seconds. +- Mitigated an issue where apps using a SQLite-enabled SDK could crash unless they defined `COUNTLY_USE_SQLITE` themselves; the public headers no longer depend on it. ## 26.8.0 - ! Minor breaking change ! The SDK now enforces internal limits on recorded data (key length, value size, segmentation entry count, breadcrumb count, stack trace lines per thread and line length) across events, views, crashes, and user properties. Defaults can be overridden by server-side SDK Behavior Settings, or during init via `setMaxKeyLength`, `setMaxValueSize`, `setMaxSegmentationValues`, `setMaxBreadcrumbCount`, `setMaxStackTraceLinesPerThread` and `setMaxStackTraceLineLength`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 4332523..667bb66 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,6 +56,7 @@ add_library(countly ${CMAKE_CURRENT_SOURCE_DIR}/src/storage_module_memory.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/event.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/configuration_module.cpp) +add_library(countly::countly ALIAS countly) target_include_directories(countly PUBLIC $ @@ -88,7 +89,7 @@ if (COUNTLY_USE_CUSTOM_SHA256) message("Compiling definitions for custom sha256") target_compile_definitions(countly PRIVATE COUNTLY_USE_CUSTOM_SHA256) else() -if (APPLE) +if (APPLE AND NOT OPENSSL_ROOT_DIR) message("Setting openssl root for Mac") execute_process(COMMAND brew --prefix openssl OUTPUT_VARIABLE OPENSSL_ROOT_DIR OUTPUT_STRIP_TRAILING_WHITESPACE) if(NOT OPENSSL_ROOT_DIR) @@ -96,8 +97,11 @@ if (APPLE) endif() endif() find_package(OpenSSL REQUIRED) -target_include_directories(countly PRIVATE ${OPENSSL_INCLUDE_DIR}) -target_link_libraries(countly ${OPENSSL_LIBRARIES}) +if(TARGET OpenSSL::SSL) + target_link_libraries(countly OpenSSL::SSL OpenSSL::Crypto) +else() + target_link_libraries(countly OpenSSL::Crypto) +endif() endif() if(COUNTLY_USE_CUSTOM_HTTP) @@ -106,12 +110,12 @@ if(COUNTLY_USE_CUSTOM_HTTP) elseif(NOT WIN32) message("Checking url as device is not windows") find_package(CURL REQUIRED) - target_include_directories(countly PRIVATE ${CURL_INCLUDE_DIRS}) - target_link_libraries(countly ${CURL_LIBRARIES}) + target_link_libraries(countly CURL::libcurl) endif() if(COUNTLY_USE_SQLITE) - target_compile_definitions(countly PRIVATE COUNTLY_USE_SQLITE) + # The public headers change with this definition, so code that includes them must see it too + target_compile_definitions(countly PUBLIC COUNTLY_USE_SQLITE) if(COUNTLY_USE_SYSTEM_SQLITE) message("Linking system SQLite") find_package(SQLite3 REQUIRED) @@ -153,8 +157,6 @@ if(COUNTLY_BUILD_TESTS) target_compile_options(countly-tests PRIVATE $<$>:-g>) target_compile_definitions(countly-tests PRIVATE COUNTLY_BUILD_TESTS) if(COUNTLY_USE_SQLITE) - message("Compiling definitions for sqlite") - target_compile_definitions(countly-tests PRIVATE COUNTLY_USE_SQLITE) if(COUNTLY_USE_SYSTEM_SQLITE) target_link_libraries(countly-tests SQLite::SQLite3) else() @@ -165,6 +167,10 @@ if(COUNTLY_BUILD_TESTS) message("Compiling definitions for custom sha256") target_compile_definitions(countly-tests PRIVATE COUNTLY_USE_CUSTOM_SHA256) endif() + if(COUNTLY_USE_CUSTOM_HTTP) + message("Compiling definitions for custom http") + target_compile_definitions(countly-tests PRIVATE COUNTLY_USE_CUSTOM_HTTP) + endif() target_include_directories(countly-tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/vendor/doctest/doctest) target_link_libraries(countly-tests countly) set_target_properties(countly-tests PROPERTIES @@ -179,8 +185,6 @@ if(COUNTLY_BUILD_SAMPLE) ${CMAKE_CURRENT_SOURCE_DIR}/examples/example_integration.cpp) if(COUNTLY_USE_SQLITE) - message("Compiling definitions for sqlite") - target_compile_definitions(countly-sample PRIVATE COUNTLY_USE_SQLITE) if(COUNTLY_USE_SYSTEM_SQLITE) target_link_libraries(countly-sample SQLite::SQLite3) else() @@ -196,12 +200,36 @@ endif() endif() install(TARGETS countly + EXPORT countlyTargets ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin - PUBLIC_HEADER DESTINATION include/countly) + PUBLIC_HEADER DESTINATION include/countly + INCLUDES DESTINATION include) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/include/countly.hpp DESTINATION include) # The installed headers include nlohmann/json.hpp, so the bundled copy ships with them if(NOT COUNTLY_USE_SYSTEM_JSON) install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/vendor/json/include/nlohmann DESTINATION include) endif() + +# The package config looks up only the dependencies this build links +set(COUNTLY_CONFIG_DEPENDENCIES "find_dependency(Threads)") +if(COUNTLY_USE_SYSTEM_JSON) + string(APPEND COUNTLY_CONFIG_DEPENDENCIES "\nfind_dependency(nlohmann_json 3.11)") +endif() +if(NOT COUNTLY_USE_CUSTOM_SHA256) + string(APPEND COUNTLY_CONFIG_DEPENDENCIES "\nfind_dependency(OpenSSL)") +endif() +if(NOT COUNTLY_USE_CUSTOM_HTTP AND NOT WIN32) + string(APPEND COUNTLY_CONFIG_DEPENDENCIES "\nfind_dependency(CURL)") +endif() +if(COUNTLY_USE_SQLITE AND COUNTLY_USE_SYSTEM_SQLITE) + string(APPEND COUNTLY_CONFIG_DEPENDENCIES "\nfind_dependency(SQLite3)") +endif() +include(CMakePackageConfigHelpers) +configure_package_config_file(${CMAKE_CURRENT_SOURCE_DIR}/cmake/countlyConfig.cmake.in ${CMAKE_CURRENT_BINARY_DIR}/countlyConfig.cmake + INSTALL_DESTINATION lib/cmake/countly) +write_basic_package_version_file(${CMAKE_CURRENT_BINARY_DIR}/countlyConfigVersion.cmake COMPATIBILITY AnyNewerVersion) +install(EXPORT countlyTargets NAMESPACE countly:: DESTINATION lib/cmake/countly) +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/countlyConfig.cmake ${CMAKE_CURRENT_BINARY_DIR}/countlyConfigVersion.cmake + DESTINATION lib/cmake/countly) diff --git a/cmake/countlyConfig.cmake.in b/cmake/countlyConfig.cmake.in new file mode 100644 index 0000000..1da3914 --- /dev/null +++ b/cmake/countlyConfig.cmake.in @@ -0,0 +1,7 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +@COUNTLY_CONFIG_DEPENDENCIES@ + +include("${CMAKE_CURRENT_LIST_DIR}/countlyTargets.cmake") +check_required_components(countly) diff --git a/include/countly.hpp b/include/countly.hpp index 64d285b..04baadf 100644 --- a/include/countly.hpp +++ b/include/countly.hpp @@ -130,8 +130,18 @@ class Countly : public cly::CountlyDelegates { void 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); + /** + * Sets the user's details, such as name and email. + * Called before start(), the details are sent with the first session; after start(), they are sent right away. + * Details still waiting when the device ID changes without merge are sent for the previous device ID. + */ void setUserDetails(const std::map &value); + /** + * Sets the user's custom properties. + * Called before start(), the properties are sent with the first session; after start(), they are sent right away. + * Properties still waiting when the device ID changes without merge are sent for the previous device ID. + */ void setCustomUserDetails(const std::map &value); /* @@ -268,15 +278,14 @@ class Countly : public cly::CountlyDelegates { std::string calculateChecksum(const std::string &salt, const std::string &data); -#ifdef COUNTLY_USE_SQLITE + /** + * Sets the file in which the SDK keeps queued events and requests, so they survive app restarts. Call it before start(). + * Only builds with COUNTLY_USE_SQLITE use the file; without a path, or when the file cannot be opened, they keep + * events and requests in memory instead. Builds without SQLite log a warning and ignore the path. + */ void setDatabasePath(const std::string &path); -#endif - void SetPath(const std::string &path) { -#ifdef COUNTLY_USE_SQLITE - setDatabasePath(path); -#endif - } + void SetPath(const std::string &path) { setDatabasePath(path); } void 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) { setMetrics(os, os_version, device, resolution, carrier, app_version); } @@ -456,14 +465,18 @@ class Countly : public cly::CountlyDelegates { void _deleteThread(); void _sendIndependantLocationRequest(); void log(LogLevel level, const std::string &message); -#ifdef COUNTLY_USE_SQLITE bool createEventTableSchema(); -#endif void _changeDeviceIdWithMerge(const std::string &value); void _changeDeviceIdWithoutMerge(const std::string &value); + /** + * Queues a request with the stored user details for the current device ID and drops the stored copy, + * so a later session does not send them again. The caller must hold the instance mutex. + */ + void queueUserDetails(); + std::chrono::steady_clock::duration getSessionDuration(std::chrono::steady_clock::time_point now); std::chrono::steady_clock::duration getSessionDuration(); @@ -507,14 +520,14 @@ class Countly : public cly::CountlyDelegates { size_t wait_milliseconds = COUNTLY_KEEPALIVE_INTERVAL; size_t max_events = COUNTLY_MAX_EVENTS_DEFAULT; -#ifndef COUNTLY_USE_SQLITE + // Members are the same in every build, so code compiled without COUNTLY_USE_SQLITE still matches the library std::deque event_queue; -#else std::string database_path; // The normalized database path this instance has claimed in the process-wide // registry, empty when it holds no claim. std::string claimed_database_path; -#endif + // Chosen in start(): true when this instance keeps its queues in the SQLite database + bool use_sqlite_storage = false; bool remote_config_enabled = false; }; diff --git a/include/countly/constants.hpp b/include/countly/constants.hpp index bf06f79..01eb1e7 100644 --- a/include/countly/constants.hpp +++ b/include/countly/constants.hpp @@ -19,7 +19,7 @@ static_assert(NLOHMANN_JSON_VERSION_MAJOR == 3 && NLOHMANN_JSON_VERSION_MINOR >= #include #define COUNTLY_SDK_NAME "cpp-native-unknown" -#define COUNTLY_SDK_VERSION "26.8.0" +#define COUNTLY_SDK_VERSION "26.8.1" #define COUNTLY_POST_THRESHOLD 2000 #define COUNTLY_KEEPALIVE_INTERVAL 3000 #define COUNTLY_MAX_EVENTS_DEFAULT 200 @@ -30,6 +30,8 @@ static_assert(NLOHMANN_JSON_VERSION_MAJOR == 3 && NLOHMANN_JSON_VERSION_MINOR >= #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 +#define COUNTLY_HTTP_CONNECT_TIMEOUT_SECONDS 10 +#define COUNTLY_HTTP_REQUEST_TIMEOUT_SECONDS 30 namespace cly { struct HTTPResponse { @@ -41,6 +43,14 @@ using HTTPClientFunction = std::function; namespace utils { +/** + * Returns a message that has no arguments to format, without passing it through printf. + * + * @param format message without format arguments + * @return the message unchanged + */ +static std::string format_string(const std::string &format) { return format; } + /** * Formats the given arguments into a string buffer. * diff --git a/include/countly/countly_configuration.hpp b/include/countly/countly_configuration.hpp index d54bef1..37b80b0 100644 --- a/include/countly/countly_configuration.hpp +++ b/include/countly/countly_configuration.hpp @@ -28,11 +28,9 @@ struct CountlyConfiguration { std::string salt; /** - * Path to the database. + * Path to the database. Only builds with COUNTLY_USE_SQLITE use it. */ -#ifdef COUNTLY_USE_SQLITE std::string databasePath; -#endif /** * Sets the interval for the automatic update calls diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..f4de024 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,45 @@ +# Package manager files + +The vcpkg port and the Conan recipe for this SDK, laid out as in their upstream repositories, so a release can be +submitted by copying them over: + +- `vcpkg/ports/countly-sdk-cpp` goes to `ports/countly-sdk-cpp` in [microsoft/vcpkg](https://github.com/microsoft/vcpkg). +- `conan/countly-sdk-cpp` goes to `recipes/countly-sdk-cpp` in [conan-io/conan-center-index](https://github.com/conan-io/conan-center-index). + +Both build against the package manager's own nlohmann/json, OpenSSL, libcurl and (with the `sqlite` feature or the +`with_sqlite` option) SQLite, and install the CMake package used as: + +```cmake +find_package(countly CONFIG REQUIRED) +target_link_libraries(main PRIVATE countly::countly) +``` + +## At a release + +1. Set the version in `vcpkg/ports/countly-sdk-cpp/vcpkg.json`, `conan/countly-sdk-cpp/config.yml` and + `conan/countly-sdk-cpp/all/conandata.yml`. +2. Fill in the hashes: + - `SHA512` in `portfile.cmake`, for GitHub's archive of the tag, which vcpkg downloads (vcpkg prints the expected + value when it is left at `0`). + - `sha256` in `conandata.yml`, for `countly-sdk-cpp-.tar.gz`, the source archive the SBOM workflow attaches + to the release (ConanCenter prefers an official archive to a generated one). That archive must not be replaced + after the recipe uses its hash. +3. Open a pull request in each upstream repository, following their contribution guides. + +## Testing locally + +```sh +vcpkg install countly-sdk-cpp --overlay-ports=packaging/vcpkg/ports +conan create packaging/conan/countly-sdk-cpp/all --version --build=missing +``` + +Both download the tagged release, so before a tag exists, point the source in the port and in `conandata.yml` at a +local archive of the tree instead. + +## In CI + +`.github/workflows/packaging.yml` does that for every commit that touches the build or these files: it packs the commit +with `git archive` (which, like GitHub's release archives, leaves the submodules out), builds the port and the recipe +from that archive on Linux, Windows and macOS, and builds an app against each, without and with SQLite. The port is +also built for arm64 Android, and the recipe also as shared libraries. It also checks that the versions in both match +`COUNTLY_SDK_VERSION`. diff --git a/packaging/conan/countly-sdk-cpp/all/conandata.yml b/packaging/conan/countly-sdk-cpp/all/conandata.yml new file mode 100644 index 0000000..40cd1fe --- /dev/null +++ b/packaging/conan/countly-sdk-cpp/all/conandata.yml @@ -0,0 +1,4 @@ +sources: + "26.8.1": + url: "https://github.com/Countly/countly-sdk-cpp/releases/download/26.8.1/countly-sdk-cpp-26.8.1.tar.gz" + sha256: "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/packaging/conan/countly-sdk-cpp/all/conanfile.py b/packaging/conan/countly-sdk-cpp/all/conanfile.py new file mode 100644 index 0000000..9323efd --- /dev/null +++ b/packaging/conan/countly-sdk-cpp/all/conanfile.py @@ -0,0 +1,88 @@ +import os + +from conan import ConanFile +from conan.errors import ConanInvalidConfiguration +from conan.tools.build import check_min_cppstd +from conan.tools.cmake import CMake, CMakeDeps, CMakeToolchain, cmake_layout +from conan.tools.files import apply_conandata_patches, copy, export_conandata_patches, get, rm, rmdir + +required_conan_version = ">=2.0.9" + + +class CountlySdkCppConan(ConanFile): + name = "countly-sdk-cpp" + description = "Countly analytics SDK for C++ applications" + license = "MIT" + url = "https://github.com/conan-io/conan-center-index" + homepage = "https://github.com/Countly/countly-sdk-cpp" + topics = ("analytics", "telemetry", "crash-reporting", "countly") + package_type = "library" + settings = "os", "arch", "compiler", "build_type" + options = { + "shared": [True, False], + "fPIC": [True, False], + "with_sqlite": [True, False], + } + default_options = { + "shared": False, + "fPIC": True, + "with_sqlite": False, + } + implements = ["auto_shared_fpic"] + + def export_sources(self): + export_conandata_patches(self) + + def layout(self): + cmake_layout(self, src_folder="src") + + def requirements(self): + self.requires("nlohmann_json/3.12.0", transitive_headers=True) + self.requires("openssl/[>=1.1 <4]") + if self.settings.os != "Windows": + self.requires("libcurl/[>=7.78 <9]") + if self.options.with_sqlite: + self.requires("sqlite3/3.54.0") + + def validate(self): + check_min_cppstd(self, 14) + if self.settings.os == "WindowsStore": + raise ConanInvalidConfiguration(f"{self.ref} uses WinHTTP, which UWP apps cannot use") + + def source(self): + get(self, **self.conan_data["sources"][self.version], strip_root=True) + apply_conandata_patches(self) + + def generate(self): + tc = CMakeToolchain(self) + tc.cache_variables["COUNTLY_USE_SYSTEM_JSON"] = True + tc.cache_variables["COUNTLY_USE_SQLITE"] = bool(self.options.with_sqlite) + tc.cache_variables["COUNTLY_USE_SYSTEM_SQLITE"] = bool(self.options.with_sqlite) + tc.cache_variables["COUNTLY_BUILD_TESTS"] = False + tc.cache_variables["COUNTLY_BUILD_SAMPLE"] = False + tc.generate() + deps = CMakeDeps(self) + deps.generate() + + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + def package(self): + copy(self, "LICENSE", self.source_folder, os.path.join(self.package_folder, "licenses")) + cmake = CMake(self) + cmake.install() + rmdir(self, os.path.join(self.package_folder, "lib", "cmake")) + rm(self, "*.pdb", self.package_folder, recursive=True) + + def package_info(self): + self.cpp_info.libs = ["countly"] + self.cpp_info.set_property("cmake_file_name", "countly") + self.cpp_info.set_property("cmake_target_name", "countly::countly") + if self.options.with_sqlite: + self.cpp_info.defines.append("COUNTLY_USE_SQLITE") + if self.settings.os in ["Linux", "FreeBSD"]: + self.cpp_info.system_libs.append("pthread") + elif self.settings.os == "Windows": + self.cpp_info.system_libs.append("winhttp") diff --git a/packaging/conan/countly-sdk-cpp/all/test_package/CMakeLists.txt b/packaging/conan/countly-sdk-cpp/all/test_package/CMakeLists.txt new file mode 100644 index 0000000..47bea1c --- /dev/null +++ b/packaging/conan/countly-sdk-cpp/all/test_package/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.15) +project(test_package LANGUAGES CXX) + +find_package(countly REQUIRED CONFIG) + +add_executable(${PROJECT_NAME} test_package.cpp) +target_link_libraries(${PROJECT_NAME} PRIVATE countly::countly) +target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_14) diff --git a/packaging/conan/countly-sdk-cpp/all/test_package/conanfile.py b/packaging/conan/countly-sdk-cpp/all/test_package/conanfile.py new file mode 100644 index 0000000..560fc69 --- /dev/null +++ b/packaging/conan/countly-sdk-cpp/all/test_package/conanfile.py @@ -0,0 +1,26 @@ +import os + +from conan import ConanFile +from conan.tools.build import can_run +from conan.tools.cmake import CMake, cmake_layout + + +class TestPackageConan(ConanFile): + settings = "os", "arch", "compiler", "build_type" + generators = "CMakeDeps", "CMakeToolchain" + + def layout(self): + cmake_layout(self) + + def requirements(self): + self.requires(self.tested_reference_str) + + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + def test(self): + if can_run(self): + bin_path = os.path.join(self.cpp.build.bindir, "test_package") + self.run(bin_path, env="conanrun") diff --git a/packaging/conan/countly-sdk-cpp/all/test_package/test_package.cpp b/packaging/conan/countly-sdk-cpp/all/test_package/test_package.cpp new file mode 100644 index 0000000..c6f18c6 --- /dev/null +++ b/packaging/conan/countly-sdk-cpp/all/test_package/test_package.cpp @@ -0,0 +1,11 @@ +#include "countly.hpp" + +#include + +int main() { + (void)cly::Countly::getInstance(); + cly::Event event("test_package", 1); + event.addSegmentation("source", "conan"); + std::cout << event.serialize() << std::endl; + return 0; +} diff --git a/packaging/conan/countly-sdk-cpp/config.yml b/packaging/conan/countly-sdk-cpp/config.yml new file mode 100644 index 0000000..ff1ebf8 --- /dev/null +++ b/packaging/conan/countly-sdk-cpp/config.yml @@ -0,0 +1,3 @@ +versions: + "26.8.1": + folder: all diff --git a/packaging/vcpkg/ports/countly-sdk-cpp/portfile.cmake b/packaging/vcpkg/ports/countly-sdk-cpp/portfile.cmake new file mode 100644 index 0000000..a4b83fc --- /dev/null +++ b/packaging/vcpkg/ports/countly-sdk-cpp/portfile.cmake @@ -0,0 +1,32 @@ +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO Countly/countly-sdk-cpp + REF "${VERSION}" + SHA512 0 + HEAD_REF master +) + +vcpkg_check_features(OUT_FEATURE_OPTIONS FEATURE_OPTIONS + FEATURES + sqlite COUNTLY_USE_SQLITE + sqlite COUNTLY_USE_SYSTEM_SQLITE +) + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + OPTIONS + ${FEATURE_OPTIONS} + -DCOUNTLY_USE_SYSTEM_JSON=ON + -DCOUNTLY_BUILD_TESTS=OFF + -DCOUNTLY_BUILD_SAMPLE=OFF + # keeps the SDK's macOS Homebrew lookup from replacing vcpkg's OpenSSL + "-DOPENSSL_ROOT_DIR=${CURRENT_INSTALLED_DIR}" +) + +vcpkg_cmake_install() +vcpkg_cmake_config_fixup(PACKAGE_NAME countly CONFIG_PATH lib/cmake/countly) +vcpkg_copy_pdbs() + +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") diff --git a/packaging/vcpkg/ports/countly-sdk-cpp/vcpkg.json b/packaging/vcpkg/ports/countly-sdk-cpp/vcpkg.json new file mode 100644 index 0000000..47c35ea --- /dev/null +++ b/packaging/vcpkg/ports/countly-sdk-cpp/vcpkg.json @@ -0,0 +1,32 @@ +{ + "name": "countly-sdk-cpp", + "version": "26.8.1", + "description": "Countly analytics SDK for C++ applications", + "homepage": "https://github.com/Countly/countly-sdk-cpp", + "license": "MIT", + "supports": "!uwp", + "dependencies": [ + { + "name": "curl", + "platform": "!windows" + }, + "nlohmann-json", + "openssl", + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + } + ], + "features": { + "sqlite": { + "description": "Store events and requests in SQLite", + "dependencies": [ + "sqlite3" + ] + } + } +} diff --git a/src/countly.cpp b/src/countly.cpp index 45c0a5a..0e2cc16 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -611,9 +611,7 @@ void Countly::setUserDetails(const std::map &value) { lk.lock(); } - std::map data = {{"app_key", session_params["app_key"].get()}, {"device_id", session_params["device_id"].get()}, {"user_details", session_params["user_details"].dump()}}; - - requestModule->addRequestToQueue(data); + queueUserDetails(); } void Countly::setCustomUserDetails(const std::map &value) { @@ -669,8 +667,13 @@ void Countly::setCustomUserDetails(const std::map &val lk.lock(); } + queueUserDetails(); +} + +void Countly::queueUserDetails() { std::map data = {{"app_key", session_params["app_key"].get()}, {"device_id", session_params["device_id"].get()}, {"user_details", session_params["user_details"].dump()}}; requestModule->addRequestToQueue(data); + session_params.erase("user_details"); } #pragma region User location @@ -821,6 +824,7 @@ void Countly::_changeDeviceIdWithMerge(const std::string &value) { session_params.erase("old_device_id"); } +/* Change device ID without merge after SDK has been initialized. Events and user details that are still waiting are sent for the old device ID first.*/ void Countly::_changeDeviceIdWithoutMerge(const std::string &value) { log(LogLevel::DEBUG, "[Countly] _changeDeviceIdWithoutMerge, deviceId = [" + value + "]"); @@ -832,6 +836,9 @@ void Countly::_changeDeviceIdWithoutMerge(const std::string &value) { { std::lock_guard lk(*mutex); + if (session_params.contains("user_details")) { + queueUserDetails(); + } session_params["device_id"] = value; configuration->deviceId = value; } @@ -855,21 +862,21 @@ void Countly::start(const std::string &app_key, const std::string &host, int por #ifdef COUNTLY_USE_SQLITE if (configuration->databasePath == "" || configuration->databasePath == " ") { - log(LogLevel::ERROR, "[Countly] start, Database path can not be empty or blank."); - return; - } - - // One instance per database file. Two instances sharing one file would eat - // each other's queues, and because rows in the `events` table carry no - // app_key, one instance would pack the other's events into a request stamped - // with the wrong key. - const std::string normalized_database_path = cly::utils::normalizeDatabasePath(configuration->databasePath); - if (!registry().tryClaimPath(normalized_database_path)) { - log(LogLevel::ERROR, "[Countly] start, Database path '" + configuration->databasePath + - "' is already in use by another Countly instance in this process. Note that stop() does not free the path: an instance keeps its claim until it is destroyed. SDK will not be initialized."); - return; + log(LogLevel::WARNING, "[Countly] start, No database path is set, so events and requests are kept in memory and are lost when the app closes. Call setDatabasePath() before start() to keep them."); + } else { + // One instance per database file. Two instances sharing one file would eat + // each other's queues, and because rows in the `events` table carry no + // app_key, one instance would pack the other's events into a request stamped + // with the wrong key. + const std::string normalized_database_path = cly::utils::normalizeDatabasePath(configuration->databasePath); + if (!registry().tryClaimPath(normalized_database_path)) { + log(LogLevel::ERROR, "[Countly] start, Database path '" + configuration->databasePath + + "' is already in use by another Countly instance in this process. Note that stop() does not free the path: an instance keeps its claim until it is destroyed. SDK will not be initialized."); + return; + } + claimed_database_path = normalized_database_path; + use_sqlite_storage = true; } - claimed_database_path = normalized_database_path; #endif log(LogLevel::INFO, "[Countly] start, Initializing SDK"); @@ -912,12 +919,19 @@ void Countly::start(const std::string &app_key, const std::string &host, int por session_params["app_key"] = app_key; -#ifdef COUNTLY_USE_SQLITE - storageModule.reset(new StorageModuleDB(configuration, logger)); -#else - storageModule.reset(new StorageModuleMemory(configuration, logger)); -#endif - storageModule->init(); + if (use_sqlite_storage) { + storageModule.reset(new StorageModuleDB(configuration, logger)); + storageModule->init(); + if (!storageModule->isInitialized() || !createEventTableSchema()) { + log(LogLevel::ERROR, "[Countly] start, Could not open the database at path: '" + configuration->databasePath + "', so events and requests are kept in memory and are lost when the app closes. Please verify the path is valid and writable."); + releaseDatabasePathClaim(); + use_sqlite_storage = false; + } + } + if (!use_sqlite_storage) { + storageModule.reset(new StorageModuleMemory(configuration, logger)); + storageModule->init(); + } requestBuilder.reset(new RequestBuilder(configuration, logger)); requestModule.reset(new RequestModule(configuration, logger, requestBuilder, storageModule)); @@ -929,28 +943,13 @@ void Countly::start(const std::string &app_key, const std::string &host, int por views_module->setConfigurationProvider(configurationModule); crash_module->setConfigurationProvider(configurationModule); - bool result = true; -#ifdef COUNTLY_USE_SQLITE - result = createEventTableSchema(); - if (!result) { - log(LogLevel::ERROR, "[Countly] start, Failed to initialize database at path: '" + configuration->databasePath + "'. SDK will not be initialized. Please verify the path is valid and writable."); - } -#endif + is_sdk_initialized = true; // after this point SDK is initialized. - is_sdk_initialized = result; // after this point SDK is initialized. - if (!is_sdk_initialized) { - log(LogLevel::ERROR, "[Countly] start, SDK initialization failed."); - releaseDatabasePathClaim(); - return; - } - - if (is_sdk_initialized) { - lk.unlock(); - configurationModule->fetchConfigFromStorage(); - configurationModule->fetchConfigFromServer(session_params); - configurationModule->startServerConfigUpdateTimer(session_params); - lk.lock(); - } + lk.unlock(); + configurationModule->fetchConfigFromStorage(); + configurationModule->fetchConfigFromServer(session_params); + configurationModule->startServerConfigUpdateTimer(session_params); + lk.lock(); if (!running) { @@ -1138,11 +1137,11 @@ void Countly::addEvent(const cly::Event &event) { } std::unique_lock lk(*mutex); -#ifndef COUNTLY_USE_SQLITE - event_queue.push_back(filteredEvent.serialize()); -#else - addEventToSqlite(filteredEvent); -#endif + if (use_sqlite_storage) { + addEventToSqlite(filteredEvent); + } else { + event_queue.push_back(filteredEvent.serialize()); + } lk.unlock(); checkAndSendEventToRQ(); } @@ -1158,30 +1157,30 @@ void Countly::checkAndSendEventToRQ() { // on a queued event or sendEventsToRQ throws; unlock/re-acquire around the // self-locking fillEventsIntoJson(). std::unique_lock lk(*mutex); -#ifdef COUNTLY_USE_SQLITE - if (queueSize >= configurationModule->getEventQueueSizeLimit()) { - log(LogLevel::DEBUG, "[Countly] checkAndSendEventToRQ, Event queue threshold is reached"); - std::string event_ids; + if (use_sqlite_storage) { + if (queueSize >= configurationModule->getEventQueueSizeLimit()) { + log(LogLevel::DEBUG, "[Countly] checkAndSendEventToRQ, Event queue threshold is reached"); + std::string event_ids; - // fetch events up to the threshold from the database - lk.unlock(); - fillEventsIntoJson(events, event_ids); - lk.lock(); - // send them to request queue - sendEventsToRQ(events); - // remove them from database - removeEventWithId(event_ids); - } -#else - if (queueSize >= configurationModule->getEventQueueSizeLimit()) { - log(LogLevel::WARNING, "[Countly] checkAndSendEventToRQ, Event queue is full, dropping the oldest event to insert a new one"); - for (const auto &event_json : event_queue) { - events.push_back(nlohmann::json::parse(event_json)); + // fetch events up to the threshold from the database + lk.unlock(); + fillEventsIntoJson(events, event_ids); + lk.lock(); + // send them to request queue + sendEventsToRQ(events); + // remove them from database + removeEventWithId(event_ids); + } + } else { + if (queueSize >= configurationModule->getEventQueueSizeLimit()) { + log(LogLevel::WARNING, "[Countly] checkAndSendEventToRQ, Event queue is full, dropping the oldest event to insert a new one"); + for (const auto &event_json : event_queue) { + events.push_back(nlohmann::json::parse(event_json)); + } + sendEventsToRQ(events); + event_queue.clear(); } - sendEventsToRQ(events); - event_queue.clear(); } -#endif } void Countly::setMaxEvents(size_t value) { @@ -1246,19 +1245,17 @@ void Countly::flushEvents(std::chrono::seconds timeout) { bool Countly::attemptSessionUpdateEQ() { // return false if event queue is empty -#ifndef COUNTLY_USE_SQLITE - { + if (use_sqlite_storage) { + int event_count = checkEQSize(); + if (event_count <= 0) { + return false; + } + } else { std::lock_guard lk(*mutex); if (event_queue.empty()) { return false; } } -#else - int event_count = checkEQSize(); - if (event_count <= 0) { - return false; - } -#endif if (configuration->manualSessionControl == false) { return !updateSession(); } else { @@ -1268,13 +1265,13 @@ bool Countly::attemptSessionUpdateEQ() { } void Countly::clearEQInternal() { -#ifndef COUNTLY_USE_SQLITE - // event_queue is guarded by mutex; the only caller (flushEvents) holds no lock here. - std::lock_guard lk(*mutex); - event_queue.clear(); -#else - clearPersistentEQ(); -#endif + if (use_sqlite_storage) { + clearPersistentEQ(); + } else { + // event_queue is guarded by mutex; the only caller (flushEvents) holds no lock here. + std::lock_guard lk(*mutex); + event_queue.clear(); + } } #ifdef COUNTLY_BUILD_TESTS @@ -1282,39 +1279,41 @@ std::vector Countly::debugReturnStateOfEQ() { try { #ifdef COUNTLY_USE_SQLITE - std::vector v; - sqlite3 *database; - int return_value, row_count, column_count; - char **table; - char *error_message; + if (use_sqlite_storage) { + std::vector v; + sqlite3 *database; + int return_value, row_count, column_count; + char **table; + char *error_message; + + return_value = cly::utils::openDatabase(configuration->databasePath, &database); + if (return_value == SQLITE_OK) { + std::ostringstream sql_statement_stream; + sql_statement_stream << "SELECT * FROM events ORDER BY evtid ASC;"; + std::string sql_statement = sql_statement_stream.str(); + + return_value = sqlite3_get_table(database, sql_statement.c_str(), &table, &row_count, &column_count, &error_message); + bool no_request = (row_count == 0); + if (return_value == SQLITE_OK && !no_request) { + + for (int event_index = 1; event_index < row_count + 1; event_index++) { + std::string rqstId = table[event_index * column_count]; + std::string rqst = table[(event_index * column_count) + 1]; + v.push_back(rqst); + } - return_value = cly::utils::openDatabase(configuration->databasePath, &database); - if (return_value == SQLITE_OK) { - std::ostringstream sql_statement_stream; - sql_statement_stream << "SELECT * FROM events ORDER BY evtid ASC;"; - std::string sql_statement = sql_statement_stream.str(); - - return_value = sqlite3_get_table(database, sql_statement.c_str(), &table, &row_count, &column_count, &error_message); - bool no_request = (row_count == 0); - if (return_value == SQLITE_OK && !no_request) { - - for (int event_index = 1; event_index < row_count + 1; event_index++) { - std::string rqstId = table[event_index * column_count]; - std::string rqst = table[(event_index * column_count) + 1]; - v.push_back(rqst); + } else if (return_value != SQLITE_OK) { + std::string error(error_message); + sqlite3_free(error_message); } - - } else if (return_value != SQLITE_OK) { - std::string error(error_message); - sqlite3_free(error_message); + sqlite3_free_table(table); } - sqlite3_free_table(table); + sqlite3_close(database); + return v; } - sqlite3_close(database); -#else +#endif std::lock_guard lk(*mutex); std::vector v(event_queue.begin(), event_queue.end()); -#endif return v; } catch (const std::system_error &e) { std::ostringstream log_message; @@ -1328,12 +1327,11 @@ std::vector Countly::debugReturnStateOfEQ() { void Countly::debugInjectRawEvent(const std::string &raw) { std::lock_guard lk(*mutex); -#ifndef COUNTLY_USE_SQLITE - event_queue.push_back(raw); -#else - (void)raw; - log(LogLevel::WARNING, "[Countly] debugInjectRawEvent, not supported in SQLite builds."); -#endif + if (use_sqlite_storage) { + log(LogLevel::WARNING, "[Countly] debugInjectRawEvent, not supported with SQLite storage."); + } else { + event_queue.push_back(raw); + } } #endif @@ -1443,16 +1441,15 @@ bool Countly::updateSession() { lk.lock(); if (!no_events) { -#ifndef COUNTLY_USE_SQLITE - for (const auto &event_json : event_queue) { - events.push_back(nlohmann::json::parse(event_json)); + if (use_sqlite_storage) { + lk.unlock(); + fillEventsIntoJson(events, event_ids); + lk.lock(); + } else { + for (const auto &event_json : event_queue) { + events.push_back(nlohmann::json::parse(event_json)); + } } -#else - // TODO: If database_path was empty there was return false here - lk.unlock(); - fillEventsIntoJson(events, event_ids); - lk.lock(); -#endif } else { log(LogLevel::DEBUG, "[Countly] updateSession, EQ empty."); } @@ -1476,14 +1473,14 @@ bool Countly::updateSession() { // clear event queue // TODO: check if we want to totally wipe the event queue in memory but not in database -#ifndef COUNTLY_USE_SQLITE - event_queue.clear(); -#else - if (!event_ids.empty()) { - // this is a partial clearance, we only remove the events that were sent - removeEventWithId(event_ids); + if (use_sqlite_storage) { + if (!event_ids.empty()) { + // this is a partial clearance, we only remove the events that were sent + removeEventWithId(event_ids); + } + } else { + event_queue.clear(); } -#endif } catch (const std::system_error &e) { std::ostringstream log_message; log_message << "[Countly] updateSession, error: " << e.what(); @@ -1507,16 +1504,15 @@ void Countly::packEvents() { lk.lock(); if (!no_events) { -#ifndef COUNTLY_USE_SQLITE - for (const auto &event_json : event_queue) { - events.push_back(nlohmann::json::parse(event_json)); + if (use_sqlite_storage) { + lk.unlock(); + fillEventsIntoJson(events, event_ids); + lk.lock(); + } else { + for (const auto &event_json : event_queue) { + events.push_back(nlohmann::json::parse(event_json)); + } } -#else - // TODO: If database_path was empty there was return false here - lk.unlock(); - fillEventsIntoJson(events, event_ids); - lk.lock(); -#endif } else { log(LogLevel::DEBUG, "[Countly] packEvents, EQ empty."); } @@ -1527,14 +1523,14 @@ void Countly::packEvents() { // clear event queue // TODO: check if we want to totally wipe the event queue in memory but not in database -#ifndef COUNTLY_USE_SQLITE - event_queue.clear(); -#else - if (!event_ids.empty()) { - // this is a partial clearance, we only remove the events that were sent - removeEventWithId(event_ids); + if (use_sqlite_storage) { + if (!event_ids.empty()) { + // this is a partial clearance, we only remove the events that were sent + removeEventWithId(event_ids); + } + } else { + event_queue.clear(); } -#endif } catch (const std::system_error &e) { std::ostringstream log_message; log_message << "[Countly] packEvents, error: " << e.what(); @@ -1620,11 +1616,7 @@ int Countly::checkEQSize() { return event_count; } -#ifdef COUNTLY_USE_SQLITE - event_count = checkPersistentEQSize(); -#else - event_count = checkMemoryEQSize(); -#endif + event_count = use_sqlite_storage ? checkPersistentEQSize() : checkMemoryEQSize(); return event_count; } @@ -1645,7 +1637,6 @@ int Countly::checkRQSize() { return request_count; } -#ifndef COUNTLY_USE_SQLITE int Countly::checkMemoryEQSize() { log(LogLevel::DEBUG, "[Countly] checkMemoryEQSize, Checking event queue size in memory."); // See checkEQSize(): locking here would deadlock a call made from inside the @@ -1659,7 +1650,6 @@ int Countly::checkMemoryEQSize() { result = static_cast(event_queue.size()); return result; } -#endif // Standalone Sqlite functions #ifdef COUNTLY_USE_SQLITE @@ -1803,22 +1793,22 @@ void Countly::addEventToSqlite(const cly::Event &event) { sqlite3 *database; int return_value; - char *error_message; return_value = cly::utils::openDatabase(database_path, &database); if (return_value == SQLITE_OK) { - std::ostringstream sql_statement_stream; - // TODO Investigate if we need to escape single quotes in serialized event - sql_statement_stream << "INSERT INTO events (event) VALUES('" << event.serialize() << "');"; - std::string sql_statement = sql_statement_stream.str(); - - return_value = sqlite3_exec(database, sql_statement.c_str(), nullptr, nullptr, &error_message); - if (return_value != SQLITE_OK) { + const std::string serialized_event = event.serialize(); + sqlite3_stmt *statement = nullptr; + return_value = sqlite3_prepare_v2(database, "INSERT INTO events (event) VALUES(?);", -1, &statement, nullptr); + if (return_value == SQLITE_OK) { + sqlite3_bind_text(statement, 1, serialized_event.c_str(), -1, SQLITE_TRANSIENT); + return_value = sqlite3_step(statement); + } + if (return_value != SQLITE_DONE) { // Say which operation failed: this event is being dropped, and a bare // "database is locked" gives no hint of what was lost. - log(LogLevel::ERROR, "[Countly] addEventToSqlite, Event was not stored, SQLite error: " + std::string(error_message)); - sqlite3_free(error_message); + log(LogLevel::ERROR, "[Countly] addEventToSqlite, Event was not stored, SQLite error: " + std::string(sqlite3_errmsg(database))); } + sqlite3_finalize(statement); } sqlite3_close(database); } catch (const std::system_error &e) { @@ -1898,6 +1888,27 @@ bool Countly::createEventTableSchema() { return false; } } +#else +// Builds without SQLite never choose SQLite storage, so these are never called +int Countly::checkPersistentEQSize() { return -1; } + +void Countly::clearPersistentEQ() {} + +void Countly::removeEventWithId(const std::string &event_ids) { (void)event_ids; } + +void Countly::fillEventsIntoJson(nlohmann::json &events, std::string &event_ids) { + (void)events; + (void)event_ids; +} + +void Countly::addEventToSqlite(const cly::Event &event) { (void)event; } + +bool Countly::createEventTableSchema() { return false; } + +void Countly::setDatabasePath(const std::string &path) { + (void)path; + log(LogLevel::WARNING, "[Countly] setDatabasePath, This build does not include SQLite storage (COUNTLY_USE_SQLITE), so the path is ignored and events and requests are kept in memory."); +} #endif void Countly::log(LogLevel level, const std::string &message) { logger->log(cly::LogLevel(level), message); } diff --git a/src/request_module.cpp b/src/request_module.cpp index 1b22070..005c700 100644 --- a/src/request_module.cpp +++ b/src/request_module.cpp @@ -48,6 +48,11 @@ class RequestModule::RequestModuleImpl { _configuration->serverUrl.insert(0, "http://"); } + const size_t scheme_length = use_https ? (sizeof("https://") - 1) : (sizeof("http://") - 1); + while (_configuration->serverUrl.size() > scheme_length && _configuration->serverUrl.back() == '/') { + _configuration->serverUrl.pop_back(); + } + if (_configuration->port <= 0) { _configuration->port = use_https ? 443 : 80; } @@ -423,6 +428,9 @@ HTTPResponse RequestModule::sendHTTP(std::string path, std::string data) { std::string full_url = full_url_stream.str(); curl_easy_setopt(curl, CURLOPT_URL, full_url.c_str()); + // Without these, a server that stops answering holds this request, and every request queued behind it, forever + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, static_cast(COUNTLY_HTTP_CONNECT_TIMEOUT_SECONDS)); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, static_cast(COUNTLY_HTTP_REQUEST_TIMEOUT_SECONDS)); std::string body; curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, countly_curl_write_callback); diff --git a/src/storage_module_db.cpp b/src/storage_module_db.cpp index 6c3aee0..e0175e1 100644 --- a/src/storage_module_db.cpp +++ b/src/storage_module_db.cpp @@ -342,22 +342,25 @@ void StorageModuleDB::RQInsertAtEnd(const std::string &request) { // Initializes variables for working with SQLite sqlite3 *database; int return_value; - char *error_message; // Opens the database connection return_value = cly::utils::openDatabase(_configuration->databasePath, &database); if (return_value == SQLITE_OK) { // Prepares the SQL statement for inserting the request into the database std::ostringstream sql_statement_stream; - sql_statement_stream << "INSERT INTO " << REQUESTS_TABLE_NAME << " (" << REQUESTS_TABLE_REQUEST_DATA << ") VALUES('" << request << "');"; + sql_statement_stream << "INSERT INTO " << REQUESTS_TABLE_NAME << " (" << REQUESTS_TABLE_REQUEST_DATA << ") VALUES(?);"; std::string sql_statement = sql_statement_stream.str(); - return_value = sqlite3_exec(database, sql_statement.c_str(), nullptr, nullptr, &error_message); - if (return_value != SQLITE_OK) { - std::string error(error_message); - _logger->log(LogLevel::ERROR, "[Countly] [StorageModuleDB] RQInsertAtEnd, error = " + error); - sqlite3_free(error_message); + sqlite3_stmt *statement = nullptr; + return_value = sqlite3_prepare_v2(database, sql_statement.c_str(), -1, &statement, nullptr); + if (return_value == SQLITE_OK) { + sqlite3_bind_text(statement, 1, request.c_str(), -1, SQLITE_TRANSIENT); + return_value = sqlite3_step(statement); + } + if (return_value != SQLITE_DONE) { + _logger->log(LogLevel::ERROR, "[Countly] [StorageModuleDB] RQInsertAtEnd, error = " + std::string(sqlite3_errmsg(database))); } + sqlite3_finalize(statement); } // Closes the database connection sqlite3_close(database); diff --git a/tests/config.cpp b/tests/config.cpp index c17c006..d8e648d 100644 --- a/tests/config.cpp +++ b/tests/config.cpp @@ -218,4 +218,44 @@ TEST_CASE("Validate setting configuration values") { CHECK(config.metrics["_resolution"] == "800x600"); CHECK(config.metrics["_device"] == "pc"); } + + // The server URL is joined with the port and the request path, so trailing slashes are not kept + SUBCASE("Validating that the server URL is stored without trailing slashes") { + const std::vector> server_urls = { + {"https://try.count.ly/", "https://try.count.ly"}, + {"https://try.count.ly//", "https://try.count.ly"}, + {"try.count.ly/", "http://try.count.ly"}, + {"https://try.count.ly", "https://try.count.ly"}, + }; + + for (const std::pair &server_url : server_urls) { + clearSDK(); + Countly &ct = Countly::getInstance(); + ct.setHTTPClient(customClient); + ct.setDeviceID("test-device-id"); + ct.SetPath(TEST_DATABASE_NAME); + ct.start("YOUR_APP_KEY", server_url.first, 443, false); + + CHECK(ct.getConfiguration().serverUrl == server_url.second); + } + } } + +#ifndef COUNTLY_USE_SQLITE +TEST_CASE("setDatabasePath is accepted and ignored in builds without SQLite") { + clearSDK(); + clearCapturedLogs(); + Countly &ct = Countly::getInstance(); + ct.setLogger(captureLog); + ct.setHTTPClient(customClient); + + ct.setDatabasePath(TEST_DATABASE_NAME); + CHECK(capturedLogContains("does not include SQLite storage")); + + ct.setDeviceID("test-device-id"); + ct.start("YOUR_APP_KEY", "https://try.count.ly", 443, false); + CHECK(ct.checkEQSize() == 0); + CHECK(ct.getConfiguration().databasePath.empty()); + ct.setLogger(nullptr); +} +#endif diff --git a/tests/multi_instance.cpp b/tests/multi_instance.cpp index 6a7a200..5fe08c4 100644 --- a/tests/multi_instance.cpp +++ b/tests/multi_instance.cpp @@ -445,20 +445,20 @@ TEST_CASE("halt releases the default instance's path claim") { CHECK(cly::Countly::debugClaimedPathCount() == 0); } -TEST_CASE("a failed initialisation leaves no stale path claim") { +TEST_CASE("an instance without a usable database leaves no stale path claim") { clearSDK(); const size_t before = cly::Countly::debugClaimedPathCount(); - // Blank path: refused before any claim is made. + // Blank path: no claim is made, and the instance keeps its queues in memory. std::shared_ptr blank = startInstanceAt("APP_KEY_A", ""); - CHECK(blank->checkEQSize() == -1); + CHECK(blank->checkEQSize() == 0); CHECK(cly::Countly::debugClaimedPathCount() == before); blank.reset(); - // Unwritable path: the claim is taken, then the schema creation fails, so the - // claim must be given back. + // Unwritable path: the claim is taken, then opening the database fails, so the + // claim must be given back before the instance falls back to memory. std::shared_ptr bad = startInstanceAt("APP_KEY_A", "mi-no-such-dir/x.db"); - CHECK(bad->checkEQSize() == -1); + CHECK(bad->checkEQSize() == 0); CHECK(cly::Countly::debugClaimedPathCount() == before); bad.reset(); } diff --git a/tests/request.cpp b/tests/request.cpp index 365147e..7d16a09 100644 --- a/tests/request.cpp +++ b/tests/request.cpp @@ -5,10 +5,23 @@ #include "test_utils.hpp" #include "doctest.h" +#include +#include #include #include +#include #include +#include #include + +#if !defined(_WIN32) && !defined(COUNTLY_USE_CUSTOM_HTTP) +#include +#include +#include +#include +#include +#include +#endif using namespace cly; using namespace std; @@ -194,4 +207,235 @@ TEST_CASE("Requests contain dow, hour, and tz") { CHECK(std::stoi(parsed["tz"]) == expected_tz); } -} \ No newline at end of file +} + +#if !defined(_WIN32) && !defined(COUNTLY_USE_CUSTOM_HTTP) +namespace { +/** + * HTTP server on 127.0.0.1 for exercising the SDK's built-in libcurl client. + * + * An answering server replies to every request with 200 and "{}" and records its + * request line. A silent server accepts connections and never replies; it records + * how long each connection stayed open until the client closed it. + */ +class LocalHTTPServer { +public: + explicit LocalHTTPServer(bool answering) : _answering(answering) { + _listener = ::socket(AF_INET, SOCK_STREAM, 0); + REQUIRE(_listener >= 0); + int reuse = 1; + ::setsockopt(_listener, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + sockaddr_in address = {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + REQUIRE(::bind(_listener, reinterpret_cast(&address), sizeof(address)) == 0); + REQUIRE(::listen(_listener, 16) == 0); + + socklen_t length = sizeof(address); + REQUIRE(::getsockname(_listener, reinterpret_cast(&address), &length) == 0); + _port = ntohs(address.sin_port); + _thread = std::thread(&LocalHTTPServer::serve, this); + } + + ~LocalHTTPServer() { stop(); } + + LocalHTTPServer(const LocalHTTPServer &) = delete; + LocalHTTPServer &operator=(const LocalHTTPServer &) = delete; + + /** + * Closes every connection and the listening socket. A client still waiting for an answer gets an error instead. + */ + void stop() { + if (_stopping.exchange(true)) { + return; + } + _thread.join(); + ::close(_listener); + } + + int port() const { return _port; } + + /** + * Request lines received so far, such as "GET /i?app_key=... HTTP/1.1". + */ + std::vector requestLines() const { + std::lock_guard lock(_mutex); + return _request_lines; + } + + /** + * For each connection the client closed, how long it had stayed open. + */ + std::vector closedConnectionDurations() const { + std::lock_guard lock(_mutex); + return _closed_durations; + } + +private: + struct HeldConnection { + int descriptor; + std::chrono::steady_clock::time_point accepted; + }; + + void serve() { + std::vector held; + while (!_stopping.load()) { + std::vector watched; + watched.push_back({_listener, POLLIN, 0}); + for (const HeldConnection &connection : held) { + watched.push_back({connection.descriptor, POLLIN, 0}); + } + if (::poll(watched.data(), watched.size(), 50) <= 0) { + continue; + } + + std::vector still_open; + for (size_t index = 0; index < held.size(); index++) { + char buffer[4096]; + if (watched[index + 1].revents == 0 || ::recv(held[index].descriptor, buffer, sizeof(buffer), 0) > 0) { + still_open.push_back(held[index]); + continue; + } + ::close(held[index].descriptor); + std::lock_guard lock(_mutex); + _closed_durations.push_back(std::chrono::duration_cast(std::chrono::steady_clock::now() - held[index].accepted)); + } + held.swap(still_open); + + if (watched[0].revents & POLLIN) { + int client = ::accept(_listener, nullptr, nullptr); + if (client >= 0 && _answering) { + answer(client); + } else if (client >= 0) { + held.push_back({client, std::chrono::steady_clock::now()}); + } + } + } + + for (const HeldConnection &connection : held) { + ::close(connection.descriptor); + } + } + + void answer(int client) { + timeval timeout = {5, 0}; + ::setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + + std::string request; + char buffer[4096]; + std::string::size_type header_end; + while ((header_end = request.find("\r\n\r\n")) == std::string::npos) { + ssize_t received = ::recv(client, buffer, sizeof(buffer), 0); + if (received <= 0) { + ::close(client); + return; + } + request.append(buffer, static_cast(received)); + } + + size_t content_length = 0; + const std::string::size_type length_header = request.find("Content-Length: "); + if (length_header != std::string::npos && length_header < header_end) { + content_length = std::stoul(request.substr(length_header + 16)); + } + while (request.size() < header_end + 4 + content_length) { + ssize_t received = ::recv(client, buffer, sizeof(buffer), 0); + if (received <= 0) { + break; + } + request.append(buffer, static_cast(received)); + } + + { + std::lock_guard lock(_mutex); + _request_lines.push_back(request.substr(0, request.find("\r\n"))); + } + + const std::string response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"; +#ifdef SO_NOSIGPIPE + int no_sigpipe = 1; + ::setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &no_sigpipe, sizeof(no_sigpipe)); +#endif +#ifdef MSG_NOSIGNAL + ::send(client, response.data(), response.size(), MSG_NOSIGNAL); +#else + ::send(client, response.data(), response.size(), 0); +#endif + ::close(client); + } + + const bool _answering; + int _listener = -1; + int _port = 0; + std::thread _thread; + std::atomic _stopping{false}; + mutable std::mutex _mutex; + std::vector _request_lines; + std::vector _closed_durations; +}; +} // namespace + +TEST_CASE("Built-in HTTP client") { + const std::string database_path = "test-countly-http.db"; + remove(database_path.c_str()); + + SUBCASE("requests reach a server whose URL ends with a slash") { + LocalHTTPServer server(true); + std::shared_ptr countly = std::make_shared(); + countly->setDeviceID(COUNTLY_TEST_DEVICE_ID); + countly->SetPath(database_path); + countly->start(COUNTLY_TEST_APP_KEY, "http://127.0.0.1/", server.port(), false); + + bool begin_session_received = false; + int queued_requests = -1; + const std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (std::chrono::steady_clock::now() < deadline) { + countly->processRQDebug(); + for (const std::string &request_line : server.requestLines()) { + if (request_line.find("GET /i?") == 0 && request_line.find("begin_session=1") != std::string::npos) { + begin_session_received = true; + } + } + queued_requests = countly->checkRQSize(); + if (begin_session_received && queued_requests == 0) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + countly.reset(); + server.stop(); + remove(database_path.c_str()); + + CHECK(begin_session_received); + CHECK(queued_requests == 0); + } + + SUBCASE("a request to a server that never answers is given up after the request timeout") { + LocalHTTPServer server(false); + std::shared_ptr countly = std::make_shared(); + countly->setDeviceID(COUNTLY_TEST_DEVICE_ID); + countly->SetPath(database_path); + countly->start(COUNTLY_TEST_APP_KEY, "http://127.0.0.1", server.port(), false); + + const std::chrono::seconds request_timeout(COUNTLY_HTTP_REQUEST_TIMEOUT_SECONDS); + std::vector open_durations; + const std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + request_timeout + std::chrono::seconds(15); + while (open_durations.empty() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + open_durations = server.closedConnectionDurations(); + } + + // The server goes first so that a client still waiting on it returns and the SDK can shut down + server.stop(); + countly.reset(); + remove(database_path.c_str()); + + REQUIRE(!open_durations.empty()); + CHECK(open_durations[0] >= request_timeout - std::chrono::seconds(1)); + CHECK(open_durations[0] <= request_timeout + std::chrono::seconds(5)); + } +} +#endif diff --git a/tests/session.cpp b/tests/session.cpp index 0b3fd48..3e4cfd0 100644 --- a/tests/session.cpp +++ b/tests/session.cpp @@ -191,6 +191,24 @@ TEST_CASE("event request unit tests") { nlohmann::json events = nlohmann::json::parse(http_call.data["events"]); CHECK(events.size() == 100); } + + SUBCASE("event with single quotes in its key and segmentation is sent unchanged") { + cly::Event event("Player's profile", 1); + event.addSegmentation("name", "O'Brien"); + event.addSegmentation("note", "it''s"); + countly.addEvent(event); + REQUIRE(countly.checkEQSize() == 1); + + countly.updateSession(); + countly.processRQDebug(); + HTTPCall http_call = popHTTPCall(); + + nlohmann::json events = nlohmann::json::parse(http_call.data["events"]); + REQUIRE(events.size() == 1); + CHECK(events[0]["key"].get() == "Player's profile"); + CHECK(events[0]["segmentation"]["name"].get() == "O'Brien"); + CHECK(events[0]["segmentation"]["note"].get() == "it''s"); + } } /** @@ -236,3 +254,109 @@ TEST_CASE("concurrent endSession calls end the session only once") { CHECK(ended.load() == 4); CHECK(countly.checkRQSize() == rq_before + 1); } + +/** + * Returns the captured requests sent for the given device ID that carry the given parameter. + */ +static std::vector callsFor(const std::string &device_id, const std::string ¶meter) { + std::vector calls; + for (size_t index = 0; index < http_call_queue.size(); index++) { + HTTPCall call = http_call_queue.at(index); + std::map::const_iterator device = call.data.find("device_id"); + if (device != call.data.end() && device->second == device_id && call.data.find(parameter) != call.data.end()) { + calls.push_back(call); + } + } + return calls; +} + +/** + * User details set after start() used to stay stored for the next session, so after a + * device ID change without merge the new user's first session carried the previous + * user's details. + */ +TEST_CASE("user details are sent only for the device ID they were set for") { + clearSDK(); + Countly &countly = Countly::getInstance(); + const std::string second_device_id = "second-user-device"; + + SUBCASE("details set after start are not sent with the next user's session") { + test_utils::initCountlyWithFakeNetworking(true, countly); + + countly.setUserDetails({{"name", "First User"}}); + countly.setCustomUserDetails({{"plan", "gold"}}); + countly.setDeviceID(second_device_id, false); + countly.processRQDebug(); + + std::vector first_user_details = callsFor(COUNTLY_TEST_DEVICE_ID, "user_details"); + REQUIRE(first_user_details.size() == 2); + CHECK(nlohmann::json::parse(first_user_details[0].data["user_details"]) == nlohmann::json({{"name", "First User"}})); + CHECK(nlohmann::json::parse(first_user_details[1].data["user_details"]) == nlohmann::json({{"custom", {{"plan", "gold"}}}})); + + CHECK(callsFor(second_device_id, "begin_session").size() == 1); + CHECK(callsFor(second_device_id, "user_details").empty()); + } + + SUBCASE("details set after start are not sent with a session the app begins for the next user") { + countly.enableManualSessionControl(); + test_utils::initCountlyWithFakeNetworking(true, countly); + + countly.setUserDetails({{"name", "First User"}}); + countly.setDeviceID(second_device_id, false); + countly.beginSession(); + countly.processRQDebug(); + + CHECK(callsFor(COUNTLY_TEST_DEVICE_ID, "user_details").size() == 1); + CHECK(callsFor(second_device_id, "begin_session").size() == 1); + CHECK(callsFor(second_device_id, "user_details").empty()); + } + + SUBCASE("details set before start are sent with the first session only") { + countly.setUserDetails({{"name", "First User"}}); + test_utils::initCountlyWithFakeNetworking(false, countly); + + countly.setDeviceID(second_device_id, false); + countly.processRQDebug(); + + std::vector first_user_sessions = callsFor(COUNTLY_TEST_DEVICE_ID, "begin_session"); + REQUIRE(first_user_sessions.size() == 1); + REQUIRE(first_user_sessions[0].data.find("user_details") != first_user_sessions[0].data.end()); + CHECK(nlohmann::json::parse(first_user_sessions[0].data["user_details"]) == nlohmann::json({{"name", "First User"}})); + + CHECK(callsFor(second_device_id, "begin_session").size() == 1); + CHECK(callsFor(second_device_id, "user_details").empty()); + } + + SUBCASE("details still waiting for the first session are sent for the first user when the user changes") { + countly.enableManualSessionControl(); + countly.setUserDetails({{"name", "First User"}}); + test_utils::initCountlyWithFakeNetworking(true, countly); + + countly.setDeviceID(second_device_id, false); + countly.beginSession(); + countly.processRQDebug(); + + std::vector first_user_details = callsFor(COUNTLY_TEST_DEVICE_ID, "user_details"); + REQUIRE(first_user_details.size() == 1); + CHECK(nlohmann::json::parse(first_user_details[0].data["user_details"]) == nlohmann::json({{"name", "First User"}})); + + CHECK(callsFor(second_device_id, "begin_session").size() == 1); + CHECK(callsFor(second_device_id, "user_details").empty()); + } + + SUBCASE("details still waiting for the first session go with it after a device ID change with merge") { + countly.enableManualSessionControl(); + countly.setUserDetails({{"name", "First User"}}); + test_utils::initCountlyWithFakeNetworking(true, countly); + + countly.setDeviceID(second_device_id, true); + countly.beginSession(); + countly.processRQDebug(); + + CHECK(callsFor(COUNTLY_TEST_DEVICE_ID, "user_details").empty()); + std::vector merged_user_sessions = callsFor(second_device_id, "begin_session"); + REQUIRE(merged_user_sessions.size() == 1); + REQUIRE(merged_user_sessions[0].data.find("user_details") != merged_user_sessions[0].data.end()); + CHECK(nlohmann::json::parse(merged_user_sessions[0].data["user_details"]) == nlohmann::json({{"name", "First User"}})); + } +} diff --git a/tests/storage.cpp b/tests/storage.cpp index 13bc1f9..a6f50c7 100644 --- a/tests/storage.cpp +++ b/tests/storage.cpp @@ -75,6 +75,27 @@ void RQInsertAtEndWithRequest(std::shared_ptr storageModule) validateSizes(storageModule, 0); } +/** + * Validate method 'RQInsertAtEnd' with requests that contain single quotes. + * Result: Requests are stored and read back unchanged, quotes included. + * @param *storageModule: a pointer to storage module. + */ +void RQInsertAtEndWithSingleQuotes(std::shared_ptr storageModule) { + storageModule->RQInsertAtEnd("it's"); + storageModule->RQInsertAtEnd("it''s"); + validateSizes(storageModule, 2); + + std::vector> requests = storageModule->RQPeekAll(); + REQUIRE(requests.size() == 2); + validateDataEntry(requests[0], 1, "it's"); + validateDataEntry(requests[1], 2, "it''s"); + + storageModule->RQRemoveFront(); + validateDataEntry(storageModule->RQPeekFront(), 2, "it''s"); + storageModule->RQRemoveFront(); + validateSizes(storageModule, 0); +} + /** * Validate method 'RQPeekFront' with empty queue. * Result: RQPeekFront will return request having id '-1' and data an empty string. @@ -486,6 +507,7 @@ TEST_CASE("Test Memory Storage Module") { SUBCASE("Validate method 'RQInsertAtEnd' with invalid request") { RQInsertAtEndWithInvalidRequest(storageModule); } SUBCASE("Validate method 'RQInsertAtEnd' with Valid requests") { RQInsertAtEndWithRequest(storageModule); } + SUBCASE("Validate method 'RQInsertAtEnd' with requests containing single quotes") { RQInsertAtEndWithSingleQuotes(storageModule); } SUBCASE("Validate method 'RQPeekFront' with empty queue") { RQPeekFrontWithEmpthQueue(storageModule); } SUBCASE("Validate method 'RQPeekFront' after inserting multiple requests.") { RQPeekFront(storageModule); } @@ -536,6 +558,7 @@ TEST_CASE("Test Sqlite Storage Module") { SUBCASE("Validate method 'RQInsertAtEnd' with invalid request") { RQInsertAtEndWithInvalidRequest(storageModule); } SUBCASE("Validate method 'RQInsertAtEnd' with Valid requests") { RQInsertAtEndWithRequest(storageModule); } + SUBCASE("Validate method 'RQInsertAtEnd' with requests containing single quotes") { RQInsertAtEndWithSingleQuotes(storageModule); } SUBCASE("Validate method 'RQPeekFront' with empty queue") { RQPeekFrontWithEmpthQueue(storageModule); } SUBCASE("Validate method 'RQPeekFront' after inserting multiple requests.") { RQPeekFront(storageModule); } diff --git a/tests/storage_db.cpp b/tests/storage_db.cpp index 9853e74..385af6b 100644 --- a/tests/storage_db.cpp +++ b/tests/storage_db.cpp @@ -47,4 +47,109 @@ TEST_CASE("Test Sqlite Storage Module Specific tests") { test_utils::storageModuleNotInitialized(storageModule); } } + +/** + * Runs flush until check passes, for up to five seconds. The SDK's own update loop may be + * sending the request queue at the moment the test asks, so a single attempt can miss. + */ +template static bool eventually(Flush flush, Check check) { + for (int attempt = 0; attempt < 50; attempt++) { + flush(); + if (check()) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + return false; +} + +/** + * Starts the default instance with the fake HTTP client and the given database path (none when empty), + * then waits for the SDK behavior settings fetch to land. + */ +static void startWithDatabasePath(Countly &countly, const std::string &database_path) { + countly.setLogger(test_utils::captureLog); + countly.setHTTPClient(test_utils::fakeSendHTTP); + countly.setDeviceID(COUNTLY_TEST_DEVICE_ID); + if (!database_path.empty()) { + countly.setDatabasePath(database_path); + } + countly.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); +} + +TEST_CASE("SQLite builds keep events and requests in memory when there is no usable database") { + test_utils::clearSDK(); + test_utils::clearCapturedLogs(); + Countly &countly = Countly::getInstance(); + + SUBCASE("no database path is set") { + startWithDatabasePath(countly, ""); + REQUIRE(countly.checkEQSize() == 0); + CHECK(test_utils::capturedLogContains("No database path is set")); + + countly.addEvent(cly::Event("event_without_database_path", 1)); + CHECK(countly.checkEQSize() == 1); + CHECK(eventually([&countly]() { countly.updateSession(); countly.processRQDebug(); }, []() { return test_utils::httpQueueContainsEvent("event_without_database_path"); })); + } + + SUBCASE("the database cannot be opened") { + startWithDatabasePath(countly, "countly-missing-folder/countly.db"); + REQUIRE(countly.checkEQSize() == 0); + CHECK(test_utils::capturedLogContains("Could not open the database")); + + countly.addEvent(cly::Event("event_after_database_failure", 1)); + CHECK(countly.checkEQSize() == 1); + CHECK(eventually([&countly]() { countly.updateSession(); countly.processRQDebug(); }, []() { return test_utils::httpQueueContainsEvent("event_after_database_failure"); })); + } + + countly.setLogger(nullptr); +} + +TEST_CASE("SQLite-build instances without database paths run side by side") { + test_utils::clearSDK(); + test_utils::InstanceFixture first("first-app-key", ""); + test_utils::InstanceFixture second("second-app-key", ""); + REQUIRE(first.initialized()); + REQUIRE(second.initialized()); + + first.sdk->addEvent(cly::Event("first_app_event", 1)); + second.sdk->addEvent(cly::Event("second_app_event", 1)); + CHECK(eventually([&first]() { first.sdk->updateSession(); first.flush(); }, [&first]() { return first.sawEvent("first_app_event"); })); + CHECK(eventually([&second]() { second.sdk->updateSession(); second.flush(); }, [&second]() { return second.sawEvent("second_app_event"); })); + CHECK_FALSE(first.sawEvent("second_app_event")); + CHECK_FALSE(second.sawEvent("first_app_event")); +} + +TEST_CASE("SQLite builds with a database path keep queued events across a restart") { + test_utils::clearSDK(); + const std::string database_path = "test-countly-restart.db"; + remove(database_path.c_str()); + + { + std::shared_ptr before_restart = std::make_shared(); + before_restart->setHTTPClient([](bool use_post, const std::string &url, const std::string &data) { + (void)use_post; + (void)url; + (void)data; + return HTTPResponse{false, nlohmann::json::object()}; + }); + before_restart->setDeviceID(COUNTLY_TEST_DEVICE_ID); + before_restart->setDatabasePath(database_path); + before_restart->start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + before_restart->addEvent(cly::Event("event_before_restart", 1)); + REQUIRE(before_restart->checkEQSize() == 1); + } + + std::shared_ptr after_restart = std::make_shared(); + after_restart->setHTTPClient(test_utils::fakeSendHTTP); + after_restart->setDeviceID(COUNTLY_TEST_DEVICE_ID); + after_restart->setDatabasePath(database_path); + after_restart->start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + REQUIRE(after_restart->checkEQSize() >= 0); + CHECK(eventually([&after_restart]() { after_restart->updateSession(); after_restart->processRQDebug(); }, []() { return test_utils::httpQueueContainsEvent("event_before_restart"); })); + + after_restart.reset(); + remove(database_path.c_str()); +} #endif \ No newline at end of file diff --git a/tests/test_utils.hpp b/tests/test_utils.hpp index 9b136d3..1dc799e 100644 --- a/tests/test_utils.hpp +++ b/tests/test_utils.hpp @@ -6,6 +6,8 @@ #include "nlohmann/json.hpp" #include #include +#include +#include using namespace cly; @@ -40,6 +42,40 @@ struct ThreadSafeHTTPCallQueue { static ThreadSafeHTTPCallQueue http_call_queue; +// Countly::setLogger takes a plain function pointer, so captured messages live in these statics. +static std::mutex captured_log_mutex; +static std::vector captured_log_messages; + +/** + * Logger that keeps every message, for tests that assert what the SDK logged. + */ +static void captureLog(LogLevel level, const std::string &message) { + (void)level; + std::lock_guard lock(captured_log_mutex); + captured_log_messages.push_back(message); +} + +/** + * Forgets the messages captured so far. + */ +static void clearCapturedLogs() { + std::lock_guard lock(captured_log_mutex); + captured_log_messages.clear(); +} + +/** + * Whether any captured message contains the given text. + */ +static bool capturedLogContains(const std::string &text) { + std::lock_guard lock(captured_log_mutex); + for (const std::string &message : captured_log_messages) { + if (message.find(text) != std::string::npos) { + return true; + } + } + return false; +} + static void clearSDK() { cly::Countly::halt(); remove(TEST_DATABASE_NAME); From 28332bc356ea81e44c2d33c553205d204573a20f Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:02:28 +0900 Subject: [PATCH 4/4] test fix --- .github/package-check/CMakeLists.txt | 2 +- .github/workflows/packaging.yml | 3 +- .github/workflows/release_notice.yml | 41 +++++++++++++++------------- src/countly.cpp | 1 - tests/request.cpp | 21 +++++++++----- tests/session.cpp | 2 +- tests/storage_db.cpp | 3 +- 7 files changed, 42 insertions(+), 31 deletions(-) diff --git a/.github/package-check/CMakeLists.txt b/.github/package-check/CMakeLists.txt index a799102..7cd3d29 100644 --- a/.github/package-check/CMakeLists.txt +++ b/.github/package-check/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.13) +cmake_minimum_required(VERSION 3.13...3.31) project(package_check CXX) # Uses the SDK only through find_package, the way an app that installed it or got it from vcpkg or Conan does diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index e057202..fc93b0e 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -109,7 +109,8 @@ jobs: git archive --format=tar.gz --prefix=countly-sdk-cpp/ -o "$work/countly-sdk-cpp.tar.gz" HEAD cp -r packaging/vcpkg/ports/countly-sdk-cpp "$work/ports/" portfile="$work/ports/countly-sdk-cpp/portfile.cmake" - ARCHIVE="$work/countly-sdk-cpp.tar.gz" perl -0pi -e 's/vcpkg_from_github\(.*?\n\)\n/vcpkg_extract_source_archive(SOURCE_PATH ARCHIVE "$ENV{ARCHIVE}")\n/s' "$portfile" + # Windows runners check files out with CRLF line endings + ARCHIVE="$work/countly-sdk-cpp.tar.gz" perl -0pi -e 's/vcpkg_from_github\(.*?\r?\n\)\r?\n/vcpkg_extract_source_archive(SOURCE_PATH ARCHIVE "$ENV{ARCHIVE}")\n/s' "$portfile" if grep -q "vcpkg_from_github" "$portfile" || ! grep -q "vcpkg_extract_source_archive" "$portfile"; then echo "::error file=packaging/vcpkg/ports/countly-sdk-cpp/portfile.cmake::could not point the port at the archive" exit 1 diff --git a/.github/workflows/release_notice.yml b/.github/workflows/release_notice.yml index 2e97659..c0c1fb0 100644 --- a/.github/workflows/release_notice.yml +++ b/.github/workflows/release_notice.yml @@ -3,25 +3,28 @@ on: release: types: [published] workflow_dispatch: - inputs: - tag_name: - description: "Version to announce, for example 26.8.1" - type: string - required: false - html_url: - description: "Link to the GitHub release" - type: string - required: false - body: - description: "Release notes" - type: string - required: false permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: + # A manual run announces the latest release again + - name: Find the release to announce + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: | + if [ -z "$TAG" ]; then + TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName) + fi + { + echo "RELEASE_TAG=$TAG" + echo "RELEASE_URL=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json url --jq .url)" + echo "RELEASE_BODY<> "$GITHUB_ENV" - name: Send custom JSON data to Slack workflow id: slack uses: slackapi/slack-github-action@v1.23.0 @@ -30,10 +33,10 @@ jobs: payload: | { "repository": "${{ github.repository }}", - "tag_name": "${{ inputs.tag_name || github.event.release.tag_name }}", + "tag_name": "${{ env.RELEASE_TAG }}", "actor": "${{ github.actor }}", - "body": ${{ toJSON(inputs.body || github.event.release.body) }}, - "html_url": "${{ inputs.html_url || github.event.release.html_url }}" + "body": ${{ toJSON(env.RELEASE_BODY) }}, + "html_url": "${{ env.RELEASE_URL }}" } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_RELEASE }} @@ -42,8 +45,8 @@ jobs: with: webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} nodetail: true - title: New ${{ github.repository }} version ${{ inputs.tag_name || github.event.release.tag_name }} published by ${{ github.actor }} + title: New ${{ github.repository }} version ${{ env.RELEASE_TAG }} published by ${{ github.actor }} description: | - Release URL: ${{ inputs.html_url || github.event.release.html_url }} + Release URL: ${{ env.RELEASE_URL }} Click [here](https://github.com/${{ github.repository }}/blob/master/CHANGELOG.md) to view the change log. - `${{ inputs.body || github.event.release.body }}` + `${{ env.RELEASE_BODY }}` diff --git a/src/countly.cpp b/src/countly.cpp index 0e2cc16..b7ce9e4 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -1297,7 +1297,6 @@ std::vector Countly::debugReturnStateOfEQ() { if (return_value == SQLITE_OK && !no_request) { for (int event_index = 1; event_index < row_count + 1; event_index++) { - std::string rqstId = table[event_index * column_count]; std::string rqst = table[(event_index * column_count) + 1]; v.push_back(rqst); } diff --git a/tests/request.cpp b/tests/request.cpp index 7d16a09..17b1ab0 100644 --- a/tests/request.cpp +++ b/tests/request.cpp @@ -19,7 +19,6 @@ #include #include #include -#include #include #endif using namespace cly; @@ -319,15 +318,23 @@ class LocalHTTPServer { } } - void answer(int client) { - timeval timeout = {5, 0}; - ::setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + /** + * Reads what the client sent, waiting at most five seconds for it. Returns what recv() returns, or -1 on a timeout. + */ + static ssize_t receiveWithin5Seconds(int client, char *buffer, size_t size) { + pollfd readable = {client, POLLIN, 0}; + if (::poll(&readable, 1, 5000) <= 0) { + return -1; + } + return ::recv(client, buffer, size, 0); + } + void answer(int client) { std::string request; char buffer[4096]; std::string::size_type header_end; while ((header_end = request.find("\r\n\r\n")) == std::string::npos) { - ssize_t received = ::recv(client, buffer, sizeof(buffer), 0); + ssize_t received = receiveWithin5Seconds(client, buffer, sizeof(buffer)); if (received <= 0) { ::close(client); return; @@ -341,7 +348,7 @@ class LocalHTTPServer { content_length = std::stoul(request.substr(length_header + 16)); } while (request.size() < header_end + 4 + content_length) { - ssize_t received = ::recv(client, buffer, sizeof(buffer), 0); + ssize_t received = receiveWithin5Seconds(client, buffer, sizeof(buffer)); if (received <= 0) { break; } @@ -394,7 +401,7 @@ TEST_CASE("Built-in HTTP client") { while (std::chrono::steady_clock::now() < deadline) { countly->processRQDebug(); for (const std::string &request_line : server.requestLines()) { - if (request_line.find("GET /i?") == 0 && request_line.find("begin_session=1") != std::string::npos) { + if (request_line.rfind("GET /i?", 0) == 0 && request_line.find("begin_session=1") != std::string::npos) { begin_session_received = true; } } diff --git a/tests/session.cpp b/tests/session.cpp index 3e4cfd0..5d3c915 100644 --- a/tests/session.cpp +++ b/tests/session.cpp @@ -262,7 +262,7 @@ static std::vector callsFor(const std::string &device_id, const std::s std::vector calls; for (size_t index = 0; index < http_call_queue.size(); index++) { HTTPCall call = http_call_queue.at(index); - std::map::const_iterator device = call.data.find("device_id"); + auto device = call.data.find("device_id"); if (device != call.data.end() && device->second == device_id && call.data.find(parameter) != call.data.end()) { calls.push_back(call); } diff --git a/tests/storage_db.cpp b/tests/storage_db.cpp index 385af6b..4a55b82 100644 --- a/tests/storage_db.cpp +++ b/tests/storage_db.cpp @@ -3,6 +3,7 @@ #include "test_utils.hpp" #include "doctest.h" +#include #include #include #include @@ -52,7 +53,7 @@ TEST_CASE("Test Sqlite Storage Module Specific tests") { * Runs flush until check passes, for up to five seconds. The SDK's own update loop may be * sending the request queue at the moment the test asks, so a single attempt can miss. */ -template static bool eventually(Flush flush, Check check) { +static bool eventually(const std::function &flush, const std::function &check) { for (int attempt = 0; attempt < 50; attempt++) { flush(); if (check()) {