chore(go.d/framework): render non-finite summary quantile values as a gap (#22643)
Ilya Mashchenko committed
Jun 7, 2026 at 07:46 UTC
17cce355ff7c7ee2b3ce8f3ba69179ad5cadaf0d
9 files changed
+215
-18
src/go/pkg/metrix/README.md
+5
-4
@@ -94,10 +94,10 @@
94
95
### Writers
96
97
-| Mode | Gauge-like family | Counter-like family |
98
-|----------|---------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|
99
-| Snapshot | `MeasureSetGauge(...).ObservePoint(...)` or preferred `ObserveFields(...)` | `MeasureSetCounter(...).ObserveTotalPoint(...)` or preferred `ObserveTotalFields(...)` |
100
-| Stateful | `MeasureSetGauge(...).SetPoint(...)`, `AddPoint(...)`, `SetFields(...)`, `AddFields(...)`, `SetField(...)`, `AddField(...)` | `MeasureSetCounter(...).AddPoint(...)`, `AddFields(...)`, `AddField(...)` |
97
+| Mode | Gauge-like family | Counter-like family |
98
+|----------|-----------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------|
99
+| Snapshot | `MeasureSetGauge(...).ObservePoint(...)` or preferred `ObserveFields(...)` | `MeasureSetCounter(...).ObserveTotalPoint(...)` or preferred `ObserveTotalFields(...)` |
100
+| Stateful | `MeasureSetGauge(...).SetPoint(...)`, `AddPoint(...)`, `SetFields(...)`, `AddFields(...)`, `SetField(...)`, `AddField(...)` | `MeasureSetCounter(...).AddPoint(...)`, `AddFields(...)`, `AddField(...)` |
101
102
### Phase-1 write contract
103
@@ -243,6 +243,7 @@ see [how-to-write-a-collector.md](/src/go/plugin/go.d/docs/how-to-write-a-collec
243
- **Schema stability** — Re-registering an existing metric name with different kind/mode/schema returns an error (or panics in strict runtime paths).
244
- **MeasureSet flatten naming** — Flattened `MeasureSet` series use per-field metric names like `<name>_<field>` and also carry a synthetic `measure_field=<field>` label.
245
- **MeasureSet counter semantics** — Stateful counter-like `MeasureSet` families reject negative `AddPoint(...)` deltas, just like scalar counters.
246
+- **Summary NaN quantiles** — a summary point may carry NaN quantile *values* (e.g. an empty observation window); they are stored (only Inf is rejected) and render as a chart gap downstream (chartengine emits `SETEMPTY`). Count and Sum must still be finite.
247
- **Collector retention** — `CollectorStore` evicts series not seen for 10 successful cycles by default.
248
249
## Internal Architecture Notes
src/go/pkg/metrix/summary.go
+5
-1
@@ -219,7 +219,11 @@ func normalizeSummaryPoint(point SummaryPoint, schema *summarySchema) (SampleVal
219
if idx == -1 {
220
panic(fmt.Errorf("%w: quantile %v is not declared", errSummaryPoint, q.Quantile))
221
}
222
- mustFiniteSample(q.Value)
222
+ // A summary may report a NaN quantile value (e.g. an empty observation window). Store it;
223
+ // chartengine renders a non-finite dimension value as a gap (SETEMPTY). Reject only Inf.
224
+ if math.IsInf(float64(q.Value), 0) {
225
+ panic(fmt.Errorf("%w: infinite quantile value %v", errSummaryPoint, q.Value))
226
+ }
227
values[idx] = q.Value
228
}
229
src/go/pkg/metrix/summary_store_test.go
+28
@@ -13,6 +13,34 @@ func TestSummaryStoreScenarios(t *testing.T) {
13
tests := map[string]struct {
14
run func(t *testing.T)
15
}{
16
+ "snapshot summary stores NaN quantile values (sparse window -> gap downstream)": {
17
+ run: func(t *testing.T) {
18
+ s := NewCollectorStore()
19
+ cc := cycleController(t, s)
20
+ sum := s.Write().SnapshotMeter("svc").Summary("latency", WithSummaryQuantiles(0.5, 0.9))
21
+
22
+ cc.BeginCycle()
23
+ require.NotPanics(t, func() {
24
+ sum.ObservePoint(SummaryPoint{
25
+ Count: 0,
26
+ Sum: 0,
27
+ Quantiles: []QuantilePoint{
28
+ {Quantile: 0.5, Value: SampleValue(math.NaN())},
29
+ {Quantile: 0.9, Value: SampleValue(math.NaN())},
30
+ },
31
+ })
32
+ })
33
+ cc.CommitCycleSuccess()
34
+
35
+ point, ok := s.Read().Summary("svc.latency", nil)
36
+ require.True(t, ok)
37
+ require.Len(t, point.Quantiles, 2)
38
+ for _, q := range point.Quantiles {
39
+ require.Truef(t, math.IsNaN(float64(q.Value)),
40
+ "quantile %v value must be stored as NaN, got %v", q.Quantile, q.Value)
41
+ }
42
+ },
43
+ },
44
"snapshot summary count sum read and flatten": {
45
run: func(t *testing.T) {
46
s := NewCollectorStore()
src/go/pkg/metrix/value_validation_test.go
+3
-1
@@ -71,7 +71,9 @@ func TestValueValidationScenarios(t *testing.T) {
71
Count: 1,
72
Sum: 1,
73
Quantiles: []QuantilePoint{
74
- {Quantile: 0.5, Value: math.NaN()},
74
+ // A NaN quantile value is accepted (stored, then rendered
75
+ // downstream as a gap), so only an Inf value panics here.
76
+ {Quantile: 0.5, Value: math.Inf(1)},
77
},
78
})
79
})
src/go/plugin/framework/chartemit/apply.go
+7
@@ -4,6 +4,7 @@ package chartemit
4
5
import (
6
"fmt"
7
+ "math"
8
"sort"
9
"strings"
10
@@ -212,6 +213,12 @@ func emitUpdatePhase(api *netdataapi.API, env EmitEnv, updates []UpdateChartActi
213
continue
214
}
215
if dim.IsFloat {
216
+ // Defensive: a non-finite float renders as 0 on the wire (the C parser accepts
217
+ // only lowercase "nan"); emit a gap. The planner already maps these to IsEmpty.
218
+ if math.IsNaN(dim.Float64) || math.IsInf(dim.Float64, 0) {
219
+ api.SETEMPTY(sanitizeWireID(dim.Name))
220
+ continue
221
+ }
222
api.SETFLOAT(sanitizeWireID(dim.Name), dim.Float64)
223
continue
224
}
src/go/plugin/framework/chartemit/apply_test.go
+42
@@ -4,6 +4,7 @@ package chartemit
4
5
import (
6
"bytes"
7
+ "math"
8
"testing"
9
10
"github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
@@ -242,6 +243,47 @@ END
243
assert.NotContains(t, out, "SET 'total' = 7.9")
244
}
245
246
+func TestApplyPlanGapsNonFiniteFloatUpdate(t *testing.T) {
247
+ var buf bytes.Buffer
248
+ api := netdataapi.New(&buf)
249
+
250
+ meta := chartengine.ChartMeta{
251
+ Title: "Latency",
252
+ Family: "Latency",
253
+ Context: "svc.latency",
254
+ Units: "ms",
255
+ Algorithm: chartengine.AlgorithmAbsolute,
256
+ Type: chartengine.ChartTypeLine,
257
+ }
258
+
259
+ plan := Plan{
260
+ Actions: []EngineAction{
261
+ chartengine.CreateChartAction{ChartTemplateID: "g0c0", ChartID: "svc_latency", Meta: meta},
262
+ chartengine.CreateDimensionAction{
263
+ ChartID: "svc_latency", ChartMeta: meta, Name: "value",
264
+ Float: true, Algorithm: chartengine.AlgorithmAbsolute, Multiplier: 1, Divisor: 1,
265
+ },
266
+ chartengine.UpdateChartAction{
267
+ ChartID: "svc_latency",
268
+ Values: []chartengine.UpdateDimensionValue{
269
+ {Name: "value", IsFloat: true, Float64: math.NaN()},
270
+ },
271
+ },
272
+ },
273
+ }
274
+
275
+ require.NoError(t, ApplyPlan(api, plan, EmitEnv{
276
+ TypeID: "collector.job", UpdateEvery: 1, Plugin: "go.d.plugin", Module: "prometheus",
277
+ JobName: "job01", MSSinceLast: 1,
278
+ }))
279
+
280
+ out := buf.String()
281
+ // A non-finite float dimension is emitted as a gap (empty SET), never "SET = NaN" (which the C
282
+ // agent would render as 0).
283
+ assert.NotContains(t, out, "NaN")
284
+ assert.Contains(t, out, "SET 'value' = \n")
285
+}
286
+
287
func TestApplyPlanDimensionOnlyCreateEmitsLabelsAndCommit(t *testing.T) {
288
var buf bytes.Buffer
289
api := netdataapi.New(&buf)
src/go/plugin/framework/chartengine/README.md
+12
-12
@@ -83,11 +83,11 @@ plan := attempt.Plan()
83
defer attempt.Abort()
84
85
err = chartemit.ApplyPlan(api, plan, chartemit.EmitEnv{
86
-TypeID: "plugin.job",
87
-UpdateEvery: 1,
88
-Plugin: "example",
89
-Module: "example",
90
-JobName: "example",
86
+ TypeID: "plugin.job",
87
+ UpdateEvery: 1,
88
+ Plugin: "example",
89
+ Module: "example",
90
+ JobName: "example",
91
})
92
// handle err
93
err = attempt.Commit()
@@ -124,13 +124,13 @@ If inferred dimensions are present without flattened reader metadata, `PreparePl
124
125
## Action Semantics
126
127
-| Action | Meaning |
128
-|-------------------------|------------------------------------------------------------------------|
129
-| `CreateChartAction` | Materialize chart instance (with chart metadata and labels) |
130
-| `CreateDimensionAction` | Materialize dimension for a chart |
131
-| `UpdateChartAction` | Emit chart values for current cycle; unseen dims become `IsEmpty=true` |
132
-| `RemoveDimensionAction` | Obsolete one dimension |
133
-| `RemoveChartAction` | Obsolete one chart |
127
+| Action | Meaning |
128
+|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
129
+| `CreateChartAction` | Materialize chart instance (with chart metadata and labels) |
130
+| `CreateDimensionAction` | Materialize dimension for a chart |
131
+| `UpdateChartAction` | Emit chart values for current cycle; unseen dims, and dims whose value is non-finite (NaN/Inf), become `IsEmpty=true` (gap, never 0) |
132
+| `RemoveDimensionAction` | Obsolete one dimension |
133
+| `RemoveChartAction` | Obsolete one chart |
134
135
`chartemit` normalizes emitted action order by phase:
136
src/go/plugin/framework/chartengine/planner.go
+7
@@ -4,6 +4,7 @@ package chartengine
4
5
import (
6
"fmt"
7
+ "math"
8
"sort"
9
"strings"
10
"time"
@@ -598,6 +599,12 @@ func (e *Engine) materializePlanCharts(ctx *planBuildContext) error {
599
for _, name := range updateNames {
600
entry, ok := cs.entries[name]
601
if ok && entry != nil && entry.seenSeq == cs.currentBuildSeq {
602
+ if math.IsNaN(entry.value) || math.IsInf(entry.value, 0) {
603
+ // A non-finite value (e.g. a summary quantile with no observations this
604
+ // cycle) must render as a gap, not 0: emit SETEMPTY rather than carry NaN.
605
+ values = append(values, UpdateDimensionValue{Name: name, IsEmpty: true})
606
+ continue
607
+ }
608
values = append(values, UpdateDimensionValue{
609
Name: name,
610
IsFloat: entry.float,
src/go/plugin/framework/chartengine/planner_test.go
+106
@@ -3,6 +3,7 @@
3
package chartengine
4
5
import (
6
+ "math"
7
"testing"
8
9
"github.com/stretchr/testify/assert"
@@ -235,6 +236,8 @@ func TestBuildPlanLegacySingleScenarioCases(t *testing.T) {
236
"BuildPlanEmptyEmissionAndScratchReusePruneAcrossCycles": {run: runTestBuildPlanEmptyEmissionAndScratchReusePruneAcrossCycles},
237
"BuildPlanAutogenContextNamespacePrefixesContext": {run: runTestBuildPlanAutogenContextNamespacePrefixesContext},
238
"BuildPlanAutogenContextNamespaceStubGroupOnly": {run: runTestBuildPlanAutogenContextNamespaceStubGroupOnly},
239
+ "BuildPlanSummaryNaNQuantileGaps": {run: runTestBuildPlanSummaryNaNQuantileGaps},
240
+ "BuildPlanSummaryMixedFiniteNaNQuantileGaps": {run: runTestBuildPlanSummaryMixedFiniteNaNQuantileGaps},
241
}
242
243
for name, tc := range tests {
@@ -2384,6 +2387,109 @@ func actionKinds(actions []EngineAction) []ActionKind {
2387
return out
2388
}
2389
2390
+// A summary scraped with NaN quantile values is still an OBSERVED point, so its quantile chart is
2391
+// created (not skipped by the observedCount==0 path) and each NaN quantile dim renders as a gap
2392
+// (IsEmpty → SETEMPTY), never a 0 value.
2393
+func runTestBuildPlanSummaryNaNQuantileGaps(t *testing.T) {
2394
+ e, err := New(WithEnginePolicy(EnginePolicy{Autogen: &AutogenPolicy{Enabled: true}}))
2395
+ require.NoError(t, err)
2396
+ require.NoError(t, e.LoadYAML([]byte(`
2397
+version: v1
2398
+groups:
2399
+ - family: Service
2400
+`), 1))
2401
+
2402
+ store := metrix.NewCollectorStore()
2403
+ cc := mustCycleController(t, store)
2404
+ sum := store.Write().SnapshotMeter("svc").Summary("latency", metrix.WithSummaryQuantiles(0.5, 0.9))
2405
+
2406
+ cc.BeginCycle()
2407
+ sum.ObservePoint(metrix.SummaryPoint{
2408
+ Count: 0,
2409
+ Sum: 0,
2410
+ Quantiles: []metrix.QuantilePoint{
2411
+ {Quantile: 0.5, Value: metrix.SampleValue(math.NaN())},
2412
+ {Quantile: 0.9, Value: metrix.SampleValue(math.NaN())},
2413
+ },
2414
+ })
2415
+ cc.CommitCycleSuccess()
2416
+
2417
+ plan, err := buildPlan(e, store.Read(metrix.ReadFlatten()))
2418
+ require.NoError(t, err)
2419
+
2420
+ require.NotNil(t, findCreateChartActionByID(plan, "svc.latency"),
2421
+ "summary quantile chart should be created from an observed all-NaN point")
2422
+
2423
+ var quantileUpdate *UpdateChartAction
2424
+ for i := range plan.Actions {
2425
+ if u, ok := plan.Actions[i].(UpdateChartAction); ok && u.ChartID == "svc.latency" {
2426
+ cp := u
2427
+ quantileUpdate = &cp
2428
+ break
2429
+ }
2430
+ }
2431
+ require.NotNil(t, quantileUpdate, "expected an update action for the quantile chart")
2432
+ require.NotEmpty(t, quantileUpdate.Values)
2433
+ for _, v := range quantileUpdate.Values {
2434
+ assert.Truef(t, v.IsEmpty, "NaN quantile dim %q must gap (IsEmpty), got %+v", v.Name, v)
2435
+ }
2436
+}
2437
+
2438
+func runTestBuildPlanSummaryMixedFiniteNaNQuantileGaps(t *testing.T) {
2439
+ e, err := New(WithEnginePolicy(EnginePolicy{Autogen: &AutogenPolicy{Enabled: true}}))
2440
+ require.NoError(t, err)
2441
+ require.NoError(t, e.LoadYAML([]byte(`
2442
+version: v1
2443
+groups:
2444
+ - family: Service
2445
+`), 1))
2446
+
2447
+ store := metrix.NewCollectorStore()
2448
+ cc := mustCycleController(t, store)
2449
+ sum := store.Write().SnapshotMeter("svc").Summary("latency", metrix.WithSummaryQuantiles(0.5, 0.9))
2450
+
2451
+ // One quantile carries a finite value, the other is NaN: the planner must gap
2452
+ // only the NaN dimension and keep the finite one (per-dimension, same chart).
2453
+ cc.BeginCycle()
2454
+ sum.ObservePoint(metrix.SummaryPoint{
2455
+ Count: 1,
2456
+ Sum: 0.4,
2457
+ Quantiles: []metrix.QuantilePoint{
2458
+ {Quantile: 0.5, Value: 0.4},
2459
+ {Quantile: 0.9, Value: metrix.SampleValue(math.NaN())},
2460
+ },
2461
+ })
2462
+ cc.CommitCycleSuccess()
2463
+
2464
+ plan, err := buildPlan(e, store.Read(metrix.ReadFlatten()))
2465
+ require.NoError(t, err)
2466
+
2467
+ require.NotNil(t, findCreateChartActionByID(plan, "svc.latency"),
2468
+ "summary quantile chart should be created")
2469
+
2470
+ var quantileUpdate *UpdateChartAction
2471
+ for i := range plan.Actions {
2472
+ if u, ok := plan.Actions[i].(UpdateChartAction); ok && u.ChartID == "svc.latency" {
2473
+ cp := u
2474
+ quantileUpdate = &cp
2475
+ break
2476
+ }
2477
+ }
2478
+ require.NotNil(t, quantileUpdate, "expected an update action for the quantile chart")
2479
+ require.Len(t, quantileUpdate.Values, 2, "expected both quantile dimensions")
2480
+
2481
+ var empty, finite int
2482
+ for _, v := range quantileUpdate.Values {
2483
+ if v.IsEmpty {
2484
+ empty++
2485
+ } else {
2486
+ finite++
2487
+ }
2488
+ }
2489
+ assert.Equalf(t, 1, empty, "exactly the NaN quantile dim must gap, got %+v", quantileUpdate.Values)
2490
+ assert.Equalf(t, 1, finite, "exactly the finite quantile dim must carry a value, got %+v", quantileUpdate.Values)
2491
+}
2492
+
2493
func findUpdateAction(plan Plan) *UpdateChartAction {
2494
for _, action := range plan.Actions {
2495
if update, ok := action.(UpdateChartAction); ok {