@cryptotaxi247 / netdata-1 / commits / 6dc93113b

feat(scripts.d/nagios): add optional check_name for perfdata identity (#22154)

Ilya Mashchenko committed Apr 7, 2026 at 13:25 UTC 6dc93113b9f61f9de4fcc52b9d60f3f05f8c0b2c
14 files changed +364 -61
src/go/plugin/scripts.d/collector/nagios/collect.go
+9 -9
@@ -65,7 +65,7 @@ func (c *Collector) executeDueCheck(ctx context.Context, now time.Time) (checkRu
65 }
66
67 func (c *Collector) completeDueCheck(now time.Time, res checkRunResult) {
68 - c.state.completeRun(now, res.ServiceState, res.JobState, c.router.route(c.job.config.Plugin, res.Parsed.Perfdata), c.job.config)
68 + c.state.completeRun(now, res.ServiceState, res.JobState, c.router.route(c.job.config.CheckName, res.Parsed.Perfdata), c.job.config)
69 }
70
71 func (c *Collector) emitMetrics(execMetrics executionMetrics) {
@@ -87,12 +87,12 @@ func (c *Collector) emitMetrics(execMetrics executionMetrics) {
87 metrix.WithUnit("state"),
88 ).ObserveStateSet(jobStatePoint)
89
90 - scriptName := perfSourceFromPlugin(c.job.config.Plugin)
90 + checkName := perfSourceFromCheckName(c.job.config.CheckName)
91 jobMeter.StateSet(
92 - "perfdata."+scriptName+".job.execution_state",
92 + "perfdata."+checkName+".job.execution_state",
93 metrix.WithStateSetMode(metrix.ModeBitSet),
94 metrix.WithStateSetStates(jobExecutionStateNames...),
95 - metrix.WithChartFamily(perfdataFamily(scriptName)),
95 + metrix.WithChartFamily(perfdataFamily(checkName)),
96 metrix.WithChartPriority(chartengine.Priority-10),
97 metrix.WithUnit("state"),
98 ).ObserveStateSet(jobStatePoint)
@@ -122,7 +122,7 @@ func (c *Collector) emitMetrics(execMetrics executionMetrics) {
122 jobMeter.MeasureSetCounter(
123 measureSet.name,
124 metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
125 - metrix.WithChartFamily(perfdataFamily(measureSet.scriptName)),
125 + metrix.WithChartFamily(perfdataFamily(measureSet.checkName)),
126 metrix.WithUnit(measureSet.unit),
127 metrix.WithFloat(true),
128 ).ObserveTotalFields(fields)
@@ -130,7 +130,7 @@ func (c *Collector) emitMetrics(execMetrics executionMetrics) {
130 jobMeter.MeasureSetGauge(
131 measureSet.name,
132 metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
133 - metrix.WithChartFamily(perfdataFamily(measureSet.scriptName)),
133 + metrix.WithChartFamily(perfdataFamily(measureSet.checkName)),
134 metrix.WithUnit(measureSet.unit),
135 metrix.WithFloat(true),
136 ).ObserveFields(fields)
@@ -142,7 +142,7 @@ func (c *Collector) emitMetrics(execMetrics executionMetrics) {
142 thresholdState.name,
143 metrix.WithStateSetMode(metrix.ModeBitSet),
144 metrix.WithStateSetStates(perfThresholdStateNames...),
145 - metrix.WithChartFamily(perfdataFamily(thresholdState.scriptName)),
145 + metrix.WithChartFamily(perfdataFamily(thresholdState.checkName)),
146 metrix.WithUnit("state"),
147 )
148 if thresholdState.state == "" {
@@ -163,6 +163,6 @@ func (c *Collector) emitMetrics(execMetrics executionMetrics) {
163 }
164 }
165
166 -func perfdataFamily(scriptName string) string {
167 - return "Perfdata/" + scriptName
166 +func perfdataFamily(checkName string) string {
167 + return "Perfdata/" + checkName
168 }
src/go/plugin/scripts.d/collector/nagios/collector_test.go
+147 -10
@@ -84,10 +84,12 @@ func TestCollector_ConfigSchema(t *testing.T) {
84 t.Helper()
85 assert.NotEmpty(t, doc.JSONSchema.Schema)
86 _, hasPlugin := doc.JSONSchema.Properties["plugin"]
87 + _, hasCheckName := doc.JSONSchema.Properties["check_name"]
88 _, hasName := doc.JSONSchema.Properties["name"]
89 _, hasTimeoutState := doc.JSONSchema.Properties["timeout_state"]
90 _, hasUIOptions := doc.UISchema["uiOptions"]
91 assert.True(t, hasPlugin)
92 + assert.True(t, hasCheckName)
93 assert.False(t, hasName)
94 assert.False(t, hasTimeoutState)
95 assert.True(t, hasUIOptions)
@@ -142,6 +144,8 @@ type nagiosConfigSchemaDoc struct {
144 }
145
146 func TestCollector_Check(t *testing.T) {
147 + truePluginPath := writeTestPluginFile(t, "true")
148 +
149 tests := map[string]struct {
150 config Config
151 wantErr bool
@@ -157,19 +161,39 @@ func TestCollector_Check(t *testing.T) {
161 UpdateEvery: 10,
162 JobConfig: JobConfig{
163 Name: "cadence",
160 - Plugin: "/bin/true",
164 + Plugin: truePluginPath,
165 CheckInterval: confDuration(5 * time.Second),
166 RetryInterval: confDuration(5 * time.Second),
167 },
168 },
169 wantErr: false,
170 },
171 + "plugin path does not exist": {
172 + config: Config{
173 + JobConfig: JobConfig{
174 + Name: "missing-plugin",
175 + Plugin: filepath.Join(t.TempDir(), "missing-check"),
176 + },
177 + },
178 + wantErr: true,
179 + errMatch: "stat error",
180 + },
181 + "plugin path must be a regular file": {
182 + config: Config{
183 + JobConfig: JobConfig{
184 + Name: "plugin-dir",
185 + Plugin: t.TempDir(),
186 + },
187 + },
188 + wantErr: true,
189 + errMatch: "must be a regular file",
190 + },
191 "valid config": {
192 config: Config{
193 UpdateEvery: 1,
194 JobConfig: JobConfig{
195 Name: "valid",
172 - Plugin: "/bin/true",
196 + Plugin: truePluginPath,
197 CheckInterval: confDuration(5 * time.Second),
198 RetryInterval: confDuration(5 * time.Second),
199 },
@@ -196,6 +220,61 @@ func TestCollector_Check(t *testing.T) {
220 }
221 }
222
223 +func TestCollector_CheckRevalidatesConfiguredPlugin(t *testing.T) {
224 + dir := t.TempDir()
225 + pluginPath := filepath.Join(dir, "check_mock.sh")
226 + mode := os.FileMode(0o644)
227 + if runtime.GOOS != "windows" {
228 + mode = 0o755
229 + }
230 + require.NoError(t, os.WriteFile(pluginPath, []byte("#!/bin/sh\nexit 0\n"), mode))
231 +
232 + coll := New()
233 + coll.runner = &fakeRunner{}
234 + coll.Config = Config{
235 + UpdateEvery: 1,
236 + JobConfig: JobConfig{
237 + Name: "revalidate",
238 + Plugin: pluginPath,
239 + CheckInterval: confDuration(5 * time.Second),
240 + RetryInterval: confDuration(5 * time.Second),
241 + },
242 + }
243 +
244 + require.NoError(t, coll.Init(context.Background()))
245 + require.NoError(t, os.Remove(pluginPath))
246 +
247 + err := coll.Check(context.Background())
248 + require.Error(t, err)
249 + assert.Contains(t, err.Error(), "stat error")
250 +}
251 +
252 +func TestCollector_CheckRejectsNonExecutablePlugin(t *testing.T) {
253 + if runtime.GOOS == "windows" {
254 + t.Skip("windows does not use unix executable bits")
255 + }
256 +
257 + dir := t.TempDir()
258 + pluginPath := filepath.Join(dir, "check_mock.sh")
259 + require.NoError(t, os.WriteFile(pluginPath, []byte("#!/bin/sh\nexit 0\n"), 0o644))
260 +
261 + coll := New()
262 + coll.runner = &fakeRunner{}
263 + coll.Config = Config{
264 + UpdateEvery: 1,
265 + JobConfig: JobConfig{
266 + Name: "not-executable",
267 + Plugin: pluginPath,
268 + CheckInterval: confDuration(5 * time.Second),
269 + RetryInterval: confDuration(5 * time.Second),
270 + },
271 + }
272 +
273 + err := coll.Check(context.Background())
274 + require.Error(t, err)
275 + assert.Contains(t, err.Error(), "must be executable")
276 +}
277 +
278 func TestCompileCollectorConfig_CadenceWarning(t *testing.T) {
279 tests := map[string]struct {
280 config Config
@@ -249,6 +328,8 @@ func TestCompileCollectorConfig_CadenceWarning(t *testing.T) {
328 }
329
330 func TestCollector_Init(t *testing.T) {
331 + truePluginPath := writeTestPluginFile(t, "true")
332 +
333 tests := map[string]struct {
334 config Config
335 assert func(*testing.T, *Collector)
@@ -257,7 +338,7 @@ func TestCollector_Init(t *testing.T) {
338 config: Config{
339 JobConfig: JobConfig{
340 Name: "defaults",
260 - Plugin: "/bin/true",
341 + Plugin: truePluginPath,
342 },
343 },
344 assert: func(t *testing.T, coll *Collector) {
@@ -280,6 +361,9 @@ func TestCollector_Init(t *testing.T) {
361 }
362
363 func TestCollector_Collect(t *testing.T) {
364 + truePluginPath := writeTestPluginFile(t, "true")
365 + pwshPluginPath := writeTestPluginFile(t, "pwsh")
366 +
367 tests := map[string]struct {
368 results []fakeRun
369 config Config
@@ -310,7 +394,7 @@ func TestCollector_Collect(t *testing.T) {
394 UpdateEvery: 1,
395 JobConfig: JobConfig{
396 Name: "check_disk",
313 - Plugin: "/bin/true",
397 + Plugin: truePluginPath,
398 CheckInterval: confDuration(5 * time.Minute),
399 RetryInterval: confDuration(1 * time.Minute),
400 },
@@ -357,6 +441,46 @@ func TestCollector_Collect(t *testing.T) {
441 assertMetricValue(t, flat, "nagios.perfdata.true.bytes_used_value", metrix.Labels{"nagios_job": "check_disk", metrix.MeasureSetFieldLabel: "value"}, 30000)
442 },
443 },
444 + "uses explicit check name for perfdata namespace": {
445 + results: []fakeRun{
446 + {
447 + result: checkRunResult{
448 + ServiceState: "OK",
449 + JobState: "OK",
450 + Parsed: output.ParsedOutput{
451 + Perfdata: []output.PerfDatum{
452 + {Label: "used", Unit: "KB", Value: 30},
453 + },
454 + },
455 + },
456 + },
457 + },
458 + config: Config{
459 + UpdateEvery: 1,
460 + JobConfig: JobConfig{
461 + Name: "check_service_job",
462 + CheckName: "check_service",
463 + Plugin: pwshPluginPath,
464 + Args: []string{"-NoProfile", "-File", "/opt/netdata/check_service.ps1"},
465 + CheckInterval: confDuration(5 * time.Minute),
466 + RetryInterval: confDuration(1 * time.Minute),
467 + },
468 + },
469 + run: func(t *testing.T, coll *Collector, runner *fakeRunner, now *time.Time) {
470 + t.Helper()
471 + runCollectCycle(t, coll)
472 + assert.Equal(t, 1, runner.calls)
473 +
474 + flat := coll.MetricStore().Read(metrix.ReadFlatten())
475 + assertMetricValue(t, flat, "nagios.perfdata.check_service.job.execution_state", metrix.Labels{"nagios_job": "check_service_job", "nagios.perfdata.check_service.job.execution_state": "ok"}, 1)
476 + assertMetricChartFamily(t, flat, "nagios.perfdata.check_service.job.execution_state", "Perfdata/check_service")
477 + assertMetricValue(t, flat, "nagios.perfdata.check_service.bytes_used_value", metrix.Labels{"nagios_job": "check_service_job", metrix.MeasureSetFieldLabel: "value"}, 30000)
478 + assertMetricMissing(t, flat, "nagios.perfdata.pwsh.job.execution_state", metrix.Labels{"nagios_job": "check_service_job", "nagios.perfdata.pwsh.job.execution_state": "ok"})
479 + assertMetricMissing(t, flat, "nagios.perfdata.pwsh.bytes_used_value", metrix.Labels{"nagios_job": "check_service_job", metrix.MeasureSetFieldLabel: "value"})
480 +
481 + *now = now.Add(1 * time.Second)
482 + },
483 + },
484 "check period blocked cycles pause job state and zero threshold states": {
485 results: []fakeRun{
486 {
@@ -404,7 +528,7 @@ func TestCollector_Collect(t *testing.T) {
528 UpdateEvery: 1,
529 JobConfig: JobConfig{
530 Name: "period_job",
407 - Plugin: "/bin/true",
531 + Plugin: truePluginPath,
532 CheckInterval: confDuration(1 * time.Hour),
533 RetryInterval: confDuration(1 * time.Minute),
534 CheckPeriod: "business",
@@ -557,7 +681,7 @@ func TestCollector_Collect(t *testing.T) {
681 UpdateEvery: 1,
682 JobConfig: JobConfig{
683 Name: "retry_job",
560 - Plugin: "/bin/true",
684 + Plugin: truePluginPath,
685 CheckInterval: confDuration(5 * time.Minute),
686 RetryInterval: confDuration(10 * time.Second),
687 MaxCheckAttempts: 3,
@@ -636,7 +760,7 @@ func TestCollector_Collect(t *testing.T) {
760 UpdateEvery: 1,
761 JobConfig: JobConfig{
762 Name: "paused_retry_job",
639 - Plugin: "/bin/true",
763 + Plugin: truePluginPath,
764 CheckInterval: confDuration(1 * time.Hour),
765 RetryInterval: confDuration(10 * time.Second),
766 MaxCheckAttempts: 3,
@@ -716,7 +840,7 @@ func TestCollector_Collect(t *testing.T) {
840 UpdateEvery: 1,
841 JobConfig: JobConfig{
842 Name: "timeout_job",
719 - Plugin: "/bin/true",
843 + Plugin: truePluginPath,
844 CheckInterval: confDuration(5 * time.Minute),
845 RetryInterval: confDuration(10 * time.Second),
846 MaxCheckAttempts: 3,
@@ -753,7 +877,7 @@ func TestCollector_Collect(t *testing.T) {
877 UpdateEvery: 1,
878 JobConfig: JobConfig{
879 Name: "infra_fail",
756 - Plugin: "/bin/true",
880 + Plugin: truePluginPath,
881 },
882 },
883 run: func(t *testing.T, coll *Collector, runner *fakeRunner, _ *time.Time) {
@@ -776,7 +900,7 @@ func TestCollector_Collect(t *testing.T) {
900 UpdateEvery: 1,
901 JobConfig: JobConfig{
902 Name: "with_vnode",
779 - Plugin: "/bin/true",
903 + Plugin: truePluginPath,
904 },
905 },
906 setup: func(coll *Collector, _ *fakeRunner, _ *time.Time) {
@@ -1133,6 +1257,19 @@ func assertMetricMissing(t *testing.T, r metrix.Reader, name string, labels metr
1257 assert.False(t, ok, "unexpected metric %s labels=%v", name, labels)
1258 }
1259
1260 +func writeTestPluginFile(t *testing.T, name string) string {
1261 + t.Helper()
1262 + path := filepath.Join(t.TempDir(), name)
1263 + mode := os.FileMode(0o644)
1264 + content := "placeholder\n"
1265 + if runtime.GOOS != "windows" {
1266 + mode = 0o755
1267 + content = "#!/bin/sh\nexit 0\n"
1268 + }
1269 + require.NoError(t, os.WriteFile(path, []byte(content), mode))
1270 + return path
1271 +}
1272 +
1273 func confDuration(d time.Duration) confopt.Duration { return confopt.Duration(d) }
1274
1275 func findChartDimensionByContext(specYAML *charttpl.Spec, context string) (charttpl.Dimension, bool) {
src/go/plugin/scripts.d/collector/nagios/config_schema.json
+14 -4
@@ -18,9 +18,14 @@
18 "minimum": 0,
19 "default": 0
20 },
21 + "check_name": {
22 + "title": "Check name",
23 + "description": "Name that identifies this check type for chart grouping and metric naming. Charts appear under `Perfdata/<check_name>` in the dashboard. When `plugin` points to an interpreter, set this to the actual check identity — otherwise all interpreter-based jobs share a single chart section. If omitted, derived from the `plugin` basename.",
24 + "type": "string"
25 + },
26 "plugin": {
27 "title": "Plugin path",
23 - "description": "Absolute path to the Nagios-compatible check executable to run. If you need a script interpreter, point this to the interpreter executable and pass the script path in `args`. The command should return exit code 0, 1, 2, or 3 and may print performance data after `|`.",
28 + "description": "Absolute path to the Nagios-compatible command entry point. This can be a packaged plugin, your own executable, or an interpreter. When `plugin` points to an interpreter, pass the script path in `args` and set `check_name` to the actual check identity. The command should return exit code 0, 1, 2, or 3 and may print performance data after `|`.",
29 "type": "string"
30 },
31 "args": {
@@ -60,21 +65,21 @@
65 "title": "Timeout",
66 "description": "Maximum time allowed for one check execution, in seconds. If the check exceeds this limit, the job state becomes `timeout`.",
67 "type": "number",
63 - "minimum": 0.001,
68 + "minimum": 1,
69 "default": 5
70 },
71 "check_interval": {
72 "title": "Check interval",
73 "description": "Requested interval between regular checks, in seconds.",
74 "type": "number",
70 - "minimum": 0.001,
75 + "minimum": 1,
76 "default": 300
77 },
78 "retry_interval": {
79 "title": "Retry interval",
80 "description": "Requested interval between retry attempts while the check is in a soft non-OK state, in seconds.",
81 "type": "number",
77 - "minimum": 0.001,
82 + "minimum": 1,
83 "default": 60
84 },
85 "max_check_attempts": {
@@ -251,6 +256,10 @@
256 "uiOptions": {
257 "fullPage": true
258 },
259 + "check_name": {
260 + "ui:placeholder": "check_ping",
261 + "ui:help": "Controls the chart section name and metric context for performance data. Required when plugin is an interpreter."
262 + },
263 "plugin": {
264 "ui:placeholder": "/usr/lib/nagios/plugins/check_ping"
265 },
@@ -287,6 +296,7 @@
296 "fields": [
297 "update_every",
298 "plugin",
299 + "check_name",
300 "args",
301 "arg_values",
302 "timeout",
src/go/plugin/scripts.d/collector/nagios/init.go
+23 -4
@@ -2,6 +2,12 @@
2
3 package nagios
4
5 +import (
6 + "fmt"
7 + "os"
8 + "runtime"
9 +)
10 +
11 func (c *Collector) initCollector() error {
12 job, err := c.compileConfiguredJob()
13 if err != nil {
@@ -13,10 +19,6 @@ func (c *Collector) initCollector() error {
19 }
20
21 func (c *Collector) checkCollector() error {
16 - if c.job.configured() {
17 - return nil
18 - }
19 -
22 _, err := c.compileConfiguredJob()
23 return err
24 }
@@ -26,6 +28,9 @@ func (c *Collector) compileConfiguredJob() (compiledJob, error) {
28 if err != nil {
29 return compiledJob{}, err
30 }
31 + if err := validateConfiguredPlugin(job.config); err != nil {
32 + return compiledJob{}, err
33 + }
34 c.warnCadenceResolution(job)
35 return job, nil
36 }
@@ -37,3 +42,17 @@ func (c *Collector) warnCadenceResolution(job compiledJob) {
42 c.Warningf("%s", job.cadenceWarning)
43 c.cadenceWarning = job.cadenceWarning
44 }
45 +
46 +func validateConfiguredPlugin(job JobConfig) error {
47 + info, err := os.Stat(job.Plugin)
48 + if err != nil {
49 + return fmt.Errorf("job '%s': plugin path '%s' stat error: %w", job.Name, job.Plugin, err)
50 + }
51 + if !info.Mode().IsRegular() {
52 + return fmt.Errorf("job '%s': plugin path '%s' must be a regular file", job.Name, job.Plugin)
53 + }
54 + if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
55 + return fmt.Errorf("job '%s': plugin path '%s' must be executable", job.Name, job.Plugin)
56 + }
57 + return nil
58 +}
src/go/plugin/scripts.d/collector/nagios/job_config.go
+2
@@ -23,6 +23,7 @@ const (
23 // JobConfig is the user-facing Nagios job configuration surface.
24 type JobConfig struct {
25 Name string `yaml:"name" json:"name"`
26 + CheckName string `yaml:"check_name,omitempty" json:"check_name"`
27 Vnode string `yaml:"vnode,omitempty" json:"vnode"`
28 Plugin string `yaml:"plugin" json:"plugin"`
29 Args []string `yaml:"args,omitempty" json:"args"`
@@ -99,6 +100,7 @@ func (cfg JobConfig) normalized() (JobConfig, error) {
100 if err := cfg.validate(); err != nil {
101 return JobConfig{}, err
102 }
103 + cfg.CheckName = normalizedCheckName(cfg.CheckName, cfg.Plugin)
104
105 cfg.Args = append([]string{}, cfg.Args...)
106 cfg.ArgValues = append([]string{}, cfg.ArgValues...)
src/go/plugin/scripts.d/collector/nagios/job_config_test.go
+28
@@ -58,3 +58,31 @@ func TestJobConfigValidate(t *testing.T) {
58 })
59 }
60 }
61 +
62 +func TestJobConfigNormalizedCheckName(t *testing.T) {
63 + tests := map[string]struct {
64 + cfg JobConfig
65 + wantCheckName string
66 + }{
67 + "derives from plugin basename when omitted": {
68 + cfg: JobConfig{Name: "sample", Plugin: "/usr/lib/nagios/plugins/check_ping.pl"},
69 + wantCheckName: "check_ping",
70 + },
71 + "sanitizes explicit check name": {
72 + cfg: JobConfig{Name: "sample", Plugin: "/bin/bash", CheckName: "Database Health Check"},
73 + wantCheckName: "database_health_check",
74 + },
75 + "explicit check name does not strip suffixes as file extensions": {
76 + cfg: JobConfig{Name: "sample", Plugin: "/bin/bash", CheckName: "check_service.ps1"},
77 + wantCheckName: "check_service_ps1",
78 + },
79 + }
80 +
81 + for name, tc := range tests {
82 + t.Run(name, func(t *testing.T) {
83 + got, err := tc.cfg.normalized()
84 + assert.NoError(t, err)
85 + assert.Equal(t, tc.wantCheckName, got.CheckName)
86 + })
87 + }
88 +}
src/go/plugin/scripts.d/collector/nagios/job_v2_integration_test.go
+3 -1
@@ -18,6 +18,8 @@ import (
18 )
19
20 func TestNagiosCollectorJobV2(t *testing.T) {
21 + truePluginPath := writeTestPluginFile(t, "true")
22 +
23 type jobCaseState struct {
24 job *jobruntime.JobV2
25 out *lockedBuffer
@@ -52,7 +54,7 @@ func TestNagiosCollectorJobV2(t *testing.T) {
54 coll.now = func() time.Time { return now }
55 coll.Config.JobConfig = JobConfig{
56 Name: "jobv2",
55 - Plugin: "/bin/true",
57 + Plugin: truePluginPath,
58 CheckInterval: confDuration(5 * time.Minute),
59 RetryInterval: confDuration(1 * time.Minute),
60 }
src/go/plugin/scripts.d/collector/nagios/metadata.yaml
+86 -5
@@ -5,7 +5,7 @@ modules:
5 plugin_name: scripts.d.plugin
6 module_name: nagios
7 monitored_instance:
8 - name: Nagios Plugins
8 + name: Nagios Plugins and Custom Scripts
9 link: https://www.nagios-plugins.org/
10 categories:
11 - data-collection.synthetic-testing
@@ -25,7 +25,7 @@ modules:
25 multi_instance: true
26 data_collection:
27 metrics_description: |
28 - This collector runs [Nagios-compatible plugins](https://www.nagios-plugins.org/) and custom scripts. It provides:
28 + This collector runs [Nagios-compatible plugins](https://www.nagios-plugins.org/) and custom scripts in any language (Bash, PowerShell, Python, Go, etc.). It provides:
29
30 - **Check state monitoring** — tracks whether each check returns OK, WARNING, CRITICAL, or UNKNOWN
31 - **Execution metrics** — measures run duration, CPU time, and memory usage of each check
@@ -202,13 +202,25 @@ modules:
202 enabled: true
203 list:
204 - name: update_every
205 - description: How often the collector's internal scheduler ticks, in seconds. Controls chart granularity. In most cases you only need to set `check_interval`.
205 + description: Minimum resolution of the collector's scheduler, in seconds. `check_interval` and `retry_interval` are rounded up to the nearest multiple of this value. For example, if `update_every` is 10 and `check_interval` is 25s, the check actually runs every 30s. In most cases the default is fine — just set `check_interval`.
206 default_value: 10
207 required: false
208 group: Collection
209
210 + - name: check_name
211 + description: |
212 + Name that identifies this check type for chart grouping and metric naming. If omitted, Netdata derives it from the basename of `plugin` (removing any file extension). Set this when `plugin` points to an interpreter (e.g. `powershell.exe`, `/bin/bash`) — otherwise all jobs using the same interpreter share the same chart section.
213 +
214 + In the dashboard, charts appear under `Synthetic > Nagios > Perfdata > <check_name>`. For example, with `check_name: check_memory` and a script that outputs `caches=2380912KB`, Netdata creates:
215 +
216 + - `nagios.perfdata.check_memory.job.execution_state` — check state (ok, warning, critical, unknown, timeout, paused, retry)
217 + - `nagios.perfdata.check_memory.bytes_caches` — perfdata value chart
218 + - `nagios.perfdata.check_memory.bytes_caches_threshold_state` — threshold state (if warn/crit thresholds are present)
219 + default_value: ""
220 + required: false
221 + group: Target
222 - name: plugin
211 - description: Absolute path to the Nagios-compatible executable to run. This can be a packaged Nagios plugin or your own executable. If you need a script interpreter, point `plugin` to that interpreter and pass the script path in `args`. The command should return exit code `0`, `1`, `2`, or `3` and may print performance data after <code>&#124;</code>.
223 + description: Absolute path to the Nagios-compatible command entry point to run. This can be a packaged Nagios plugin, your own executable, or an interpreter executable. When `plugin` points to an interpreter, pass the script path in `args` and set `check_name` to the actual check identity — otherwise all jobs using the same interpreter share the same chart section. The command should return exit code `0`, `1`, `2`, or `3` and may print performance data after <code>&#124;</code>.
224 default_value: ""
225 required: true
226 group: Target
@@ -379,6 +391,69 @@ modules:
391 plugin: /opt/netdata/check_memory.sh
392 timeout: 5s
393 check_interval: 1m
394 + - name: End-to-end custom script (Windows PowerShell)
395 + description: |
396 + Write a PowerShell check script, then configure Netdata to run it. Because `plugin` points to the PowerShell interpreter, `check_name` is required to keep charts grouped under the script identity instead of `powershell`.
397 +
398 + **1. Create the script** (e.g., `C:\Netdata\checks\check_service.ps1`):
399 +
400 + ```powershell
401 + # Check if a Windows service is running
402 + param([string]$ServiceName = "W3SVC")
403 +
404 + $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
405 +
406 + if (-not $svc) {
407 + Write-Host "UNKNOWN - Service $ServiceName not found | running=0;;;0;1"
408 + exit 3
409 + }
410 +
411 + if ($svc.Status -eq 'Running') {
412 + Write-Host "OK - $ServiceName is running | running=1;;;0;1"
413 + exit 0
414 + } else {
415 + Write-Host "CRITICAL - $ServiceName is $($svc.Status) | running=0;;;0;1"
416 + exit 2
417 + }
418 + ```
419 +
420 + **2. Test from PowerShell** (run as the user the Netdata service runs under):
421 +
422 + ```powershell
423 + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Netdata\checks\check_service.ps1"
424 + echo "Exit code: $LASTEXITCODE"
425 + ```
426 +
427 + **3. Add the configuration below, then restart Netdata** (`Restart-Service netdata`).
428 + config: |
429 + jobs:
430 + - name: service_health_win
431 + check_name: check_service
432 + plugin: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
433 + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\Netdata\\checks\\check_service.ps1"]
434 + timeout: 10s
435 + check_interval: 1m
436 + - name: Interpreter-launched script (Bash)
437 + description: |
438 + Run a Bash script through an interpreter. Without `check_name`, charts would be grouped under `bash`.
439 + config: |
440 + jobs:
441 + - name: db_health
442 + check_name: check_postgres
443 + plugin: /bin/bash
444 + args: ["/opt/netdata/checks/check_postgres.sh", "--host", "db.example.com"]
445 + timeout: 10s
446 + check_interval: 5m
447 + - name: Remote check via NRPE
448 + description: |
449 + Run a check on a remote host using `check_nrpe`. This works exactly like a Nagios NRPE configuration — install `nagios-nrpe-plugin` and point to the remote NRPE agent. Increase `timeout` if the remote host is slow to respond.
450 + config: |
451 + jobs:
452 + - name: remote_disk
453 + plugin: /usr/lib/nagios/plugins/check_nrpe
454 + args: ["-H", "192.168.1.10", "-c", "check_disk", "-a", "20% 10% /"]
455 + timeout: 30s
456 + check_interval: 5m
457 - name: Check with a job-local schedule
458 description: Run a check only during selected hours by defining time periods inside the job.
459 config: |
@@ -437,9 +512,15 @@ modules:
512 - name: Script stderr output is not visible
513 description: |
514 Netdata captures the check's standard output for status and performance data parsing. Standard error (stderr) is logged by the collector but not used for state or charts. If your script writes errors to stderr, check the Netdata error log for details.
515 + - name: Job state is always timeout
516 + description: |
517 + The default timeout is 5 seconds, which is too short for many checks — especially remote checks (`check_nrpe`, `check_ssh`) or HTTP checks with SSL negotiation. Increase the `timeout` value in your job configuration (e.g. `timeout: 30s`).
518 + - name: Check works as root but fails under Netdata
519 + description: |
520 + The Netdata Agent runs as the `netdata` user. If a check needs to read protected files, access SNMP, or connect to local sockets, it must be accessible to the `netdata` user. Test as that user first: `sudo -u netdata /path/to/check`. Common fixes include adding the `netdata` user to the required system group or using `sudo` with a specific NOPASSWD rule for the check command.
521 - name: Windows checks need an executable entry point
522 description: |
442 - The collector runs the command named in `plugin` directly. On Windows, point `plugin` to an executable or to an interpreter such as `powershell.exe` and pass the script path in `args`.
523 + The collector runs the command named in `plugin` directly. On Windows, point `plugin` to the absolute path of a compiled executable or an interpreter (e.g. `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`). When using an interpreter, pass the script path in `args` and always set `check_name` — without it, all jobs using the same interpreter share the same chart section (e.g. `Perfdata/powershell`).
524 alerts:
525 - name: nagios_job_execution_state_warn
526 metric: nagios.job.execution_state
src/go/plugin/scripts.d/collector/nagios/perf_measureset.go
+6 -6
@@ -32,16 +32,16 @@ var (
32 )
33
34 type perfValueMeasureSet struct {
35 - name string
36 - scriptName string
37 - unit string
38 - counter bool
39 - value metrix.SampleValue
35 + name string
36 + checkName string
37 + unit string
38 + counter bool
39 + value metrix.SampleValue
40 }
41
42 type perfThresholdStateSet struct {
43 name string
44 - scriptName string
44 + checkName string
45 perfdataValue string
46 state string
47 }
src/go/plugin/scripts.d/collector/nagios/perfdata_normalize.go
+15
@@ -243,3 +243,18 @@ func perfSourceFromPlugin(pluginPath string) string {
243 }
244 return sanitizeMetricKey(base)
245 }
246 +
247 +func perfSourceFromCheckName(checkName string) string {
248 + trimmed := strings.TrimSpace(checkName)
249 + if trimmed == "" {
250 + return "check"
251 + }
252 + return sanitizeMetricKey(trimmed)
253 +}
254 +
255 +func normalizedCheckName(checkName, pluginPath string) string {
256 + if strings.TrimSpace(checkName) == "" {
257 + return perfSourceFromPlugin(pluginPath)
258 + }
259 + return perfSourceFromCheckName(checkName)
260 +}
src/go/plugin/scripts.d/collector/nagios/perfdata_router.go
+8 -8
@@ -25,11 +25,11 @@ func newPerfdataRouter(maxPerJob int) *perfdataRouter {
25 }
26 }
27
28 -func (r *perfdataRouter) route(pluginPath string, perf []output.PerfDatum) perfRouteResult {
28 +func (r *perfdataRouter) route(checkName string, perf []output.PerfDatum) perfRouteResult {
29 if len(perf) == 0 {
30 return perfRouteResult{}
31 }
32 - source := perfSourceFromPlugin(pluginPath)
32 + source := perfSourceFromCheckName(checkName)
33
34 items := make([]perfPreparedDatum, 0, len(perf))
35 for _, datum := range perf {
@@ -81,11 +81,11 @@ func (r *perfdataRouter) route(pluginPath string, perf []output.PerfDatum) perfR
81 base := perfMetricIdentity(source, item)
82 tail := perfMetricTail(item)
83 result.values = append(result.values, perfValueMeasureSet{
84 - name: base,
85 - scriptName: source,
86 - unit: unitForClass(item.class),
87 - counter: item.class == perfClassCounter,
88 - value: item.value,
84 + name: base,
85 + checkName: source,
86 + unit: unitForClass(item.class),
87 + counter: item.class == perfClassCounter,
88 + value: item.value,
89 })
90
91 if item.class == perfClassCounter {
@@ -97,7 +97,7 @@ func (r *perfdataRouter) route(pluginPath string, perf []output.PerfDatum) perfR
97
98 result.thresholdStates = append(result.thresholdStates, perfThresholdStateSet{
99 name: perfThresholdStateMetricName(base),
100 - scriptName: source,
100 + checkName: source,
101 perfdataValue: tail,
102 state: thresholdStateForPerfDatum(item),
103 })
src/go/plugin/scripts.d/collector/nagios/perfdata_router_test.go
+4 -4
@@ -12,14 +12,14 @@ import (
12 "github.com/stretchr/testify/require"
13 )
14
15 -const testPluginPath = "/opt/nagios-scripts/check_memory.pl"
15 +const testCheckName = "check_memory"
16
17 func TestPerfdataRouterRoutesAndCanonicalizesUnits(t *testing.T) {
18 router := newPerfdataRouter(64)
19
20 warnLow := 100.0
21 warnHigh := 500.0
22 - got := router.route(testPluginPath, []output.PerfDatum{
22 + got := router.route(testCheckName, []output.PerfDatum{
23 {
24 Label: "latency",
25 Unit: "ms",
@@ -150,11 +150,11 @@ func TestPerfdataRouterPolicies(t *testing.T) {
150 t.Run(name, func(t *testing.T) {
151 router := newPerfdataRouter(tc.budget)
152 if len(tc.prime) > 0 {
153 - primed := router.route(testPluginPath, tc.prime)
153 + primed := router.route(testCheckName, tc.prime)
154 require.NotEmpty(t, primed.values)
155 }
156
157 - got := router.route(testPluginPath, tc.input)
157 + got := router.route(testCheckName, tc.input)
158 tc.assert(t, got)
159 })
160 }
src/go/plugin/scripts.d/collector/nagios/v2_gate_test.go
+10 -10
@@ -17,7 +17,7 @@ import (
17 "github.com/stretchr/testify/require"
18 )
19
20 -const gatePluginPath = "/opt/nagios-scripts/check_gate.pl"
20 +const gateCheckName = "check_gate"
21
22 func TestV2Gate_G1_TemplateCompileProof(t *testing.T) {
23 templateYAML := New().ChartTemplateYAML()
@@ -36,7 +36,7 @@ func TestV2Gate_G2_PerfdataRouting(t *testing.T) {
36 warnHigh := 500.0
37 critLow := 200.0
38 critHigh := 900.0
39 - samples := router.route(gatePluginPath, []output.PerfDatum{
39 + samples := router.route(gateCheckName, []output.PerfDatum{
40 {
41 Label: "latency", Unit: "ms", Value: 120,
42 Warn: &output.ThresholdRange{Inclusive: true, Low: &warnLow, High: &warnHigh},
@@ -89,7 +89,7 @@ func TestV2Gate_G2_PerfdataRouting(t *testing.T) {
89 sm.MeasureSetCounter(
90 measureSet.name,
91 metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
92 - metrix.WithChartFamily(perfdataFamily(measureSet.scriptName)),
92 + metrix.WithChartFamily(perfdataFamily(measureSet.checkName)),
93 metrix.WithUnit(measureSet.unit),
94 ).ObserveTotalFields(fields, labels)
95 continue
@@ -97,7 +97,7 @@ func TestV2Gate_G2_PerfdataRouting(t *testing.T) {
97 sm.MeasureSetGauge(
98 measureSet.name,
99 metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
100 - metrix.WithChartFamily(perfdataFamily(measureSet.scriptName)),
100 + metrix.WithChartFamily(perfdataFamily(measureSet.checkName)),
101 metrix.WithUnit(measureSet.unit),
102 ).ObserveFields(fields, labels)
103 }
@@ -106,7 +106,7 @@ func TestV2Gate_G2_PerfdataRouting(t *testing.T) {
106 thresholdState.name,
107 metrix.WithStateSetMode(metrix.ModeBitSet),
108 metrix.WithStateSetStates(perfThresholdStateNames...),
109 - metrix.WithChartFamily(perfdataFamily(thresholdState.scriptName)),
109 + metrix.WithChartFamily(perfdataFamily(thresholdState.checkName)),
110 metrix.WithUnit("state"),
111 ).Enable(thresholdState.state)
112 sm.WithLabelSet(labels).WithLabels(
@@ -154,7 +154,7 @@ func TestV2Gate_G2_PerfdataRouting(t *testing.T) {
154 metrix.MeasureSetFieldLabel: perfFieldValue,
155 }, metrix.MetricKindCounter)
156
157 - changedClass := router.route(gatePluginPath, []output.PerfDatum{
157 + changedClass := router.route(gateCheckName, []output.PerfDatum{
158 {Label: "latency", Unit: "%", Value: 1}, // same label, different class => new identity
159 })
160 changedSamples := valueSampleMap(changedClass.values)
@@ -286,7 +286,7 @@ func TestV2Gate_G5_ScalingPrecisionEquivalence(t *testing.T) {
286 router := newPerfdataRouter(64)
287 displayV1 := legacyDisplayValue(tc.unit, tc.raw)
288
289 - samples := router.route(gatePluginPath, []output.PerfDatum{
289 + samples := router.route(gateCheckName, []output.PerfDatum{
290 {Label: "sample", Unit: tc.unit, Value: tc.raw},
291 })
292 var (
@@ -325,7 +325,7 @@ func TestV2Gate_G5_ScalingPrecisionEquivalence(t *testing.T) {
325 sm.MeasureSetCounter(
326 measureSet.name,
327 metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
328 - metrix.WithChartFamily(perfdataFamily(measureSet.scriptName)),
328 + metrix.WithChartFamily(perfdataFamily(measureSet.checkName)),
329 metrix.WithUnit(measureSet.unit),
330 ).ObserveTotalFields(fields, sm.LabelSet())
331 continue
@@ -333,7 +333,7 @@ func TestV2Gate_G5_ScalingPrecisionEquivalence(t *testing.T) {
333 sm.MeasureSetGauge(
334 measureSet.name,
335 metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
336 - metrix.WithChartFamily(perfdataFamily(measureSet.scriptName)),
336 + metrix.WithChartFamily(perfdataFamily(measureSet.checkName)),
337 metrix.WithUnit(measureSet.unit),
338 ).ObserveFields(fields, sm.LabelSet())
339 }
@@ -435,7 +435,7 @@ func assertPlanHasNoRemoveForTarget(t *testing.T, plan chartengine.Plan, removeM
435 func TestV2Gate_SmokeCollect(t *testing.T) {
436 coll := New()
437 coll.runner = &fakeRunner{}
438 - coll.Config.JobConfig.Plugin = "/bin/true"
438 + coll.Config.JobConfig.Plugin = writeTestPluginFile(t, "true")
439 coll.Config.JobConfig.Name = "smoke"
440 require.NoError(t, coll.Check(context.Background()))
441 }
src/go/plugin/scripts.d/config/scripts.d/nagios.conf
+9
@@ -17,3 +17,12 @@
17 # - type: weekly
18 # days: [sunday, monday, tuesday, wednesday, thursday, friday, saturday]
19 # ranges: ["00:00-24:00"]
20 +#
21 +# # When plugin is an interpreter, set check_name to identify the actual check.
22 +# # Without it, charts are grouped under the interpreter name (e.g. "powershell").
23 +# - name: service_health_win
24 +# check_name: check_service
25 +# plugin: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
26 +# args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\Netdata\\checks\\check_service.ps1"]
27 +# timeout: 10s
28 +# check_interval: 1m