diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c096c71..d59c7d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,11 @@ jobs: # Safety net: a hung/looping test should fail the job quickly instead of burning the runner's full time # allotment. --test-timeout-seconds below covers a single hung test; this covers everything else. A sanitizer # build compiles and runs several times slower, so it gets longer. - timeout-minutes: ${{ matrix.sanitize && 10 || 5 }} + timeout-minutes: ${{ matrix.sanitize && 15 || 10 }} + env: + # cmake --build compiles up to 4 files at once (the runners have 4 cores). The Visual Studio generator only + # builds projects in parallel with it, so the MSVC job also compiles with -MP (its cxxflags). + CMAKE_BUILD_PARALLEL_LEVEL: 4 strategy: fail-fast: false matrix: @@ -33,6 +37,11 @@ jobs: generator: "" build_args: "--config Release" exe_path: build/bin/Release/ASWLogTests.exe + # cl takes -MP as well as /MP; Git Bash would turn "/MP" into a path when it starts CMake. + cxxflags: -MP + # A multi-config generator, so the package check builds and installs both into the same prefix (the Debug + # library is ASWLogd) + package_configs: Debug Release sanitize: "" - name: Windows (MinGW) os: windows-latest @@ -101,6 +110,10 @@ jobs: export CC="${{ matrix.cc }}" export CXX="${{ matrix.cxx }}" fi + # CMake adds CXXFLAGS to its default flags (setting CMAKE_CXX_FLAGS instead would replace them). + if [ -n "${{ matrix.cxxflags }}" ]; then + export CXXFLAGS="${{ matrix.cxxflags }}" + fi if [ -n "${{ matrix.generator }}" ]; then cmake -S tests/cmake -B build -G "${{ matrix.generator }}" -DCMAKE_BUILD_TYPE=Release \ -DASWLOG_SANITIZE="${{ matrix.sanitize }}" @@ -156,3 +169,90 @@ jobs: name: test-results-${{ matrix.name }} path: junit-results.xml if-no-files-found: error + + - name: Check the installed CMake package + # Builds and installs the library (the root project) to a temporary prefix, then builds tests/package against + # it with find_package() and runs it, which checks the install rules and the package files. Not in the + # sanitizer jobs: they build the same CMake files, and the package would pass their flags on to the consumer. + if: ${{ !matrix.sanitize }} + shell: bash + run: | + if [ -n "${{ matrix.cc }}" ]; then + export CC="${{ matrix.cc }}" + export CXX="${{ matrix.cxx }}" + fi + if [ -n "${{ matrix.cxxflags }}" ]; then + export CXXFLAGS="${{ matrix.cxxflags }}" + fi + generatorArgs=() + if [ -n "${{ matrix.generator }}" ]; then + generatorArgs=(-G "${{ matrix.generator }}") + fi + # Forward slashes, which CMake takes on Windows too (RUNNER_TEMP has backslashes there) + prefix="${RUNNER_TEMP//\\//}/aswlog" + configs="${{ matrix.package_configs || 'Release' }}" + cmake -S . -B build-install "${generatorArgs[@]}" -DCMAKE_BUILD_TYPE=Release + for config in $configs; do + cmake --build build-install --config "$config" + cmake --install build-install --config "$config" --prefix "$prefix" + done + cmake -S tests/package -B build-package "${generatorArgs[@]}" -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$prefix" + for config in $configs; do + cmake --build build-package --config "$config" + ctest --test-dir build-package -C "$config" --output-on-failure + done + + coverage: + # Which lines of the library (ASWLog/) the tests run, on Linux: a table on this job's summary page, and an HTML + # report (each source line marked) as an artifact. Informational: continue-on-error keeps it from failing the run; + # the jobs above are the ones that must pass. + name: Linux (GCC, coverage) + runs-on: ubuntu-latest + timeout-minutes: 15 + continue-on-error: true + env: + CMAKE_BUILD_PARALLEL_LEVEL: 4 + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + submodules: true + + - name: Configure + # Debug (no optimization), so each line is counted as written. -fprofile-update=atomic keeps the counts right + # in the tests that log from several threads at once; without it the threads' updates race and get lost. + env: + CC: gcc + CXX: g++ + CXXFLAGS: "--coverage -fprofile-update=atomic" + run: cmake -S tests/cmake -B build -DCMAKE_BUILD_TYPE=Debug + + - name: Build + run: cmake --build build + + - name: Run unit tests + # A failing test fails the jobs above; the coverage of this run is still worth reporting + continue-on-error: true + run: ./build/bin/ASWLogTests --test-timeout-seconds 120 --catch-crashes + + - name: Install gcovr + # 8.4 added the Markdown report. pipx installs it in an environment of its own (the runner's system Python + # refuses pip installs). + run: pipx install "gcovr>=8.4" + + - name: Coverage report + # The crash handlers' tests crash child processes, which end before they can write their counts, so the crash + # paths show as not run. + run: | + mkdir -p coverage-html + gcovr --root . --filter 'ASWLog/' --print-summary --markdown coverage.md \ + --html-details coverage-html/index.html + cat coverage.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload coverage report + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: coverage-html/ + if-no-files-found: error diff --git a/AGENTS.md b/AGENTS.md index 838d34d..e219277 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,15 +6,17 @@ These instructions apply to the entire repository unless a more specific `AGENTS ## Repository Overview -ASWLog is a thread-safe light-weight C++ logging tool for Windows and Linux projects. +ASWLog is a thread-safe, dependency-free C++20 logging library for Windows and Linux. - `ASWLog/` contains the framework implementation. -- `tests/` contains the unit tests: test modules in `tests/tests/`, their CMake project in `tests/cmake/`, their RAD Studio console project in `tests/rad370/`, and their RAD Studio VCL GUI runner project in `tests/vcl/gui/rad370/` (built from the submodule's `vcl/gui/src`). `tests/compile_checks/` holds "must not compile" checks, which `tests/cmake/CMakeLists.txt` runs with `try_compile` when configuring (a failed check stops the configure). +- `tests/` contains the unit tests: test modules (`Test_ASWLog_`, the tests of `ASWLog_`) and shared test helpers (`UT_Helper_`, e.g. `UT_Helper_DateTime`) in `tests/tests/`, their CMake project in `tests/cmake/`, their RAD Studio console project in `tests/rad370/`, and their RAD Studio VCL GUI runner project in `tests/vcl/gui/rad370/` (built from the submodule's `vcl/gui/src`). `tests/compile_checks/` holds "must not compile" checks, which `tests/cmake/CMakeLists.txt` runs with `try_compile` when configuring (a failed check stops the configure). `tests/package/` is a project that builds against an installed ASWLog with `find_package` (CI installs the library and runs it). +- `CMakeLists.txt` (the root) defines the `ASWLog::ASWLog` library target and its install rules; `cmake/ASWLogConfig.cmake.in` is the template of the installed package file. The project version is read from `ASWLog/ASWLog_Version.h`. - `third_party/asw-unit-tests/` is the ASWUnitTests framework, a git submodule pinned to a release tag. Never edit files in it; test modules self-register with `ASW_REGISTER_TEST_GROUP`, so adding a test only touches `tests/` and its build files. -- `.github/workflows/ci.yml` builds and runs the unit tests on Windows (MSVC, MinGW) and Linux (GCC, Clang), plus Linux Clang builds with ThreadSanitizer and with AddressSanitizer + UBSan (`ASWLOG_SANITIZE`). +- `.github/workflows/ci.yml` builds and runs the unit tests on Windows (MSVC, MinGW) and Linux (GCC, Clang), plus Linux Clang builds with ThreadSanitizer and with AddressSanitizer + UBSan (`ASWLOG_SANITIZE`); the non-sanitizer jobs also check the installed CMake package (`tests/package/`). A Linux GCC coverage job (informational, it never fails the run) shows the library's line coverage on its summary page and uploads an HTML report. - `example/` contains example app that uses the logger. - `example/rad370/` contains the RAD Studio 13.1 project and Windows build scripts. -- `README.md` contains repo details. +- `README.md` contains repo details: features, quick start, project setup, API overview, versions and tests. +- `docs/` contains the reference documentation: `Configuration.md` (every `TASWLogConfig` setting, its default and which loggers use it, and the environment variables), `Guarantees.md` (thread safety, order, callback threads, lifetimes, crash durability) and `CustomLoggers.md` (custom formatters and loggers). - `CHANGELOG.md` records notable changes per release (see Changelog below). - `CONTRIBUTING.md` contains the guidelines for contributors (issues, branches, commit history, pull requests, and bug reports); it defers to this file for coding rules, so keep the two consistent. - `.uncrustify.cfg` and `.githooks/` define the repository formatting workflow. @@ -53,6 +55,7 @@ Prefer standard C++ and portable library facilities. Avoid compiler-, IDE-, or o - Follow the surrounding naming, indentation, brace, include, and comment style. Avoid unrelated formatting changes. - Keep declarations and definitions consistent, and update all relevant project or build files when adding source files. - Add or update focused tests for behavior changes, including error and platform-specific cases where applicable. +- Keep the documentation in step: a new or changed `TASWLogConfig` setting or environment key goes in `docs/Configuration.md`, a new logger in the README's API overview, and a change to threading, lifetimes or durability in `docs/Guarantees.md`. Add a new docs file to `Deploy.bat`. ## Building and Testing @@ -67,7 +70,7 @@ cmake --build tests/cmake/build --config Release The VCL GUI runner builds with `tests/vcl/gui/rad370/Build_Win64x_Debug.bat` or `Build_Win64x_Release.bat` into the same output folder. To run every test in it without interaction, use `start /wait ASWLogTests_VCL_GUI.exe --run --exit --layout-ignore --report-junit results.xml`; its exit code matches the console runner's. -When adding a test module, add its `.cpp`/`.h` to `tests/cmake/CMakeLists.txt`, `tests/rad370/ASWLogTests.cbproj`, and `tests/vcl/gui/rad370/ASWLogTests_VCL_GUI.cbproj`, and keep their include paths in sync. Never open the GUI's main form (`ASWUnitTests_GUI_MainForm`) in the RAD Studio designer and save it, since that writes into the submodule. +When adding a test module or a test helper, add its `.cpp`/`.h` to `tests/cmake/CMakeLists.txt`, `tests/rad370/ASWLogTests.cbproj`, and `tests/vcl/gui/rad370/ASWLogTests_VCL_GUI.cbproj`, and keep their include paths in sync. Never open the GUI's main form (`ASWUnitTests_GUI_MainForm`) in the RAD Studio designer and save it, since that writes into the submodule. For other environments, use the repository's CMake configuration when present or the IDE's native project configuration. Tests should be run outside the debugger unless debugging an expected exception is intentional. Before considering a change complete: diff --git a/ASWLog/ASWLog_Base.cpp b/ASWLog/ASWLog_Base.cpp index 5932ceb..2c0924c 100644 --- a/ASWLog/ASWLog_Base.cpp +++ b/ASWLog/ASWLog_Base.cpp @@ -79,6 +79,47 @@ class TReportingErrorScope TReportingErrorScope& operator=(const TReportingErrorScope&) = delete; }; +// True while this thread is in a logger's OnBeforeWrite hook (see TASWLogBase::WriteThroughHook()). Read only by a +// logger that has a hook, since a thread_local read is a library call with some compilers (e.g. MinGW's GCC). +thread_local bool IsRunningBeforeWrite = false; + +// Sets IsRunningBeforeWrite while alive (hooks don't nest: an entry logged inside one skips them) +class TRunningBeforeWriteScope +{ +public: + TRunningBeforeWriteScope() noexcept + { + IsRunningBeforeWrite = true; + } + + ~TRunningBeforeWriteScope() + { + IsRunningBeforeWrite = false; + } + + TRunningBeforeWriteScope(const TRunningBeforeWriteScope&) = delete; + TRunningBeforeWriteScope& operator=(const TRunningBeforeWriteScope&) = delete; +}; + +//--------------------------------------------------------------------------- + +void ForgetCopiedViews(TASWLogRecord& record) noexcept; + +//--------------------------------------------------------------------------- + +/* + ForgetCopiedViews + + Empties the views of a kept record whose texts and fields are held in copies beside it (see TBacktraceEntry) +*/ +void ForgetCopiedViews(TASWLogRecord& record) noexcept +{ + record.Message = {}; + record.Category = {}; + record.Fields = nullptr; + record.Scope = nullptr; +} + } // namespace //--------------------------------------------------------------------------- @@ -171,20 +212,28 @@ void TASWLogBase::KeepInBacktrace(const TASWLogRecord& record) if (m_BacktraceCapacity == 0) return; // Switched off since the gate let the entry in + // The fields are copied first, so a failed copy changes nothing + Detail::TOwnedFields fields; + fields.AssignMerged(record.Scope, record.GetOwnFields()); + if (m_Backtrace.size() < m_BacktraceCapacity) { - // The message is copied first, so a failed copy leaves no empty entry behind - TBacktraceEntry entry{ record, std::string(record.Message) }; - entry.Record.Message = {}; + // The texts are copied first, so a failed copy leaves no empty entry behind + TBacktraceEntry entry{ record, std::string(record.Message), std::string(record.Category), std::move(fields) }; + ForgetCopiedViews(entry.Record); m_Backtrace.push_back(std::move(entry)); } else { - // Replaces the oldest, reusing its message's memory + // Replaces the oldest, reusing its message's memory. The category is copied first and swapped in last, so a + // failed copy leaves the oldest entry as it was. auto& entry = m_Backtrace[m_BacktraceOldest]; + std::string category(record.Category); entry.Message.assign(record.Message); + entry.Category.swap(category); + entry.Fields = std::move(fields); entry.Record = record; - entry.Record.Message = {}; + ForgetCopiedViews(entry.Record); m_BacktraceOldest = (m_BacktraceOldest + 1) % m_BacktraceCapacity; } @@ -292,6 +341,8 @@ std::shared_ptr TASWLogBase::SetConfig(const TASWLogConfig& m_Config.swap(snapshot); } + m_HasBeforeWrite.store(config.OnBeforeWrite != nullptr, std::memory_order_relaxed); + if (KeepsBacktrace()) ApplyBacktraceConfig(config.Backtrace); @@ -333,30 +384,25 @@ void TASWLogBase::Write(const TASWLogRecord& record) noexcept { // Off isn't a severity and a disabled logger writes nothing, even when forced; a forced entry ignores only the // minimum level. Checked before stamping, so a filtered entry costs no clock or thread id read. - const bool isAccepted = record.Forced ? record.LogLevel != Level::Off && IsEnabled() : PassesLevelGate(record.LogLevel); - if (!isAccepted) + if (!PassesLevelGate(record)) return; // Stamped now, on the calling thread and before any lock, so the time is the moment of the call TASWLogRecord stampedRecord = record; StampRecord(stampedRecord); + if (stampedRecord.Scope == nullptr) + stampedRecord.Scope = TASWLogScope::GetCurrent(); + // A derived logger's WriteRecord() may throw (e.g. out of memory, or a custom logger's own error); logging must // never throw into the application, so the entry is dropped instead try { - // Let in only for the backtrace. Read again: if the minimum level changed since the gate, the entry is kept or - // written as if it had been logged just before or after the change. - if (!record.Forced && record.LogLevel < GetMinimumLevel()) - { - KeepInBacktrace(stampedRecord); - return; - } - - if (m_HasBacktrace.load(std::memory_order_relaxed) && record.LogLevel >= m_BacktraceDumpLevel.load(std::memory_order_relaxed)) - WriteBacktrace(); - - WriteRecord(stampedRecord); + // An entry logged from inside a hook is written as it is, so a hook that logs doesn't recurse + if (m_HasBeforeWrite.load(std::memory_order_relaxed) && !IsRunningBeforeWrite) + WriteThroughHook(stampedRecord); + else + WriteOrKeep(stampedRecord); } catch (...) { @@ -394,15 +440,77 @@ void TASWLogBase::WriteBacktrace() for (const auto& entry : entries) { + const auto fields = entry.Fields.Get(); + auto record = entry.Record; record.Message = entry.Message; + record.Category = entry.Category; + record.Fields = &fields; record.Forced = true; + WriteRecord(record); } writeMarker(Detail::BacktraceEndText); } +//--------------------------------------------------------------------------- +/* + TASWLogBase::WriteOrKeep + + Write()'s last step, for a stamped entry that passed the gate (and the hook, if any): keeps it in the backtrace if + only the backtrace let it in, else writes the backtrace first if the entry's level dumps it, then the entry. +*/ +void TASWLogBase::WriteOrKeep(const TASWLogRecord& record) +{ + // Read again: if the minimum level changed since the gate, the entry is kept or written as if it had been logged + // just before or after the change + if (!record.Forced && record.LogLevel < GetMinimumLevelFor(record)) + { + KeepInBacktrace(record); + return; + } + + if (m_HasBacktrace.load(std::memory_order_relaxed) && record.LogLevel >= m_BacktraceDumpLevel.load(std::memory_order_relaxed)) + WriteBacktrace(); + + WriteRecord(record); +} + +//--------------------------------------------------------------------------- +/* + TASWLogBase::WriteThroughHook + + Passes a stamped entry to the config's OnBeforeWrite hook, then on to WriteOrKeep() as the hook changed it, unless + the hook drops it. A hook that throws drops the entry, which is reported. +*/ +void TASWLogBase::WriteThroughHook(const TASWLogRecord& record) +{ + const auto config = GetConfig(); + if (config->OnBeforeWrite == nullptr) + { + WriteOrKeep(record); // Reconfigured without a hook since Write() checked + return; + } + + TASWPendingEntry entry(record); + bool isKept = false; + + try + { + TRunningBeforeWriteScope runningScope; + isKept = config->OnBeforeWrite(entry); + } + catch (...) + { + ReportError(*config, MakeExceptionError("Dropped an entry, OnBeforeWrite threw")); + return; + } + + if (isKept) + WriteOrKeep(entry.GetRecord()); +} + //--------------------------------------------------------------------------- /* TASWLogBase::WriteErrorToStdErr diff --git a/ASWLog/ASWLog_Base.h b/ASWLog/ASWLog_Base.h index 5035cd4..1a6cf3f 100644 --- a/ASWLog/ASWLog_Base.h +++ b/ASWLog/ASWLog_Base.h @@ -2,7 +2,11 @@ ASWLog_Base.h Author: Anthony S. West - ASW Software -A light-weight logging tool. +A logging library. + +TASWLogBase, the base of the built-in loggers: the minimum level, the +enabled flag, the backtrace, the config and the error reporting that every +logger shares. Requires C++ 20 or higher. @@ -112,12 +116,14 @@ class TASWLogBase : public IASWLog std::atomic m_Levels{ PackLevels(Level::Info, Level::Off) }; std::atomic m_IsEnabled{ true }; - // An entry the backtrace keeps (see TASWBacktraceConfig): the record, with its message copied (the record's - // Message is empty) + // An entry the backtrace keeps (see TASWBacktraceConfig): the record, with its message and category copied (the + // record's Message and Category are empty) struct TBacktraceEntry { - TASWLogRecord Record; + TASWLogRecord Record; // Its Message, Category, Fields and Scope are empty: the copies below hold them std::string Message; + std::string Category; + Detail::TOwnedFields Fields; // The entry's fields and its scopes', merged }; // The backtrace, guarded by m_BacktraceMutex: at most m_BacktraceCapacity entries, the oldest at m_BacktraceOldest @@ -134,6 +140,7 @@ class TASWLogBase : public IASWLog // m_ConfigMutex held, and the derived logger with its own lock held (see GetConfigUnlocked()). mutable Detail::TMutex m_ConfigMutex; std::shared_ptr m_Config{ std::make_shared() }; + std::atomic m_HasBeforeWrite{ false }; // The config has an OnBeforeWrite hook; read for each written entry // When ReportError() last reported each ErrorKind, and how many it left out since (see // TASWLogConfig::ErrorReportInterval). Guarded by m_ErrorReportMutex. @@ -153,6 +160,8 @@ class TASWLogBase : public IASWLog std::vector TakeBacktrace() noexcept; void WriteBacktrace(); void WriteErrorToStdErr(const TASWLogError& error) const; + void WriteOrKeep(const TASWLogRecord& record); + void WriteThroughHook(const TASWLogRecord& record); protected: std::atomic m_IsInitialized{ false }; @@ -176,6 +185,13 @@ class TASWLogBase : public IASWLog // Pure virtual helper so the base class knows what implementation name to print virtual std::string_view GetLoggerClassName() const noexcept = 0; + // The minimum level 'record' needs: its category's level if set (see TASWLogRecord::CategoryLevel), else this + // logger's + Level GetMinimumLevelFor(const TASWLogRecord& record) const noexcept + { + return record.CategoryLevel ? *record.CategoryLevel : GetMinimumLevel(); + } + // Whether this logger keeps a backtrace when its config asks for one (see TASWBacktraceConfig), read by SetConfig(). // True by default; a logger that passes its entries on to other loggers, which keep their own (a multi-log), // returns false, and its gate is then its minimum level alone. @@ -194,6 +210,23 @@ class TASWLogBase : public IASWLog level >= GateLevelOf(m_Levels.load(std::memory_order_relaxed)); } + // This logger's check for 'record', as Write() makes it (see ShouldLog(const TASWLogRecord&)): a forced entry + // needs only to be enabled and not Off; a category's level, if set, takes the minimum level's place, and the + // backtrace's level still lets in what it keeps. + bool PassesLevelGate(const TASWLogRecord& record) const noexcept + { + if (record.Forced) + return record.LogLevel != Level::Off && IsEnabled(); + + if (!record.CategoryLevel) + return PassesLevelGate(record.LogLevel); + + const Level backtraceLevel = BacktraceLevelOf(m_Levels.load(std::memory_order_relaxed)); + + return m_IsEnabled.load(std::memory_order_relaxed) && record.LogLevel != Level::Off && + record.LogLevel >= GateLevelOf(PackLevels(*record.CategoryLevel, backtraceLevel)); + } + // The current time, used for log line timestamps, daily rolling, and backup file names. Override it to control // the logger's clock, e.g. in tests. A system_clock time point has no time zone (it counts from the UTC epoch). virtual std::chrono::system_clock::time_point NowUTC() const noexcept @@ -225,10 +258,10 @@ class TASWLogBase : public IASWLog void StampRecord(TASWLogRecord& record) const noexcept; // For a crash handler (see TASWTextLogBase): if the backtrace's lock is free, calls visit(record, index, count) for - // each kept entry, oldest first ('record' is marked Forced, and its Message refers to the kept copy, valid during the - // call), then forgets them if 'mustClear', and returns true. Returns false at once if the lock is busy (e.g. the - // crashed thread holds it). Allocates nothing itself, except that clearing frees the copies, so with a visitor that - // doesn't allocate and 'mustClear' false, it can run in a signal handler. + // each kept entry, oldest first ('record' is marked Forced, and its Message, Category and Fields refer to the kept + // copies, valid during the call), then forgets them if 'mustClear', and returns true. Returns false at once if the + // lock is busy (e.g. the crashed thread holds it). Allocates nothing itself, except that clearing frees the copies, + // so with a visitor that doesn't allocate and 'mustClear' false, it can run in a signal handler. template bool VisitBacktraceForCrash(TVisit&& visit, bool mustClear) { @@ -240,8 +273,11 @@ class TASWLogBase : public IASWLog for (std::size_t index = 0; index < count; ++index) { const auto& entry = m_Backtrace[(m_BacktraceOldest + index) % count]; + const auto fields = entry.Fields.Get(); TASWLogRecord record = entry.Record; record.Message = entry.Message; + record.Category = entry.Category; + record.Fields = &fields; record.Forced = true; visit(static_cast(record), index, count); } @@ -257,7 +293,8 @@ class TASWLogBase : public IASWLog } // Called by Write() with each entry this logger writes: it passed the enabled, Level::Off and minimum level checks - // (see Write()), and has its Timestamp, ProcessId and ThreadId filled in. Called on the logging thread. May throw: + // (see Write()), has its Timestamp, ProcessId and ThreadId filled in, and is as the config's OnBeforeWrite hook, if + // any, changed it (the backtrace's entries were changed when kept). Called on the logging thread. May throw: // Write() catches the exception and drops the entry, so it never reaches the application. virtual void WriteRecord(const TASWLogRecord& record) = 0; @@ -314,12 +351,17 @@ class TASWLogBase : public IASWLog return PassesLevelGate(level); } + bool ShouldLog(const TASWLogRecord& record) const noexcept override + { + return PassesLevelGate(record); + } + public: - // Applies this logger's checks (enabled, not Level::Off, and the minimum level unless record.Forced), then stamps - // the record (see StampRecord()) and passes it to WriteRecord(). A filtered entry is never stamped. An entry below - // the minimum level that the backtrace keeps (see TASWBacktraceConfig) is stamped and kept instead; one at or above - // its DumpAtLevel first passes the kept entries to WriteRecord(), between the two marker lines. If WriteRecord() - // throws, the entry is dropped and reported (see ReportError()). + // Applies this logger's checks (enabled, not Level::Off, and the minimum level, or record.CategoryLevel if set, + // unless record.Forced), then stamps the record (see StampRecord()) and passes it to WriteRecord(). A filtered + // entry is never stamped. An entry below that minimum level that the backtrace keeps (see TASWBacktraceConfig) is + // stamped and kept instead; one at or above its DumpAtLevel first passes the kept entries to WriteRecord(), between + // the two marker lines. If WriteRecord() throws, the entry is dropped and reported (see ReportError()). void Write(const TASWLogRecord& record) noexcept final; }; diff --git a/ASWLog/ASWLog_CategoryLog.cpp b/ASWLog/ASWLog_CategoryLog.cpp new file mode 100644 index 0000000..3271ed9 --- /dev/null +++ b/ASWLog/ASWLog_CategoryLog.cpp @@ -0,0 +1,402 @@ +/* ************************************************************************** +ASWLog_CategoryLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWLog_CategoryLog.h" +//--------------------------------------------------------------------------- +// System includes here +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_Utils.h" +#include "ASWLog_Version.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +namespace +{ + +// A level TASWCategoryLog::ApplyLevels() gives the categories its name covers +struct TLevelRule +{ + std::string Name; // "*", or a category's name + Level MinimumLevel = Level::Info; +}; + +// The categories that exist, and the rules TASWCategoryLog::ApplyLevels() set last +struct TCategoryRegistry +{ + std::mutex Mutex; + std::vector Categories; + std::vector Rules; +}; + +//--------------------------------------------------------------------------- + +const TLevelRule* FindLevelRule(const std::vector& rules, std::string_view name) noexcept; +TCategoryRegistry& GetCategoryRegistry(); +std::string MakeCategoryName(std::string_view name, const IASWLog& log); +bool ParseLevelRules(std::string_view spec, std::vector& rules, std::string& error); + +//--------------------------------------------------------------------------- + +/* + FindLevelRule + + The rule of 'rules' with the longest name that covers the category 'name' (its own name, the name of a category + above it, or "*"), or null if none covers it +*/ +const TLevelRule* FindLevelRule(const std::vector& rules, std::string_view name) noexcept +{ + const TLevelRule* found = nullptr; + std::size_t foundLength = 0; + + for (const auto& rule : rules) + { + const bool isAll = rule.Name == "*"; + const std::size_t length = isAll ? 0 : rule.Name.size(); + const bool covers = isAll || (name.size() >= length && Detail::EqualsIgnoringCase(name.substr(0, length), rule.Name) && + (name.size() == length || name[length] == '.')); + + if (covers && (found == nullptr || length > foundLength)) + { + found = &rule; + foundLength = length; + } + } + + return found; +} + +/* + GetCategoryRegistry + + The process's category registry. Never destroyed, so categories destroyed at exit (e.g. static ones in other files) + can still leave it. +*/ +TCategoryRegistry& GetCategoryRegistry() +{ + static auto* const registry = new TCategoryRegistry(); + + return *registry; +} + +/* + MakeCategoryName + + The full name of a category called 'name' wrapping 'log': "." if 'log' is a category, else 'name' +*/ +std::string MakeCategoryName(std::string_view name, const IASWLog& log) +{ + const auto* category = dynamic_cast(&log); + if (category == nullptr) + return std::string(name); + + std::string fullName(category->GetName()); + fullName += '.'; + fullName.append(name); + return fullName; +} + +/* + ParseLevelRules + + Adds the "name=level" items of 'spec' to 'rules' (see TASWCategoryLog::ApplyLevels()), a name given again replacing + its earlier level. Returns false at the first invalid item, describing it in 'error'. +*/ +bool ParseLevelRules(std::string_view spec, std::vector& rules, std::string& error) +{ + std::size_t start = 0; + + while (start <= spec.size()) + { + const auto comma = spec.find(',', start); + const auto end = comma == std::string_view::npos ? spec.size() : comma; + const auto item = Detail::TrimSpaces(spec.substr(start, end - start)); + start = end + 1; + + if (item.empty()) + continue; + + const auto equals = item.find('='); + if (equals == std::string_view::npos) + { + error = std::format("'{}' has no '=' (expected name=level)", item); + return false; + } + + const auto name = Detail::TrimSpaces(item.substr(0, equals)); + const auto levelText = Detail::TrimSpaces(item.substr(equals + 1)); + + if (name.empty()) + { + error = std::format("'{}' has no category name", item); + return false; + } + + if (name != "*" && name.find('*') != std::string_view::npos) + { + error = std::format("'{}': a name can't hold '*' (a name covers the categories under it; \"*\" covers all)", item); + return false; + } + + const auto level = Level_FromString(levelText); + if (!level) + { + error = std::format("'{}': '{}' isn't a level (Trace, Debug, Info, Warn, Error, Critical or Off)", item, levelText); + return false; + } + + const auto existing = std::find_if(rules.begin(), rules.end(), [name](const TLevelRule& rule) + { + return Detail::EqualsIgnoringCase(rule.Name, name); + }); + + if (existing != rules.end()) + existing->MinimumLevel = *level; + else + rules.push_back(TLevelRule{ std::string(name), *level }); + } + + return true; +} + +} // namespace + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWCategoryLog +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TASWCategoryLog::TASWCategoryLog(std::string_view name, IASWLog& log) + : m_Log(log), + m_Name(MakeCategoryName(name, log)) +{ + auto& registry = GetCategoryRegistry(); + std::lock_guard lock(registry.Mutex); + + registry.Categories.push_back(this); + + if (const auto* rule = FindLevelRule(registry.Rules, m_Name)) + m_Level.store(static_cast(rule->MinimumLevel), std::memory_order_relaxed); +} + +//--------------------------------------------------------------------------- +TASWCategoryLog::~TASWCategoryLog() +{ + auto& registry = GetCategoryRegistry(); + std::lock_guard lock(registry.Mutex); + + std::erase(registry.Categories, this); +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::ApplyLevels(std::string_view spec) +{ + std::string error; + + return ApplyLevels(spec, error); +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::ApplyLevels(std::string_view spec, std::string& error) +{ + error.clear(); + + std::vector rules; + if (!ParseLevelRules(spec, rules, error)) + return false; + + auto& registry = GetCategoryRegistry(); + std::lock_guard lock(registry.Mutex); + + registry.Rules = std::move(rules); + + for (auto* category : registry.Categories) + { + // Not through SetMinimumLevel(), a virtual call: a category of a derived class may be in its destructor, + // waiting for the lock to leave the registry + if (const auto* rule = FindLevelRule(registry.Rules, category->m_Name)) + category->m_Level.store(static_cast(rule->MinimumLevel), std::memory_order_relaxed); + } + + return true; +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::Close() noexcept +{ + return false; +} + +//--------------------------------------------------------------------------- +void TASWCategoryLog::DumpBacktrace() noexcept +{ + if (IsEnabled()) + m_Log.DumpBacktrace(); +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::Flush() noexcept +{ + return m_Log.Flush(); +} + +//--------------------------------------------------------------------------- +std::shared_ptr TASWCategoryLog::GetConfig() const noexcept +{ + return m_Log.GetConfig(); +} + +//--------------------------------------------------------------------------- +std::string TASWCategoryLog::GetFullVersionStr() const +{ + return std::format("TASWCategoryLog - Base version {}", GetVersionStr()); +} + +//--------------------------------------------------------------------------- +Level TASWCategoryLog::GetMinimumLevel() const noexcept +{ + const auto level = m_Level.load(std::memory_order_relaxed); + return level == NoOwnLevel ? m_Log.GetMinimumLevel() : static_cast(level); +} + +//--------------------------------------------------------------------------- +std::string_view TASWCategoryLog::GetName() const noexcept +{ + return m_Name; +} + +//--------------------------------------------------------------------------- +std::string_view TASWCategoryLog::GetVersionStr() const noexcept +{ + return Version; // See ASWLog_Version.h +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::HasOwnMinimumLevel() const noexcept +{ + return m_Level.load(std::memory_order_relaxed) != NoOwnLevel; +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::Initialize(const TASWLogConfig& /*config*/) noexcept +{ + return false; +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::IsEnabled() const noexcept +{ + return m_IsEnabled.load(std::memory_order_relaxed); +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::IsOpen() const noexcept +{ + return m_Log.IsOpen(); +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::Open() noexcept +{ + return false; +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::Reconfigure(const TASWLogConfig& /*config*/) noexcept +{ + return false; +} + +//--------------------------------------------------------------------------- +void TASWCategoryLog::ResetMinimumLevel() noexcept +{ + m_Level.store(NoOwnLevel, std::memory_order_relaxed); +} + +//--------------------------------------------------------------------------- +void TASWCategoryLog::SetEnabled(bool enabled) noexcept +{ + m_IsEnabled.store(enabled, std::memory_order_relaxed); +} + +//--------------------------------------------------------------------------- +void TASWCategoryLog::SetMinimumLevel(Level level) noexcept +{ + m_Level.store(static_cast(level), std::memory_order_relaxed); +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::ShouldLog(Level level) const noexcept +{ + TASWLogRecord record; + record.LogLevel = level; + + return ShouldLog(record); +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::ShouldLog(const TASWLogRecord& record) const noexcept +{ + return IsEnabled() && m_Log.ShouldLog(WithCategory(record)); +} + +//--------------------------------------------------------------------------- +/* + TASWCategoryLog::WithCategory + + 'record' with this category's name and own level filled in, where a category wrapping this one hasn't set them +*/ +TASWLogRecord TASWCategoryLog::WithCategory(const TASWLogRecord& record) const noexcept +{ + TASWLogRecord result = record; + if (result.Category.empty()) + result.Category = m_Name; + + if (!result.CategoryLevel) + { + const auto level = m_Level.load(std::memory_order_relaxed); + if (level != NoOwnLevel) + result.CategoryLevel = static_cast(level); + } + + return result; +} + +//--------------------------------------------------------------------------- +void TASWCategoryLog::Write(const TASWLogRecord& record) noexcept +{ + if (IsEnabled()) + m_Log.Write(WithCategory(record)); +} + +//--------------------------------------------------------------------------- + +} // namespace ASWLog diff --git a/ASWLog/ASWLog_CategoryLog.h b/ASWLog/ASWLog_CategoryLog.h new file mode 100644 index 0000000..58c20f8 --- /dev/null +++ b/ASWLog/ASWLog_CategoryLog.h @@ -0,0 +1,160 @@ +/* ************************************************************************** +ASWLog_CategoryLog.h +Author: Anthony S. West - ASW Software + +A logging library. + +TASWCategoryLog, a named category of entries (e.g. "Net") with a minimum +level of its own, which wraps another logger. + +Requires C++ 20 or higher. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef ASWLog_CategoryLogH +#define ASWLog_CategoryLogH +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_Interface.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +///////////////////////////////////////////////////////////////////////////// +// TASWCategoryLog +// +// A named category of entries, e.g. "Net" or "Db", with a minimum level of its own. It wraps another logger (a file, +// console or multi-log, or another category) and passes every entry on to it, with the category's name in +// TASWLogRecord::Category, which the built-in layout shows after the level, e.g. "[Net]" (see +// TASWLineConfig::ShowCategory): +// static ASWLog::TASWCategoryLog NetLog("Net", ASWLog::TASWFileLog::GetInstance()); +// NetLog.LogDebugFmt("connected to {}", host); +// +// Until SetMinimumLevel() is called, the category follows the wrapped logger's minimum level. Once set, its level +// replaces the wrapped logger's for this category's entries, both ways: with the file logger at Info, +// NetLog.SetMinimumLevel(Level::Debug) lets Net's Debug entries in, and DbLog.SetMinimumLevel(Level::Warn) keeps Db's +// Info entries out. The wrapped logger's backtrace still keeps the entries below it (see TASWBacktraceConfig), and a +// multi-log's loggers still apply their own minimum levels. ResetMinimumLevel() makes it follow again. +// +// A category wrapping another category adds its name to that one's, e.g. "Net.Http" for HttpLog("Http", NetLog), and +// uses its own level if set, else that category's, and so on up to the wrapped logger's. +// +// The output belongs to the wrapped logger: GetConfig(), Flush(), IsOpen() and DumpBacktrace() pass on to it, while +// Initialize(), Reconfigure(), Open() and Close() do nothing and return false, so a category can't reconfigure or close +// a log the rest of the application uses. SetEnabled(false) silences only this category (and the categories wrapping +// it); the wrapped logger must be enabled too. +// +// The levels of several categories can also be set by name, e.g. from an environment variable, with ApplyLevels() (see +// TASWLogConfig::ApplyEnvironment()). +// +// Lock-free and thread-safe (making or destroying a category takes a process-wide lock, for ApplyLevels()). The wrapped +// logger must outlive the category, and must not be a multi-log that holds the category. A logger that keeps an entry +// beyond the call (e.g. in its backtrace) copies the category's name, so the category may be destroyed meanwhile. +///////////////////////////////////////////////////////////////////////////// +class TASWCategoryLog : public IASWLog +{ +private: + typedef IASWLog inherited; + +private: + // m_Level's value while the category has no level of its own + static constexpr std::uint8_t NoOwnLevel = 0xFF; + +private: + IASWLog& m_Log; + const std::string m_Name; + std::atomic m_Level{ NoOwnLevel }; + std::atomic m_IsEnabled{ true }; + +private: + TASWLogRecord WithCategory(const TASWLogRecord& record) const noexcept; + +public: + // Sets the minimum level of the categories 'spec' covers, process-wide: of those that exist now, and of those made + // until the next call. 'spec' is a list of "name=level" items separated by commas, e.g. "*=Info,Net=Debug, + // Net.Http=Trace": a name covers the category of that name and those under it (Net covers Net and Net.Http, but + // not Network), "*" covers every category, and the longest name that covers a category gives its level. Names and + // levels ignore case and the spaces around them; the levels are those of Level_FromString(). The categories it + // doesn't cover keep their level, and a later SetMinimumLevel() or ResetMinimumLevel() changes a category's level + // as usual. An empty spec covers none. Returns false and changes nothing if an item is invalid (no '=', no name, a + // '*' in a name other than "*", or an unknown level), describing it in 'error'. Throws std::bad_alloc if out of + // memory. + static bool ApplyLevels(std::string_view spec); + static bool ApplyLevels(std::string_view spec, std::string& error); + +public: + // Shows 'name' as is, or, if 'log' is a category, after that category's name and a '.'. Takes its level from the + // levels ApplyLevels() set last, if they cover it. Throws std::bad_alloc if out of memory. + TASWCategoryLog(std::string_view name, IASWLog& log); + ~TASWCategoryLog() override; + + TASWCategoryLog(const TASWCategoryLog&) = delete; + TASWCategoryLog& operator=(const TASWCategoryLog&) = delete; + + // The full name, e.g. "Net.Http" + std::string_view GetName() const noexcept; + // True once SetMinimumLevel() has given the category a level of its own, until ResetMinimumLevel() + bool HasOwnMinimumLevel() const noexcept; + // Makes the category follow the wrapped logger's minimum level again + void ResetMinimumLevel() noexcept; + + std::string_view GetVersionStr() const noexcept override; + std::string GetFullVersionStr() const override; + + // The wrapped logger's + std::shared_ptr GetConfig() const noexcept override; + + // Do nothing and return false: the wrapped logger's owner initializes, configures, opens and closes it + bool Initialize(const TASWLogConfig& config) noexcept override; + bool Reconfigure(const TASWLogConfig& config) noexcept override; + bool Open() noexcept override; + bool Close() noexcept override; + + // Pass on to the wrapped logger (DumpBacktrace() only while this category is enabled) + bool Flush() noexcept override; + bool IsOpen() const noexcept override; + void DumpBacktrace() noexcept override; + + // This category's own: disabled, it passes nothing on, not even forced entries + bool IsEnabled() const noexcept override; + void SetEnabled(bool enabled) noexcept override; + + // The category's own level if it has one, else the wrapped logger's + Level GetMinimumLevel() const noexcept override; + // Gives the category a level of its own, which replaces the wrapped logger's minimum level for its entries + void SetMinimumLevel(Level level) noexcept override; + + // Whether the wrapped logger would use an entry passed on by this category (see Write()) + bool ShouldLog(Level level) const noexcept override; + bool ShouldLog(const TASWLogRecord& record) const noexcept override; + + // Unless this category is disabled, passes the record on to the wrapped logger with the category's name in Category + // and its own level, if set, in CategoryLevel, where a category wrapping this one hasn't set them already + void Write(const TASWLogRecord& record) noexcept override; +}; + +} // namespace ASWLog + +#endif // ASWLog_CategoryLogH diff --git a/ASWLog/ASWLog_Config.cpp b/ASWLog/ASWLog_Config.cpp index bcc92e6..e1f4cef 100644 --- a/ASWLog/ASWLog_Config.cpp +++ b/ASWLog/ASWLog_Config.cpp @@ -25,14 +25,278 @@ limitations under the License. #include "ASWLog_Config.h" //--------------------------------------------------------------------------- // System includes here -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_CategoryLog.h" +#include "ASWLog_Formatter.h" +#include "ASWLog_Utils.h" //--------------------------------------------------------------------------- namespace ASWLog { +namespace +{ + +// The keys ApplyEnvironment() reads, in the order it applies them (PATTERN after FORMAT) +constexpr std::array SettingKeys{ + "LEVEL", "CATEGORIES", "FOLDER", "FILE", "FLUSH", "SYNC_AT_LEVEL", "ASYNC", "BACKTRACE", "TIME_ZONE", + "TIME_PRECISION", "MULTILINE", "SHOW_FUNCTION", "SHOW_SOURCE", "FORMAT", "PATTERN" +}; + +constexpr std::string_view BoolValues = "1, true, yes or on; 0, false, no or off"; +constexpr std::string_view LevelValues = "Trace, Debug, Info, Warn, Error, Critical or Off"; + +//--------------------------------------------------------------------------- + +bool ApplyNamedSetting(TASWLogConfig& config, std::string_view name, std::string_view key, std::string_view value); +template +std::string Assign(const std::optional& parsed, T& setting, std::string_view expected); +std::optional ParseBool(std::string_view text) noexcept; +std::optional ParseCount(std::string_view text) noexcept; +void ReportInvalidSetting(const TASWLogConfig& config, std::string message); +std::filesystem::path UTF8ToPath(std::string_view text); + +//--------------------------------------------------------------------------- + +/* + ApplyNamedSetting + + Sets the setting 'key' of 'config' from 'value' (see TASWLogConfig::ApplySetting()), naming it 'name' in a report +*/ +bool ApplyNamedSetting(TASWLogConfig& config, std::string_view name, std::string_view key, std::string_view value) +{ + const auto is = [key](std::string_view candidate) + { + return Detail::EqualsIgnoringCase(key, candidate); + }; + + if (std::none_of(SettingKeys.begin(), SettingKeys.end(), is)) + { + ReportInvalidSetting(config, std::format("'{}' isn't a setting", name)); + return false; + } + + const auto text = Detail::TrimSpaces(value); + if (text.empty()) + return true; + + std::string problem; // Why 'text' isn't a valid value, if it isn't + + if (is("LEVEL")) + { + problem = Assign(Level_FromString(text), config.InitialMinimumLevel, LevelValues); + } + else if (is("CATEGORIES")) + { + TASWCategoryLog::ApplyLevels(text, problem); + } + else if (is("FOLDER")) + { + config.File.FolderPath = UTF8ToPath(text); + } + else if (is("FILE")) + { + config.File.FilePath = UTF8ToPath(text); + } + else if (is("FLUSH")) + { + problem = Assign(FlushMode_FromString(text), config.File.Flush, "EveryWrite, OnNewLine, Manual or Periodic"); + } + else if (is("SYNC_AT_LEVEL")) + { + problem = Assign(Level_FromString(text), config.File.SyncToDiskAtLevel, LevelValues); + } + else if (is("ASYNC")) + { + problem = Assign(ParseBool(text), config.Async.Enabled, BoolValues); + } + else if (is("BACKTRACE")) + { + problem = Assign(ParseCount(text), config.Backtrace.Capacity, "a number of entries, 0 = none"); + } + else if (is("TIME_ZONE")) + { + problem = Assign(TimeZone_FromString(text), config.Line.TimestampZone, "UTC or Local"); + } + else if (is("TIME_PRECISION")) + { + problem = Assign(TimePrecision_FromString(text), config.Line.TimestampPrecision, + "Milliseconds, Microseconds or Nanoseconds (ms, us, ns)"); + } + else if (is("MULTILINE")) + { + problem = Assign(MultilineMode_FromString(text), config.Line.Multiline, "Preserve, Indent or Escape"); + } + else if (is("SHOW_FUNCTION")) + { + problem = Assign(ParseBool(text), config.Line.ShowFunctionName, BoolValues); + } + else if (is("SHOW_SOURCE")) + { + problem = Assign(ParseBool(text), config.Line.ShowSourceLine, BoolValues); + } + else if (is("FORMAT")) + { + if (Detail::EqualsIgnoringCase(text, "TEXT")) + config.Line.Formatter.reset(); + else if (Detail::EqualsIgnoringCase(text, "JSON")) + config.Line.Formatter = std::make_shared(); + else + problem = "expected Text or JSON"; + } + else if (is("PATTERN")) + { + try + { + config.Line.Formatter = std::make_shared(text); + } + catch (const std::invalid_argument& exception) + { + problem = exception.what(); + } + } + + if (problem.empty()) + return true; + + ReportInvalidSetting(config, std::format("{}='{}': {}", name, text, problem)); + + return false; +} + +/* + Assign + + Sets 'setting' to the parsed value and returns an empty text, or, if the value couldn't be parsed, leaves 'setting' + as it is and returns "expected " +*/ +template +std::string Assign(const std::optional& parsed, T& setting, std::string_view expected) +{ + if (!parsed) + return std::format("expected {}", expected); + + setting = *parsed; + + return {}; +} + +/* + ParseBool + + 1, true, yes or on as true; 0, false, no or off as false, ignoring case; otherwise nullopt +*/ +std::optional ParseBool(std::string_view text) noexcept +{ + for (const std::string_view yes : { "1", "true", "yes", "on" }) + { + if (Detail::EqualsIgnoringCase(text, yes)) + return true; + } + + for (const std::string_view no : { "0", "false", "no", "off" }) + { + if (Detail::EqualsIgnoringCase(text, no)) + return false; + } + + return std::nullopt; +} + +/* + ParseCount + + 'text' as a number of decimal digits that fits a std::size_t, or nullopt +*/ +std::optional ParseCount(std::string_view text) noexcept +{ + std::size_t count = 0; + const auto result = std::from_chars(text.data(), text.data() + text.size(), count); + if (result.ec != std::errc() || result.ptr != text.data() + text.size()) + return std::nullopt; + + return count; +} + +/* + ReportInvalidSetting + + Reports an invalid setting to the config's OnError, or, if it has none, writes it to stderr. Swallows exceptions. +*/ +void ReportInvalidSetting(const TASWLogConfig& config, std::string message) +{ + try + { + TASWLogError error; + error.Kind = ErrorKind::InvalidSetting; + error.Message = std::move(message); + + if (config.OnError != nullptr) + { + config.OnError(error); + } + else + { + const auto line = std::format("ASWLog: {}\n", error.ToString()); + std::fwrite(line.data(), 1, line.size(), stderr); + std::fflush(stderr); + } + } + catch (...) + { + } +} + +/* + UTF8ToPath + + The path named by 'text' (UTF-8), on Windows too, where a path made from a std::string reads it in the ANSI code page +*/ +std::filesystem::path UTF8ToPath(std::string_view text) +{ + return std::filesystem::path(std::u8string(text.begin(), text.end())); +} + +} // namespace + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWLogConfig +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +bool TASWLogConfig::ApplyEnvironment(std::string_view prefix) +{ + bool isValid = true; + std::string name; + + for (const auto key : SettingKeys) + { + name.assign(prefix); + name.append(key); + + const auto value = Detail::ReadEnvironmentVariable(name); + if (value && !ApplyNamedSetting(*this, name, key, *value)) + isValid = false; + } + + return isValid; +} + +//--------------------------------------------------------------------------- +bool TASWLogConfig::ApplySetting(std::string_view key, std::string_view value) +{ + return ApplyNamedSetting(*this, key, Detail::TrimSpaces(key), value); +} + //--------------------------------------------------------------------------- } // namespace ASWLog diff --git a/ASWLog/ASWLog_Config.h b/ASWLog/ASWLog_Config.h index b18fd13..29d7e2e 100644 --- a/ASWLog/ASWLog_Config.h +++ b/ASWLog/ASWLog_Config.h @@ -2,9 +2,11 @@ ASWLog_Config.h Author: Anthony S. West - ASW Software -A light-weight logging tool. +A logging library. -Source for the ASWLog config options. +TASWLogConfig, the settings of a logger, and the structs that group them +(line layout, file, asynchronous writing, backtrace, startup and shutdown +lines, and the settings of the system loggers). Requires C++ 20 or higher. @@ -29,6 +31,7 @@ limitations under the License. #ifndef ASWLog_ConfigH #define ASWLog_ConfigH //--------------------------------------------------------------------------- +#include #include #include #include @@ -111,6 +114,21 @@ struct TASWBackupInfo }; +///////////////////////////////////////////////////////////////////////////// +// TASWEventLogConfig +// +// How a TASWWindowsEventLog reports its entries to the Windows Event Log (TASWLogConfig::EventLog). +///////////////////////////////////////////////////////////////////////////// +struct TASWEventLogConfig +{ + // The event source Event Viewer shows, in the Application log. Empty: the executable's file name without its + // extension. Register it once to get plain messages (see TASWWindowsEventLog::RegisterSource()). + std::string Source; + // The event ID of each level's entries, indexed by Level (Trace first): by default 1000 to 1005, Critical 1005 + std::array EventIds{ 1000, 1001, 1002, 1003, 1004, 1005 }; +}; + + ///////////////////////////////////////////////////////////////////////////// // TASWFileConfig // @@ -156,9 +174,16 @@ struct TASWFileConfig // After a failed size rotation (e.g. another program holds the file), entries keep going to the current file, and // rotating isn't tried again until this long after the failure. 0 = try on every entry. std::chrono::milliseconds RotationRetryDelay{ 500 }; - // Rolls the file over at UTC midnight, and at Initialize() if the file was last written on an earlier UTC day. The - // backup is named for the day it holds. + // Rolls the file over at midnight, and at Initialize() if the file was last written on an earlier day, both in + // Line.TimestampZone (UTC by default). The backup is named for the day it holds. bool EnableDailyRolling = false; + // Starts each file the logger begins after Initialize() (by rotation, daily rolling or Reconfigure() to a new path) + // with a line saying where the log continues from, e.g. "Log continued from aswlog.size.2026-10-10_143005_123.bak + // (size)" or "Log continued from logs\old.txt (reconfigure)", followed by the startup lines as Initialize() writes + // them (see TASWStartupConfig, whose settings still apply), so each file says what wrote it. They are Info lines, + // written before the entry that started the file. Not after a failed rotation, and with AutoOpenClosePerWrite, only + // by the process that rotated the shared log. + bool WriteStartupLinesInNewFiles = true; // --- Backup Event --- // Called once for each backup that rotation makes, e.g. to compress it, upload it or move it elsewhere. Called @@ -213,20 +238,51 @@ struct TASWFileConfig ///////////////////////////////////////////////////////////////////////////// struct TASWLineConfig { - // Formats each entry's line (not LogRaw entries). Empty: TASWTextFormatter's layout, from the Show* options below. - // One formatter can be shared by several loggers. + // Formats each entry's line (LogRaw entries only if the formatter says so, as TASWJSONFormatter does). Empty: + // TASWTextFormatter's layout, from the Show* options below. For a layout of your own, assign a TASWPatternFormatter + // (e.g. "{time} {level:5} {message}") or your own formatter; for JSON Lines, a TASWJSONFormatter. One formatter can + // be shared by several loggers. std::shared_ptr Formatter; - LineEnding Ending = LineEnding::LF; // Added after each line (not after LogRaw entries) - - // The fields TASWTextFormatter writes before the message - bool ShowTimestamp = true; // The entry's time, in UTC + LineEnding Ending = LineEnding::LF; // Added after each formatted line (not after LogRaw entries written as is) + // The line breaks inside a formatted line, e.g. of a multi-line message: written as they are (the default), each + // followed by " | " on the next line (Indent), or as \r and \n (Escape), so that every entry is recognizable as + // one (see MultilineMode). LogRaw entries written as is always stay as they are; a JSON line has no line breaks. + MultilineMode Multiline = MultilineMode::Preserve; + + // The clock and precision of the timestamps, e.g. 2026-09-28T21:02:44.342Z (the defaults) or, with + // TimeZone::Local and TimePrecision::Microseconds, 2026-09-28T16:02:44.342519-05:00. The zone also sets when daily + // rolling starts a new file (File.EnableDailyRolling), the time in backup names, and the shutdown line's time. A + // crash line written in a POSIX signal handler, or when the logger's lock stays busy, is always UTC with + // milliseconds (see ASWLog_CrashHandler.h). Local time costs a C runtime call per line (localtime_s/localtime_r). + TimeZone TimestampZone = TimeZone::UTC; + TimePrecision TimestampPrecision = TimePrecision::Milliseconds; + + // The fields TASWTextFormatter and TASWJSONFormatter write before the message (a TASWPatternFormatter's pattern + // decides its own) + bool ShowTimestamp = true; // The entry's time (see TimestampZone and TimestampPrecision) bool ShowLevel = true; + bool ShowCategory = true; // The entry's category, if it has one (see TASWCategoryLog) bool ShowProcessId = true; bool ShowThreadId = true; bool ShowWorkingSet = false; // The process's memory use bool ShowPeakWorkingSet = false; bool ShowFunctionName = false; // The function that logged the entry bool ShowSourceLine = false; // The source file and line that logged the entry + bool ShowFields = true; // The entry's fields (its own and its scopes'; see TASWLogScope), if it has any +}; + + +///////////////////////////////////////////////////////////////////////////// +// TASWMemoryConfig +// +// How much a TASWMemoryLog keeps (TASWLogConfig::Memory). When a new line doesn't fit, the oldest lines go. +///////////////////////////////////////////////////////////////////////////// +struct TASWMemoryConfig +{ + std::size_t MaxLines = 1000; // 0 = no limit + // The most bytes of text kept, the lines' characters (not the memory each line takes beyond them); 0 = no limit. A + // line longer than this on its own is kept cut to fit, ending with " [cut]". + std::size_t MaxBytes = 0; }; @@ -252,10 +308,16 @@ struct TASWShutdownConfig ///////////////////////////////////////////////////////////////////////////// struct TASWStartupConfig { + // The application's version, written on the "App:" line as app_version='...' (see WriteApplicationInfo). Empty: on + // Windows, the executable's file version from its version resource, if it has one (see GetFileVersionString()). + std::string ApplicationVersion; std::string Banner; // Written first, if set bool WriteApplicationInfo = true; bool WriteCommandLine = false; // Added to the application info line bool WriteDriveInfo = true; + // "ASWLog: version=, logger=", the first line after the banner, so a log says which + // ASWLog wrote it + bool WriteLoggerVersion = true; bool WriteMemoryUsage = true; bool WriteOSInfo = true; bool WriteSystemMemoryInfo = true; @@ -263,6 +325,21 @@ struct TASWStartupConfig }; +///////////////////////////////////////////////////////////////////////////// +// TASWSyslogConfig +// +// How a TASWSyslogLog sends its entries to syslog (TASWLogConfig::Syslog). +///////////////////////////////////////////////////////////////////////////// +struct TASWSyslogConfig +{ + // The name syslog shows for each message, e.g. "myapp" in "myapp[1234]: ...". Empty: the executable's file name. + // Process-wide: the syslog logger initialized or reconfigured last sets it, and the application's own openlog() + // call replaces it (see TASWSyslogLog). + std::string Ident; + SyslogFacility Facility = SyslogFacility::User; // Sent with each message, so each syslog logger can have its own +}; + + ///////////////////////////////////////////////////////////////////////////// // TASWLogConfig // @@ -283,6 +360,24 @@ struct TASWLogConfig TASWFileConfig File; TASWAsyncConfig Async; TASWBacktraceConfig Backtrace; + TASWMemoryConfig Memory; + TASWSyslogConfig Syslog; + TASWEventLogConfig EventLog; + + // --- Pre-write Hook --- + // A last chance for the application to drop or change each of its entries before the logger writes it, e.g. to + // redact secrets or personal data, or to drop noisy entries by content or category. Return false to drop the entry; + // to change it, call entry.SetMessage(), SetField() or RemoveField(). Called for each application entry that passes + // the level checks (LogRaw() entries included, with record.Raw set; those the backtrace keeps too, which it then + // keeps as changed), on the logging thread, before the logger takes its lock or queues the entry, so it may log to + // another logger, and threads that log at the same time call it at the same time (keep it thread-safe and quick). + // An entry logged from inside the hook, on its thread, skips the hooks and is written as it is. The + // logger's own lines (startup, shutdown, backtrace markers, crash lines) don't pass through it. If it throws, the + // entry is dropped and the exception reported (see OnError). OnLogEntry gets the changed entry. A multi-log calls + // its hook once per entry and passes the changed entry on; the config it passes to its loggers has no hook. With no + // hook set, a written entry costs one more flag check. + using BeforeWriteCallback = std::function; + BeforeWriteCallback OnBeforeWrite; // --- Log Entry Callback Options --- // Receives the entry's record (with its time and ids) and the line as written. Both are only valid during the call. @@ -305,6 +400,39 @@ struct TASWLogConfig // and counts the failures it leaves out in its next report of that kind (TASWLogError::SuppressedCount). Measured // on the logger's clock (see TASWLogBase::NowUTC()). 0 = report every failure. std::chrono::milliseconds ErrorReportInterval{ std::chrono::minutes(1) }; + + // --- Settings from the Environment --- + // Sets the settings below from environment variables named with 'prefix', e.g. ASWLOG_LEVEL=Debug, so that a + // deployed application can log more, or elsewhere, without being rebuilt. Nothing reads them unless the application + // calls this; call it on the config before Initialize(), after setting OnError. Their values replace what the code + // set; LEVEL seeds the minimum level at Initialize() only (Reconfigure() keeps the current level). Each logger can + // read its own variables, e.g. config.ApplyEnvironment("MYAPP_CONSOLE_"). A variable that isn't set, or is empty, + // leaves its setting as it is, and so does an invalid value, which is reported to OnError (or written to stderr if + // it's empty) as ErrorKind::InvalidSetting. Variables with the prefix that aren't keys are ignored. Returns false + // if a value was invalid. Keys and values (ignoring case and the spaces around a value): + // LEVEL Trace, Debug, Info, Warn, Error, Critical or Off InitialMinimumLevel + // CATEGORIES the category levels, e.g. "*=Info,Net=Debug"; process-wide, so set it under one prefix only + // (see TASWCategoryLog::ApplyLevels()) + // FOLDER a folder (UTF-8) File.FolderPath + // FILE a file name or path (UTF-8) File.FilePath + // FLUSH EveryWrite, OnNewLine, Manual or Periodic File.Flush + // SYNC_AT_LEVEL a level File.SyncToDiskAtLevel + // ASYNC 1, true, yes or on; 0, false, no or off Async.Enabled + // BACKTRACE how many entries to keep, 0 = none Backtrace.Capacity + // TIME_ZONE UTC or Local Line.TimestampZone + // TIME_PRECISION ms, us or ns (or Milliseconds, Microseconds...) Line.TimestampPrecision + // MULTILINE Preserve, Indent or Escape Line.Multiline + // SHOW_FUNCTION as ASYNC Line.ShowFunctionName + // SHOW_SOURCE as ASYNC Line.ShowSourceLine + // FORMAT Text (the built-in layout) or JSON Line.Formatter + // PATTERN a TASWPatternFormatter pattern, e.g. "{time} {message}", applied after FORMAT + // Throws std::bad_alloc if out of memory. + bool ApplyEnvironment(std::string_view prefix = "ASWLOG_"); + // Sets one of ApplyEnvironment()'s settings, 'key' (without a prefix, ignoring case), from 'value', e.g. from the + // application's command line or settings file. An empty value leaves the setting as it is. Reports an unknown key + // or an invalid value as ApplyEnvironment() does, and returns false for them. Throws std::bad_alloc if out of + // memory. + bool ApplySetting(std::string_view key, std::string_view value); }; } // namespace ASWLog diff --git a/ASWLog/ASWLog_ConsoleLog.cpp b/ASWLog/ASWLog_ConsoleLog.cpp index 902dd42..6395d77 100644 --- a/ASWLog/ASWLog_ConsoleLog.cpp +++ b/ASWLog/ASWLog_ConsoleLog.cpp @@ -38,6 +38,8 @@ limitations under the License. #include #endif //--------------------------------------------------------------------------- +#include "ASWLog_Utils.h" +//--------------------------------------------------------------------------- namespace ASWLog { @@ -48,16 +50,11 @@ namespace constexpr std::string_view AnsiReset = "\x1b[0m"; // True if the environment variable 'name' is set to a non-empty value. -bool IsEnvironmentVariableSet(const char* name) noexcept +bool IsEnvironmentVariableSet(std::string_view name) noexcept { -#if defined(_WIN32) - // With no buffer, returns the size the value needs including its terminating null (1 for an empty value), or 0 - // if the variable isn't set. Avoids getenv(), which MSVC warns about. - return GetEnvironmentVariableA(name, nullptr, 0) > 1; -#else - const char* value = std::getenv(name); - return value != nullptr && *value != '\0'; -#endif + const auto value = Detail::ReadEnvironmentVariable(name); + + return value.has_value() && !value->empty(); } } // namespace diff --git a/ASWLog/ASWLog_ConsoleLog.h b/ASWLog/ASWLog_ConsoleLog.h index 3ea3338..11e5fd1 100644 --- a/ASWLog/ASWLog_ConsoleLog.h +++ b/ASWLog/ASWLog_ConsoleLog.h @@ -2,7 +2,10 @@ ASWLog_ConsoleLog.h Author: Anthony S. West - ASW Software -A light-weight logging tool. +A logging library. + +TASWConsoleLog, a text logger that writes to the console (stdout, and +stderr for warnings and errors), optionally in color. Requires C++ 20 or higher. diff --git a/ASWLog/ASWLog_CrashHandler.cpp b/ASWLog/ASWLog_CrashHandler.cpp index e6c2217..5fec125 100644 --- a/ASWLog/ASWLog_CrashHandler.cpp +++ b/ASWLog/ASWLog_CrashHandler.cpp @@ -47,6 +47,7 @@ limitations under the License. #endif //--------------------------------------------------------------------------- #include "ASWLog_TextLogBase.h" +#include "ASWLog_Utils.h" //--------------------------------------------------------------------------- namespace ASWLog @@ -539,6 +540,76 @@ void UninstallCrashHandlers() noexcept namespace Detail { +namespace +{ + +// The room a fixed-layout line keeps after its fields: the rest of the line's own text, part of the message, and the +// line ending +constexpr std::size_t FieldsReserved = 96; + +//--------------------------------------------------------------------------- + +bool AppendCrashFields(TCrashText& line, const TASWLogRecord& record, bool isJSON) noexcept; + +//--------------------------------------------------------------------------- + +/* + AppendCrashFields + + Appends the entry's fields as the text layout writes them (key="text" key=17, separated by spaces) or, for 'isJSON', + as JSON members ("key":"text","key":17), each only if it fits whole while FieldsReserved characters stay free (a long + text is cut to fit), and returns false if none was written. Allocates nothing. +*/ +bool AppendCrashFields(TCrashText& line, const TASWLogRecord& record, bool isJSON) noexcept +{ + bool hasField = false; + + record.ForEachField([&line, &hasField, isJSON](const TASWLogField& field) { + const std::size_t used = line.GetSize() + FieldsReserved; + if (used >= TCrashText::Capacity) + return; + + // Built aside, so that a field that doesn't fit is left out whole + const std::size_t room = TCrashText::Capacity - used; + TCrashText text; + if (hasField) + text.Append(isJSON ? "," : " "); + + if (isJSON) + { + text.AppendJSONString(field.Key, TCrashText::Capacity - room); + text.Append(":"); + } + else + { + text.Append(field.Key); + text.Append("="); + } + + if (field.Value.GetKind() == ValueKind::Text) + { + text.AppendJSONString(field.Value.GetText(), TCrashText::Capacity - room); + } + else + { + char buffer[ScalarValueBufferSize]; + text.Append(WriteScalarValue(buffer, field.Value, isJSON)); + } + + if (text.GetSize() <= room) + { + line.Append(text.View()); + hasField = true; + } + }); + + return hasField; +} + +} // namespace + +//--------------------------------------------------------------------------- + ///////////////////////////////////////////////////////////////////////////// // TCrashText ///////////////////////////////////////////////////////////////////////////// @@ -592,25 +663,71 @@ void TCrashText::AppendHex(std::uint64_t value, int minDigits) noexcept //--------------------------------------------------------------------------- void TCrashText::AppendISO8601(std::chrono::system_clock::time_point time) noexcept { - // Calendar arithmetic only, which doesn't allocate (unlike Time::ToISO8601String()) - const auto day = std::chrono::floor(time); - const std::chrono::year_month_day date{ day }; - const auto milliseconds = std::chrono::duration_cast(time - day).count(); - - AppendDecimal(static_cast(static_cast(date.year())), 4); - Append("-"); - AppendDecimal(static_cast(date.month()), 2); - Append("-"); - AppendDecimal(static_cast(date.day()), 2); - Append("T"); - AppendDecimal(static_cast(milliseconds / 3600000), 2); - Append(":"); - AppendDecimal(static_cast(milliseconds / 60000 % 60), 2); - Append(":"); - AppendDecimal(static_cast(milliseconds / 1000 % 60), 2); - Append("."); - AppendDecimal(static_cast(milliseconds % 1000), 3); - Append("Z"); + // Doesn't allocate (unlike Time::ToISO8601String()) + char buffer[Time::ISO8601BufferSize]; + Append(std::string_view(buffer, Time::WriteISO8601(buffer, time))); +} + +//--------------------------------------------------------------------------- +void TCrashText::AppendJSONString(std::string_view text, std::size_t reserved) noexcept +{ + // The text ends before the closing quote and 'reserved' + const std::size_t limit = Capacity - std::min(Capacity, reserved + 1); + char escape[6]; + std::size_t index = 0; + + Append("\""); + + while (index < text.size()) + { + const auto piece = NextJSONPiece(text, index, escape); + const std::size_t room = limit > m_Size ? limit - m_Size : 0; + if (piece.Text.size() <= room) + { + Append(piece.Text); + continue; + } + + // A run of plain characters is cut between UTF-8 sequences; an escape or U+FFFD is left out whole + if (!piece.IsEscape) + { + std::size_t count = room; + while (count > 0 && (static_cast(piece.Text[count]) & 0xC0) == 0x80) + --count; + + Append(piece.Text.substr(0, count)); + } + + break; + } + + Append("\""); +} + +//--------------------------------------------------------------------------- +void TCrashText::AppendMultiline(std::string_view text, MultilineMode mode, LineEnding ending, + std::size_t reserved) noexcept +{ + const std::size_t limit = Capacity - std::min(Capacity, reserved); + std::size_t index = 0; + + while (index < text.size()) + { + const auto piece = NextMultilinePiece(text, index, mode, ending); + const std::size_t room = limit > m_Size ? limit - m_Size : 0; + + if (piece.Text.size() <= room) + { + Append(piece.Text); + continue; + } + + // A run of text is cut; a rewritten line break is left out whole + if (!piece.IsLineBreak) + Append(piece.Text.substr(0, room)); + + break; + } } //--------------------------------------------------------------------------- @@ -707,7 +824,57 @@ void AppendBacktraceBeginText(TCrashText& text, std::size_t count) noexcept } //--------------------------------------------------------------------------- -void AppendCrashLine(TCrashText& line, const TASWLogRecord& record, bool usesCRLF) noexcept +void AppendCrashJSONLine(TCrashText& line, const TASWLogRecord& record, bool usesCRLF) noexcept +{ + // The room kept after the category: the ids, "raw", an empty message, the closing brace and the line ending + constexpr std::size_t CategoryReserved = 64; + const std::string_view ending = usesCRLF ? "\r\n" : "\n"; + + char digits[24]; + const auto sinceEpoch = std::chrono::floor(record.Timestamp.time_since_epoch()); + const auto result = std::to_chars(digits, digits + sizeof(digits), static_cast(sinceEpoch.count())); + + line.Append("{\"time\":\""); + line.AppendISO8601(record.Timestamp); + line.Append("\",\"epoch_ms\":"); + line.Append(std::string_view(digits, static_cast(result.ptr - digits))); + line.Append(",\"level\":\""); + line.Append(Level_ToString(record.LogLevel)); + line.Append("\""); + + if (!record.Category.empty()) + { + line.Append(",\"category\":"); + line.AppendJSONString(record.Category, CategoryReserved); + } + + line.Append(",\"pid\":"); + line.AppendDecimal(record.ProcessId); + line.Append(",\"tid\":"); + line.AppendDecimal(record.ThreadId); + + if (record.HasFields()) + { + const std::size_t sizeBefore = line.GetSize(); + line.Append(",\"fields\":{"); + + if (AppendCrashFields(line, record, true)) + line.Append("}"); + else + line.Truncate(sizeBefore); + } + + if (record.Raw) + line.Append(",\"raw\":true"); + + line.Append(",\"message\":"); + line.AppendJSONString(record.Message, 1 + ending.size()); + line.Append("}"); + line.Append(ending); +} + +//--------------------------------------------------------------------------- +void AppendCrashLine(TCrashText& line, const TASWLogRecord& record, bool usesCRLF, MultilineMode multiline) noexcept { const std::string_view ending = usesCRLF ? "\r\n" : "\n"; @@ -715,12 +882,29 @@ void AppendCrashLine(TCrashText& line, const TASWLogRecord& record, bool usesCRL line.AppendISO8601(record.Timestamp); line.Append("]["); line.Append(Level_ToString(record.LogLevel)); + + if (!record.Category.empty()) + { + line.Append("]["); + line.Append(record.Category); + } + line.Append("][P:"); line.AppendDecimal(record.ProcessId); line.Append("][T:"); line.AppendDecimal(record.ThreadId); + + if (record.HasFields()) + { + const std::size_t sizeBefore = line.GetSize(); + line.Append("]["); + + if (!AppendCrashFields(line, record, false)) + line.Truncate(sizeBefore); + } + line.Append("]: "); - line.Append(record.Message); + line.AppendMultiline(record.Message, multiline, usesCRLF ? LineEnding::CRLF : LineEnding::LF, ending.size()); line.Truncate(TCrashText::Capacity - ending.size()); line.Append(ending); diff --git a/ASWLog/ASWLog_CrashHandler.h b/ASWLog/ASWLog_CrashHandler.h index d17f188..b176262 100644 --- a/ASWLog/ASWLog_CrashHandler.h +++ b/ASWLog/ASWLog_CrashHandler.h @@ -2,7 +2,10 @@ ASWLog_CrashHandler.h Author: Anthony S. West - ASW Software -A light-weight logging tool. +A logging library. + +Crash handlers that flush every text logger and write a "Crash: " +line when the application crashes (InstallCrashHandlers(), HandleCrash()). Requires C++ 20 or higher. @@ -70,9 +73,15 @@ struct TASWCrashHandlerOptions // costs at most the line. // - A POSIX signal handler may only do async-signal-safe work, so there the line has a fixed layout, // "[][CRITICAL][P:][T:]: Crash: ", built without allocating and written straight to the -// file. The buffered entries are flushed only if the logger's lock is free (fflush isn't on POSIX's list of -// async-signal-safe functions, but no other thread can use the file then). The backtrace's lines get the same -// layout, with their own level, and only if the backtrace's lock is free. +// file. A logger whose formatter is a TASWJSONFormatter gets it as JSON instead, {"time":"", +// "epoch_ms":,"level":"CRITICAL","pid":,"tid":,"message":"Crash: "}, whatever its Show* +// options say. Its time is UTC with milliseconds whatever TASWLineConfig::TimestampZone and TimestampPrecision say, +// since converting to local time isn't async-signal-safe. The buffered entries are flushed only if the logger's lock +// is free (fflush isn't on POSIX's list of async-signal-safe functions, but no other thread can use the file then). +// The backtrace's lines get the same layout, with their own level and category (after the level, as "[Net]", or as +// "category" in JSON), and only if the backtrace's lock is free. The lines show the entry's fields too (see +// TASWLogScope; the crash line has the crashing thread's scope fields), after the thread id, as far as they fit. +// The text lines write the line breaks in their messages as TASWLineConfig::Multiline says, like the other lines. // - If a logger's lock stays busy until WaitTimeout has passed (e.g. the crash happened while that thread was // writing an entry), its buffered entries are lost and only the fixed-layout lines (backtrace and crash line) are // written, straight to the file. @@ -138,6 +147,12 @@ class TCrashText void AppendDecimal(std::uint64_t value, int minDigits = 1) noexcept; // Padded with leading zeros to 'minDigits' void AppendHex(std::uint64_t value, int minDigits) noexcept; // "0x" and at least 'minDigits' upper case digits void AppendISO8601(std::chrono::system_clock::time_point time) noexcept; // "YYYY-MM-DDTHH:mm:ss.mmmZ" + // 'text' as a JSON string (see JSON::AppendString()), cut between characters if needed so that 'reserved' + // characters stay free after it; the string is always closed + void AppendJSONString(std::string_view text, std::size_t reserved) noexcept; + // 'text' with its line breaks written as 'mode' asks (see Detail::NextMultilinePiece()), cut if needed so that + // 'reserved' characters stay free after it; a rewritten line break is never cut + void AppendMultiline(std::string_view text, MultilineMode mode, LineEnding ending, std::size_t reserved) noexcept; std::size_t GetSize() const noexcept; void Truncate(std::size_t size) noexcept; // Cuts the text to 'size' characters, if it is longer std::string_view View() const noexcept; @@ -170,9 +185,16 @@ inline constexpr std::string_view BacktraceEndText = "Backtrace end"; // the minimum level". Doesn't allocate, so a crash handler can use it too. void AppendBacktraceBeginText(TCrashText& text, std::size_t count) noexcept; -// Appends the fixed-layout crash line for 'record' (see the crash handling notes above), with a CRLF or LF ending. A -// message too long for the buffer is cut off, but the line always ends with the line ending. -void AppendCrashLine(TCrashText& line, const TASWLogRecord& record, bool usesCRLF) noexcept; +// Appends the fixed-layout crash line for 'record' as a JSON object, for a logger that uses TASWJSONFormatter (see +// the crash handling notes above), with a CRLF or LF ending. A text too long for the buffer is cut off, but the line +// is always a whole JSON object, followed by the line ending. +void AppendCrashJSONLine(TCrashText& line, const TASWLogRecord& record, bool usesCRLF) noexcept; + +// Appends the fixed-layout crash line for 'record' (see the crash handling notes above), with a CRLF or LF ending, the +// message's line breaks written as 'multiline' asks (TASWLineConfig::Multiline). A message too long for the buffer is +// cut off, but the line always ends with the line ending. +void AppendCrashLine(TCrashText& line, const TASWLogRecord& record, bool usesCRLF, + MultilineMode multiline = MultilineMode::Preserve) noexcept; // Pauses briefly (about a millisecond) and returns true, or returns false at once if 'deadline' has passed. For the // polling waits of a crash handler, which can't wait on a condition variable in a signal handler. diff --git a/ASWLog/ASWLog_DebuggerLog.cpp b/ASWLog/ASWLog_DebuggerLog.cpp new file mode 100644 index 0000000..2190822 --- /dev/null +++ b/ASWLog/ASWLog_DebuggerLog.cpp @@ -0,0 +1,155 @@ +/* ************************************************************************** +ASWLog_DebuggerLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWLog_DebuggerLog.h" +//--------------------------------------------------------------------------- +// System includes here +#include +#include + +#if defined(_WIN32) +#include +#endif +//--------------------------------------------------------------------------- +#include "ASWLog_CrashHandler.h" +#include "ASWLog_Unicode.h" +#include "ASWLog_Utils.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +namespace +{ + +// The platform has a debugger output to write to +#if defined(_WIN32) +constexpr bool HasDebuggerOutput = true; +#else +constexpr bool HasDebuggerOutput = false; +#endif + +} // namespace + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWDebuggerLog +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TASWDebuggerLog::~TASWDebuggerLog() +{ + Finalize(); +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::CloseUnlocked() +{ + m_IsOpen.store(false); + m_IsInitialized.store(false); + + return true; +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::FlushUnlocked() +{ + return m_IsOpen.load(); +} + +//--------------------------------------------------------------------------- +TASWDebuggerLog& TASWDebuggerLog::GetInstance() +{ + // Never deleted, so the instance stays usable through static destruction. It is only finalized at exit, by a + // handler registered right after it is created, which runs where a static instance's destructor would have run. + static TASWDebuggerLog* const instance = new TASWDebuggerLog(); + [[maybe_unused]] static const int atExitResult = std::atexit([] { + instance->Finalize(); + }); + return *instance; +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::InitializeUnlocked() +{ + return OpenUnlocked(); +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::OpenUnlocked() +{ + m_IsOpen.store(true); + m_IsInitialized.store(true); + + return true; +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::PrepareWriteUnlocked(std::chrono::system_clock::time_point /*now*/) +{ + // Before the line is formatted, so nothing is formatted where it would be dropped + return HasDebuggerOutput; +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::ShouldLog(Level level) const noexcept +{ + return HasDebuggerOutput && inherited::ShouldLog(level); +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::ShouldLog(const TASWLogRecord& record) const noexcept +{ + return HasDebuggerOutput && inherited::ShouldLog(record); +} + +//--------------------------------------------------------------------------- +void TASWDebuggerLog::WriteCrashLineDirect(std::string_view line) noexcept +{ +#if defined(_WIN32) + // Converted in a buffer on the stack, since the heap may be damaged in a crash + wchar_t wide[Detail::TCrashText::Capacity + 1]; + const int size = static_cast(line.size() < Detail::TCrashText::Capacity ? line.size() : Detail::TCrashText::Capacity); + const int wideSize = size > 0 ? MultiByteToWideChar(CP_UTF8, 0, line.data(), size, wide, static_cast(Detail::TCrashText::Capacity)) : 0; + wide[wideSize > 0 ? wideSize : 0] = L'\0'; + OutputDebugStringW(wide); +#else + static_cast(line); +#endif +} + +//--------------------------------------------------------------------------- +void TASWDebuggerLog::WriteLineUnlocked(Level /*level*/, std::string_view line, bool /*endsLine*/) +{ +#if defined(_WIN32) + OutputDebugStringW(UTF8ToWide(line).c_str()); +#else + static_cast(line); +#endif +} + +//--------------------------------------------------------------------------- + +} // namespace ASWLog diff --git a/ASWLog/ASWLog_DebuggerLog.h b/ASWLog/ASWLog_DebuggerLog.h new file mode 100644 index 0000000..64ab5aa --- /dev/null +++ b/ASWLog/ASWLog_DebuggerLog.h @@ -0,0 +1,96 @@ +/* ************************************************************************** +ASWLog_DebuggerLog.h +Author: Anthony S. West - ASW Software + +A logging library. + +TASWDebuggerLog, a text logger that writes to the debugger's output on +Windows (OutputDebugStringW); it writes nothing on other platforms. + +Requires C++ 20 or higher. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef ASWLog_DebuggerLogH +#define ASWLog_DebuggerLogH +//--------------------------------------------------------------------------- +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_TextLogBase.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +///////////////////////////////////////////////////////////////////////////// +// TASWDebuggerLog +// +// Writes each line to the debugger's output: on Windows with OutputDebugStringW, so it shows in the RAD Studio Event +// Log, Visual Studio's Output window, and DebugView (which needs no debugger). With nobody listening, each line still +// costs some microseconds, so add it where it's wanted, e.g. in debug builds. Each line is formatted like a file +// logger's (TASWLogConfig::Line), including its line ending; the text is converted from UTF-8 to UTF-16. +// +// Other platforms have no debugger output, so there it writes nothing: the class builds everywhere, so the same code +// can add it on every platform, but there ShouldLog() is false and no line is formatted. (On Linux, see the syslog +// logger.) +// +// Takes the same TASWLogConfig as the other text loggers, ignoring its File settings. Thread-safe. +///////////////////////////////////////////////////////////////////////////// +class TASWDebuggerLog : public TASWTextLogBase +{ +private: + typedef TASWTextLogBase inherited; + +protected: // TASWTextLogBase hooks + bool CloseUnlocked() override; + bool FlushUnlocked() override; // Nothing is buffered: true while open + bool InitializeUnlocked() override; + bool OpenUnlocked() override; + bool PrepareWriteUnlocked(std::chrono::system_clock::time_point now) override; // False where it writes nothing + void WriteCrashLineDirect(std::string_view line) noexcept override; + void WriteLineUnlocked(Level level, std::string_view line, bool endsLine) override; + +protected: + std::string_view GetLoggerClassName() const noexcept override + { + return "TASWDebuggerLog"; + } + +public: // Static methods + // Singleton support for the common static instance. Never destroyed; finalized at exit in static destruction + // order, like TASWFileLog::GetInstance() (see there). + static TASWDebuggerLog& GetInstance(); + +public: + TASWDebuggerLog() = default; + // Writes the shutdown line (see TASWTextLogBase::Finalize()) + ~TASWDebuggerLog() override; + + TASWDebuggerLog(const TASWDebuggerLog&) = delete; + TASWDebuggerLog& operator=(const TASWDebuggerLog&) = delete; + + // As for the other loggers on Windows; always false where it writes nothing + bool ShouldLog(Level level) const noexcept override; + bool ShouldLog(const TASWLogRecord& record) const noexcept override; +}; + +} // namespace ASWLog + +#endif // #ifndef ASWLog_DebuggerLogH diff --git a/ASWLog/ASWLog_Fields.cpp b/ASWLog/ASWLog_Fields.cpp new file mode 100644 index 0000000..6bac6bb --- /dev/null +++ b/ASWLog/ASWLog_Fields.cpp @@ -0,0 +1,374 @@ +/* ************************************************************************** +ASWLog_Fields.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWLog_Fields.h" +//--------------------------------------------------------------------------- +// System includes here +#include +#include +#include +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_Utils.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +namespace +{ + +// The calling thread's innermost scope. Constant-initialized (no dynamic initialization on a thread's first use), so a +// crash handler can read it, even in a POSIX signal handler. +thread_local const TASWLogScope* CurrentScope = nullptr; + +// How many scopes exist, on all threads. GetCurrent() reads CurrentScope only while there are some: reading a +// thread_local costs a library call with some compilers (MinGW's GCC emulates thread-local storage), which added ~65 ns +// to every written entry. A thread sees its own scopes counted, which is all GetCurrent() needs, so relaxed is enough. +std::atomic LiveScopeCount{ 0 }; + +//--------------------------------------------------------------------------- + +void AppendFieldValue(std::string& output, const TASWLogValue& value, bool isJSON); + +//--------------------------------------------------------------------------- + +/* + AppendFieldValue + + Appends a field's value: text as a JSON string (in the text layouts too), anything else as Detail::WriteScalarValue() + writes it +*/ +void AppendFieldValue(std::string& output, const TASWLogValue& value, bool isJSON) +{ + if (value.GetKind() == ValueKind::Text) + { + JSON::AppendString(output, value.GetText()); + return; + } + + char buffer[Detail::ScalarValueBufferSize]; + output.append(Detail::WriteScalarValue(buffer, value, isJSON)); +} + +} // namespace + +//--------------------------------------------------------------------------- + +namespace Detail +{ + +///////////////////////////////////////////////////////////////////////////// +// TOwnedFields +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TOwnedFields::TOwnedFields(const TOwnedFields& other) +{ + Assign(other.Get()); +} + +//--------------------------------------------------------------------------- +TOwnedFields& TOwnedFields::operator=(const TOwnedFields& other) +{ + if (this != &other) + Assign(other.Get()); + + return *this; +} + +//--------------------------------------------------------------------------- +void TOwnedFields::Assign(std::span fields) +{ + std::size_t textSize = 0; + for (const auto& field : fields) + { + textSize += field.Key.size(); + if (field.Value.GetKind() == ValueKind::Text) + textSize += field.Value.GetText().size(); + } + + // Built aside and swapped in, so a failed allocation leaves the fields as they were (and 'fields' may be this + // object's own) + std::vector text; + text.reserve(textSize); + std::vector copies; + copies.reserve(fields.size()); + + // 'text' never grows past its reserve, so the views into it stay valid + const auto copyText = [&text](std::string_view source) { + const std::size_t start = text.size(); + text.insert(text.end(), source.begin(), source.end()); + + return std::string_view(text.data() + start, source.size()); + }; + + for (const auto& field : fields) + { + TASWLogField copy{ copyText(field.Key), field.Value }; + if (field.Value.GetKind() == ValueKind::Text) + copy.Value = TASWLogValue(copyText(field.Value.GetText())); + + copies.push_back(copy); + } + + m_Text.swap(text); + m_Fields.swap(copies); +} + +//--------------------------------------------------------------------------- +void TOwnedFields::AssignMerged(const TASWLogScope* scope, std::span fields) +{ + if (scope == nullptr && fields.empty()) + { + Clear(); + return; + } + + std::vector merged; + ForEachLogField(scope, fields, [&merged](const TASWLogField& field) { + merged.push_back(field); + }); + Assign(merged); +} + +//--------------------------------------------------------------------------- +void TOwnedFields::Clear() noexcept +{ + m_Fields.clear(); + m_Text.clear(); +} + +//--------------------------------------------------------------------------- +std::span TOwnedFields::Get() const noexcept +{ + return m_Fields; +} + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// Free functions +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +void AppendFieldsJSON(std::string& output, const TASWLogScope* scope, std::span fields) +{ + const std::size_t start = output.size(); + bool hasField = false; + + output += "\"fields\":{"; + ForEachLogField(scope, fields, [&output, &hasField](const TASWLogField& field) { + if (hasField) + output += ','; + + JSON::AppendString(output, field.Key); + output += ':'; + AppendFieldValue(output, field.Value, true); + hasField = true; + }); + + if (!hasField) + { + output.resize(start); + return; + } + + output += "},"; +} + +//--------------------------------------------------------------------------- +bool AppendFieldsText(std::string& output, const TASWLogScope* scope, std::span fields, + std::string_view prefix, std::string_view suffix) +{ + bool hasField = false; + + ForEachLogField(scope, fields, [&output, &hasField, prefix](const TASWLogField& field) { + output.append(hasField ? std::string_view(" ") : prefix); + output.append(field.Key); + output += '='; + AppendFieldValue(output, field.Value, false); + hasField = true; + }); + + if (hasField) + output.append(suffix); + + return hasField; +} + +//--------------------------------------------------------------------------- +std::string_view WriteScalarValue(char (& buffer)[ScalarValueBufferSize], const TASWLogValue& value, bool isJSON) noexcept +{ + char* const end = buffer + ScalarValueBufferSize; + std::to_chars_result result{ buffer, std::errc() }; + + switch (value.GetKind()) + { + case ValueKind::Int: + result = std::to_chars(buffer, end, value.GetInt()); + break; + + case ValueKind::UInt: + result = std::to_chars(buffer, end, value.GetUInt()); + break; + + case ValueKind::Double: + { + const double number = value.GetDouble(); + if (isJSON && !std::isfinite(number)) + return std::isnan(number) ? "\"NaN\"" : (number > 0 ? "\"Infinity\"" : "\"-Infinity\""); + + result = std::to_chars(buffer, end, number); + break; + } + + case ValueKind::Bool: + return value.GetBool() ? "true" : "false"; + + case ValueKind::Text: + break; + } + + return std::string_view(buffer, static_cast(result.ptr - buffer)); +} + +//--------------------------------------------------------------------------- + +} // namespace Detail + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWLogContext +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +std::span TASWLogContext::GetFields() const noexcept +{ + return m_Fields.Get(); +} + +//--------------------------------------------------------------------------- +bool TASWLogContext::IsEmpty() const noexcept +{ + return m_Fields.Get().empty(); +} + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWLogScope +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TASWLogScope::TASWLogScope(std::initializer_list fields) noexcept + : m_Parent(CurrentScope) +{ + try + { + m_Fields.Assign(std::span(fields.begin(), fields.size())); + } + catch (...) + { + } + + LiveScopeCount.fetch_add(1, std::memory_order_relaxed); + CurrentScope = this; +} + +//--------------------------------------------------------------------------- +TASWLogScope::TASWLogScope(const TASWLogContext& context, std::initializer_list fields) noexcept + : m_Parent(CurrentScope) +{ + try + { + // The context's fields first, so that 'fields' come later and replace those with the same key + std::vector combined(context.GetFields().begin(), context.GetFields().end()); + combined.insert(combined.end(), fields.begin(), fields.end()); + m_Fields.Assign(combined); + } + catch (...) + { + } + + LiveScopeCount.fetch_add(1, std::memory_order_relaxed); + CurrentScope = this; +} + +//--------------------------------------------------------------------------- +TASWLogScope::~TASWLogScope() +{ + // Ended out of order, a scope leaves the inner one current rather than reinstating one that has ended + if (CurrentScope == this) + CurrentScope = m_Parent; + + LiveScopeCount.fetch_sub(1, std::memory_order_relaxed); +} + +//--------------------------------------------------------------------------- +TASWLogContext TASWLogScope::Capture() noexcept +{ + TASWLogContext context; + + try + { + context.m_Fields.AssignMerged(CurrentScope, {}); + } + catch (...) + { + context.m_Fields.Clear(); + } + + return context; +} + +//--------------------------------------------------------------------------- +const TASWLogScope* TASWLogScope::GetCurrent() noexcept +{ + if (LiveScopeCount.load(std::memory_order_relaxed) == 0) + return nullptr; + + return CurrentScope; +} + +//--------------------------------------------------------------------------- +std::span TASWLogScope::GetFields() const noexcept +{ + return m_Fields.Get(); +} + +//--------------------------------------------------------------------------- +const TASWLogScope* TASWLogScope::GetParent() const noexcept +{ + return m_Parent; +} + +//--------------------------------------------------------------------------- + +} // namespace ASWLog diff --git a/ASWLog/ASWLog_Fields.h b/ASWLog/ASWLog_Fields.h new file mode 100644 index 0000000..3bf8c20 --- /dev/null +++ b/ASWLog/ASWLog_Fields.h @@ -0,0 +1,452 @@ +/* ************************************************************************** +ASWLog_Fields.h +Author: Anthony S. West - ASW Software + +A logging library. + +Structured fields: TASWLogField, a named value attached to one entry, and +TASWLogScope, context that every entry logged on the thread carries. + +Requires C++ 20 or higher. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef ASWLog_FieldsH +#define ASWLog_FieldsH +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +class TASWLogContext; +class TASWLogScope; + +//--------------------------------------------------------------------------- + +/* + ValueKind enum + + What a field's value holds (see TASWLogValue). +*/ +enum class ValueKind : std::uint8_t +{ + Int, // std::int64_t, from any signed integer + UInt, // std::uint64_t, from any unsigned integer + Double, // From any floating-point type + Bool, + Text, // A view of the caller's text +}; + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWLogValue +// +// A field's value: a signed or unsigned integer, a double, a bool, or text. Made implicitly from those types, so a +// field reads {"orderId", 17}, {"total", 9.99}, {"cached", true} or {"user", userName}. Text is a view of the caller's +// characters (a string literal, std::string or std::string_view), not a copy, so making one never allocates; a logger +// copies what it keeps beyond the call. Characters (char, wchar_t, ...), enums, pointers other than to text, and null +// pointers aren't accepted; turn other types into a number or text first (e.g. with std::format). +///////////////////////////////////////////////////////////////////////////// +class TASWLogValue +{ +private: + template + static constexpr bool IsCharacter = std::is_same_v || std::is_same_v || std::is_same_v || + std::is_same_v || std::is_same_v; + + union + { + std::int64_t m_Int; + std::uint64_t m_UInt; + double m_Double; + bool m_Bool; + const char* m_TextData; + }; + + std::size_t m_TextSize = 0; + ValueKind m_Kind; + +public: + constexpr TASWLogValue() noexcept + : m_Int(0), + m_Kind(ValueKind::Int) + { + } + + template + requires(std::is_integral_v && std::is_signed_v && !IsCharacter) + constexpr TASWLogValue(T value) noexcept + : m_Int(value), + m_Kind(ValueKind::Int) + { + } + + template + requires(std::is_integral_v && std::is_unsigned_v && !std::is_same_v && !IsCharacter) + constexpr TASWLogValue(T value) noexcept + : m_UInt(value), + m_Kind(ValueKind::UInt) + { + } + + template + requires std::is_floating_point_v + constexpr TASWLogValue(T value) noexcept + : m_Double(static_cast(value)), + m_Kind(ValueKind::Double) + { + } + + // A template, so that only a bool matches it: a pointer or an enum would otherwise become a bool + template + requires std::is_same_v + constexpr TASWLogValue(T value) noexcept + : m_Bool(value), + m_Kind(ValueKind::Bool) + { + } + + constexpr TASWLogValue(std::string_view value) noexcept + : m_TextData(value.data()), + m_TextSize(value.size()), + m_Kind(ValueKind::Text) + { + } + + // Without it, a string literal would become a bool + constexpr TASWLogValue(const char* value) noexcept + : TASWLogValue(std::string_view(value != nullptr ? value : "")) + { + } + + TASWLogValue(const std::string& value) noexcept + : TASWLogValue(std::string_view(value)) + { + } + + template + requires IsCharacter + TASWLogValue(T) = delete; // A character: use text ("x") or a number (static_cast(c)) + + TASWLogValue(std::nullptr_t) = delete; + + // The value, for the kind GetKind() returns + [[nodiscard]] constexpr bool GetBool() const noexcept + { + return m_Bool; + } + + [[nodiscard]] constexpr double GetDouble() const noexcept + { + return m_Double; + } + + [[nodiscard]] constexpr std::int64_t GetInt() const noexcept + { + return m_Int; + } + + [[nodiscard]] constexpr ValueKind GetKind() const noexcept + { + return m_Kind; + } + + [[nodiscard]] constexpr std::string_view GetText() const noexcept + { + return std::string_view(m_TextData, m_TextSize); + } + + [[nodiscard]] constexpr std::uint64_t GetUInt() const noexcept + { + return m_UInt; + } +}; + + +///////////////////////////////////////////////////////////////////////////// +// TASWLogField +// +// A named value attached to a log entry, by the logging call (e.g. logger.LogInfo("Order placed", {{"orderId", 17}})) +// or by a TASWLogScope. Like the value's text, the key is a view of the caller's characters. +///////////////////////////////////////////////////////////////////////////// +struct TASWLogField +{ + std::string_view Key; + TASWLogValue Value; +}; + +//--------------------------------------------------------------------------- +// Internals, used by the classes below and the loggers. Not part of the public interface. +//--------------------------------------------------------------------------- +namespace Detail +{ + +// The size of the buffer WriteScalarValue() writes to +inline constexpr std::size_t ScalarValueBufferSize = 32; + +//--------------------------------------------------------------------------- + +/* + AppendFieldsJSON + + Appends the fields ForEachLogField() gives for 'scope' and 'fields' as a JSON object's member, + "fields":{"":,...}, followed by a comma, or nothing if there are none. Texts are JSON strings, other + values as WriteScalarValue() writes them. In its own source file, so the formatters don't inline it into their + usual path, which has no fields. +*/ +void AppendFieldsJSON(std::string& output, const TASWLogScope* scope, std::span fields); + +/* + AppendFieldsText + + Appends the fields ForEachLogField() gives for 'scope' and 'fields' as the text layouts write them, + = separated by spaces, texts in double quotes and escaped like JSON strings (so a value can't break the + line or the brackets around it), other values as WriteScalarValue() writes them, between 'prefix' and 'suffix'. + Appends nothing, and returns false, if there are none. In its own source file, like AppendFieldsJSON(). +*/ +bool AppendFieldsText(std::string& output, const TASWLogScope* scope, std::span fields, + std::string_view prefix = {}, std::string_view suffix = {}); + +/* + WriteScalarValue + + Writes a value that isn't text into 'buffer' and returns it: an integer in decimal, a double in the shortest form + that reads back the same (e.g. 9.99), a bool as true or false. For JSON ('isJSON'), a NaN or infinite double, which + a JSON number can't hold, is written as the string "NaN", "Infinity" or "-Infinity", quotes included. Allocates + nothing. +*/ +[[nodiscard]] std::string_view WriteScalarValue(char (& buffer)[ScalarValueBufferSize], const TASWLogValue& value, + bool isJSON) noexcept; + +///////////////////////////////////////////////////////////////////////////// +// TOwnedFields +// +// A copy of fields that owns their keys and texts (the fields' views refer to its own buffer), for the scopes, a +// captured context, and a logger that keeps an entry beyond the call (the backtrace, an asynchronous OnLogEntry). +///////////////////////////////////////////////////////////////////////////// +class TOwnedFields +{ +private: + std::vector m_Text; // Moving it keeps its buffer, so the views stay valid + std::vector m_Fields; + +public: + TOwnedFields() = default; + TOwnedFields(const TOwnedFields& other); + TOwnedFields(TOwnedFields&& other) noexcept = default; + TOwnedFields& operator=(const TOwnedFields& other); + TOwnedFields& operator=(TOwnedFields&& other) noexcept = default; + + // Replaces the fields with copies of 'fields'. Throws std::bad_alloc if they can't be copied; the fields are + // then unchanged. + void Assign(std::span fields); + // Replaces the fields with copies of those ForEachLogField() gives for 'scope' and 'fields', each key once, so the + // copy no longer depends on the thread's scopes. Throws like Assign(). + void AssignMerged(const TASWLogScope* scope, std::span fields); + void Clear() noexcept; + [[nodiscard]] std::span Get() const noexcept; +}; + +// Calls visit(field) for each field of 'scope' and the scopes it is in, from the outermost one in, then for each of +// 'fields', duplicate keys included, until visit returns false; returns false if it did. Allocates nothing. +template +bool VisitAllLogFields(const TASWLogScope* scope, std::span fields, TVisit& visit); + +} // namespace Detail + +//--------------------------------------------------------------------------- + +/* + ForEachLogField + + Calls visit(field) once for each key among the fields of 'scope' and the scopes it is in, and 'fields' (an entry's + own fields, which come last): in the order the keys first appear, from the outermost scope in, each with the value + of its last appearance, so an inner scope's value beats an outer one's, and the entry's own value beats both. + Allocates nothing, so a crash handler can use it too. TASWLogRecord::ForEachField() calls it for an entry. +*/ +template +void ForEachLogField(const TASWLogScope* scope, std::span fields, TVisit&& visit); + + +///////////////////////////////////////////////////////////////////////////// +// TASWLogContext +// +// A copy of a thread's scope fields (see TASWLogScope::Capture()), to apply on another thread with +// TASWLogScope scope(context);. It owns its keys and texts, so it can be copied and kept as long as needed. +///////////////////////////////////////////////////////////////////////////// +class TASWLogContext +{ +private: + friend class TASWLogScope; + + Detail::TOwnedFields m_Fields; // Each key once, in the order ForEachLogField() gives them + +public: + // Its fields, each key once + [[nodiscard]] std::span GetFields() const noexcept; + [[nodiscard]] bool IsEmpty() const noexcept; +}; + + +///////////////////////////////////////////////////////////////////////////// +// TASWLogScope +// +// Fields that every entry logged on this thread carries while the scope exists, e.g. at the top of a request handler: +// ASWLog::TASWLogScope scope{{"requestId", requestId}}; +// Every entry the thread logs until the scope ends has requestId, in the helper functions it calls too, so one +// request's entries can be picked out of interleaved traffic. Scopes nest: an inner scope's fields add to the outer +// ones', and replace those with the same key (see ForEachLogField()). They don't cross threads; to take them to +// another thread (e.g. a thread pool), Capture() them and open a scope from the copy there: +// auto context = ASWLog::TASWLogScope::Capture(); +// pool.Submit([context] { ASWLog::TASWLogScope scope(context); ... }); +// An asynchronous logger keeps the fields an entry had when it was logged. +// +// A scope copies its keys and texts, so they may be temporaries. It belongs to the thread that made it and must end +// on it, in the reverse order of making (as local variables do). If the copy can't be made (out of memory), the scope +// has no fields rather than throwing. +///////////////////////////////////////////////////////////////////////////// +class TASWLogScope +{ +private: + const TASWLogScope* m_Parent; // The thread's scope before this one, or null + Detail::TOwnedFields m_Fields; + +public: + // A copy of the calling thread's scope fields, each key once, to use on another thread. Empty if the thread has no + // scope, or if the copy can't be made (out of memory). + [[nodiscard]] static TASWLogContext Capture() noexcept; + + // The calling thread's innermost scope, or null + [[nodiscard]] static const TASWLogScope* GetCurrent() noexcept; + +public: + explicit TASWLogScope(std::initializer_list fields) noexcept; + // The fields of 'context' (see Capture()), plus 'fields', which replace those with the same key + explicit TASWLogScope(const TASWLogContext& context, std::initializer_list fields = {}) noexcept; + ~TASWLogScope(); + + TASWLogScope(const TASWLogScope&) = delete; + TASWLogScope& operator=(const TASWLogScope&) = delete; + + // This scope's own fields, as given + [[nodiscard]] std::span GetFields() const noexcept; + + // The scope this one is in, or null + [[nodiscard]] const TASWLogScope* GetParent() const noexcept; +}; + +//--------------------------------------------------------------------------- + +namespace Detail +{ + +//--------------------------------------------------------------------------- +template +bool VisitAllLogFields(const TASWLogScope* scope, std::span fields, TVisit& visit) +{ + if (scope != nullptr && !VisitAllLogFields(scope->GetParent(), scope->GetFields(), visit)) + return false; + + for (const auto& field : fields) + { + if (!visit(field)) + return false; + } + + return true; +} + +} // namespace Detail + +//--------------------------------------------------------------------------- +template +void ForEachLogField(const TASWLogScope* scope, std::span fields, TVisit&& visit) +{ + if (scope == nullptr) + { + // The usual case, without a scope: only the entry's own fields, which rarely repeat a key + for (std::size_t index = 0; index < fields.size(); ++index) + { + bool isRepeated = false; + for (std::size_t earlier = 0; earlier < index && !isRepeated; ++earlier) + isRepeated = fields[earlier].Key == fields[index].Key; + + if (isRepeated) + continue; + + const TASWLogField* winner = &fields[index]; + for (std::size_t later = index + 1; later < fields.size(); ++later) + { + if (fields[later].Key == fields[index].Key) + winner = &fields[later]; + } + + visit(*winner); + } + + return; + } + + std::size_t position = 0; + auto visitCandidate = [&](const TASWLogField& candidate) { + bool isRepeated = false; + const TASWLogField* winner = &candidate; + std::size_t index = 0; + auto findWinner = [&](const TASWLogField& other) { + if (other.Key == candidate.Key) + { + if (index < position) + { + isRepeated = true; + return false; + } + + winner = &other; + } + + ++index; + + return true; + }; + + Detail::VisitAllLogFields(scope, fields, findWinner); + ++position; + + if (!isRepeated) + visit(*winner); + + return true; + }; + + Detail::VisitAllLogFields(scope, fields, visitCandidate); +} + +} // namespace ASWLog + +#endif // ASWLog_FieldsH diff --git a/ASWLog/ASWLog_FileLog.cpp b/ASWLog/ASWLog_FileLog.cpp index 0f90f3c..d3a6103 100644 --- a/ASWLog/ASWLog_FileLog.cpp +++ b/ASWLog/ASWLog_FileLog.cpp @@ -218,11 +218,12 @@ std::vector ListBackups(const std::filesystem::path& logPath, std:: return backups; } -// Formats a UTC time for a backup file name: "YYYY-MM-DD_HHMMSS_mmm". -std::string ToBackupTimeLabel(std::chrono::system_clock::time_point timePoint) +// Formats a time in 'zone' for a backup file name: "YYYY-MM-DD_HHMMSS_mmm". +std::string ToBackupTimeLabel(std::chrono::system_clock::time_point timePoint, TimeZone zone) { - // Cut from the ISO 8601 form "YYYY-MM-DDTHH:mm:ss.mmmZ", leaving out the characters not wanted in a file name - const auto isoTime = Time::ToISO8601String(timePoint); + // Cut from the ISO 8601 form "YYYY-MM-DDTHH:mm:ss.mmmZ" (or "...mmm+hh:mm" in local time), leaving out the + // characters not wanted in a file name + const auto isoTime = Time::ToISO8601String(timePoint, zone); const std::string_view iso(isoTime); return std::format("{}_{}{}{}_{}", iso.substr(0, 10), iso.substr(11, 2), iso.substr(14, 2), iso.substr(17, 2), iso.substr(20, 3)); @@ -481,8 +482,8 @@ TASWFileLog::~TASWFileLog() //--------------------------------------------------------------------------- void TASWFileLog::AfterEntryUnlocked() { - // In this mode the file is only open while an entry (or Initialize()'s startup lines) is written. Unlike Close(), - // this leaves the logger initialized. + // In this mode the file is only open while an entry (or the startup lines) is written. Unlike Close(), this leaves + // the logger initialized. if (GetConfigUnlocked().File.AutoOpenClosePerWrite) CloseFileUnlocked(); } @@ -703,12 +704,15 @@ bool TASWFileLog::InitializeUnlocked() if (GetConfigUnlocked().File.EnableDailyRolling) RotateDailyLogFromEarlierDayUnlocked(); + // Initialize() writes the startup lines itself, and Reconfigure() its own continuation line + m_PendingContinuationLine.clear(); + if (!OpenUnlocked()) { return false; } - m_LastLogDateStr = Time::ToDateString(NowUTC()); + m_LastLogDateStr = Time::ToDateString(NowUTC(), GetConfigUnlocked().Line.TimestampZone); return true; } @@ -786,11 +790,14 @@ bool TASWFileLog::OpenUnlocked() if (m_LastLogDateStr.empty()) { - m_LastLogDateStr = Time::ToDateString(NowUTC()); + m_LastLogDateStr = Time::ToDateString(NowUTC(), GetConfigUnlocked().Line.TimestampZone); } m_IsOpen.store(true, std::memory_order_release); m_IsInitialized.store(true, std::memory_order_release); + + WriteNewFileStartupLinesUnlocked(); + return true; } @@ -798,8 +805,9 @@ bool TASWFileLog::OpenUnlocked() /* TASWFileLog::PrepareWriteUnlocked - Rotates the log file if the day has changed or the file has reached its maximum size, as configured. Returns false - if the file isn't open (e.g. it couldn't be reopened after a rotation), which drops the line. + Rotates the log file if the day has changed or the file has reached its maximum size, as configured, but not while + the startup lines are written (the new file's own, or Initialize()'s): the next entry rotates it. Returns false if + the file isn't open (e.g. it couldn't be reopened after a rotation), which drops the line. */ bool TASWFileLog::PrepareWriteUnlocked(std::chrono::system_clock::time_point now) { @@ -807,8 +815,8 @@ bool TASWFileLog::PrepareWriteUnlocked(std::chrono::system_clock::time_point now { // Only rolls forward (ISO dates compare as text): an entry stamped just before midnight can get the lock after // another thread's entry has rolled the log over, and then goes into the new day's log - const auto currentDateStr = Time::ToDateString(now); - if (currentDateStr > m_LastLogDateStr) + const auto currentDateStr = Time::ToDateString(now, GetConfigUnlocked().Line.TimestampZone); + if (currentDateStr > m_LastLogDateStr && !IsWritingStartupLinesUnlocked()) { // Name the backup for the day its content is from, not the day that just started. A log shared with other // processes may already have been rolled over by one of them, so there the file's last write decides. @@ -824,9 +832,10 @@ bool TASWFileLog::PrepareWriteUnlocked(std::chrono::system_clock::time_point now // Uses the size tracked by the stream, since on Windows the size read through the path isn't current while open. // After a failed rotation (e.g. another program holds the file), waits RotationRetryDelay before trying again. if (GetConfigUnlocked().File.EnableRotation && m_FileStream.IsOpen() && m_FileStream.GetSize() >= GetConfigUnlocked().File.MaxFileSizeBytes && - IsRetryDue(m_LastRotationFailure, now, GetConfigUnlocked().File.RotationRetryDelay)) + IsRetryDue(m_LastRotationFailure, now, GetConfigUnlocked().File.RotationRetryDelay) && + !IsWritingStartupLinesUnlocked()) { - RotateLogFilesUnlocked("size", ToBackupTimeLabel(now)); + RotateLogFilesUnlocked("size", ToBackupTimeLabel(now, GetConfigUnlocked().Line.TimestampZone)); } return m_FileStream.IsOpen(); @@ -837,22 +846,30 @@ bool TASWFileLog::PrepareWriteUnlocked(std::chrono::system_clock::time_point now TASWFileLog::ReconfigureUnlocked If the file path or AutoOpenClosePerWrite changed, closes the old file and starts on the new one as Initialize() - does (rolling over a file from an earlier day, if daily rolling is on, then opening it). With AutoOpenClosePerWrite - the new file is closed again until the next entry, but opening it here reports a file that can't be opened. A - failed open keeps the logger initialized, so later entries try again (see EnsureReadyUnlocked()). + does (rolling over a file from an earlier day, if daily rolling is on, then opening it). A new path starts with + the startup lines (see WriteNewFileStartupLinesUnlocked()). With AutoOpenClosePerWrite the new file is closed again + until the next entry, but opening it here reports a file that can't be opened. A failed open keeps the logger + initialized, so later entries try again (see EnsureReadyUnlocked()), and the first one that opens the file writes + the startup lines. Otherwise the file stays open: flushes what the previous flush mode may have buffered, and the other settings take effect with the next entry (a FlushMode::Periodic thread is started, stopped or given its new interval after the - lock is released, see GetWorkerIntervalUnlocked()). + lock is released, see GetWorkerIntervalUnlocked()). A new Line.TimestampZone makes the file's day today's date in + that zone, so daily rolling next rolls over at that zone's midnight. */ bool TASWFileLog::ReconfigureUnlocked(const TASWLogConfig& previous) { - const auto& file = GetConfigUnlocked().File; + const auto& config = GetConfigUnlocked(); + const auto& file = config.File; m_SyncsCrashLine.store(Level::Critical >= file.SyncToDiskAtLevel, std::memory_order_relaxed); - if (file.ResolvePath() == previous.File.ResolvePath() && - file.AutoOpenClosePerWrite == previous.File.AutoOpenClosePerWrite) + const auto previousPath = previous.File.ResolvePath(); + const bool isNewPath = file.ResolvePath() != previousPath; + if (!isNewPath && file.AutoOpenClosePerWrite == previous.File.AutoOpenClosePerWrite) { + if (config.Line.TimestampZone != previous.Line.TimestampZone) + m_LastLogDateStr = Time::ToDateString(NowUTC(), config.Line.TimestampZone); + if (m_FileStream.IsOpen()) FlushUnlocked(); @@ -861,6 +878,13 @@ bool TASWFileLog::ReconfigureUnlocked(const TASWLogConfig& previous) CloseFileUnlocked(); const bool isOpen = InitializeUnlocked(); + + if (isNewPath && file.WriteStartupLinesInNewFiles) + { + m_PendingContinuationLine = std::format("Log continued from {} (reconfigure)", PathToUTF8String(previousPath)); + WriteNewFileStartupLinesUnlocked(); + } + AfterEntryUnlocked(); return isOpen; @@ -907,7 +931,18 @@ bool TASWFileLog::RotateLogFiles(std::string_view reasonTag) { TDeferredWorkRunner deferredWorkRunner(*this); std::lock_guard lock(m_Mutex); - return RotateLogFilesUnlocked(reasonTag, ToBackupTimeLabel(NowUTC())); + const auto timeLabel = ToBackupTimeLabel(NowUTC(), GetConfigUnlocked().Line.TimestampZone); + const bool isRotated = RotateLogFilesUnlocked(reasonTag, timeLabel); + + // A file that is closed between entries (AutoOpenClosePerWrite) is opened for its startup lines now, so they come + // first even if another process writes to it next + if (isRotated && !m_PendingContinuationLine.empty() && m_IsInitialized.load(std::memory_order_acquire) && + EnsureReadyUnlocked()) + { + AfterEntryUnlocked(); + } + + return isRotated; } //--------------------------------------------------------------------------- @@ -944,8 +979,9 @@ void TASWFileLog::RotateDailyLogFromEarlierDayUnlocked() const auto lastWriteUTC = now - std::chrono::duration_cast(fileAge); // YYYY-MM-DD strings compare in date order - const auto lastWriteDateStr = Time::ToDateString(lastWriteUTC); - if (lastWriteDateStr < Time::ToDateString(now)) + const auto zone = GetConfigUnlocked().Line.TimestampZone; + const auto lastWriteDateStr = Time::ToDateString(lastWriteUTC, zone); + if (lastWriteDateStr < Time::ToDateString(now, zone)) RotateLogFilesUnlocked("daily", lastWriteDateStr); } @@ -987,6 +1023,13 @@ bool TASWFileLog::RotateLogFilesUnlocked(std::string_view reasonTag, std::string return false; } + // The next file starts with the startup lines, written once it is open (see WriteNewFileStartupLinesUnlocked()) + if (!backupPath.empty() && GetConfigUnlocked().File.WriteStartupLinesInNewFiles) + { + m_PendingContinuationLine = std::format("Log continued from {} ({})", PathToUTF8String(backupPath.filename()), + reasonTag); + } + const bool isOpen = wasOpen ? OpenUnlocked() : true; // OnBackupCreated calls into the application and the cleanup scans the folder, so both run after the lock is @@ -1053,6 +1096,25 @@ void TASWFileLog::WriteLineUnlocked(Level level, std::string_view line, bool end MaybeFlush(level, endsLine); } +//--------------------------------------------------------------------------- +/* + TASWFileLog::WriteNewFileStartupLinesUnlocked + + Starts a file the logger began after Initialize() (see TASWFileConfig::WriteStartupLinesInNewFiles), once it is + open: writes the line saying where the log continues from, then the startup lines. Called by OpenUnlocked(), so a + rotation writes them before the entry that triggered it, and a file that couldn't be opened right away gets them + from the open that succeeds. Does nothing if no new file is waiting for them. +*/ +void TASWFileLog::WriteNewFileStartupLinesUnlocked() +{ + if (m_PendingContinuationLine.empty() || !m_FileStream.IsOpen()) + return; + + // Taken first, so that the lines are written once even if they fail + const auto continuationLine = std::exchange(m_PendingContinuationLine, std::string()); + WriteStartupLinesUnlocked(continuationLine); +} + //--------------------------------------------------------------------------- } // namespace ASWLog diff --git a/ASWLog/ASWLog_FileLog.h b/ASWLog/ASWLog_FileLog.h index f6d92b1..f2c0939 100644 --- a/ASWLog/ASWLog_FileLog.h +++ b/ASWLog/ASWLog_FileLog.h @@ -2,7 +2,10 @@ ASWLog_FileLog.h Author: Anthony S. West - ASW Software -A light-weight logging tool. +A logging library. + +TASWFileLog, a text logger that writes to a file, with rotation by size and +by day, backup cleanup, and flush and sync settings for crash durability. Requires C++ 20 or higher. @@ -125,6 +128,9 @@ class TASWFileLog : public TASWTextLogBase std::string m_LastLogDateStr; // Stores YYYY-MM-DD state to detect structural calendar shifts std::chrono::system_clock::time_point m_LastOpenFailure{}; // NowUTC() when opening the file last failed std::chrono::system_clock::time_point m_LastRotationFailure{}; // NowUTC() when a rotation last failed + // The first line of a new file the logger started (see TASWFileConfig::WriteStartupLinesInNewFiles), waiting for + // the file to be open; empty if none + std::string m_PendingContinuationLine; // File.SyncToDiskAtLevel is Critical or lower, for WriteCrashLineDirect(), which may run without m_Mutex std::atomic m_SyncsCrashLine{ false }; @@ -139,6 +145,7 @@ class TASWFileLog : public TASWTextLogBase void RotateDailyLogFromEarlierDayUnlocked(); bool RotateLogFilesUnlocked(std::string_view reasonTag, std::string_view timeLabel); void SyncToDiskUnlocked(); + void WriteNewFileStartupLinesUnlocked(); protected: // TASWTextLogBase hooks void AfterEntryUnlocked() override; @@ -181,9 +188,11 @@ class TASWFileLog : public TASWTextLogBase TASWFileLog() = default; ~TASWFileLog(); - // Renames the log file to "...bak" (UTC), adding "_1", "_2", ... to the - // time if that name is taken, so an existing backup is never replaced. Then reopens the log if it was open. A - // 'reasonTag' that contains '.' makes a backup that TASWFileConfig's backup cleanup doesn't recognize, so it is kept. + // Renames the log file to "...bak" (the time in Line.TimestampZone, UTC by + // default), adding "_1", "_2", ... to the time if that name is taken, so an existing backup is never replaced. Then + // reopens the log if it was open, and starts the new file with the startup lines (see + // TASWFileConfig::WriteStartupLinesInNewFiles; with AutoOpenClosePerWrite it is opened for them). A 'reasonTag' + // that contains '.' makes a backup that TASWFileConfig's backup cleanup doesn't recognize, so it is kept. bool RotateLogFiles(std::string_view reasonTag = "manual"); }; diff --git a/ASWLog/ASWLog_Formatter.cpp b/ASWLog/ASWLog_Formatter.cpp index af97730..9fe306c 100644 --- a/ASWLog/ASWLog_Formatter.cpp +++ b/ASWLog/ASWLog_Formatter.cpp @@ -25,9 +25,16 @@ limitations under the License. #include "ASWLog_Formatter.h" //--------------------------------------------------------------------------- // System includes here +#include +#include +#include +#include #include #include #include +#include +#include +#include //--------------------------------------------------------------------------- #include "ASWLog_Utils.h" //--------------------------------------------------------------------------- @@ -35,6 +42,493 @@ limitations under the License. namespace ASWLog { +namespace +{ + +// The widest placeholder width a pattern may ask for +constexpr std::size_t MaxPatternWidth = 1000; + +//--------------------------------------------------------------------------- + +void AppendField(std::string& line, std::string_view value, std::string_view prefix, std::string_view suffix, + std::size_t width); +template +void AppendJSONNumber(std::string& line, std::string_view name, TInteger value); +void AppendJSONString(std::string& line, std::string_view name, std::string_view value); +void AppendNumberField(std::string& line, std::string_view label, std::uint64_t value); +bool IsASCIIDigit(char character) noexcept; +bool IsASCIILetter(char character) noexcept; +std::invalid_argument MakePatternError(std::string_view problem, std::size_t index, std::string_view pattern); +std::string_view ToDecimal(char (& buffer)[Time::ISO8601BufferSize], std::uint64_t value) noexcept; + +//--------------------------------------------------------------------------- + +/* + AppendField + + Appends a pattern's field: nothing if 'value' is empty, else the prefix, the value padded with spaces on the right + to 'width', and the suffix +*/ +void AppendField(std::string& line, std::string_view value, std::string_view prefix, std::string_view suffix, + std::size_t width) +{ + if (value.empty()) + return; + + line.append(prefix); + line.append(value); + + if (value.size() < width) + line.append(width - value.size(), ' '); + + line.append(suffix); +} + +/* + AppendJSONNumber + + Appends a JSON object's member "":, followed by a comma +*/ +template +void AppendJSONNumber(std::string& line, std::string_view name, TInteger value) +{ + char digits[24]; + const auto result = std::to_chars(digits, digits + sizeof(digits), value); + + line += '"'; + line.append(name); + line += "\":"; + line.append(digits, result.ptr); + line += ','; +} + +/* + AppendJSONString + + Appends a JSON object's member "":"", the value escaped, followed by a comma +*/ +void AppendJSONString(std::string& line, std::string_view name, std::string_view value) +{ + line += '"'; + line.append(name); + line += "\":"; + JSON::AppendString(line, value); + line += ','; +} + +/* + AppendNumberField + + Appends "[