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 1/2] 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 00000000..a799102e --- /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 00000000..d56ff0dc --- /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 00000000..e0572026 --- /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 e17315a0..2e976595 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 4e9c48f0..9f64bc6f 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 eec33177..dab326b1 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 e5583ef1..03ad254b 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 43325233..667bb662 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 00000000..1da39147 --- /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 64d285be..04baadf0 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 bf06f794..01eb1e7c 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 d54bef1a..37b80b0b 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 00000000..f4de0249 --- /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 00000000..40cd1fe5 --- /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 00000000..9323efd4 --- /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 00000000..47bea1c5 --- /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 00000000..560fc69d --- /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 00000000..c6f18c62 --- /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 00000000..ff1ebf84 --- /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 00000000..a4b83fcc --- /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 00000000..47c35eaa --- /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 45c0a5ac..0e2cc164 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 1b220706..005c7000 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 6c3aee01..e0175e17 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 c17c0067..d8e648db 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 6a7a2007..5fe08c4f 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 365147e4..7d16a09d 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 0b3fd482..3e4cfd07 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 13bc1f93..a6f50c79 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 9853e746..385af6b5 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 9b136d32..1dc799e1 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 2/2] 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 a799102e..7cd3d294 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 e0572026..fc93b0e2 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 2e976595..c0c1fb0a 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 0e2cc164..b7ce9e42 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 7d16a09d..17b1ab09 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 3e4cfd07..5d3c915e 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 385af6b5..4a55b82d 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()) {