Skip to content

Commit 1444433

Browse files
committed
Fixed #748 support for nagios range based syntax in performance data
1 parent ef6621a commit 1444433

11 files changed

Lines changed: 392 additions & 28 deletions

File tree

include/nscapi/protobuf/functions_perfdata.cpp

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,22 +67,41 @@ struct perf_builder : parsers::perfdata::builder {
6767
void set_unit(const std::string &value) override {
6868
if (lastPerf) lastPerf->mutable_float_value()->set_unit(value);
6969
}
70+
void set_warning_range(const std::string &range) override {
71+
if (lastPerf) lastPerf->mutable_float_value()->set_warning_range(range);
72+
}
73+
void set_critical_range(const std::string &range) override {
74+
if (lastPerf) lastPerf->mutable_float_value()->set_critical_range(range);
75+
}
7076
void next() override {}
7177
};
7278

7379
void parse_float_perf_value(std::stringstream &ss, const PB::Common::PerformanceData_FloatValue &val) {
7480
ss << str::xtos_non_sci(val.value());
7581
if (!val.unit().empty()) ss << val.unit();
76-
if (!val.has_warning() && !val.has_critical() && !val.has_minimum() && !val.has_maximum()) {
82+
// `*_range` fields preserve the original Nagios range syntax for
83+
// warning/critical (issue #748). When set they take precedence over the
84+
// numeric `warning`/`critical` siblings, which carry only the lower
85+
// bound for back-compat. min/max are spec'd as single values so they're
86+
// numeric-only.
87+
const bool has_warning = val.has_warning() || !val.warning_range().empty();
88+
const bool has_critical = val.has_critical() || !val.critical_range().empty();
89+
if (!has_warning && !has_critical && !val.has_minimum() && !val.has_maximum()) {
7790
return;
7891
}
7992
ss << ";";
80-
if (val.has_warning()) ss << str::xtos_non_sci(val.warning().value());
81-
if (!val.has_critical() && !val.has_minimum() && !val.has_maximum()) {
93+
if (!val.warning_range().empty())
94+
ss << val.warning_range();
95+
else if (val.has_warning())
96+
ss << str::xtos_non_sci(val.warning().value());
97+
if (!has_critical && !val.has_minimum() && !val.has_maximum()) {
8298
return;
8399
}
84100
ss << ";";
85-
if (val.has_critical()) ss << str::xtos_non_sci(val.critical().value());
101+
if (!val.critical_range().empty())
102+
ss << val.critical_range();
103+
else if (val.has_critical())
104+
ss << str::xtos_non_sci(val.critical().value());
86105
if (!val.has_minimum() && !val.has_maximum()) {
87106
return;
88107
}

include/nscapi/protobuf/functions_perfdata_test.cpp

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,3 +408,50 @@ TEST(PerfDataBuildTest, build_with_no_string_value) {
408408
const auto result = nscapi::protobuf::functions::build_performance_data(line, nscapi::protobuf::functions::no_truncation);
409409
EXPECT_EQ("'test'=", result);
410410
}
411+
412+
// =================================================================
413+
// Threshold-range round-trip (issue #748)
414+
//
415+
// External-script stdout like
416+
// 'FOO'=10;4:5;6:9
417+
// used to round-trip as
418+
// 'FOO'=10;4;6
419+
// because trim_to_double truncated at the colon and the protobuf had no
420+
// place to carry the original syntax. These tests pin the fixed behaviour
421+
// end-to-end: parse a string into protobuf, format it back, and compare.
422+
// =================================================================
423+
424+
TEST(PerfDataRangeRoundTrip, simple_warning_range) { EXPECT_EQ("'FOO'=10;4:5;6:9", do_parse("'FOO'=10;4:5;6:9")); }
425+
426+
TEST(PerfDataRangeRoundTrip, open_lower_bound) { EXPECT_EQ("'x'=1;10:;:20", do_parse("'x'=1;10:;:20")); }
427+
428+
TEST(PerfDataRangeRoundTrip, inverted_and_infinity) { EXPECT_EQ("'x'=1;@10:20;~:30", do_parse("'x'=1;@10:20;~:30")); }
429+
430+
TEST(PerfDataRangeRoundTrip, range_with_unit) { EXPECT_EQ("'x'=5s;4:6;7:8", do_parse("'x'=5s;4:6;7:8")); }
431+
432+
TEST(PerfDataRangeRoundTrip, mixed_numeric_warning_range_critical) {
433+
// Numeric warning, range critical: both must survive separately.
434+
EXPECT_EQ("'x'=10;5;6:9", do_parse("'x'=10;5;6:9"));
435+
}
436+
437+
TEST(PerfDataRangeRoundTrip, range_with_min_max) {
438+
// min/max are spec'd as single numbers, not ranges - keep them numeric.
439+
EXPECT_EQ("'x'=50%;@10:90;:95;0;100", do_parse("'x'=50%;@10:90;:95;0;100"));
440+
}
441+
442+
// Builder-level test: setting the range explicitly via the protobuf
443+
// makes the formatter emit it verbatim, regardless of what was put in the
444+
// numeric warning/critical sibling.
445+
TEST(PerfDataBuildTest, build_range_takes_precedence_over_numeric) {
446+
PB::Commands::QueryResponseMessage::Response::Line line;
447+
auto* perf = line.add_perf();
448+
perf->set_alias("metric");
449+
perf->mutable_float_value()->set_value(50);
450+
perf->mutable_float_value()->mutable_warning()->set_value(4); // would be the lower bound
451+
perf->mutable_float_value()->set_warning_range("4:5");
452+
perf->mutable_float_value()->mutable_critical()->set_value(6);
453+
perf->mutable_float_value()->set_critical_range("6:9");
454+
455+
const auto result = nscapi::protobuf::functions::build_performance_data(line, nscapi::protobuf::functions::no_truncation);
456+
EXPECT_EQ("'metric'=50;4:5;6:9", result);
457+
}

include/parsers/perfdata.hpp

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ struct builder {
1818
virtual void set_maximum(double value) = 0;
1919
virtual void set_unit(const std::string &value) = 0;
2020

21+
// Threshold-range setters (Nagios range syntax like "4:5", ":10",
22+
// "@0:90", "~:10"). Defaulted to no-ops so existing builders compile
23+
// unchanged; the parser still calls set_warning / set_critical with the
24+
// lower bound for back-compat. Builders that need to preserve the full
25+
// syntax (notably the protobuf builder used by external-script paths)
26+
// override these to carry the string through. See GitHub issue #748.
27+
virtual void set_warning_range(const std::string & /*range*/) {}
28+
virtual void set_critical_range(const std::string & /*range*/) {}
29+
2130
virtual void next() = 0;
2231

2332
virtual void add_string(std::string alias, std::string value) = 0;
@@ -37,6 +46,25 @@ inline double trim_to_double(std::string s) {
3746
}
3847
}
3948

49+
// True if the threshold field uses Nagios range syntax (anything beyond a
50+
// simple number with optional UOM). The Nagios plugin development
51+
// guidelines define ranges as:
52+
// value single value, treated as 0..value
53+
// low:high alert if outside [low, high]
54+
// :high alert if value > high
55+
// low: alert if value < low
56+
// @low:high alert if value is inside [low, high] (inverted)
57+
// ~ marker -infinity (e.g. ~:10 = alert when > 10)
58+
// We don't try to parse the range here - we only detect that it IS a
59+
// range so the parser preserves the original string. The numeric float
60+
// (lower bound) is still set so consumers that only read the float don't
61+
// regress.
62+
inline bool is_threshold_range(const std::string &s) {
63+
if (s.empty()) return false;
64+
if (s[0] == '@' || s[0] == '~') return true;
65+
return s.find(':') != std::string::npos;
66+
}
67+
4068
inline void parse(std::shared_ptr<builder> builder, const std::string &perff) {
4169
std::string perf = perff;
4270
// TODO: make this work with const!
@@ -115,8 +143,18 @@ inline void parse(std::shared_ptr<builder> builder, const std::string &perff) {
115143
builder->set_value(trim_to_double(fitem.second.substr(0, pend)));
116144
builder->set_unit(fitem.second.substr(pend));
117145
}
118-
if (items.size() >= 2 && !items[1].empty()) builder->set_warning(trim_to_double(items[1]));
119-
if (items.size() >= 3 && !items[2].empty()) builder->set_critical(trim_to_double(items[2]));
146+
// Warning / critical can be range syntax (e.g. "4:5"). Set the numeric
147+
// lower bound for back-compat AND, when the field is a range, forward
148+
// the original string so the formatter can round-trip it (issue #748).
149+
if (items.size() >= 2 && !items[1].empty()) {
150+
builder->set_warning(trim_to_double(items[1]));
151+
if (is_threshold_range(items[1])) builder->set_warning_range(items[1]);
152+
}
153+
if (items.size() >= 3 && !items[2].empty()) {
154+
builder->set_critical(trim_to_double(items[2]));
155+
if (is_threshold_range(items[2])) builder->set_critical_range(items[2]);
156+
}
157+
// min/max are single values per the Nagios spec - no range syntax.
120158
if (items.size() >= 4 && !items[3].empty()) builder->set_minimum(trim_to_double(items[3]));
121159
if (items.size() >= 5 && !items[4].empty()) builder->set_maximum(trim_to_double(items[4]));
122160
builder->next();

include/parsers/perfdata_test.cpp

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ struct perf_entry {
3434
double minimum = -1.0;
3535
double maximum = -1.0;
3636
std::string unit;
37+
std::string warning_range;
38+
std::string critical_range;
3739
bool has_warning = false;
3840
bool has_critical = false;
3941
bool has_minimum = false;
@@ -72,6 +74,8 @@ struct test_builder final : parsers::perfdata::builder {
7274
current_.has_maximum = true;
7375
}
7476
void set_unit(const std::string& value) override { current_.unit = value; }
77+
void set_warning_range(const std::string& range) override { current_.warning_range = range; }
78+
void set_critical_range(const std::string& range) override { current_.critical_range = range; }
7579
void next() override {
7680
entries.push_back(current_);
7781
current_ = perf_entry();
@@ -377,3 +381,73 @@ TEST_F(PerfDataParserTest, ValueIsOnlyUnit) {
377381
ASSERT_EQ(1, b->entries.size());
378382
EXPECT_DOUBLE_EQ(0.0, b->entries[0].value);
379383
}
384+
385+
// ==============================================================
386+
// is_threshold_range
387+
// ==============================================================
388+
389+
TEST(IsThresholdRange, PlainNumberIsNotARange) { EXPECT_FALSE(parsers::perfdata::is_threshold_range("10")); }
390+
TEST(IsThresholdRange, NumberWithUnitIsNotARange) { EXPECT_FALSE(parsers::perfdata::is_threshold_range("10s")); }
391+
TEST(IsThresholdRange, EmptyIsNotARange) { EXPECT_FALSE(parsers::perfdata::is_threshold_range("")); }
392+
TEST(IsThresholdRange, ColonInsideIsRange) { EXPECT_TRUE(parsers::perfdata::is_threshold_range("4:5")); }
393+
TEST(IsThresholdRange, ColonPrefixIsRange) { EXPECT_TRUE(parsers::perfdata::is_threshold_range(":10")); }
394+
TEST(IsThresholdRange, ColonSuffixIsRange) { EXPECT_TRUE(parsers::perfdata::is_threshold_range("10:")); }
395+
TEST(IsThresholdRange, InvertedPrefixIsRange) { EXPECT_TRUE(parsers::perfdata::is_threshold_range("@10:20")); }
396+
TEST(IsThresholdRange, NegativeInfinityPrefixIsRange) { EXPECT_TRUE(parsers::perfdata::is_threshold_range("~:10")); }
397+
398+
// ==============================================================
399+
// parse — threshold ranges (issue #748)
400+
// ==============================================================
401+
402+
// The parser must preserve the original range string AND set the numeric
403+
// lower bound so consumers that read only the float don't regress.
404+
TEST_F(PerfDataParserTest, RangeWarningPreservesColon) {
405+
parsers::perfdata::parse(b, "'FOO'=10;4:5;6:9");
406+
ASSERT_EQ(1, b->entries.size());
407+
EXPECT_EQ("FOO", b->entries[0].alias);
408+
EXPECT_DOUBLE_EQ(10.0, b->entries[0].value);
409+
// Numeric back-compat: lower bound of the range.
410+
EXPECT_TRUE(b->entries[0].has_warning);
411+
EXPECT_DOUBLE_EQ(4.0, b->entries[0].warning);
412+
EXPECT_TRUE(b->entries[0].has_critical);
413+
EXPECT_DOUBLE_EQ(6.0, b->entries[0].critical);
414+
// Full range syntax preserved.
415+
EXPECT_EQ("4:5", b->entries[0].warning_range);
416+
EXPECT_EQ("6:9", b->entries[0].critical_range);
417+
}
418+
419+
TEST_F(PerfDataParserTest, RangeWarningOpenBounds) {
420+
// 10: = "alert if value < 10", :20 = "alert if value > 20"
421+
parsers::perfdata::parse(b, "'x'=1;10:;:20");
422+
ASSERT_EQ(1, b->entries.size());
423+
EXPECT_EQ("10:", b->entries[0].warning_range);
424+
EXPECT_EQ(":20", b->entries[0].critical_range);
425+
}
426+
427+
TEST_F(PerfDataParserTest, RangeInvertedAndInfinity) {
428+
parsers::perfdata::parse(b, "'x'=1;@10:20;~:30");
429+
ASSERT_EQ(1, b->entries.size());
430+
EXPECT_EQ("@10:20", b->entries[0].warning_range);
431+
EXPECT_EQ("~:30", b->entries[0].critical_range);
432+
}
433+
434+
TEST_F(PerfDataParserTest, RangeWithUnitSurvives) {
435+
parsers::perfdata::parse(b, "'x'=5s;4:6;7:8");
436+
ASSERT_EQ(1, b->entries.size());
437+
EXPECT_DOUBLE_EQ(5.0, b->entries[0].value);
438+
EXPECT_EQ("s", b->entries[0].unit);
439+
EXPECT_EQ("4:6", b->entries[0].warning_range);
440+
EXPECT_EQ("7:8", b->entries[0].critical_range);
441+
}
442+
443+
// Important regression guard: plain-numeric thresholds must NOT set the
444+
// range fields. Otherwise we'd pollute the wire with empty-looking ranges
445+
// for every existing producer.
446+
TEST_F(PerfDataParserTest, PlainNumericThresholdsDoNotSetRange) {
447+
parsers::perfdata::parse(b, "'x'=1;5;10");
448+
ASSERT_EQ(1, b->entries.size());
449+
EXPECT_DOUBLE_EQ(5.0, b->entries[0].warning);
450+
EXPECT_DOUBLE_EQ(10.0, b->entries[0].critical);
451+
EXPECT_TRUE(b->entries[0].warning_range.empty());
452+
EXPECT_TRUE(b->entries[0].critical_range.empty());
453+
}

libs/protobuf/common.proto

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,16 @@ option optimize_for = LITE_RUNTIME;
157157
OptionalFloat minimum = 6;
158158
// The highest possible value
159159
OptionalFloat maximum = 7;
160+
// Original Nagios range syntax for the warning threshold, e.g.
161+
// "4:5", ":10", "@0:90" or "~:10". When non-empty, formatters
162+
// emit this verbatim and ignore the numeric `warning` sibling.
163+
// Parsers set both: the float is the lower bound of the range
164+
// (for back-compat with consumers that only read the numeric
165+
// field) and this string carries the full syntax through the
166+
// pipeline. See GitHub issue #748.
167+
string warning_range = 8;
168+
// Same semantics as warning_range, for the critical threshold.
169+
string critical_range = 9;
160170
}
161171
// The name of the value
162172
string alias = 1;

modules/CheckHelpers/CheckHelpers.cpp

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,12 +496,20 @@ struct filter_obj {
496496
if (data.has_string_value()) return data.string_value().value();
497497
return "";
498498
}
499+
// %(warn) / %(crit) template substitutions: prefer the original Nagios
500+
// range syntax (e.g. "4:5") when present, fall back to the numeric
501+
// lower bound for plain-number thresholds (issue #748). Without this,
502+
// `render_perf` would replace a "4:5" warning with just "4".
499503
std::string get_warn() const {
500-
if (data.has_float_value() && data.float_value().has_warning()) return str::xtos(data.float_value().warning().value());
504+
if (!data.has_float_value()) return "";
505+
if (!data.float_value().warning_range().empty()) return data.float_value().warning_range();
506+
if (data.float_value().has_warning()) return str::xtos(data.float_value().warning().value());
501507
return "";
502508
}
503509
std::string get_crit() const {
504-
if (data.has_float_value() && data.float_value().has_critical()) return str::xtos(data.float_value().critical().value());
510+
if (!data.has_float_value()) return "";
511+
if (!data.float_value().critical_range().empty()) return data.float_value().critical_range();
512+
if (data.float_value().has_critical()) return str::xtos(data.float_value().critical().value());
505513
return "";
506514
}
507515
std::string get_max() const {

modules/WEBServer/legacy_command_controller.cpp

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,24 @@ void legacy_command_controller::handle_query(Mongoose::Request &request, boost::
6363
json::object perf;
6464
perf["alias"] = p.alias();
6565
if (p.has_float_value()) {
66+
const auto &fv = p.float_value();
6667
json::object float_value;
67-
float_value["value"] = p.float_value().value();
68-
if (p.float_value().has_minimum()) float_value["minimum"] = p.float_value().minimum().value();
69-
if (p.float_value().has_maximum()) float_value["maximum"] = p.float_value().maximum().value();
70-
if (p.float_value().has_warning()) float_value["warning"] = p.float_value().warning().value();
71-
if (p.float_value().has_critical()) float_value["critical"] = p.float_value().critical().value();
72-
float_value["unit"] = p.float_value().unit();
68+
float_value["value"] = fv.value();
69+
if (fv.has_minimum()) float_value["minimum"] = fv.minimum().value();
70+
if (fv.has_maximum()) float_value["maximum"] = fv.maximum().value();
71+
// Prefer the original Nagios range syntax over the numeric
72+
// lower bound so the API doesn't silently drop "4:5"-style
73+
// thresholds (issue #748). Same shape as the v2 queries
74+
// endpoint in query_controller.cpp.
75+
if (!fv.warning_range().empty())
76+
float_value["warning"] = fv.warning_range();
77+
else if (fv.has_warning())
78+
float_value["warning"] = fv.warning().value();
79+
if (!fv.critical_range().empty())
80+
float_value["critical"] = fv.critical_range();
81+
else if (fv.has_critical())
82+
float_value["critical"] = fv.critical().value();
83+
float_value["unit"] = fv.unit();
7384
perf["float_value"] = float_value;
7485
}
7586
if (p.has_string_value()) {

modules/WEBServer/query_controller.cpp

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,25 @@ void query_controller::execute_query(std::string module, arg_vector args, Mongoo
158158
json::object pdata;
159159

160160
if (p.has_float_value()) {
161-
pdata["value"] = p.float_value().value();
162-
if (p.float_value().has_minimum()) pdata["minimum"] = p.float_value().minimum().value();
163-
if (p.float_value().has_maximum()) pdata["maximum"] = p.float_value().maximum().value();
164-
if (p.float_value().has_warning()) pdata["warning"] = p.float_value().warning().value();
165-
if (p.float_value().has_critical()) pdata["critical"] = p.float_value().critical().value();
166-
pdata["unit"] = p.float_value().unit();
161+
const auto &fv = p.float_value();
162+
pdata["value"] = fv.value();
163+
if (fv.has_minimum()) pdata["minimum"] = fv.minimum().value();
164+
if (fv.has_maximum()) pdata["maximum"] = fv.maximum().value();
165+
// Threshold fields carry Nagios range syntax when the original
166+
// input used it (e.g. "4:5") - prefer that string over the
167+
// numeric lower bound so the API faithfully reports what the
168+
// upstream plugin emitted (issue #748). JSON consumers see
169+
// `number` for plain thresholds and `string` for range syntax;
170+
// the api.ts type mirrors that union.
171+
if (!fv.warning_range().empty())
172+
pdata["warning"] = fv.warning_range();
173+
else if (fv.has_warning())
174+
pdata["warning"] = fv.warning().value();
175+
if (!fv.critical_range().empty())
176+
pdata["critical"] = fv.critical_range();
177+
else if (fv.has_critical())
178+
pdata["critical"] = fv.critical().value();
179+
pdata["unit"] = fv.unit();
167180
}
168181
if (p.has_string_value()) {
169182
pdata["value"] = p.string_value().value();

0 commit comments

Comments
 (0)