diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ab66483 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Golden output is byte-for-byte UTF-8/LF on every platform. +tests/fixtures/logging-format.txt text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12ea4f2..a02cd2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -352,11 +352,35 @@ jobs: - name: Install if: matrix.kind != 'msvc' - run: cmake --install cmake-build-ci --prefix cmake-build-ci/install + run: | + cmake -S . -B cmake-build-install -G Ninja -DBUILD_DOCS=OFF -DBUILD_TESTS=OFF \ + -DLIZARDBYTE_COMMON_INSTALL=ON -DCMAKE_BUILD_TYPE=Release + cmake --build cmake-build-install --parallel 2 + cmake --install cmake-build-install --prefix cmake-build-install/install - name: Install MSVC if: matrix.kind == 'msvc' - run: cmake --install cmake-build-ci --config Debug --prefix cmake-build-ci/install + run: | + cmake -S . -B cmake-build-install -A x64 -DBUILD_DOCS=OFF -DBUILD_TESTS=OFF ` + -DLIZARDBYTE_COMMON_INSTALL=ON + cmake --build cmake-build-install --config Release --parallel 2 + cmake --install cmake-build-install --config Release --prefix cmake-build-install/install + + - name: Test installed consumer + if: matrix.kind != 'msvc' + run: | + cmake -S tests/consumer -B cmake-build-consumer -G Ninja \ + -DCMAKE_PREFIX_PATH="${GITHUB_WORKSPACE}/cmake-build-install/install" + cmake --build cmake-build-consumer + ./cmake-build-consumer/logging_consumer + + - name: Test installed consumer MSVC + if: matrix.kind == 'msvc' + run: | + cmake -S tests/consumer -B cmake-build-consumer -A x64 ` + -DCMAKE_PREFIX_PATH="$env:GITHUB_WORKSPACE/cmake-build-install/install" + cmake --build cmake-build-consumer --config Release + ./cmake-build-consumer/Release/logging_consumer.exe - name: Upload report artifact if: >- @@ -372,6 +396,68 @@ jobs: path: cmake-build-ci/reports if-no-files-found: error + rust: + name: Rust (${{ matrix.os }}) + needs: + - release-setup + permissions: + contents: read + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + env: + RUSTDOCFLAGS: -D warnings + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Rust + run: | + rustup toolchain install stable --profile minimal \ + --component clippy,rustfmt,llvm-tools-preview --no-self-update + rustup default stable + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --locked --all-targets -- -D warnings + + - name: Test + run: cargo test --locked --all-targets + + - name: Documentation + run: cargo doc --locked --no-deps + + - name: Install coverage tool + if: runner.os == 'Linux' + run: cargo install cargo-llvm-cov --locked + + - name: Rust coverage + if: runner.os == 'Linux' + run: | + mkdir -p coverage + cargo llvm-cov --locked --all-targets --cobertura --output-path coverage/rust-coverage.xml + + - name: Upload Rust coverage + if: runner.os == 'Linux' + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + with: + disable_search: true + fail_ci_if_error: true + files: ./coverage/rust-coverage.xml + flags: Linux-rust + report_type: coverage + token: ${{ secrets.CODECOV_TOKEN }} + codecov: name: Codecov (${{ matrix.flag }}) if: >- @@ -441,6 +527,7 @@ jobs: - build - release-setup - pytest + - rust permissions: {} runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 40089e8..b876dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -301,6 +301,9 @@ __marimo__/ # Project specific # +# Rust +/target/ + # Dockle /.dockle/ /_site/ diff --git a/.gitmodules b/.gitmodules index 3c3a800..5292056 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,6 @@ [submodule "third-party/googletest"] path = third-party/googletest url = https://github.com/google/googletest.git +[submodule "third-party/spdlog"] + path = third-party/spdlog + url = https://github.com/gabime/spdlog.git diff --git a/.readthedocs.yaml b/.readthedocs.yaml index fc35885..4ba634e 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,6 +10,7 @@ build: tools: nodejs: "26" python: "miniforge3-26.3" + rust: latest jobs: build: html: diff --git a/CMakeLists.txt b/CMakeLists.txt index 86115e4..a1a8930 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,6 +61,7 @@ endif() # Library code is located here When building tests this must be after the # coverage flags are set # +include(cmake/logging.cmake) add_subdirectory(src) # diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a6fa813 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,924 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lizardbyte-common" +version = "0.0.0" +dependencies = [ + "chrono", + "spdlog-rs", + "tempfile", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_buf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b57892601430298e96ce14d68f61dff758b648da87f3d491fbe5ec523e749e" +dependencies = [ + "serde_core", + "zerocopy", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_fmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "spdlog-internal" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eb988bafd6393bd840d78ba07826c857060bb1ba473bd57e8ae4d10a77eb276" +dependencies = [ + "nom", +] + +[[package]] +name = "spdlog-macros" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9a23693b67ee8c1515d2043ddd01ea3561872cf1f19bc00947857c0184a62aa" +dependencies = [ + "proc-macro2", + "quote", + "spdlog-internal", + "syn 2.0.119", +] + +[[package]] +name = "spdlog-rs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7415ec666ebcaa1d92c5d9fba5d0b44e1a9fdddc8df6c0b2c45e89af8b830b6d" +dependencies = [ + "arc-swap", + "atomic", + "bytemuck", + "chrono", + "crossbeam", + "dyn-clone", + "env_filter", + "libc", + "log", + "once_cell", + "parking_lot", + "rustc_version", + "spdlog-macros", + "value-bag", + "winapi", +] + +[[package]] +name = "sval" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b81b254da21fe1fcc4e3a74fe39b46e25e3a863078f8b71c954d47f84889dbc6" + +[[package]] +name = "sval_buffer" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50be352d2822ffafb59e3e2ddac9d5ee60f2eeadbb7b5a2a951b9f3651e87a6f" +dependencies = [ + "sval", + "sval_ref", + "zerocopy", +] + +[[package]] +name = "sval_dynamic" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048ca293b998d9a45659159f94a64063791e74cdc670164943dbb434405573d" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b5888e40f80568733217f27b7317b845f463400ced36c424b1a804730e53b2" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17664d6bb6b74947afaab9d7c991caa9bf5638d4dee16fcbef637f440796049" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c059969ca5ca163ea7fef6c9661758973d17691aba92abdcf5c428f4ec122c" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d6b29ff568c85c87561807f51d2adfff4b6016c6363133f7cd1652a12548f3" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f33ec9edc42b12764d5c90ca0a1d84189c6bde81ed27507f1e661c6e4e05853" +dependencies = [ + "serde_core", + "sval", + "sval_nested", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "value-bag" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0941feceafbe7a8f59ea1096d45b97002884a41306315ad797b3684b63a81d8c" +dependencies = [ + "erased-serde", + "serde_buf", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839752af8179287d27eb2b94164641b1ede9e60ab7424163388dc21ebd0508cd" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bf90f55 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "lizardbyte-common" +version = "0.0.0" +edition = "2021" +rust-version = "1.85" +description = "Shared logging policy for LizardByte applications" +license = "MIT" +repository = "https://github.com/LizardByte/lizardbyte-common" +readme = "README.md" +include = ["rust/**", "tests/fixtures/logging-format.txt", "docs/logging.md", "Cargo.toml", "Cargo.lock", "README.md", "LICENSE"] + +[lib] +path = "rust/lib.rs" + +[dependencies] +chrono = { version = "0.4.45", default-features = false, features = ["std"] } +spdlog = { package = "spdlog-rs", version = "0.5.3", features = ["log", "source-location", "multi-thread"] } + +[dev-dependencies] +tempfile = "3.23" diff --git a/README.md b/README.md index 6951b30..d563472 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,19 @@ The current tooling includes Python-managed helpers, reusable GitHub workflows, - `scripts/update_clang_format.py` runs `clang-format` across supported source directories. - `scripts/localize.py` updates gettext and Babel locale files. - `.github/workflows/localize.yml` runs the locale helper from GitHub Actions and opens localization update pull requests. -- `lizardbyte::common` provides shared C++ helpers, starting with environment variable manipulation. +- `lizardbyte::common` provides shared C++ environment helpers and spdlog logging support. +- `lizardbyte_common.logging` provides Python standard-library logging handlers and formatting. +- The `lizardbyte-common` Cargo crate provides Rust logging support over spdlog-rs. - `lizardbyte::test_support` provides shared GoogleTest fixtures and test macros for LizardByte C++ projects. +## 📝 Shared Logging + +C++, Python, and Rust consumers share UTC millisecond timestamps, severity/category/source fields, +project-root-relative locations, console colors, redaction, and 10 MiB rolling files with five backups. +Use multiple named loggers with independently filtered console, file, callback/webhook, or native sinks. +See the [logging contract and consumer examples](docs/logging.md), including the migration needs identified +in every project listed in [roadmap issue 186](https://github.com/LizardByte/roadmap/issues/186). + ## ⚙️ Python Tooling Install [uv](https://docs.astral.sh/uv/) and sync the locked tool environment: @@ -61,9 +71,9 @@ git submodule update --init --recursive Configure, build, and test the C++ helpers with CMake: ```bash -cmake -DBUILD_DOCS=OFF -DBUILD_TESTS=ON -B build -S . -cmake --build build --parallel -ctest --test-dir build --output-on-failure +cmake -DBUILD_DOCS=OFF -DBUILD_TESTS=ON -B cmake-build-common -S . +cmake --build cmake-build-common --parallel +ctest --test-dir cmake-build-common --output-on-failure ``` The C++ library exports the `lizardbyte::common` target and public headers under `lizardbyte/common/`. @@ -94,8 +104,8 @@ TEST(MySuite, CapturesOutputUntilFailure) { Build the Doxygen documentation through Dockle: ```bash -cmake -DBUILD_DOCS=ON -DBUILD_TESTS=OFF -B build/docs -S . -cmake --build build/docs --target docs +cmake -DBUILD_DOCS=ON -DBUILD_TESTS=OFF -B cmake-build-docs -S . +cmake --build cmake-build-docs --target docs ``` ## 📐 Consuming Projects @@ -193,7 +203,7 @@ uv run --locked --only-group test-python pytest Run the C++ GoogleTest suite: ```bash -cmake -DBUILD_DOCS=OFF -DBUILD_TESTS=ON -B build -S . -cmake --build build --parallel -ctest --test-dir build --output-on-failure +cmake -DBUILD_DOCS=OFF -DBUILD_TESTS=ON -B cmake-build-common -S . +cmake --build cmake-build-common --parallel +ctest --test-dir cmake-build-common --output-on-failure ``` diff --git a/cmake/lizardbyte-common-config.cmake.in b/cmake/lizardbyte-common-config.cmake.in index cfbc34d..235345e 100644 --- a/cmake/lizardbyte-common-config.cmake.in +++ b/cmake/lizardbyte-common-config.cmake.in @@ -1,3 +1,6 @@ @PACKAGE_INIT@ +include(CMakeFindDependencyMacro) +find_dependency(spdlog CONFIG) + include("${CMAKE_CURRENT_LIST_DIR}/lizardbyte-common-targets.cmake") diff --git a/cmake/logging.cmake b/cmake/logging.cmake new file mode 100644 index 0000000..8e8b85b --- /dev/null +++ b/cmake/logging.cmake @@ -0,0 +1,5 @@ +# Reuse a consumer-provided spdlog target, otherwise use the pinned submodule. +if(NOT TARGET spdlog::spdlog) + set(SPDLOG_INSTALL ${LIZARDBYTE_COMMON_INSTALL}) + add_subdirectory(third-party/spdlog) +endif() diff --git a/dockle.toml b/dockle.toml index 50d7e44..4971c43 100644 --- a/dockle.toml +++ b/dockle.toml @@ -19,7 +19,7 @@ source = "." home = true [targets.doxygen] -inputs = ["README.md", "src/include", "tests/support/include"] +inputs = ["README.md", "docs/logging.md", "src/include", "tests/support/include"] extra_stylesheets = [ "https://cdn.jsdelivr.net/npm/@lizardbyte/shared-web@2026.930.34219/dist/crowdin-dockle-css.css", ] @@ -28,3 +28,10 @@ extra_javascript = [ "docs/crowdin.js", ] main_page = "README.md" + +[[targets]] +name = "rust-api" +title = "lizardbyte-common Rust API" +framework = "rustdoc" +source = "." +entry = "lizardbyte_common" diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..368ea10 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,204 @@ +# Shared logging + +This is the logging contract for C++, Python, and Rust consumers of lizardbyte-common. +It implements the reusable foundation for [roadmap issue 186](https://github.com/LizardByte/roadmap/issues/186). +Consumer migrations happen separately; this change does not close the organization-wide issue. + +## Backends and format + +Use native logging APIs rather than a second logging framework: + +- C++ uses [spdlog](https://github.com/gabime/spdlog), pinned as a submodule. An existing + `spdlog::spdlog` target can be supplied by the consumer. The production target remains `lizardbyte::common`. +- Python uses the standard `logging` package and `logging.handlers`, with no runtime dependencies. +- Rust uses [spdlog-rs](https://docs.rs/spdlog-rs), including its `log` facade bridge and async sinks. + The crate can be consumed as a Cargo path dependency from the same submodule. + +Every physical line has this format, with one space separating fields: + +```text +2026-10-18T12:34:56.789Z [INFO] [network] [src/network/client.cpp:42] Connected +``` + +Timestamps are UTC, with exactly three fractional digits. Category is the native logger name +(or the `log` target when the Rust bridge uses an unnamed logger), defaulting to `app`. +Severity names are `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, and `CRITICAL`. +Sunshine's verbose maps to trace and fatal maps to critical. Python trace is level `5` (`TRACE`); +use `logger.log(TRACE, ...)`. The Rust `log` facade has no critical level; use the native critical macro. +Unknown source metadata is represented as `unknown:?`. + +Source locations keep the **entire path relative to the consuming project's root**, never just the basename. +Pass that root explicitly at startup; it is not inferred from the current working directory or this dependency's +source directory. C++ callers should pass their own `PROJECT_SOURCE_DIR` through a target compile definition; +Rust workspace applications should pass their workspace root, which may be above `CARGO_MANIFEST_DIR`. +This keeps locations clickable when running from an IDE or a build directory. Already-relative compiler paths +stay relative. Files outside the configured root stay absolute so IDEs can still open external dependency code. +Separators become `/`; Windows drive paths are matched without ASCII case sensitivity and with a directory boundary. +Pass normalized absolute roots and compiler paths; path resolution is lexical and does not access the filesystem. +If a packaged build moves source files, supply the original build source root to remove that prefix. + +Text is UTF-8 and files use LF on every OS. CRLF and bare CR in messages become line breaks; +every resulting line repeats the original prefix. A trailing message newline produces an empty prefixed line. +ASCII control bytes, including embedded ANSI escapes, become visible `\xNN` sequences. +Brackets and controls in category and location fields are escaped to preserve the field boundaries. +Terminal styling is added only by the output destination, never stored in files or callback text. + +The common palette is magenta trace, blue debug, green info, yellow warning, red error, and bold red critical. +Only the **first severity label** of a record is styled, including for multiline records. Automatic mode disables +color when redirected. C++ uses native Windows console attributes; Python and Rust use ANSI, so Windows hosts +must enable virtual-terminal processing when necessary. Exact shades and bold rendering depend on the terminal. + +## Rolling files + +Defaults are **10 MiB** (`10 * 1024 * 1024` bytes) per active file and **five backups**: + +```text +application.log # active, appended to on restart +application.1.log # newest backup +application.2.log +... +application.5.log # oldest retained backup +``` + +The index goes before the last extension. Names without extensions and dotfiles append the index. +Parent directories are created. Both limits must be positive; backup count is at most 200000. +Before writing a complete formatted record, rotate if the nonempty active file plus the incoming UTF-8 bytes +would **exceed** the size limit. Equality does not rotate. An oversized record is written intact to an empty +file and rotated before the next record. There is no startup truncation, compression, or time-based rotation. +Writes flush before returning in the configured synchronous loggers; this is not an fsync/durability guarantee. + +The C++ implementation uses spdlog's native rotating sink. Python subclasses the standard rotating handler only +to align byte accounting, filename placement, and Windows newlines. Rust's native rotator uses underscore names +and counts the active file in retention, so a small `Write` adapter provides this exact contract while native +spdlog-rs handles locking, formatting, dispatch, async delivery, and error handling. + +**One sink or handler owns a given file path.** Share it between named loggers in a process. For multiprocessing, +send worker records through a queue to one writer/listener, as Themerr already does. Independent processes, +different language runtimes, or distinct sink instances must not rotate the same path concurrently. +Do not externally truncate, rename, or delete active files; rotation ownership belongs to the selected backend. + +## C++ usage + +```cmake +add_subdirectory(third-party/lizardbyte-common) +target_link_libraries(my_app PRIVATE lizardbyte::common) +target_compile_definitions(my_app PRIVATE APP_SOURCE_ROOT="${PROJECT_SOURCE_DIR}") +``` + +```cpp +#include + +namespace logging = lizardbyte::common::logging; +auto console = logging::console_sink(); +console->set_level(spdlog::level::info); +auto file = logging::rolling_file_sink("logs/application.log"); +file->set_level(spdlog::level::debug); +auto logger = logging::make_logger("network", {console, file}, {APP_SOURCE_ROOT, {"a-secret-token"}}); +LIZARDBYTE_LOG(logger, spdlog::level::info, "Connected to {}", "server"); +logger->flush(); +``` + +The macro captures the call site without changing spdlog's formatting API. Native `logger->info(...)` calls +remain available but have no source location. The factory does not register a global logger. It accepts trace +and above; sink thresholds determine destinations. Set `logger->set_level` for an additional logger-wide filter. +Factories configure immediate flushing. Keep shared sinks on the same formatting policy, since formatters belong +to sinks. Installed package consumers use `find_package(lizardbyte-common CONFIG REQUIRED)`; spdlog is an exported +dependency and is installed alongside the library when the bundled dependency is used. + +Use `callback_sink` for formatted, redacted webhook text. Use native `ringbuffer_sink_mt` for bounded UI retention, +backtrace buffering for diagnostics, or `async_logger` with a bounded thread pool for slow destinations. Set the +common `Formatter` and sink filters before starting workers. Shutdown the pool to drain queued records; an async +flush call queues a flush and does not itself wait for completion. Set overflow policy deliberately. + +## Python usage + +```python +import logging +from pathlib import Path +from lizardbyte_common.logging import CallbackHandler, RollingFileHandler, TRACE, console_handler + +root = str(Path(__file__).resolve().parent) # choose the actual application root +logger = logging.getLogger('network') +logger.setLevel(TRACE) +logger.propagate = False +logger.addHandler(console_handler(root, level=logging.INFO)) +logger.addHandler(RollingFileHandler('logs/application.log', root, level=logging.DEBUG)) +# Add CallbackHandler(enqueue_webhook_text, root, level=logging.WARNING) when configured. +logger.info('Connected to %s', 'server') +``` + +Install the same `lizardbyte-common` Python path dependency used for repository tooling; runtime imports live +under `lizardbyte_common`. Named loggers, filters, `stacklevel`, traceback/stack formatting, library loggers, +`QueueHandler`/`QueueListener`, `MemoryHandler`, and third-party handlers remain native Python facilities. +Attach `Formatter(root, secrets=[...])` to custom handlers too. Use `respect_handler_level=True` on QueueListener. +Only one listener owns rolling files. Stop listeners before closing handlers or calling `logging.shutdown()`. +This module never clears or modifies existing root logger handlers or installs global exception hooks. + +## Rust usage + +```toml +[dependencies] +lizardbyte-common = { path = "third-party/lizardbyte-common" } +``` + +```rust +use lizardbyte_common::logging::{self, spdlog, Formatter, BACKUP_COUNT, MAX_BYTES}; +use spdlog::{Level, LevelFilter}; +use spdlog::sink::Sink; + +let format = Formatter::new(env!("CARGO_MANIFEST_DIR"), vec![]); // workspace apps pass workspace root +let console = logging::console_sink()?; +console.set_level_filter(LevelFilter::MoreSevereEqual(Level::Info)); +let file = logging::rolling_file_sink("logs/application.log", MAX_BYTES, BACKUP_COUNT)?; +file.set_level_filter(LevelFilter::MoreSevereEqual(Level::Debug)); +let logger = logging::make_logger("network", vec![console, file], &format)?; +spdlog::info!(logger: logger, "Connected to {}", "server"); +logger.flush(); +# Ok::<(), Box>(()) +``` + +Native macros capture call-site locations. `spdlog::init_log_crate_proxy()` optionally bridges existing `log` +callers (including Koko's dependencies); install it once in the application, choose its logger/filter, and set +the facade's maximum level. Use an unnamed native logger to retain each `log` record's target/category. +Native `AsyncPoolSink` provides bounded asynchronous delivery; configure overflow and explicitly drain/join the +pool on shutdown. `WriteSink` accepts any `Write + Send` transport, memory buffer, or bounded queue; the shared +formatter supplies standard redacted text. Sinks shared between loggers must use the same formatter policy. + +## Delivery, redaction, and migration needs + +The standard covers presentation and storage; it does not choose a network client, application data directory, +authentication scheme, retry schedule, event loop, webhook embed schema, or global exception policy. +Transport callbacks must not recursively log through themselves. Use bounded queues and expose dropped-record +counts when choosing a dropping overflow policy. The native frameworks report sink failures through their error +handlers; one failing destination does not prevent other native destinations from receiving the record. Keep +error reporting separate from the failing transport to prevent recursion. + +Supply literal secret values through formatter configuration; longest values are replaced first, and empty +values are ignored. Redaction covers rendered category, source path, message and Python tracebacks, without +changing records used by other handlers. It does **not** automatically discover credentials or sanitize arbitrary +structured fields. A custom sink forwarding raw records must apply the same redaction policy. Queue records +and native backtrace/raw ring buffers may retain unredacted data; restrict access and prefer formatted buffers. +Application-specific regex redaction and filters can be layered on native facilities before dispatch. + +The adjacent projects were reviewed for these migration needs: + +| Project | Existing behavior | Reusable integration path | +| --- | --- | --- | +| Koko | fern/log, colored categories/source, ANSI cleanup, password/token patterns, web log reader | `log` bridge, shared formatter/rolling writer, preserve application regex policy and update the reader | +| Moonlight-XboxOG | bounded structured ring, independent file/debugger thresholds, startup console, optional locations | native retained/formatted sink plus callbacks for startup/xemu/overlay; keep NXDK/SDL adapters in consumer | +| support-bot | stdout, 10 MiB/five backups, warning-only Discord embeds, 50-message startup queue, async tasks, library thresholds | standard handlers plus bounded queue and formatted callback; Discord/event-loop/embed policy remains in bot | +| Themerr | named channels, config/regex redaction, traceback hooks, multiprocessing queues, session log viewer, yt-dlp adapter | native named handlers/filters/listener; preserve exception, redaction and session-viewer policy in consumer | +| Sunshine | Boost async dispatch, startup rotation, verbose/fatal, Android callback, flush/deinit, periodic min/max/average diagnostics | spdlog levels/async/error hooks and callback sinks; periodic statistics and Android adaptation remain in consumer | +| One | Qt warnings and protocol log callbacks | Qt message handler and protocol callback feeding named native logger | +| libvirtualhid | optional consumer callback, disable-on-exception, bounded Windows driver trace files | consumer callback into common logger; preserve ABI, exception containment and driver-platform ownership | + +No production common target depends on these projects, Discord, Qt, SDL, Boost.Log, or a network library. +Platform console and driver integration still needs native consumer testing when those migrations happen. + +## Validation + +`tests/fixtures/logging-format.txt` is a shared byte-for-byte golden fixture for all three implementations. +Suites also cover project-root paths, Unicode size boundaries, append/restart, backup retention, oversized records, +independent filters, named loggers, redacted callbacks, native failure handling, queues, and concurrency. +Run `pytest`, the CMake GoogleTest executable, and `cargo test --locked --all-targets`. +CI builds C++ and runs Python/Rust on Linux, macOS, and Windows; Rust also runs formatting, Clippy and rustdoc. diff --git a/lizardbyte_common/__init__.py b/lizardbyte_common/__init__.py new file mode 100644 index 0000000..ea0a77e --- /dev/null +++ b/lizardbyte_common/__init__.py @@ -0,0 +1 @@ +"""Runtime helpers shared by LizardByte Python applications.""" diff --git a/lizardbyte_common/logging.py b/lizardbyte_common/logging.py new file mode 100644 index 0000000..3f8d07c --- /dev/null +++ b/lizardbyte_common/logging.py @@ -0,0 +1,157 @@ +"""Standard logging policy built on Python's logging handlers. + +Use ordinary named loggers, filters, QueueHandler/QueueListener, MemoryHandler, +and third-party handlers. This module never changes the process-wide root logger. +See docs/logging.md for the cross-language format and rotation contract. +""" + +from datetime import datetime, UTC +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path +import sys + +TRACE = 5 +MAX_BYTES = 10 * 1024 * 1024 +BACKUP_COUNT = 5 +_LABELS = ((50, 'CRITICAL'), (40, 'ERROR'), (30, 'WARN'), (20, 'INFO'), (10, 'DEBUG'), (0, 'TRACE')) +_COLORS = {'TRACE': '35', 'DEBUG': '34', 'INFO': '32', 'WARN': '33', 'ERROR': '31', 'CRITICAL': '1;31'} + + +def source_path(file: str, project_root: str) -> str: + """Keep source paths relative to the consumer root, with portable separators. + + Paths outside the supplied root stay absolute. Relative compiler paths stay + relative. Windows drive paths are compared case-insensitively on every OS. + """ + file = file.replace('\\', '/').rstrip('/') or '/' + root = str(project_root).replace('\\', '/').rstrip('/') + while file.startswith('./'): + file = file[2:] + prefix = root + '/' + comparable_file, comparable_prefix = file, prefix + if len(root) > 1 and root[1] == ':': + comparable_file, comparable_prefix = file.lower(), prefix.lower() + if project_root and comparable_file.startswith(comparable_prefix): + file = file[len(prefix):] + return file if file != '/' else 'unknown' + + +def _escape(text: str, field: bool = False) -> str: + return ''.join(f'\\x{ord(c):02x}' if ord(c) < 32 or ord(c) == 127 or (field and c in '[]') else c for c in text) + + +class Formatter(logging.Formatter): + """UTC milliseconds, severity, logger name, project-relative file:line, message. + + Literal secrets are removed from formatted text, including tracebacks and + metadata. This does not mutate LogRecords used by other handlers. + """ + + def __init__(self, project_root: str = '', *, color: bool = False, secrets=()): + super().__init__() + self.project_root = str(project_root) + self.color = color + self.secrets = sorted((secret for secret in secrets if secret), key=len, reverse=True) + + def _redact(self, text: str) -> str: + for secret in self.secrets: + text = text.replace(secret, '***') + return text + + def format(self, record: logging.LogRecord) -> str: + """Format without a terminal newline, as expected by logging.Handler.""" + timestamp = datetime.fromtimestamp(record.created, UTC).isoformat(timespec='milliseconds') + timestamp = timestamp.replace('+00:00', 'Z') + label = next(label for minimum, label in _LABELS if record.levelno >= minimum) + path = source_path(record.pathname, self.project_root) + location = 'unknown:?' + if path != 'unknown' and record.lineno > 0: + location = f'{_escape(self._redact(path), True)}:{record.lineno}' + prefix = f'{timestamp} [{label}] [{_escape(self._redact(record.name or "app"), True)}] [{location}] ' + message = record.getMessage() + if record.exc_info: + message += '\n' + self.formatException(record.exc_info) + elif record.exc_text: + message += '\n' + record.exc_text + if record.stack_info: + message += '\n' + self.formatStack(record.stack_info) + message = self._redact(message).replace('\r\n', '\n').replace('\r', '\n') + result = '\n'.join(prefix + _escape(line) for line in message.split('\n')) + if self.color: + start = len(timestamp) + 2 + result = result[:start] + f'\x1b[{_COLORS[label]}m' + label + '\x1b[0m' + result[start + len(label):] + return result + + +def console_handler(project_root: str = '', *, level: int = logging.INFO, stream=None, color: bool | None = None, + secrets=()) -> logging.StreamHandler: + """Create a stdout handler; automatic color uses the stream's isatty(). + + Windows callers must enable ANSI terminal processing before forcing color. + Streams remain owned by the caller. No global logger is reconfigured. + """ + stream = sys.stdout if stream is None else stream + handler = logging.StreamHandler(stream) + handler.setLevel(level) + use_color = bool(getattr(stream, 'isatty', lambda: False)()) if color is None else color + handler.setFormatter(Formatter(project_root, color=use_color, secrets=secrets)) + return handler + + +class RollingFileHandler(RotatingFileHandler): + """Native rotation with UTF-8 byte accounting and spdlog-compatible names. + + app.log becomes app.1.log through app.5.log. One handler/listener must own a + path; share that handler between named loggers. Complete oversized records + stay intact. Existing files are appended to after restart. + """ + + def __init__(self, filename, project_root: str = '', *, level: int = logging.INFO, + max_bytes: int = MAX_BYTES, backup_count: int = BACKUP_COUNT, secrets=()): + if max_bytes <= 0 or not 1 <= backup_count <= 200000: + raise ValueError('Positive logging limits and at most 200000 backups are required') + path = Path(filename) + path.parent.mkdir(parents=True, exist_ok=True) + super().__init__(path, maxBytes=max_bytes, backupCount=backup_count, encoding='utf-8') + self.setLevel(level) + self.setFormatter(Formatter(project_root, secrets=secrets)) + self.namer = self._backup_name + + @staticmethod + def _backup_name(default_name: str) -> str: + base, index = default_name.rsplit('.', 1) + path = Path(base) + return str(path.with_name(f'{path.stem}.{index}{path.suffix}')) + + def _open(self): + # Native FileHandler otherwise translates LF to CRLF on Windows. + return open(self.baseFilename, self.mode, encoding=self.encoding, errors=self.errors, newline='\n') + + def shouldRollover(self, record: logging.LogRecord) -> bool: + """Rotate before an incoming record would exceed the byte limit.""" + self.stream.flush() + size = Path(self.baseFilename).stat().st_size + incoming = len((self.format(record) + self.terminator).encode('utf-8')) + return size > 0 and size + incoming > self.maxBytes + + +class CallbackHandler(logging.Handler): + """Send standard, redacted text to a caller-provided transport or queue. + + The callback gets one complete record including LF. Handler locking + serializes callbacks. Use QueueHandler/QueueListener for slow network I/O. + Delivery, retries, authentication, and destination limits belong to the app. + """ + + def __init__(self, callback, project_root: str = '', *, level: int = logging.WARNING, secrets=()): + super().__init__(level) + self.callback = callback + self.setFormatter(Formatter(project_root, secrets=secrets)) + + def emit(self, record: logging.LogRecord): + """Report transport errors through the standard logging error handler.""" + try: + self.callback(self.format(record) + '\n') + except Exception: + self.handleError(record) diff --git a/pyproject.toml b/pyproject.toml index 1dc80c5..f722fe3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ Repository = "https://github.com/LizardByte/lizardbyte-common" Issues = "https://github.com/LizardByte/lizardbyte-common/issues" [tool.setuptools] -packages = ["scripts"] +packages = ["scripts", "lizardbyte_common"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -96,6 +96,7 @@ addopts = [ "--color=yes", "-p", "no:cacheprovider", "--cov=scripts", + "--cov=lizardbyte_common", "--cov-report=term-missing", "--cov-report=xml:coverage/python-coverage.xml", "--cov-fail-under=100", @@ -107,7 +108,7 @@ markers = [ ] [tool.coverage.run] -source = ["scripts"] +source = ["scripts", "lizardbyte_common"] omit = [ "*/tests/*", "*/__pycache__/*", diff --git a/rust/lib.rs b/rust/lib.rs new file mode 100644 index 0000000..ac32e67 --- /dev/null +++ b/rust/lib.rs @@ -0,0 +1,7 @@ +//! Shared runtime policy for LizardByte Rust applications. +//! +//! See [`logging`] and the repository's `docs/logging.md` for the common contract. +pub mod logging; + +#[cfg(test)] +mod tests; diff --git a/rust/logging.rs b/rust/logging.rs new file mode 100644 index 0000000..3d2d755 --- /dev/null +++ b/rust/logging.rs @@ -0,0 +1,326 @@ +//! Common format, colors, and rotation policy over `spdlog-rs`. +//! +//! Native loggers support independent sink filters, asynchronous pools, error +//! handlers, custom writers, and the `log` facade. No global logger is installed. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::ops::Range; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; + +use chrono::{DateTime, SecondsFormat, Utc}; +use spdlog::formatter::{Formatter as NativeFormatter, FormatterContext}; +use spdlog::sink::{Sink, StdStreamSink, WriteSink}; +use spdlog::terminal_style::{Color, Style, StyleMode}; +use spdlog::{Level, LevelFilter, Logger, Record, StringBuf}; + +/// Native logging types and macros are available without a second dependency. +pub use spdlog; + +/// Default maximum active log size: 10 MiB. +pub const MAX_BYTES: u64 = 10 * 1024 * 1024; +/// Default number of retained backups, excluding the active file. +pub const BACKUP_COUNT: usize = 5; + +/// Normalize separators and remove the consuming project's absolute source root. +/// External absolute paths and relative compiler paths keep their full location. +#[must_use] +pub fn source_path(file: &str, project_root: &str) -> String { + let file = file.replace('\\', "/"); + let file = file.trim_end_matches('/').trim_start_matches("./"); + let root = project_root.replace('\\', "/"); + let prefix = format!("{}/", root.trim_end_matches('/')); + let windows = root.as_bytes().get(1) == Some(&b':'); + let matches = if windows { + file.to_ascii_lowercase() + .starts_with(&prefix.to_ascii_lowercase()) + } else { + file.starts_with(&prefix) + }; + let file = if !root.is_empty() && matches { + &file[prefix.len()..] + } else { + file + }; + if file.is_empty() { + "unknown".into() + } else { + file.into() + } +} + +fn escape(text: &str, field: bool) -> String { + let mut output = String::new(); + for c in text.chars() { + if c < ' ' || c == '\u{7f}' || (field && matches!(c, '[' | ']')) { + use std::fmt::Write as _; + write!(output, "\\x{:02x}", u32::from(c)).expect("writing to a String cannot fail"); + } else { + output.push(c); + } + } + output +} + +/// Clonable native formatter; secrets are literal, longest-first replacements. +#[derive(Clone, Default)] +pub struct Formatter { + project_root: String, + secrets: Vec, +} + +impl Formatter { + /// Supply the consuming project root, rather than this crate's manifest path. + #[must_use] + pub fn new(project_root: impl Into, mut secrets: Vec) -> Self { + secrets.retain(|secret| !secret.is_empty()); + secrets.sort_by_key(|secret| std::cmp::Reverse(secret.len())); + Self { + project_root: project_root.into(), + secrets, + } + } + + fn redact(&self, text: &str) -> String { + self.secrets + .iter() + .fold(text.to_owned(), |text, secret| text.replace(secret, "***")) + } + + /// Format external diagnostic fields with the same policy as native records. + /// The returned byte range identifies the first severity label for coloring. + #[must_use] + pub fn render( + &self, + time: SystemTime, + level: Level, + category: &str, + location: Option<(&str, u32)>, + message: &str, + ) -> (String, Range) { + let time: DateTime = time.into(); + let timestamp = time.to_rfc3339_opts(SecondsFormat::Millis, true); + let label = match level { + Level::Trace => "TRACE", + Level::Debug => "DEBUG", + Level::Info => "INFO", + Level::Warn => "WARN", + Level::Error => "ERROR", + Level::Critical => "CRITICAL", + }; + let source = match location { + Some((file, line)) if !file.is_empty() && line > 0 => format!( + "{}:{line}", + escape(&self.redact(&source_path(file, &self.project_root)), true) + ), + _ => "unknown:?".into(), + }; + let category = if category.is_empty() { "app" } else { category }; + let prefix = format!( + "{timestamp} [{label}] [{}] [{source}] ", + escape(&self.redact(category), true) + ); + let message = self + .redact(message) + .replace("\r\n", "\n") + .replace('\r', "\n"); + let text = message + .split('\n') + .map(|line| format!("{prefix}{}\n", escape(line, false))) + .collect(); + let start = timestamp.len() + 2; + (text, start..start + label.len()) + } +} + +impl NativeFormatter for Formatter { + fn format( + &self, + record: &Record, + destination: &mut StringBuf, + context: &mut FormatterContext, + ) -> spdlog::Result<()> { + let location = record + .source_location() + .map(|source| (source.file(), source.line())); + let (text, range) = self.render( + record.time(), + record.level(), + record.logger_name().unwrap_or("app"), + location, + record.payload(), + ); + let offset = destination.len(); + context.set_style_range(Some(range.start + offset..range.end + offset)); + destination.push_str(&text); + Ok(()) + } +} + +/// Configure an unregistered native logger. Each sink keeps its own filter. +pub fn make_logger( + name: &str, + sinks: Vec>, + formatter: &Formatter, +) -> spdlog::Result> { + for sink in &sinks { + sink.set_formatter(Box::new(formatter.clone())); + } + Logger::builder() + .name(name) + .sinks(sinks) + .level_filter(LevelFilter::All) + .flush_level_filter(LevelFilter::All) + .build_arc() +} + +/// Native stdout sink with automatic terminal detection and the common palette. +pub fn console_sink() -> spdlog::Result> { + let mut sink = StdStreamSink::builder() + .stdout() + .style_mode(StyleMode::Auto) + .build()?; + for (level, color) in [ + (Level::Trace, Color::Magenta), + (Level::Debug, Color::Blue), + (Level::Info, Color::Green), + (Level::Warn, Color::Yellow), + (Level::Error, Color::Red), + (Level::Critical, Color::Red), + ] { + let mut style = Style::builder(); + style.color(color); + if level == Level::Critical { + style.bold(); + } + sink.set_style(level, style.build()); + } + Ok(Arc::new(sink)) +} + +fn backup_path(path: &Path, index: usize) -> PathBuf { + let extension = path.extension().filter(|extension| !extension.is_empty()); + let mut name = if extension.is_some() { + path.file_stem() + } else { + path.file_name() + } + .unwrap_or_default() + .to_os_string(); + name.push(format!(".{index}")); + if let Some(extension) = extension { + name.push("."); + name.push(extension); + } + path.with_file_name(name) +} + +/// Small writer adapter for common rotation naming and boundary semantics. +/// `spdlog-rs`'s built-in rotator uses different names and counts the active file. +/// Use through `rolling_file_sink`, which serializes access with a native WriteSink. +pub struct RollingWriter { + path: PathBuf, + max_bytes: u64, + backups: usize, + file: Option, +} + +impl RollingWriter { + /// Open in append mode. Parent directories are created, as with native spdlog. + pub fn new(path: impl Into, max_bytes: u64, backups: usize) -> io::Result { + if max_bytes == 0 || !(1..=200000).contains(&backups) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "positive limits and at most 200000 backups are required", + )); + } + let path = path.into(); + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + let mut writer = Self { + path, + max_bytes, + backups, + file: None, + }; + writer.reopen()?; + Ok(writer) + } + + fn reopen(&mut self) -> io::Result<()> { + self.file = Some( + OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?, + ); + Ok(()) + } + + fn rotate(&mut self) -> io::Result<()> { + self.file.take(); // Windows requires the active handle to close before rename. + for index in (1..=self.backups).rev() { + let previous = if index == 1 { + self.path.clone() + } else { + backup_path(&self.path, index - 1) + }; + if previous.exists() { + let destination = backup_path(&self.path, index); + if destination.exists() { + fs::remove_file(&destination)?; + } + fs::rename(previous, destination)?; + } + } + self.reopen() + } +} + +impl Write for RollingWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + if self.file.is_none() { + self.reopen()?; + } + let size = self + .file + .as_ref() + .expect("file was opened") + .metadata()? + .len(); + if size > 0 && (size >= self.max_bytes || buffer.len() as u64 > self.max_bytes - size) { + self.rotate()?; + } + self.file + .as_mut() + .expect("file was opened") + .write_all(buffer)?; + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + if let Some(file) = &mut self.file { + file.flush()?; + } + Ok(()) + } +} + +/// Standard rolling destination. WriteSink provides locking, formatting and errors. +pub fn rolling_file_sink( + path: impl Into, + max_bytes: u64, + backups: usize, +) -> io::Result>> { + let writer = RollingWriter::new(path, max_bytes, backups)?; + WriteSink::builder() + .target(writer) + .build_arc() + .map_err(io::Error::other) +} diff --git a/rust/tests.rs b/rust/tests.rs new file mode 100644 index 0000000..4e012d3 --- /dev/null +++ b/rust/tests.rs @@ -0,0 +1,276 @@ +use std::io::Write; +use std::sync::Arc; +use std::time::{Duration, UNIX_EPOCH}; + +use spdlog::sink::{Sink, WriteSink}; +use spdlog::{Level, LevelFilter}; + +use crate::logging::{self, Formatter, RollingWriter}; + +#[test] +fn shared_golden_bytes() { + let formatter = Formatter::new( + "/workspace/project", + vec!["secret".into(), "secret-long".into(), "".into()], + ); + let time = UNIX_EPOCH + Duration::from_millis(123); + let mut actual = String::new(); + for level in [ + Level::Trace, + Level::Debug, + Level::Info, + Level::Warn, + Level::Error, + Level::Critical, + ] { + let (text, range) = formatter.render( + time, + level, + "network", + Some(("/workspace/project/src/client.cpp", 42)), + "héllo", + ); + assert!(!text[range].contains('[')); + actual += &text; + } + actual += &formatter + .render( + time, + Level::Info, + "net]\n", + Some(("/workspace/project/src/client.cpp", 42)), + "secret-long\r\nnext\t\x1b[31m", + ) + .0; + actual += &formatter + .render(time, Level::Info, "", None, "metadata absent") + .0; + assert_eq!( + actual.as_bytes(), + include_bytes!("../tests/fixtures/logging-format.txt") + ); +} + +#[test] +fn empty_messages_and_trailing_newlines_are_preserved() { + let formatter = Formatter::default(); + let time = UNIX_EPOCH + Duration::from_millis(123); + let prefix = "1970-01-01T00:00:00.123Z [INFO] [app] [unknown:?] "; + assert_eq!( + formatter.render(time, Level::Info, "", None, "").0, + format!("{prefix}\n") + ); + assert_eq!( + formatter.render(time, Level::Info, "", None, "x\r").0, + format!("{prefix}x\n{prefix}\n") + ); +} + +#[test] +fn source_locations_keep_consumer_root() { + for (file, root, expected) in [ + ( + "C:\\Projects\\App\\src\\main.rs", + "c:/projects/app/", + "src/main.rs", + ), + ("/app2/src/main.rs", "/app", "/app2/src/main.rs"), + ("./src/main.rs", "/app", "src/main.rs"), + ("/src/main.rs", "/", "src/main.rs"), + ("", "", "unknown"), + ] { + assert_eq!(logging::source_path(file, root), expected); + } +} + +#[test] +fn rotation_preserves_exact_boundary_unicode_and_restart() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("nested/application.log"); + let formatter = Formatter::default(); + let text = formatter + .render( + UNIX_EPOCH + Duration::from_millis(123), + Level::Info, + "test", + None, + "é", + ) + .0; + for _ in 0..7 { + let mut writer = RollingWriter::new(&path, text.len() as u64 * 2, 2).unwrap(); + writer.write_all(text.as_bytes()).unwrap(); + writer.flush().unwrap(); + } + assert_eq!(std::fs::read(&path).unwrap(), text.as_bytes()); + assert_eq!( + std::fs::read(path.with_file_name("application.1.log")).unwrap(), + (text.clone() + &text).as_bytes() + ); + assert_eq!( + std::fs::read(path.with_file_name("application.2.log")).unwrap(), + (text.clone() + &text).as_bytes() + ); + assert!(!path.with_file_name("application.3.log").exists()); +} + +#[test] +fn oversized_records_remain_intact_without_empty_backups() { + let directory = tempfile::tempdir().unwrap(); + for name in ["large.log", "noextension", ".hidden"] { + let path = directory.path().join(name); + let backup = match name { + "large.log" => "large.1.log", + "noextension" => "noextension.1", + _ => ".hidden.1", + }; + let mut writer = RollingWriter::new(&path, 1, 1).unwrap(); + writer.write_all(b"complete record").unwrap(); + assert!(!directory.path().join(backup).exists()); + writer.write_all(b"next").unwrap(); + drop(writer); + assert_eq!( + std::fs::read(directory.path().join(backup)).unwrap(), + b"complete record" + ); + } +} + +#[test] +fn native_loggers_filter_share_sinks_and_capture_source() { + let output = WriteSink::builder() + .target(Vec::::new()) + .level_filter(LevelFilter::MoreSevereEqual(Level::Info)) + .build_arc() + .unwrap(); + let warning = WriteSink::builder() + .target(Vec::::new()) + .level_filter(LevelFilter::MoreSevereEqual(Level::Warn)) + .build_arc() + .unwrap(); + let formatter = Formatter::new(env!("CARGO_MANIFEST_DIR"), vec!["secret".into()]); + let first = + logging::make_logger("first", vec![output.clone(), warning.clone()], &formatter).unwrap(); + let second = + logging::make_logger("second", vec![output.clone(), warning.clone()], &formatter).unwrap(); + spdlog::debug!(logger: first, "ignored"); + spdlog::info!(logger: first, "hello"); + spdlog::warn!(logger: second, "secret"); + let text = output.with_target(|target| String::from_utf8(target.clone()).unwrap()); + assert!(!text.contains("ignored")); + assert!(text.contains("[rust/tests.rs:")); + let text = warning.with_target(|target| String::from_utf8(target.clone()).unwrap()); + assert_eq!(text.lines().count(), 1); + assert!(text.contains("[second]") && text.contains("***")); +} + +#[test] +fn native_writer_reports_transport_errors_and_continues_fanout() { + struct FailingWriter; + impl Write for FailingWriter { + fn write(&mut self, _: &[u8]) -> std::io::Result { + Err(std::io::Error::other("transport failed")) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let failed = WriteSink::builder() + .target(FailingWriter) + .build_arc() + .unwrap(); + let memory = WriteSink::builder() + .target(Vec::::new()) + .build_arc() + .unwrap(); + let logger = logging::make_logger( + "errors", + vec![failed, memory.clone()], + &Formatter::default(), + ) + .unwrap(); + let errors = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let captured = errors.clone(); + logger.set_error_handler(move |_| { + captured.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + }); + spdlog::warn!(logger: logger, "still delivered"); + assert_eq!(errors.load(std::sync::atomic::Ordering::Relaxed), 1); + assert!(memory + .with_target(|target| String::from_utf8(target.clone()).unwrap()) + .contains("still delivered")); +} + +#[test] +fn native_sinks_serialize_concurrent_records() { + let memory = WriteSink::builder() + .target(Vec::::new()) + .build_arc() + .unwrap(); + let logger = + logging::make_logger("workers", vec![memory.clone()], &Formatter::default()).unwrap(); + let threads: Vec<_> = (0..4) + .map(|_| { + let logger = logger.clone(); + std::thread::spawn(move || { + for _ in 0..25 { + spdlog::info!(logger: logger, "record"); + } + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + assert_eq!( + memory + .with_target(|target| String::from_utf8(target.clone()).unwrap()) + .lines() + .count(), + 100 + ); +} + +#[test] +fn invalid_limits_and_open_failures_are_reported() { + for (size, count) in [(0, 1), (1, 0), (1, 200001)] { + assert!(RollingWriter::new("unused.log", size, count).is_err()); + } + let directory = tempfile::tempdir().unwrap(); + assert!(RollingWriter::new(directory.path(), 10, 1).is_err()); + let sink = logging::rolling_file_sink(directory.path().join("valid.log"), 10, 1).unwrap(); + sink.flush().unwrap(); + assert!(logging::console_sink().is_ok()); +} + +#[test] +fn invalid_paths_and_blocked_parent_directories_return_errors() { + // An empty relative path has no parent and must fail without creating a file. + assert!(RollingWriter::new("", 10, 1).is_err()); + let directory = tempfile::tempdir().unwrap(); + let parent = directory.path().join("not-a-directory"); + std::fs::write(&parent, b"keep this file").unwrap(); + assert!(RollingWriter::new(parent.join("app.log"), 10, 1).is_err()); + assert_eq!(std::fs::read(parent).unwrap(), b"keep this file"); +} + +#[test] +fn failed_rotation_preserves_records_and_recovers_after_the_obstruction_is_removed() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("recovery.log"); + let backup = directory.path().join("recovery.1.log"); + let mut writer = RollingWriter::new(&path, 3, 1).unwrap(); + writer.write_all(b"one").unwrap(); + std::fs::create_dir(&backup).unwrap(); + + assert!(writer.write_all(b"two").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"one"); + // A failed rotation has already closed the handle, so flushing is a safe no-op. + writer.flush().unwrap(); + std::fs::remove_dir(&backup).unwrap(); + + writer.write_all(b"two").unwrap(); + writer.flush().unwrap(); + assert_eq!(std::fs::read(backup).unwrap(), b"one"); + assert_eq!(std::fs::read(path).unwrap(), b"two"); +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7176829..8c32581 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,13 +2,15 @@ add_library(lizardbyte_common STATIC) add_library(lizardbyte::common ALIAS lizardbyte_common) target_sources(lizardbyte_common - PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/common/env.cpp") + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/common/env.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/common/logging.cpp") target_include_directories( lizardbyte_common PUBLIC $ $) target_compile_features(lizardbyte_common PUBLIC cxx_std_17) +target_link_libraries(lizardbyte_common PUBLIC spdlog::spdlog) set_target_properties( lizardbyte_common PROPERTIES EXPORT_NAME common OUTPUT_NAME lizardbyte-common) diff --git a/src/common/logging.cpp b/src/common/logging.cpp new file mode 100644 index 0000000..9f69345 --- /dev/null +++ b/src/common/logging.cpp @@ -0,0 +1,212 @@ +/** + * @file src/common/logging.cpp + * @brief Common policy layered over spdlog's formatters and sinks. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// lib includes +#include +#include + +// local includes +#include + +namespace lizardbyte::common::logging { + namespace { + constexpr std::array names {"TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL", "OFF"}; + + class FormattedCallbackSink final: public spdlog::sinks::base_sink { + public: + explicit FormattedCallbackSink(std::function callback): + callback_(std::move(callback)) { + if (!callback_) { + throw std::invalid_argument("A logging callback is required"); + } + } + + protected: + void sink_it_(const spdlog::details::log_msg &record) override { + spdlog::memory_buf_t text; + formatter_->format(record, text); + callback_(std::string(text.data(), text.size())); + } + + void flush_() override {} + + private: + std::function callback_; + }; + + std::string escaped(std::string_view value, bool field) { + constexpr std::string_view hex = "0123456789abcdef"; + std::string result; + for (const unsigned char c : value) { + if (c < 32 || c == 127 || (field && (c == '[' || c == ']'))) { + result += "\\x"; + result += hex[c >> 4]; + result += hex[c & 15]; + } else { + result += static_cast(c); + } + } + return result; + } + + std::string normalized(std::string path) { + std::replace(path.begin(), path.end(), '\\', '/'); + while (path.size() > 1 && path.back() == '/') { + path.pop_back(); + } + while (path.compare(0, 2, "./") == 0) { + path.erase(0, 2); + } + return path; + } + + std::string timestamp_text(spdlog::log_clock::time_point timestamp) { + const auto millis = std::chrono::floor(timestamp.time_since_epoch()); + const auto seconds = std::chrono::floor(millis); + const auto time = spdlog::log_clock::to_time_t(spdlog::log_clock::time_point(seconds)); + std::tm utc {}; +#ifdef _WIN32 + const bool valid = gmtime_s(&utc, &time) == 0; +#else + const bool valid = gmtime_r(&time, &utc) != nullptr; +#endif + if (!valid) { + throw std::out_of_range("Logging timestamp is outside the platform range"); + } + std::ostringstream output; + output.imbue(std::locale::classic()); + output << std::put_time(&utc, "%Y-%m-%dT%H:%M:%S") << '.' << std::setfill('0') << std::setw(3) << (millis - seconds).count() << 'Z'; + return output.str(); + } + } // namespace + + std::string source_path(std::string file, std::string project_root) { + file = normalized(std::move(file)); + project_root = normalized(std::move(project_root)); + auto comparable_file = file; + auto comparable_root = project_root; + if (project_root.size() > 1 && project_root[1] == ':') { + const auto lower = [](unsigned char c) { + return static_cast(std::tolower(c)); + }; + std::transform(comparable_file.begin(), comparable_file.end(), comparable_file.begin(), lower); + std::transform(comparable_root.begin(), comparable_root.end(), comparable_root.begin(), lower); + } + const auto prefix = comparable_root == "/" ? "/" : comparable_root + '/'; + if (!project_root.empty() && comparable_file.compare(0, prefix.size(), prefix) == 0) { + file.erase(0, prefix.size()); + } + return file.empty() ? "unknown" : file; + } + + Formatter::Formatter(Options options): + options_(std::move(options)) { + auto &secrets = options_.secrets; + secrets.erase(std::remove(secrets.begin(), secrets.end(), ""), secrets.end()); + std::stable_sort(secrets.begin(), secrets.end(), [](const auto &left, const auto &right) { + return left.size() > right.size(); + }); + } + + std::string Formatter::redact(std::string text) const { + for (const auto &secret : options_.secrets) { + std::size_t offset = 0; + while ((offset = text.find(secret, offset)) != std::string::npos) { + text.replace(offset, secret.size(), "***"); + offset += 3; + } + } + return text; + } + + void Formatter::format(const spdlog::details::log_msg &record, spdlog::memory_buf_t &destination) { + const auto label = names.at(static_cast(record.level)); + const auto file = source_path(record.source.filename ? record.source.filename : "", options_.project_root); + const auto source = file == "unknown" || record.source.line <= 0 ? "unknown:?" : escaped(redact(file), true) + ":" + std::to_string(record.source.line); + const auto category = record.logger_name.size() == 0 ? std::string("app") : std::string(record.logger_name.data(), record.logger_name.size()); + const auto time = timestamp_text(record.time); + const auto prefix = time + " [" + std::string(label) + "] [" + escaped(redact(category), true) + "] [" + source + "] "; + const auto message = redact(std::string(record.payload.data(), record.payload.size())); + record.color_range_start = destination.size() + time.size() + 2; + record.color_range_end = record.color_range_start + label.size(); + std::size_t start = 0; + const auto append_line = [&](std::size_t end) { + const auto text = prefix + escaped(std::string_view(message).substr(start, end - start), false) + '\n'; + destination.append(text.data(), text.data() + text.size()); + }; + for (std::size_t i = 0; i < message.size(); ++i) { + if (message[i] == '\r' || message[i] == '\n') { + append_line(i); + if (message[i] == '\r' && i + 1 < message.size() && message[i + 1] == '\n') { + ++i; + } + start = i + 1; + } + } + append_line(message.size()); + } + + std::unique_ptr Formatter::clone() const { + return std::make_unique(options_); + } + + std::shared_ptr make_logger(std::string name, std::vector sinks, const Options &options) { + for (const auto &sink : sinks) { + if (!sink) { + throw std::invalid_argument("Logging sinks must not be null"); + } + } + auto logger = std::make_shared(std::move(name), sinks.begin(), sinks.end()); + logger->set_level(spdlog::level::trace); + logger->set_formatter(std::make_unique(options)); + logger->flush_on(spdlog::level::trace); + return logger; + } + + std::shared_ptr console_sink(spdlog::color_mode mode) { + auto sink = std::make_shared(mode); +#ifdef _WIN32 + // Win32 foreground bits: blue=1, green=2, red=4, intensity=8. + sink->set_color(spdlog::level::trace, 5); + sink->set_color(spdlog::level::debug, 1); + sink->set_color(spdlog::level::info, 2); + sink->set_color(spdlog::level::warn, 6); + sink->set_color(spdlog::level::err, 4); + sink->set_color(spdlog::level::critical, 12); +#else + sink->set_color(spdlog::level::trace, sink->magenta); + sink->set_color(spdlog::level::debug, sink->blue); + sink->set_color(spdlog::level::info, sink->green); + sink->set_color(spdlog::level::warn, sink->yellow); + sink->set_color(spdlog::level::err, sink->red); + sink->set_color(spdlog::level::critical, sink->red_bold); +#endif + return sink; + } + + spdlog::sink_ptr rolling_file_sink(const spdlog::filename_t &path, std::size_t max_bytes, std::size_t backup_count) { + if (max_bytes == 0 || backup_count == 0 || backup_count > 200000) { + throw std::invalid_argument("Positive logging limits and at most 200000 backups are required"); + } + return std::make_shared(path, max_bytes, backup_count, false); + } + + spdlog::sink_ptr callback_sink(std::function callback) { + return std::make_shared(std::move(callback)); + } +} // namespace lizardbyte::common::logging diff --git a/src/include/lizardbyte/common/logging.h b/src/include/lizardbyte/common/logging.h new file mode 100644 index 0000000..c31915f --- /dev/null +++ b/src/include/lizardbyte/common/logging.h @@ -0,0 +1,84 @@ +/** + * @file src/include/lizardbyte/common/logging.h + * @brief Standard formatting and spdlog configuration for LizardByte consumers. + */ +#pragma once + +// standard includes +#include +#include +#include +#include + +// lib includes +#include +#include +#include + +namespace lizardbyte::common::logging { + /// Formatting policy shared by all destinations of an application. + struct Options { + std::string project_root; ///< Consuming project's absolute source root, independent of the working directory. + std::vector secrets; ///< Literal secrets to replace with *** in formatted output. + }; + + /// Normalize separators and make source files beneath project_root relative to it. + /// Relative compiler paths remain relative; external absolute paths remain clickable. + /// @param file Source path reported by the compiler or an external logging adapter. + /// @param project_root Absolute source root of the consuming application. + /// @return Normalized source path, or unknown when no file was supplied. + [[nodiscard]] std::string source_path(std::string file, std::string project_root); + + /// Standard UTC formatter. Every physical message line receives a complete prefix. + /// Native console sinks color the first severity label; files always contain plain UTF-8/LF. + class Formatter final: public spdlog::formatter { + public: + /// Empty secrets are ignored; longer secrets are replaced first. + /// @param options Consumer source root and literal values to redact. + explicit Formatter(Options options = {}); + /// Append one record using the common format and set the native console color range. + /// @param record Native spdlog record with timestamp, logger name and optional source. + /// @param destination Buffer to receive complete UTF-8 lines with LF terminators. + void format(const spdlog::details::log_msg &record, spdlog::memory_buf_t &destination) override; + /// Copy the formatting policy for another native sink. + /// @return An independent formatter with identical options. + [[nodiscard]] std::unique_ptr clone() const override; + + private: + Options options_; + [[nodiscard]] std::string redact(std::string text) const; + }; + + /// Create an unregistered native spdlog logger with independently filtered sinks. + /// The logger accepts trace and above; set each sink's threshold separately. + /// Sinks may be shared by loggers using the same Options. Native async, callback, + /// ringbuffer, backtrace, and error-handler APIs remain available; see docs/logging.md. + /// @param name Category included in each record emitted by this logger. + /// @param sinks Destinations with independently configured severity thresholds. + /// @param options Common formatting and redaction policy to apply to every sink. + /// @return A trace-enabled native logger that flushes after each accepted record. + [[nodiscard]] std::shared_ptr make_logger(std::string name, std::vector sinks, const Options &options = {}); + + /// Standard stdout colors; automatic mode detects terminal capabilities. + /// @param mode Native color policy for automatic, forced, or disabled styling. + /// @return A thread-safe native stdout sink with the common severity palette. + [[nodiscard]] std::shared_ptr console_sink(spdlog::color_mode mode = spdlog::color_mode::automatic); + + /// Size rotation: append at startup, app.log -> app.1.log, newest backup first. + /// One shared sink must own each path; defaults are 10 MiB and five backups. + /// Both limits must be positive, with at most 200000 backups (spdlog's limit). + /// @param path Active log filename. Missing parent directories are created. + /// @param max_bytes Size limit measured in formatted UTF-8 bytes. + /// @param backup_count Number of retained backups, excluding the active file. + /// @return A thread-safe native rotating file sink in append mode. + [[nodiscard]] spdlog::sink_ptr rolling_file_sink(const spdlog::filename_t &path, std::size_t max_bytes = 10 * 1024 * 1024, std::size_t backup_count = 5); + + /// Deliver complete standard, redacted text to a transport or bounded queue. + /// Native spdlog locking/error handling applies; do not recursively log to this sink. + /// @param callback Destination accepting one complete formatted record including LF. + /// @return A thread-safe sink invoking the callback after formatting and redaction. + [[nodiscard]] spdlog::sink_ptr callback_sink(std::function callback); +} // namespace lizardbyte::common::logging + +/// Capture a C++17 source location while preserving native spdlog format arguments. +#define LIZARDBYTE_LOG(logger, level, ...) (logger)->log(spdlog::source_loc {__FILE__, __LINE__, SPDLOG_FUNCTION}, (level), __VA_ARGS__) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8826faa..87b56f2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,8 +3,14 @@ include(GoogleTest) set(TEST_BINARY test_lizardbyte_common) add_executable( - ${TEST_BINARY} "${CMAKE_CURRENT_SOURCE_DIR}/cpp/test_env.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/cpp/test_testing.cpp") + ${TEST_BINARY} + "${CMAKE_CURRENT_SOURCE_DIR}/cpp/test_env.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/cpp/test_logging.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/cpp/test_testing.cpp") + +target_compile_definitions( + ${TEST_BINARY} + PRIVATE LOGGING_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures") target_link_libraries(${TEST_BINARY} PRIVATE gmock_main lizardbyte::common lizardbyte::test_support) diff --git a/tests/consumer/CMakeLists.txt b/tests/consumer/CMakeLists.txt new file mode 100644 index 0000000..bccf594 --- /dev/null +++ b/tests/consumer/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.24) +project(logging_consumer LANGUAGES CXX) + +find_package(lizardbyte-common CONFIG REQUIRED) +add_executable(logging_consumer main.cpp) +target_link_libraries(logging_consumer PRIVATE lizardbyte::common) diff --git a/tests/consumer/main.cpp b/tests/consumer/main.cpp new file mode 100644 index 0000000..b05de48 --- /dev/null +++ b/tests/consumer/main.cpp @@ -0,0 +1,21 @@ +/** + * @file tests/consumer/main.cpp + * @brief Installed-package consumer smoke test. + */ + +// standard includes +#include + +// lib includes +#include + +// local includes +#include + +int main() { + std::ostringstream text; + auto output = std::make_shared(text); + auto logger = lizardbyte::common::logging::make_logger("consumer", {output}); + LIZARDBYTE_LOG(logger, spdlog::level::info, "installed package"); + return text.str().find("[consumer]") == std::string::npos ? 1 : 0; +} diff --git a/tests/cpp/test_logging.cpp b/tests/cpp/test_logging.cpp new file mode 100644 index 0000000..3a009f4 --- /dev/null +++ b/tests/cpp/test_logging.cpp @@ -0,0 +1,189 @@ +/** + * @file tests/cpp/test_logging.cpp + * @brief Cross-language format and native spdlog integration tests. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include + +// lib includes +#include +#include +#include + +// local includes +#include +#include + +namespace { + namespace logging = lizardbyte::common::logging; + using namespace std::chrono_literals; + + std::string read_file(const std::filesystem::path &path) { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; + } + + std::string render(logging::Formatter &formatter, spdlog::level::level_enum level, const std::string &category, const std::string &message, const char *file = "/workspace/project/src/client.cpp", int line = 42) { + spdlog::details::log_msg record {spdlog::log_clock::time_point(123ms), spdlog::source_loc {file, line, "test"}, category, level, message}; + spdlog::memory_buf_t output; + formatter.format(record, output); + constexpr std::array labels {"TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"}; + EXPECT_EQ(std::string(output.data() + record.color_range_start, record.color_range_end - record.color_range_start), labels.at(static_cast(level))); + return {output.data(), output.size()}; + } + + class LoggingTest: public testing::Test { + protected: + std::filesystem::path directory; + + void SetUp() override { + directory = std::filesystem::temp_directory_path() / ("lb-logging-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(directory); + } + + void TearDown() override { + std::filesystem::remove_all(directory); + } + }; +} // namespace + +TEST(LoggingFormat, MatchesSharedGoldenBytes) { + logging::Formatter formatter {{"/workspace/project", {"secret", "secret-long", ""}}}; + std::string actual; + for (const auto level : {spdlog::level::trace, spdlog::level::debug, spdlog::level::info, spdlog::level::warn, spdlog::level::err, spdlog::level::critical}) { + actual += render(formatter, level, "network", u8"héllo"); + } + actual += render(formatter, spdlog::level::info, "net]\n", "secret-long\r\nnext\t\033[31m"); + actual += render(formatter, spdlog::level::info, "", "metadata absent", "", 0); + EXPECT_EQ(actual, read_file(std::filesystem::path(LOGGING_FIXTURE_DIR) / "logging-format.txt")); + auto cloned = formatter.clone(); + EXPECT_NE(cloned, nullptr); +} + +TEST(LoggingFormat, PreservesEmptyMessagesAndTrailingNewlines) { + logging::Formatter formatter; + const std::string prefix = "1970-01-01T00:00:00.123Z [INFO] [app] [unknown:?] "; + EXPECT_EQ(render(formatter, spdlog::level::info, "", "", "", 0), prefix + '\n'); + EXPECT_EQ(render(formatter, spdlog::level::info, "", "x\r", "", 0), prefix + "x\n" + prefix + '\n'); +} + +TEST(LoggingFormat, ResolvesProjectRootWithoutWorkingDirectory) { + EXPECT_EQ(logging::source_path("C:\\Projects\\App\\src\\main.cpp", "c:/projects/app/"), "src/main.cpp"); + EXPECT_EQ(logging::source_path("/app2/src/main.cpp", "/app"), "/app2/src/main.cpp"); + EXPECT_EQ(logging::source_path("./src/main.cpp", "/app"), "src/main.cpp"); + EXPECT_EQ(logging::source_path("/src/main.cpp", "/"), "src/main.cpp"); + EXPECT_EQ(logging::source_path("", ""), "unknown"); +} + +#ifdef _MSC_VER +TEST(LoggingFormat, RejectsTimestampsOutsideTheMsvcUtcRange) { + logging::Formatter formatter; + spdlog::details::log_msg record {spdlog::log_clock::time_point::max(), {}, "clock", spdlog::level::info, "invalid timestamp"}; + spdlog::memory_buf_t output; + EXPECT_THROW(formatter.format(record, output), std::out_of_range); + EXPECT_EQ(output.size(), 0); +} +#endif + +TEST_F(LoggingTest, RotatesByUtf8BytesAndRetainsBackupsAcrossRestart) { + const auto path = directory / "application.log"; + logging::Formatter formatter; + const auto text = render(formatter, spdlog::level::info, "test", u8"é", "", 0); + for (int i = 0; i < 7; ++i) { + auto logger = logging::make_logger("test", {logging::rolling_file_sink(path.string(), text.size() * 2, 2)}); + logger->log(spdlog::log_clock::time_point(123ms), {}, spdlog::level::info, u8"é"); + logger->flush(); + } + EXPECT_EQ(read_file(path), text); + EXPECT_EQ(read_file(directory / "application.1.log"), text + text); + EXPECT_EQ(read_file(directory / "application.2.log"), text + text); + EXPECT_FALSE(std::filesystem::exists(directory / "application.3.log")); +} + +TEST_F(LoggingTest, OversizedRecordIsNotSplitOrRotatedFromEmptyFile) { + auto logger = logging::make_logger("test", {logging::rolling_file_sink((directory / "large.log").string(), 1, 1)}); + logger->info("complete record"); + EXPECT_FALSE(std::filesystem::exists(directory / "large.1.log")); + logger->info("next record"); + EXPECT_NE(read_file(directory / "large.1.log").find("complete record"), std::string::npos); +} + +TEST(LoggingIntegration, MultipleNamedLoggersShareIndependentlyFilteredSinks) { + std::ostringstream stream; + auto output = std::make_shared(stream); + output->set_level(spdlog::level::info); + std::vector received; + auto callback = logging::callback_sink([&](const auto &text) { + received.push_back(text); + }); + callback->set_level(spdlog::level::warn); + auto first = logging::make_logger("first", {output, callback}, {"", {"secret"}}); + auto second = logging::make_logger("second", {output, callback}, {"", {"secret"}}); + first->debug("ignored"); + LIZARDBYTE_LOG(first, spdlog::level::info, "hello {}", 1); + second->warn("secret"); + first->flush(); + ASSERT_EQ(received.size(), 1); + EXPECT_NE(received[0].find("[second]"), std::string::npos); + EXPECT_NE(received[0].find("***"), std::string::npos); + EXPECT_EQ(stream.str().find("ignored"), std::string::npos); + EXPECT_NE(stream.str().find("test_logging.cpp:"), std::string::npos); +} + +TEST(LoggingIntegration, FailuresUseNativeErrorHandlerAndOtherSinksStillRun) { + auto failure = logging::callback_sink([](const auto &) { + throw std::runtime_error("transport failed"); + }); + auto memory = std::make_shared(2); + auto logger = logging::make_logger("errors", {failure, memory}); + std::string error; + logger->set_error_handler([&](const auto &message) { + error = message; + }); + logger->warn("still retained"); + EXPECT_NE(error.find("transport failed"), std::string::npos); + ASSERT_EQ(memory->last_formatted().size(), 1); +} + +TEST(LoggingIntegration, ConcurrentAndAsyncDeliveryUseNativeSinks) { + std::atomic count = 0; + auto callback = logging::callback_sink([&](const auto &) { + ++count; + }); + auto logger = logging::make_logger("concurrent", {callback}); + std::vector workers; + for (int i = 0; i < 4; ++i) { + workers.emplace_back([&] { + for (int j = 0; j < 50; ++j) { + logger->info("record"); + } + }); + } + for (auto &worker : workers) { + worker.join(); + } + EXPECT_EQ(count, 200); + { + auto pool = std::make_shared(16, 1); + auto async = std::make_shared("async", callback, pool, spdlog::async_overflow_policy::block); + async->info("queued"); + async->flush(); + } + EXPECT_EQ(count, 201); +} + +TEST(LoggingIntegration, RejectsInvalidConfigurationAndSupportsPlainConsole) { + EXPECT_THROW(static_cast(logging::make_logger("test", {nullptr})), std::invalid_argument); + EXPECT_THROW(static_cast(logging::callback_sink({})), std::invalid_argument); + EXPECT_THROW(static_cast(logging::rolling_file_sink("unused.log", 0, 1)), std::invalid_argument); + EXPECT_THROW(static_cast(logging::rolling_file_sink("unused.log", 1, 0)), std::invalid_argument); + EXPECT_THROW(static_cast(logging::rolling_file_sink("unused.log", 1, 200001)), std::invalid_argument); + EXPECT_NE(logging::console_sink(spdlog::color_mode::never), nullptr); +} diff --git a/tests/fixtures/logging-format.txt b/tests/fixtures/logging-format.txt new file mode 100644 index 0000000..81ae2a3 --- /dev/null +++ b/tests/fixtures/logging-format.txt @@ -0,0 +1,9 @@ +1970-01-01T00:00:00.123Z [TRACE] [network] [src/client.cpp:42] héllo +1970-01-01T00:00:00.123Z [DEBUG] [network] [src/client.cpp:42] héllo +1970-01-01T00:00:00.123Z [INFO] [network] [src/client.cpp:42] héllo +1970-01-01T00:00:00.123Z [WARN] [network] [src/client.cpp:42] héllo +1970-01-01T00:00:00.123Z [ERROR] [network] [src/client.cpp:42] héllo +1970-01-01T00:00:00.123Z [CRITICAL] [network] [src/client.cpp:42] héllo +1970-01-01T00:00:00.123Z [INFO] [net\x5d\x0a] [src/client.cpp:42] *** +1970-01-01T00:00:00.123Z [INFO] [net\x5d\x0a] [src/client.cpp:42] next\x09\x1b[31m +1970-01-01T00:00:00.123Z [INFO] [app] [unknown:?] metadata absent diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py new file mode 100644 index 0000000..eda1489 --- /dev/null +++ b/tests/unit/test_logging.py @@ -0,0 +1,182 @@ +"""Cross-language conformance and standard-library integration tests.""" + +from concurrent.futures import ThreadPoolExecutor +import io +import logging +from logging.handlers import MemoryHandler, QueueHandler, QueueListener +from pathlib import Path +from queue import Queue +import sys + +import pytest + +from lizardbyte_common import logging as common + + +def record(level=logging.INFO, name='network', message='héllo', file='/workspace/project/src/client.cpp', line=42): + value = logging.LogRecord(name, level, file, line, message, (), None) + value.created = 0.123 + return value + + +def test_shared_golden_bytes(): + formatter = common.Formatter('/workspace/project', secrets=['secret', 'secret-long', '']) + actual = ''.join(formatter.format(record(level)) + '\n' for level in [5, 10, 20, 30, 40, 50]) + actual += formatter.format(record(name='net]\n', message='secret-long\r\nnext\t\x1b[31m')) + '\n' + actual += formatter.format(record(name='', message='metadata absent', file='', line=0)) + '\n' + fixture = Path(__file__).parents[1] / 'fixtures/logging-format.txt' + assert actual.encode() == fixture.read_bytes() + + +def test_empty_messages_and_trailing_newlines(): + prefix = '1970-01-01T00:00:00.123Z [INFO] [app] [unknown:?] ' + formatter = common.Formatter() + assert formatter.format(record(name='', message='', file='', line=0)) == prefix + assert formatter.format(record(name='', message='x\r', file='', line=0)) == prefix + 'x\n' + prefix + + +@pytest.mark.parametrize(('file', 'root', 'expected'), [ + ('C:\\Projects\\App\\src\\main.py', 'c:/projects/app/', 'src/main.py'), + ('/app2/src/main.py', '/app', '/app2/src/main.py'), + ('./src/main.py', '/app', 'src/main.py'), + ('/src/main.py', '/', 'src/main.py'), + ('', '', 'unknown'), +]) +def test_source_paths(file, root, expected): + assert common.source_path(file, root) == expected + + +def test_color_only_marks_first_severity(): + text = common.Formatter(color=True).format(record(message='first\nsecond')) + assert text.count('\x1b[') == 2 + assert '[\x1b[32mINFO\x1b[0m]' in text + assert '\n1970-01-01T00:00:00.123Z [INFO]' in text + + +def test_console_detects_terminal_and_keeps_stream_owned(monkeypatch): + stream = io.StringIO() + stream.isatty = lambda: True + handler = common.console_handler(stream=stream) + handler.handle(record()) + assert '\x1b[32m' in stream.getvalue() + handler.close() + assert not stream.closed + plain = common.console_handler(stream=io.StringIO(), color=False) + assert '\x1b' not in plain.format(record()) + monkeypatch.setattr(sys, 'stdout', stream) + assert common.console_handler().stream is stream + + +def test_traceback_stack_and_cached_exception_are_redacted(): + value = record(message='token %s') + value.args = ('secret',) + try: + raise ValueError('secret') + except ValueError: + value.exc_info = sys.exc_info() + value.stack_info = 'stack secret' + formatter = common.Formatter(secrets=['secret']) + output = formatter.format(value) + assert 'ValueError: ***' in output + assert 'stack ***' in output + assert 'secret' not in output + assert value.args == ('secret',) + value.exc_info = None + value.exc_text = 'cached secret' + assert 'cached ***' in formatter.format(value) + + +def test_rollover_boundary_unicode_retention_and_restart(tmp_path): + path = tmp_path / 'nested/application.log' + value = record(name='test', message='é', file='', line=0) + text = (common.Formatter().format(value) + '\n').encode() + for _ in range(7): + handler = common.RollingFileHandler(path, max_bytes=len(text) * 2, backup_count=2) + handler.handle(value) + handler.close() + assert path.read_bytes() == text + assert path.with_name('application.1.log').read_bytes() == text * 2 + assert path.with_name('application.2.log').read_bytes() == text * 2 + assert not path.with_name('application.3.log').exists() + assert b'\r' not in text + + +def test_oversized_record_is_preserved_and_no_empty_backup(tmp_path): + path = tmp_path / 'large.log' + handler = common.RollingFileHandler(path, max_bytes=1, backup_count=1) + handler.handle(record(message='complete record')) + assert not path.with_name('large.1.log').exists() + handler.handle(record(message='next record')) + handler.close() + assert b'complete record' in path.with_name('large.1.log').read_bytes() + + +@pytest.mark.parametrize(('max_bytes', 'backups'), [(0, 1), (1, 0), (1, 200001)]) +def test_invalid_limits(tmp_path, max_bytes, backups): + with pytest.raises(ValueError, match='Positive'): + common.RollingFileHandler(tmp_path / 'unused.log', max_bytes=max_bytes, backup_count=backups) + + +@pytest.mark.parametrize(('base', 'expected'), [ + ('app.log.1', 'app.1.log'), ('app.1', 'app.1'), ('.hidden.1', '.hidden.1'), +]) +def test_backup_names(base, expected): + assert common.RollingFileHandler._backup_name(base) == expected + + +def test_multiple_named_loggers_independent_sinks_and_callbacks(): + stream = io.StringIO() + console = common.console_handler(stream=stream, level=logging.INFO) + received = [] + callback = common.CallbackHandler(received.append, secrets=['secret']) + loggers = [logging.Logger('first', common.TRACE), logging.Logger('second', common.TRACE)] + for logger in loggers: + logger.addHandler(console) + logger.addHandler(callback) + loggers[0].debug('ignored') + loggers[0].info('first message') + loggers[1].warning('secret') + assert len(received) == 1 + assert '[second]' in received[0] + assert '***' in received[0] + assert received[0].endswith('\n') + assert 'ignored' not in stream.getvalue() + + +def test_callback_failure_uses_native_error_handler(monkeypatch): + def fail(_): + raise OSError('transport failed') + handler = common.CallbackHandler(fail) + errors = [] + monkeypatch.setattr(handler, 'handleError', errors.append) + value = record(logging.WARNING) + handler.handle(value) + assert errors == [value] + + +def test_bounded_startup_buffer_and_async_queue(): + received = [] + target = common.CallbackHandler(received.append, level=common.TRACE) + memory = MemoryHandler(capacity=2, flushLevel=logging.ERROR, target=target) + memory.handle(record(message='startup')) + assert not received + memory.flush() + assert 'startup' in received[0] + queue = Queue(maxsize=64) + listener = QueueListener(queue, target, respect_handler_level=True) + logger = logging.Logger('worker', common.TRACE) + logger.addHandler(QueueHandler(queue)) + listener.start() + logger.info('asynchronous') + listener.stop() + assert 'asynchronous' in received[-1] + memory.close() + + +def test_threaded_records_remain_complete(): + received = [] + handler = common.CallbackHandler(received.append, level=common.TRACE) + with ThreadPoolExecutor(max_workers=4) as pool: + list(pool.map(lambda index: handler.handle(record(message=str(index))), range(100))) + assert len(received) == 100 + assert all(text.count('\n') == 1 for text in received) diff --git a/third-party/spdlog b/third-party/spdlog new file mode 160000 index 0000000..79524dd --- /dev/null +++ b/third-party/spdlog @@ -0,0 +1 @@ +Subproject commit 79524ddd08a4ec981b7fea76afd08ee05f83755d