@cryptotaxi247 / netdata-1 / commits / 2e00cf6c8

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