feat(go.d/ping): add jitter and variance metrics (#21683)
Costa Tsaousis committed
Jan 30, 2026 at 15:31 UTC
2e00cf6c82b81047d4e7c22c830d097a008846f8
10 files changed
+298
-48
src/go/plugin/go.d/collector/ping/charts.go
+56
-30
@@ -12,17 +12,12 @@ import (
12
const (
13
prioHostRTT = module.Priority + iota
14
prioHostStdDevRTT
15
+ prioHostJitter
16
+ prioHostVariance
17
prioHostPingPacketLoss
18
prioHostPingPackets
19
)
20
19
-var hostChartsTmpl = module.Charts{
20
- hostRTTChartTmpl.Copy(),
21
- hostStdDevRTTChartTmpl.Copy(),
22
- hostPacketLossChartTmpl.Copy(),
23
- hostPacketsChartTmpl.Copy(),
24
-}
25
-
21
var (
22
hostRTTChartTmpl = module.Chart{
23
ID: "host_%s_rtt",
@@ -49,31 +44,62 @@ var (
44
{ID: "host_%s_std_dev_rtt", Name: "std_dev", Div: 1e3},
45
},
46
}
47
+ hostJitterChartTmpl = module.Chart{
48
+ ID: "host_%s_jitter",
49
+ Title: "Ping latency jitter",
50
+ Units: "milliseconds",
51
+ Fam: "latency",
52
+ Ctx: "ping.host_jitter",
53
+ Priority: prioHostJitter,
54
+ Dims: module.Dims{
55
+ {ID: "host_%s_mean_jitter", Name: "mean", Div: 1e3},
56
+ {ID: "host_%s_ewma_jitter", Name: "ewma", Div: 1e3},
57
+ {ID: "host_%s_sma_jitter", Name: "sma", Div: 1e3},
58
+ },
59
+ }
60
+ hostVarianceChartTmpl = module.Chart{
61
+ ID: "host_%s_rtt_variance",
62
+ Title: "Ping round-trip time variance",
63
+ Units: "ms²",
64
+ Fam: "latency",
65
+ Ctx: "ping.host_rtt_variance",
66
+ Priority: prioHostVariance,
67
+ Dims: module.Dims{
68
+ {ID: "host_%s_rtt_variance", Name: "variance", Div: 1e6},
69
+ },
70
+ }
71
+ hostPacketLossChartTmpl = module.Chart{
72
+ ID: "host_%s_packet_loss",
73
+ Title: "Ping packet loss",
74
+ Units: "percentage",
75
+ Fam: "packet loss",
76
+ Ctx: "ping.host_packet_loss",
77
+ Priority: prioHostPingPacketLoss,
78
+ Dims: module.Dims{
79
+ {ID: "host_%s_packet_loss", Name: "loss", Div: 1000},
80
+ },
81
+ }
82
+ hostPacketsChartTmpl = module.Chart{
83
+ ID: "host_%s_packets",
84
+ Title: "Ping packets transferred",
85
+ Units: "packets",
86
+ Fam: "packets",
87
+ Ctx: "ping.host_packets",
88
+ Priority: prioHostPingPackets,
89
+ Dims: module.Dims{
90
+ {ID: "host_%s_packets_recv", Name: "received"},
91
+ {ID: "host_%s_packets_sent", Name: "sent"},
92
+ },
93
+ }
94
)
95
54
-var hostPacketLossChartTmpl = module.Chart{
55
- ID: "host_%s_packet_loss",
56
- Title: "Ping packet loss",
57
- Units: "percentage",
58
- Fam: "packet loss",
59
- Ctx: "ping.host_packet_loss",
60
- Priority: prioHostPingPacketLoss,
61
- Dims: module.Dims{
62
- {ID: "host_%s_packet_loss", Name: "loss", Div: 1000},
63
- },
64
-}
65
-
66
-var hostPacketsChartTmpl = module.Chart{
67
- ID: "host_%s_packets",
68
- Title: "Ping packets transferred",
69
- Units: "packets",
70
- Fam: "packets",
71
- Ctx: "ping.host_packets",
72
- Priority: prioHostPingPackets,
73
- Dims: module.Dims{
74
- {ID: "host_%s_packets_recv", Name: "received"},
75
- {ID: "host_%s_packets_sent", Name: "sent"},
76
- },
96
+var hostChartsTmpl = module.Charts{
97
+ hostRTTChartTmpl.Copy(),
98
+ hostStdDevRTTChartTmpl.Copy(),
99
+ hostJitterChartTmpl.Copy(),
100
+ hostVarianceChartTmpl.Copy(),
101
+ hostPacketLossChartTmpl.Copy(),
102
+ hostPacketsChartTmpl.Copy(),
103
}
104
105
func newHostCharts(host string) *module.Charts {
src/go/plugin/go.d/collector/ping/collect.go
+57
@@ -5,6 +5,7 @@ package ping
5
import (
6
"fmt"
7
"sync"
8
+ "time"
9
)
10
11
func (c *Collector) collect() (map[string]int64, error) {
@@ -42,8 +43,64 @@ func (c *Collector) pingHost(host string, mx map[string]int64, mu *sync.Mutex) {
43
mx[px+"max_rtt"] = stats.MaxRtt.Microseconds()
44
mx[px+"avg_rtt"] = stats.AvgRtt.Microseconds()
45
mx[px+"std_dev_rtt"] = stats.StdDevRtt.Microseconds()
46
+
47
+ // variance = stddev² stored as μs² (chart Div: 1e6 converts to ms²)
48
+ stdDevUs := stats.StdDevRtt.Microseconds()
49
+ mx[px+"rtt_variance"] = stdDevUs * stdDevUs
50
+ }
51
+
52
+ // jitter requires at least 2 RTT samples
53
+ if len(stats.Rtts) >= 2 {
54
+ meanJitter := calcMeanJitter(stats.Rtts)
55
+ mx[px+"mean_jitter"] = meanJitter.Microseconds()
56
+ mx[px+"ewma_jitter"] = c.updateEWMAJitter(host, meanJitter).Microseconds()
57
+ mx[px+"sma_jitter"] = c.updateSMAJitter(host, meanJitter).Microseconds()
58
}
59
+
60
mx[px+"packets_recv"] = int64(stats.PacketsRecv)
61
mx[px+"packets_sent"] = int64(stats.PacketsSent)
62
mx[px+"packet_loss"] = int64(stats.PacketLoss * 1000)
63
}
64
+
65
+// calcMeanJitter calculates mean of absolute consecutive RTT differences
66
+func calcMeanJitter(rtts []time.Duration) time.Duration {
67
+ if len(rtts) < 2 {
68
+ return 0
69
+ }
70
+ var sum int64
71
+ for i := 1; i < len(rtts); i++ {
72
+ diff := rtts[i] - rtts[i-1]
73
+ if diff < 0 {
74
+ diff = -diff
75
+ }
76
+ sum += int64(diff)
77
+ }
78
+ return time.Duration(sum / int64(len(rtts)-1))
79
+}
80
+
81
+// updateEWMAJitter updates exponentially weighted moving average jitter
82
+// Formula: J(i) = α * current + (1-α) * J(i-1), where α = 1/N
83
+func (c *Collector) updateEWMAJitter(host string, current time.Duration) time.Duration {
84
+ prev := c.jitterEWMA[host]
85
+ curr := float64(current)
86
+ alpha := 1.0 / float64(c.JitterEWMASamples)
87
+ ewma := alpha*curr + (1-alpha)*prev
88
+ c.jitterEWMA[host] = ewma
89
+ return time.Duration(ewma)
90
+}
91
+
92
+// updateSMAJitter updates simple moving average jitter over a sliding window
93
+func (c *Collector) updateSMAJitter(host string, current time.Duration) time.Duration {
94
+ window := c.jitterSMA[host]
95
+ window = append(window, float64(current))
96
+ if len(window) > c.JitterSMAWindow {
97
+ window = window[1:]
98
+ }
99
+ c.jitterSMA[host] = window
100
+
101
+ var sum float64
102
+ for _, v := range window {
103
+ sum += v
104
+ }
105
+ return time.Duration(sum / float64(len(window)))
106
+}
src/go/plugin/go.d/collector/ping/collector.go
+16
-8
@@ -37,19 +37,25 @@ func New() *Collector {
37
Packets: 5,
38
Interval: confopt.Duration(time.Millisecond * 100),
39
},
40
+ JitterEWMASamples: 16,
41
+ JitterSMAWindow: 10,
42
},
43
42
- charts: &module.Charts{},
43
- hosts: make(map[string]bool),
44
- newProber: NewProber,
44
+ charts: &module.Charts{},
45
+ hosts: make(map[string]bool),
46
+ newProber: NewProber,
47
+ jitterEWMA: make(map[string]float64),
48
+ jitterSMA: make(map[string][]float64),
49
}
50
}
51
52
type Config struct {
49
- Vnode string `yaml:"vnode,omitempty" json:"vnode"`
50
- UpdateEvery int `yaml:"update_every,omitempty" json:"update_every"`
51
- Hosts []string `yaml:"hosts" json:"hosts"`
52
- ProberConfig `yaml:",inline" json:",inline"`
53
+ Vnode string `yaml:"vnode,omitempty" json:"vnode"`
54
+ UpdateEvery int `yaml:"update_every,omitempty" json:"update_every"`
55
+ Hosts []string `yaml:"hosts" json:"hosts"`
56
+ JitterEWMASamples int `yaml:"jitter_ewma_samples,omitempty" json:"jitter_ewma_samples"`
57
+ JitterSMAWindow int `yaml:"jitter_sma_window,omitempty" json:"jitter_sma_window"`
58
+ ProberConfig `yaml:",inline" json:",inline"`
59
}
60
61
type Collector struct {
@@ -61,7 +67,9 @@ type Collector struct {
67
prober Prober
68
newProber func(ProberConfig, *logger.Logger) Prober
69
64
- hosts map[string]bool
70
+ hosts map[string]bool
71
+ jitterEWMA map[string]float64 // EWMA jitter state per host
72
+ jitterSMA map[string][]float64 // SMA jitter window per host
73
}
74
75
func (c *Collector) Configuration() any {
src/go/plugin/go.d/collector/ping/collector_test.go
+115
-5
@@ -122,6 +122,10 @@ func TestCollector_Collect(t *testing.T) {
122
"host_192.0.2.1_packets_recv": 5,
123
"host_192.0.2.1_packets_sent": 5,
124
"host_192.0.2.1_std_dev_rtt": 5000,
125
+ "host_192.0.2.1_rtt_variance": 25000000,
126
+ "host_192.0.2.1_mean_jitter": 2500,
127
+ "host_192.0.2.1_ewma_jitter": 156,
128
+ "host_192.0.2.1_sma_jitter": 2500,
129
"host_192.0.2.2_avg_rtt": 15000,
130
"host_192.0.2.2_max_rtt": 20000,
131
"host_192.0.2.2_min_rtt": 10000,
@@ -129,6 +133,10 @@ func TestCollector_Collect(t *testing.T) {
133
"host_192.0.2.2_packets_recv": 5,
134
"host_192.0.2.2_packets_sent": 5,
135
"host_192.0.2.2_std_dev_rtt": 5000,
136
+ "host_192.0.2.2_rtt_variance": 25000000,
137
+ "host_192.0.2.2_mean_jitter": 2500,
138
+ "host_192.0.2.2_ewma_jitter": 156,
139
+ "host_192.0.2.2_sma_jitter": 2500,
140
"host_example.com_avg_rtt": 15000,
141
"host_example.com_max_rtt": 20000,
142
"host_example.com_min_rtt": 10000,
@@ -136,6 +144,10 @@ func TestCollector_Collect(t *testing.T) {
144
"host_example.com_packets_recv": 5,
145
"host_example.com_packets_sent": 5,
146
"host_example.com_std_dev_rtt": 5000,
147
+ "host_example.com_rtt_variance": 25000000,
148
+ "host_example.com_mean_jitter": 2500,
149
+ "host_example.com_ewma_jitter": 156,
150
+ "host_example.com_sma_jitter": 2500,
151
},
152
wantNumCharts: 3 * len(hostChartsTmpl),
153
},
@@ -183,6 +195,98 @@ func casePingError(t *testing.T) *Collector {
195
return collr
196
}
197
198
+func TestCalcMeanJitter(t *testing.T) {
199
+ tests := map[string]struct {
200
+ rtts []time.Duration
201
+ want time.Duration
202
+ }{
203
+ "empty": {
204
+ rtts: nil,
205
+ want: 0,
206
+ },
207
+ "single": {
208
+ rtts: []time.Duration{time.Millisecond * 10},
209
+ want: 0,
210
+ },
211
+ "two samples": {
212
+ rtts: []time.Duration{time.Millisecond * 10, time.Millisecond * 15},
213
+ want: time.Millisecond * 5,
214
+ },
215
+ "five samples": {
216
+ // 10, 12, 15, 18, 20 -> diffs: 2, 3, 3, 2 -> mean = 10/4 = 2.5
217
+ rtts: []time.Duration{
218
+ time.Millisecond * 10,
219
+ time.Millisecond * 12,
220
+ time.Millisecond * 15,
221
+ time.Millisecond * 18,
222
+ time.Millisecond * 20,
223
+ },
224
+ want: time.Microsecond * 2500,
225
+ },
226
+ "negative differences": {
227
+ // 20, 15, 10 -> diffs: |-5|=5, |-5|=5 -> mean = 5
228
+ rtts: []time.Duration{
229
+ time.Millisecond * 20,
230
+ time.Millisecond * 15,
231
+ time.Millisecond * 10,
232
+ },
233
+ want: time.Millisecond * 5,
234
+ },
235
+ }
236
+
237
+ for name, test := range tests {
238
+ t.Run(name, func(t *testing.T) {
239
+ got := calcMeanJitter(test.rtts)
240
+ assert.Equal(t, test.want, got)
241
+ })
242
+ }
243
+}
244
+
245
+func TestCollector_UpdateEWMAJitter(t *testing.T) {
246
+ collr := New()
247
+ collr.JitterEWMASamples = 16
248
+ collr.jitterEWMA = make(map[string]float64)
249
+
250
+ // First call: prev=0, current=2500μs -> ewma = 1/16 * 2500000 + 15/16 * 0 = 156250ns
251
+ got := collr.updateEWMAJitter("host1", time.Microsecond*2500)
252
+ assert.Equal(t, time.Duration(156250), got)
253
+
254
+ // Second call: prev=156250, current=2500μs -> ewma = 1/16 * 2500000 + 15/16 * 156250 = 302734ns
255
+ got = collr.updateEWMAJitter("host1", time.Microsecond*2500)
256
+ assert.Equal(t, time.Duration(302734), got)
257
+
258
+ // Test that EWMA can decrease when current is lower
259
+ // Set EWMA to a high value
260
+ collr.jitterEWMA["host2"] = 1000000 // 1ms
261
+ // Current jitter is 0 -> EWMA should decrease
262
+ got = collr.updateEWMAJitter("host2", 0)
263
+ // ewma = 1/16 * 0 + 15/16 * 1000000 = 937500ns (decreased from 1000000)
264
+ assert.Equal(t, time.Duration(937500), got)
265
+ assert.True(t, got < time.Microsecond*1000, "EWMA should decrease when current is lower")
266
+}
267
+
268
+func TestCollector_UpdateSMAJitter(t *testing.T) {
269
+ collr := New()
270
+ collr.JitterSMAWindow = 3
271
+ collr.jitterSMA = make(map[string][]float64)
272
+
273
+ // First call: window=[1000] -> sma = 1000
274
+ got := collr.updateSMAJitter("host1", time.Microsecond*1000)
275
+ assert.Equal(t, time.Microsecond*1000, got)
276
+
277
+ // Second call: window=[1000, 2000] -> sma = 1500
278
+ got = collr.updateSMAJitter("host1", time.Microsecond*2000)
279
+ assert.Equal(t, time.Microsecond*1500, got)
280
+
281
+ // Third call: window=[1000, 2000, 3000] -> sma = 2000
282
+ got = collr.updateSMAJitter("host1", time.Microsecond*3000)
283
+ assert.Equal(t, time.Microsecond*2000, got)
284
+
285
+ // Fourth call: window slides [2000, 3000, 4000] -> sma = 3000
286
+ got = collr.updateSMAJitter("host1", time.Microsecond*4000)
287
+ assert.Equal(t, time.Microsecond*3000, got)
288
+}
289
+
290
type mockProber struct {
291
errOnPing bool
292
}
@@ -198,11 +302,17 @@ func (m *mockProber) Ping(host string) (*probing.Statistics, error) {
302
PacketsRecvDuplicates: 0,
303
PacketLoss: 0,
304
Addr: host,
201
- Rtts: nil,
202
- MinRtt: time.Millisecond * 10,
203
- MaxRtt: time.Millisecond * 20,
204
- AvgRtt: time.Millisecond * 15,
205
- StdDevRtt: time.Millisecond * 5,
305
+ Rtts: []time.Duration{
306
+ time.Millisecond * 10,
307
+ time.Millisecond * 12,
308
+ time.Millisecond * 15,
309
+ time.Millisecond * 18,
310
+ time.Millisecond * 20,
311
+ },
312
+ MinRtt: time.Millisecond * 10,
313
+ MaxRtt: time.Millisecond * 20,
314
+ AvgRtt: time.Millisecond * 15,
315
+ StdDevRtt: time.Millisecond * 5,
316
}
317
318
return &stats, nil
src/go/plugin/go.d/collector/ping/config_schema.json
+15
-1
@@ -9,7 +9,7 @@
9
"description": "Data collection interval, measured in seconds.",
10
"type": "integer",
11
"minimum": 1,
12
- "default": 1
12
+ "default": 5
13
},
14
"privileged": {
15
"title": "Privileged mode",
@@ -62,6 +62,20 @@
62
"type": "string",
63
"default": ""
64
},
65
+ "jitter_ewma_samples": {
66
+ "title": "Jitter EWMA samples",
67
+ "description": "EWMA smoothing factor for jitter calculation. Higher values result in smoother but slower-responding jitter metrics.",
68
+ "type": "integer",
69
+ "minimum": 1,
70
+ "default": 16
71
+ },
72
+ "jitter_sma_window": {
73
+ "title": "Jitter SMA window",
74
+ "description": "Number of iterations for calculating Simple Moving Average jitter.",
75
+ "type": "integer",
76
+ "minimum": 1,
77
+ "default": 10
78
+ },
79
"vnode": {
80
"title": "Vnode",
81
"description": "Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes).",
src/go/plugin/go.d/collector/ping/init.go
+6
@@ -14,6 +14,12 @@ func (c *Collector) validateConfig() error {
14
if c.Packets <= 0 {
15
return errors.New("'send_packets' can't be <= 0")
16
}
17
+ if c.JitterEWMASamples <= 0 {
18
+ c.JitterEWMASamples = 16
19
+ }
20
+ if c.JitterSMAWindow <= 0 {
21
+ c.JitterSMAWindow = 10
22
+ }
23
return nil
24
}
25
src/go/plugin/go.d/collector/ping/metadata.yaml
+27
-2
@@ -116,6 +116,17 @@ modules:
116
required: false
117
group: Ping Settings
118
119
+ - name: jitter_ewma_samples
120
+ description: EWMA smoothing factor for jitter calculation. Higher values = smoother, slower response.
121
+ default_value: 16
122
+ required: false
123
+ group: Jitter Settings
124
+ - name: jitter_sma_window
125
+ description: Number of iterations for SMA jitter calculation.
126
+ default_value: 10
127
+ required: false
128
+ group: Jitter Settings
129
+
130
- name: vnode
131
description: Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes).
132
default_value: ""
@@ -166,7 +177,7 @@ modules:
177
alerts:
178
- name: ping_host_reachable
179
metric: ping.host_packet_loss
169
- info: "network host ${lab1el:host} reachability status"
180
+ info: "network host ${label:host} reachability status"
181
link: https://github.com/netdata/netdata/blob/master/src/health/health.d/ping.conf
182
- name: ping_packet_loss
183
metric: ping.host_packet_loss
@@ -192,7 +203,7 @@ modules:
203
- name: ping.host_rtt
204
description: Ping round-trip time
205
unit: milliseconds
195
- chart_type: line
206
+ chart_type: area
207
dimensions:
208
- name: min
209
- name: max
@@ -203,6 +214,20 @@ modules:
214
chart_type: line
215
dimensions:
216
- name: std_dev
217
+ - name: ping.host_jitter
218
+ description: Ping latency jitter
219
+ unit: milliseconds
220
+ chart_type: line
221
+ dimensions:
222
+ - name: mean
223
+ - name: ewma
224
+ - name: sma
225
+ - name: ping.host_rtt_variance
226
+ description: Ping round-trip time variance
227
+ unit: ms²
228
+ chart_type: line
229
+ dimensions:
230
+ - name: variance
231
- name: ping.host_packet_loss
232
description: Ping packet loss
233
unit: percentage
src/go/plugin/go.d/collector/ping/prober.go
+1
-1
@@ -47,7 +47,7 @@ func (p *pingProber) Ping(host string) (*probing.Statistics, error) {
47
return nil, fmt.Errorf("DNS lookup '%s' : %v", host, err)
48
}
49
50
- pr.RecordRtts = false
50
+ pr.RecordRtts = true
51
pr.RecordTTLs = false
52
pr.Interval = p.conf.Interval.Duration()
53
pr.Count = p.conf.Packets
src/go/plugin/go.d/collector/ping/testdata/config.json
+3
-1
@@ -8,5 +8,7 @@
8
"privileged": true,
9
"packets": 123,
10
"interval": 123.123,
11
- "interface": "ok"
11
+ "interface": "ok",
12
+ "jitter_ewma_samples": 123,
13
+ "jitter_sma_window": 123
14
}
src/go/plugin/go.d/collector/ping/testdata/config.yaml
+2
@@ -7,3 +7,5 @@ privileged: yes
7
packets: 123
8
interval: 123.123
9
interface: "ok"
10
+jitter_ewma_samples: 123
11
+jitter_sma_window: 123