From c7551a4ff4524e4be01749ed76300f8776d1c3f3 Mon Sep 17 00:00:00 2001 From: ZayanKhan-12 <108294002+ZayanKhan-12@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:38:11 -0400 Subject: [PATCH 1/2] Add support for negative logging categories in the log gateway Categories prefixed with '!' in the logging_categories setting specify messages that should not be written to the error log. A negative match takes precedence over any positive match, and when only negative categories are specified, all other messages remain pertinent, so e.g. ["!access"] logs everything except access messages. Behaviour is unchanged when logging_categories is omitted (everything is logged), empty (nothing is logged), or contains only positive categories. Closes #338 Co-Authored-By: Claude Fable 5 --- Development/cmake/NmosCppTest.cmake | 1 + Development/nmos/log_gate.h | 26 +++++++++- Development/nmos/settings.h | 2 + Development/nmos/test/log_gate_test.cpp | 69 +++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 Development/nmos/test/log_gate_test.cpp diff --git a/Development/cmake/NmosCppTest.cmake b/Development/cmake/NmosCppTest.cmake index 083f680bf..cabd0693e 100644 --- a/Development/cmake/NmosCppTest.cmake +++ b/Development/cmake/NmosCppTest.cmake @@ -57,6 +57,7 @@ set(NMOS_CPP_TEST_NMOS_TEST_SOURCES nmos/test/json_validator_test.cpp nmos/test/jwt_generator_test.cpp nmos/test/jwt_validation_test.cpp + nmos/test/log_gate_test.cpp nmos/test/mdns_test.cpp nmos/test/node_interfaces_test.cpp nmos/test/paging_utils_test.cpp diff --git a/Development/nmos/log_gate.h b/Development/nmos/log_gate.h index 08a3669b5..bd9ac9f32 100644 --- a/Development/nmos/log_gate.h +++ b/Development/nmos/log_gate.h @@ -63,14 +63,36 @@ namespace nmos const auto& pertinent_categories = nmos::fields::logging_categories(model.settings); + // categories prefixed with '!' specify messages that should not be logged; + // a negative match takes precedence over any positive match, and when only + // negative categories are specified, all other messages are pertinent + const auto is_negative = [](const web::json::value& category) + { + const auto& s = category.as_string(); + return !s.empty() && U('!') == s.front(); + }; + const bool default_pertinent = 0 != pertinent_categories.size() + && pertinent_categories.end() == boost::range::find_if(pertinent_categories, [&](const web::json::value& category) + { + return !is_negative(category); + }); + if (categories.empty()) { static const auto no_category = web::json::value::string(utility::string_t()); - return pertinent_categories.end() != boost::range::find(pertinent_categories, no_category); + return default_pertinent || pertinent_categories.end() != boost::range::find(pertinent_categories, no_category); } // this could be made more efficient if there may be many pertinent categories - return categories.end() != boost::range::find_if(categories, [&](const nmos::category& c) + if (categories.end() != boost::range::find_if(categories, [&](const nmos::category& c) + { + return pertinent_categories.end() != boost::range::find(pertinent_categories, web::json::value::string(utility::s2us("!" + c))); + })) + { + return false; + } + + return default_pertinent || categories.end() != boost::range::find_if(categories, [&](const nmos::category& c) { return pertinent_categories.end() != boost::range::find(pertinent_categories, web::json::value::string(utility::s2us(c))); }); diff --git a/Development/nmos/settings.h b/Development/nmos/settings.h index 008461129..e4dd8d25b 100644 --- a/Development/nmos/settings.h +++ b/Development/nmos/settings.h @@ -100,6 +100,8 @@ namespace nmos const web::json::field_as_integer_or logging_level{ U("logging_level"), 0 }; // 0, rather than slog::severities::info or slog::nil_severity, just to avoid a #include // logging_categories [registry, node]: array of logging categories to be included in the error log + // categories prefixed with '!' are excluded, even if another category matches positively; + // when only excluded categories are specified, all other log messages are included const web::json::field_as_array logging_categories{ U("logging_categories") }; // when omitted, all log messages are included // Configuration settings and defaults for the NMOS APIs diff --git a/Development/nmos/test/log_gate_test.cpp b/Development/nmos/test/log_gate_test.cpp new file mode 100644 index 000000000..004ab844a --- /dev/null +++ b/Development/nmos/test/log_gate_test.cpp @@ -0,0 +1,69 @@ +// The first "test" is of course whether the header compiles standalone +#include "nmos/log_gate.h" + +#include +#include "bst/test/test.h" + +namespace +{ + struct test_gate : nmos::experimental::log_gate + { + test_gate(std::ostream& error_log, std::ostream& access_log, nmos::experimental::log_model& model) + : nmos::experimental::log_gate(error_log, access_log, model) {} + using nmos::experimental::log_gate::pertinent; + }; +} + +//////////////////////////////////////////////////////////////////////////////////////////// +BST_TEST_CASE(testLogGatePertinentCategories) +{ + using web::json::value_of; + + std::ostringstream error_log; + std::ostringstream access_log; + nmos::experimental::log_model model; + test_gate gate(error_log, access_log, model); + + const std::list no_categories; + const std::list access{ "access" }; + const std::list send_query_ws_events{ "send_query_ws_events" }; + const std::list both{ "send_query_ws_events", "access" }; + + // when logging_categories is omitted, all messages are pertinent + BST_REQUIRE(gate.pertinent(no_categories)); + BST_REQUIRE(gate.pertinent(access)); + BST_REQUIRE(gate.pertinent(both)); + + // when logging_categories is empty, no messages are pertinent + model.settings[nmos::fields::logging_categories] = web::json::value::array(); + BST_REQUIRE(!gate.pertinent(no_categories)); + BST_REQUIRE(!gate.pertinent(access)); + BST_REQUIRE(!gate.pertinent(both)); + + // positive categories select the messages to be logged + model.settings[nmos::fields::logging_categories] = value_of({ U("send_query_ws_events") }); + BST_REQUIRE(!gate.pertinent(no_categories)); + BST_REQUIRE(gate.pertinent(send_query_ws_events)); + BST_REQUIRE(!gate.pertinent(access)); + BST_REQUIRE(gate.pertinent(both)); + + // the empty string selects messages with no category + model.settings[nmos::fields::logging_categories] = value_of({ U("") }); + BST_REQUIRE(gate.pertinent(no_categories)); + BST_REQUIRE(!gate.pertinent(access)); + + // a category prefixed with '!' excludes matching messages, even if another + // category matches positively + model.settings[nmos::fields::logging_categories] = value_of({ U("send_query_ws_events"), U("!access") }); + BST_REQUIRE(gate.pertinent(send_query_ws_events)); + BST_REQUIRE(!gate.pertinent(access)); + BST_REQUIRE(!gate.pertinent(both)); + BST_REQUIRE(!gate.pertinent(no_categories)); + + // when only excluded categories are specified, all other messages are pertinent + model.settings[nmos::fields::logging_categories] = value_of({ U("!access") }); + BST_REQUIRE(gate.pertinent(no_categories)); + BST_REQUIRE(gate.pertinent(send_query_ws_events)); + BST_REQUIRE(!gate.pertinent(access)); + BST_REQUIRE(!gate.pertinent(both)); +} From 676aca76f403d962773c31c52169b221b478079e Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Wed, 12 Aug 2026 13:47:21 +0100 Subject: [PATCH 2/2] Treat "!" as negated empty logging category Complete the negative-prefix rule for uncategorized messages, align example config comments with settings.h, and add coverage for same- category positive/negative conflict. Signed-off-by: Gareth Sylvester-Bradley --- Development/nmos-cpp-node/config.json | 2 ++ Development/nmos-cpp-registry/config.json | 2 ++ Development/nmos/log_gate.h | 28 +++++++++++++++++------ Development/nmos/settings.h | 2 +- Development/nmos/test/log_gate_test.cpp | 18 +++++++++++++++ 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/Development/nmos-cpp-node/config.json b/Development/nmos-cpp-node/config.json index 05f4f6ee4..fea89bf0c 100644 --- a/Development/nmos-cpp-node/config.json +++ b/Development/nmos-cpp-node/config.json @@ -79,6 +79,8 @@ //"logging_level": 0, // logging_categories [registry, node]: array of logging categories to be included in the error log + // categories prefixed with '!' are excluded, even if another category matches positively; + // "!" excludes messages with no category; when only excluded categories are specified, all other log messages are included //"logging_categories": ["node_implementation"], // Configuration settings and defaults for the NMOS APIs diff --git a/Development/nmos-cpp-registry/config.json b/Development/nmos-cpp-registry/config.json index 4b4a3e89b..56735147a 100644 --- a/Development/nmos-cpp-registry/config.json +++ b/Development/nmos-cpp-registry/config.json @@ -14,6 +14,8 @@ //"logging_level": 0, // logging_categories [registry, node]: array of logging categories to be included in the error log + // categories prefixed with '!' are excluded, even if another category matches positively; + // "!" excludes messages with no category; when only excluded categories are specified, all other log messages are included //"logging_categories": ["send_query_ws_events"], // Configuration settings and defaults for the NMOS APIs diff --git a/Development/nmos/log_gate.h b/Development/nmos/log_gate.h index bd9ac9f32..8097af79d 100644 --- a/Development/nmos/log_gate.h +++ b/Development/nmos/log_gate.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include "nmos/log_model.h" @@ -56,6 +57,13 @@ namespace nmos protected: virtual bool pertinent(const std::list& categories) const { + // logging_categories setting: + // - omitted: log everything + // - empty list: log nothing + // - only positives: allowlist (log just those; "" includes uncategorized) + // - only negatives ('!' prefixes): blocklist (log everything except those; "!" excludes uncategorized) + // - mix: allowlist of the positives, but a negative match still wins + if (!model.settings.has_field(nmos::fields::logging_categories)) { return true; @@ -63,14 +71,11 @@ namespace nmos const auto& pertinent_categories = nmos::fields::logging_categories(model.settings); - // categories prefixed with '!' specify messages that should not be logged; - // a negative match takes precedence over any positive match, and when only - // negative categories are specified, all other messages are pertinent const auto is_negative = [](const web::json::value& category) { - const auto& s = category.as_string(); - return !s.empty() && U('!') == s.front(); + return boost::starts_with(category.as_string(), U("!")); }; + // true when the list is non-empty and every entry is negative (blocklist mode) const bool default_pertinent = 0 != pertinent_categories.size() && pertinent_categories.end() == boost::range::find_if(pertinent_categories, [&](const web::json::value& category) { @@ -79,6 +84,13 @@ namespace nmos if (categories.empty()) { + // "!" is the negative of "", i.e. excludes messages with no category + static const auto no_category_negative = web::json::value::string(U("!")); + if (pertinent_categories.end() != boost::range::find(pertinent_categories, no_category_negative)) + { + return false; + } + static const auto no_category = web::json::value::string(utility::string_t()); return default_pertinent || pertinent_categories.end() != boost::range::find(pertinent_categories, no_category); } @@ -86,7 +98,8 @@ namespace nmos // this could be made more efficient if there may be many pertinent categories if (categories.end() != boost::range::find_if(categories, [&](const nmos::category& c) { - return pertinent_categories.end() != boost::range::find(pertinent_categories, web::json::value::string(utility::s2us("!" + c))); + const auto category_negative = web::json::value::string(utility::s2us("!" + c)); + return pertinent_categories.end() != boost::range::find(pertinent_categories, category_negative); })) { return false; @@ -94,7 +107,8 @@ namespace nmos return default_pertinent || categories.end() != boost::range::find_if(categories, [&](const nmos::category& c) { - return pertinent_categories.end() != boost::range::find(pertinent_categories, web::json::value::string(utility::s2us(c))); + const auto category = web::json::value::string(utility::s2us(c)); + return pertinent_categories.end() != boost::range::find(pertinent_categories, category); }); } diff --git a/Development/nmos/settings.h b/Development/nmos/settings.h index e4dd8d25b..e98c9101a 100644 --- a/Development/nmos/settings.h +++ b/Development/nmos/settings.h @@ -101,7 +101,7 @@ namespace nmos // logging_categories [registry, node]: array of logging categories to be included in the error log // categories prefixed with '!' are excluded, even if another category matches positively; - // when only excluded categories are specified, all other log messages are included + // "!" excludes messages with no category; when only excluded categories are specified, all other log messages are included const web::json::field_as_array logging_categories{ U("logging_categories") }; // when omitted, all log messages are included // Configuration settings and defaults for the NMOS APIs diff --git a/Development/nmos/test/log_gate_test.cpp b/Development/nmos/test/log_gate_test.cpp index 004ab844a..0b2ca994b 100644 --- a/Development/nmos/test/log_gate_test.cpp +++ b/Development/nmos/test/log_gate_test.cpp @@ -66,4 +66,22 @@ BST_TEST_CASE(testLogGatePertinentCategories) BST_REQUIRE(gate.pertinent(send_query_ws_events)); BST_REQUIRE(!gate.pertinent(access)); BST_REQUIRE(!gate.pertinent(both)); + + // a negative match takes precedence over the same category listed positively + model.settings[nmos::fields::logging_categories] = value_of({ U("access"), U("!access") }); + BST_REQUIRE(!gate.pertinent(access)); + BST_REQUIRE(!gate.pertinent(both)); + BST_REQUIRE(!gate.pertinent(no_categories)); + BST_REQUIRE(!gate.pertinent(send_query_ws_events)); + + // "!" excludes messages with no category (negation of "") + model.settings[nmos::fields::logging_categories] = value_of({ U("!") }); + BST_REQUIRE(!gate.pertinent(no_categories)); + BST_REQUIRE(gate.pertinent(access)); + BST_REQUIRE(gate.pertinent(send_query_ws_events)); + + model.settings[nmos::fields::logging_categories] = value_of({ U("!"), U("!access") }); + BST_REQUIRE(!gate.pertinent(no_categories)); + BST_REQUIRE(!gate.pertinent(access)); + BST_REQUIRE(gate.pertinent(send_query_ws_events)); }