@cryptotaxi247 / netdata / commits / 17cce355f

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 {