| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package chartemit |
| 4 | |
| 5 | import ( |
| 6 | "fmt" |
| 7 | "math" |
| 8 | "sort" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/netdata/netdata/go/plugins/pkg/netdataapi" |
| 12 | ) |
| 13 | |
| 14 | const ( |
| 15 | // Netdata wire protocol label sources (CLABEL third argument). |
| 16 | labelSourceAuto = 1 |
| 17 | labelSourceConf = 2 |
| 18 | collectJobReservedLabel = "_collect_job" |
| 19 | maxTypeIDLen = 1200 |
| 20 | ) |
| 21 | |
| 22 | type normalizedActions struct { |
| 23 | createCharts map[string]CreateChartAction |
| 24 | createDimsByID map[string][]CreateDimensionAction |
| 25 | updateCharts []UpdateChartAction |
| 26 | removeDimensions []RemoveDimensionAction |
| 27 | removeCharts []RemoveChartAction |
| 28 | } |
| 29 | |
| 30 | type dimensionEmission struct { |
| 31 | Name string |
| 32 | Hidden bool |
| 33 | Float bool |
| 34 | Algorithm string |
| 35 | Multiplier int |
| 36 | Divisor int |
| 37 | Obsolete bool |
| 38 | } |
| 39 | |
| 40 | // ApplyPlan emits chartengine actions to the Netdata wire API. |
| 41 | func ApplyPlan(api *netdataapi.API, plan Plan, env EmitEnv) error { |
| 42 | if api == nil { |
| 43 | return fmt.Errorf("chartemit: nil netdata api") |
| 44 | } |
| 45 | if strings.TrimSpace(env.TypeID) == "" { |
| 46 | return fmt.Errorf("chartemit: emit env type_id is required") |
| 47 | } |
| 48 | if env.UpdateEvery <= 0 { |
| 49 | env.UpdateEvery = 1 |
| 50 | } |
| 51 | normalized := normalizeActions(plan.Actions) |
| 52 | if err := validateTypeIDBudget(env.TypeID, normalized); err != nil { |
| 53 | return err |
| 54 | } |
| 55 | if !hasEmissions(normalized) { |
| 56 | return nil |
| 57 | } |
| 58 | if err := emitHostSelection(api, env); err != nil { |
| 59 | return err |
| 60 | } |
| 61 | emitCreatePhase(api, env, normalized) |
| 62 | emitUpdatePhase(api, env, normalized.updateCharts) |
| 63 | emitRemovePhase(api, env, normalized) |
| 64 | return nil |
| 65 | } |
| 66 | |
| 67 | func hasEmissions(actions normalizedActions) bool { |
| 68 | return len(actions.createCharts) > 0 || |
| 69 | len(actions.createDimsByID) > 0 || |
| 70 | len(actions.updateCharts) > 0 || |
| 71 | len(actions.removeDimensions) > 0 || |
| 72 | len(actions.removeCharts) > 0 |
| 73 | } |
| 74 | |
| 75 | func emitHostSelection(api *netdataapi.API, env EmitEnv) error { |
| 76 | if env.HostScope == nil { |
| 77 | api.HOST("") |
| 78 | return nil |
| 79 | } |
| 80 | |
| 81 | guid := strings.TrimSpace(env.HostScope.GUID) |
| 82 | if guid == "" { |
| 83 | return fmt.Errorf("chartemit: emit env host scope guid is required") |
| 84 | } |
| 85 | if sanitizeWireID(guid) != guid { |
| 86 | return fmt.Errorf("chartemit: emit env host scope guid contains unsupported characters") |
| 87 | } |
| 88 | if env.HostScope.Define != nil { |
| 89 | define, err := PrepareHostInfo(*env.HostScope.Define) |
| 90 | if err != nil { |
| 91 | return err |
| 92 | } |
| 93 | if define.GUID != guid { |
| 94 | return fmt.Errorf("chartemit: host define guid %q does not match host scope guid %q", env.HostScope.Define.GUID, env.HostScope.GUID) |
| 95 | } |
| 96 | api.HOSTINFO(define) |
| 97 | } |
| 98 | api.HOST(guid) |
| 99 | return nil |
| 100 | } |
| 101 | |
| 102 | func validateTypeIDBudget(typeID string, actions normalizedActions) error { |
| 103 | typeID = sanitizeWireID(typeID) |
| 104 | if typeID == "" { |
| 105 | return fmt.Errorf("chartemit: emit env type_id is required") |
| 106 | } |
| 107 | seen := make(map[string]struct{}) |
| 108 | for chartID := range actions.createCharts { |
| 109 | seen[chartID] = struct{}{} |
| 110 | } |
| 111 | for chartID := range actions.createDimsByID { |
| 112 | seen[chartID] = struct{}{} |
| 113 | } |
| 114 | for _, update := range actions.updateCharts { |
| 115 | seen[update.ChartID] = struct{}{} |
| 116 | } |
| 117 | for _, removeDim := range actions.removeDimensions { |
| 118 | seen[removeDim.ChartID] = struct{}{} |
| 119 | } |
| 120 | for _, removeChart := range actions.removeCharts { |
| 121 | seen[removeChart.ChartID] = struct{}{} |
| 122 | } |
| 123 | for chartID := range seen { |
| 124 | id := sanitizeWireID(chartID) |
| 125 | if len(typeID)+1+len(id) > maxTypeIDLen { |
| 126 | return fmt.Errorf("chartemit: type.id exceeds max length (%d): %s.%s", maxTypeIDLen, typeID, id) |
| 127 | } |
| 128 | } |
| 129 | return nil |
| 130 | } |
| 131 | |
| 132 | func normalizeActions(actions []EngineAction) normalizedActions { |
| 133 | out := normalizedActions{ |
| 134 | createCharts: make(map[string]CreateChartAction), |
| 135 | createDimsByID: make(map[string][]CreateDimensionAction), |
| 136 | } |
| 137 | for _, action := range actions { |
| 138 | switch v := action.(type) { |
| 139 | case CreateChartAction: |
| 140 | out.createCharts[v.ChartID] = v |
| 141 | case CreateDimensionAction: |
| 142 | out.createDimsByID[v.ChartID] = append(out.createDimsByID[v.ChartID], v) |
| 143 | case UpdateChartAction: |
| 144 | out.updateCharts = append(out.updateCharts, v) |
| 145 | case RemoveDimensionAction: |
| 146 | out.removeDimensions = append(out.removeDimensions, v) |
| 147 | case RemoveChartAction: |
| 148 | out.removeCharts = append(out.removeCharts, v) |
| 149 | } |
| 150 | } |
| 151 | return out |
| 152 | } |
| 153 | |
| 154 | func emitCreatePhase(api *netdataapi.API, env EmitEnv, actions normalizedActions) { |
| 155 | createdChartIDs := make([]string, 0, len(actions.createCharts)) |
| 156 | for chartID := range actions.createCharts { |
| 157 | createdChartIDs = append(createdChartIDs, chartID) |
| 158 | } |
| 159 | sort.Strings(createdChartIDs) |
| 160 | for _, chartID := range createdChartIDs { |
| 161 | createChart := actions.createCharts[chartID] |
| 162 | emitChart(api, env, createChart.ChartID, createChart.Meta, false) |
| 163 | emitChartLabels(api, env, createChart.Labels) |
| 164 | api.CLABELCOMMIT() |
| 165 | dims := actions.createDimsByID[chartID] |
| 166 | for _, dim := range dims { |
| 167 | emitDimension(api, dimensionEmission{ |
| 168 | Name: dim.Name, |
| 169 | Hidden: dim.Hidden, |
| 170 | Float: dim.Float, |
| 171 | Algorithm: string(dim.Algorithm), |
| 172 | Multiplier: dim.Multiplier, |
| 173 | Divisor: dim.Divisor, |
| 174 | }) |
| 175 | } |
| 176 | // Mark dimensions as emitted alongside explicit chart-create path. |
| 177 | delete(actions.createDimsByID, chartID) |
| 178 | } |
| 179 | |
| 180 | remainingChartIDs := make([]string, 0, len(actions.createDimsByID)) |
| 181 | for chartID := range actions.createDimsByID { |
| 182 | remainingChartIDs = append(remainingChartIDs, chartID) |
| 183 | } |
| 184 | sort.Strings(remainingChartIDs) |
| 185 | for _, chartID := range remainingChartIDs { |
| 186 | dims := actions.createDimsByID[chartID] |
| 187 | if len(dims) == 0 { |
| 188 | continue |
| 189 | } |
| 190 | emitChart(api, env, chartID, dims[0].ChartMeta, false) |
| 191 | // Dimension-only chart creation path still needs chart labels and commit. |
| 192 | emitChartLabels(api, env, nil) |
| 193 | api.CLABELCOMMIT() |
| 194 | for _, dim := range dims { |
| 195 | emitDimension(api, dimensionEmission{ |
| 196 | Name: dim.Name, |
| 197 | Hidden: dim.Hidden, |
| 198 | Float: dim.Float, |
| 199 | Algorithm: string(dim.Algorithm), |
| 200 | Multiplier: dim.Multiplier, |
| 201 | Divisor: dim.Divisor, |
| 202 | }) |
| 203 | } |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | func emitUpdatePhase(api *netdataapi.API, env EmitEnv, updates []UpdateChartAction) { |
| 208 | for _, update := range updates { |
| 209 | api.BEGIN(sanitizeWireID(env.TypeID), sanitizeWireID(update.ChartID), env.MSSinceLast) |
| 210 | for _, dim := range update.Values { |
| 211 | if dim.IsEmpty { |
| 212 | api.SETEMPTY(sanitizeWireID(dim.Name)) |
| 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 | } |
| 225 | api.SET(sanitizeWireID(dim.Name), dim.Int64) |
| 226 | } |
| 227 | api.END() |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | func emitRemovePhase(api *netdataapi.API, env EmitEnv, actions normalizedActions) { |
| 232 | for _, removeDim := range actions.removeDimensions { |
| 233 | emitChart(api, env, removeDim.ChartID, removeDim.ChartMeta, false) |
| 234 | emitDimension(api, dimensionEmission{ |
| 235 | Name: removeDim.Name, |
| 236 | Hidden: removeDim.Hidden, |
| 237 | Float: removeDim.Float, |
| 238 | Algorithm: string(removeDim.Algorithm), |
| 239 | Multiplier: removeDim.Multiplier, |
| 240 | Divisor: removeDim.Divisor, |
| 241 | Obsolete: true, |
| 242 | }) |
| 243 | } |
| 244 | for _, removeChart := range actions.removeCharts { |
| 245 | emitChart(api, env, removeChart.ChartID, removeChart.Meta, true) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | func emitDimension(api *netdataapi.API, dim dimensionEmission) { |
| 250 | name := sanitizeWireID(dim.Name) |
| 251 | if name == "" { |
| 252 | return |
| 253 | } |
| 254 | api.DIMENSION(netdataapi.DimensionOpts{ |
| 255 | ID: name, |
| 256 | Name: name, |
| 257 | Algorithm: dim.Algorithm, |
| 258 | Multiplier: handleZero(dim.Multiplier), |
| 259 | Divisor: handleZero(dim.Divisor), |
| 260 | Options: makeDimensionOptions(dim.Hidden, dim.Obsolete, dim.Float), |
| 261 | }) |
| 262 | } |