| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package chartengine |
| 4 | |
| 5 | import ( |
| 6 | "sort" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine/internal/program" |
| 10 | "github.com/stretchr/testify/assert" |
| 11 | "github.com/stretchr/testify/require" |
| 12 | |
| 13 | "github.com/netdata/netdata/go/plugins/pkg/metrix" |
| 14 | ) |
| 15 | |
| 16 | func TestAutogenRouteBuilderScenarios(t *testing.T) { |
| 17 | tests := map[string]struct { |
| 18 | run func(t *testing.T) |
| 19 | }{ |
| 20 | "build scalar autogen route": { |
| 21 | run: runTestBuildScalarAutogenRoute, |
| 22 | }, |
| 23 | "build histogram bucket autogen route": { |
| 24 | run: runTestBuildHistogramBucketAutogenRoute, |
| 25 | }, |
| 26 | "build summary quantile autogen route": { |
| 27 | run: runTestBuildSummaryQuantileAutogenRoute, |
| 28 | }, |
| 29 | "build state-set autogen route": { |
| 30 | run: runTestBuildStateSetAutogenRoute, |
| 31 | }, |
| 32 | "build measure-set autogen route": { |
| 33 | run: runTestBuildMeasureSetAutogenRoute, |
| 34 | }, |
| 35 | } |
| 36 | |
| 37 | for name, tc := range tests { |
| 38 | t.Run(name, tc.run) |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | func runTestBuildScalarAutogenRoute(t *testing.T) { |
| 43 | tests := map[string]struct { |
| 44 | metricName string |
| 45 | labels map[string]string |
| 46 | meta metrix.SeriesMeta |
| 47 | wantID string |
| 48 | wantDim string |
| 49 | wantAlg program.Algorithm |
| 50 | wantUnits string |
| 51 | }{ |
| 52 | "counter metric uses incremental algorithm and static dimension": { |
| 53 | metricName: "svc.requests_total", |
| 54 | labels: map[string]string{ |
| 55 | "instance": "db1", |
| 56 | "job": "mysql", |
| 57 | }, |
| 58 | meta: metrix.SeriesMeta{Kind: metrix.MetricKindCounter}, |
| 59 | wantID: "svc.requests_total-instance=db1-job=mysql", |
| 60 | wantDim: "requests_total", |
| 61 | wantAlg: program.AlgorithmIncremental, |
| 62 | wantUnits: "events/s", |
| 63 | }, |
| 64 | "gauge metric uses absolute algorithm": { |
| 65 | metricName: "svc.temperature_celsius", |
| 66 | labels: map[string]string{ |
| 67 | "instance": "db1", |
| 68 | }, |
| 69 | meta: metrix.SeriesMeta{Kind: metrix.MetricKindGauge}, |
| 70 | wantID: "svc.temperature_celsius-instance=db1", |
| 71 | wantDim: "temperature_celsius", |
| 72 | wantAlg: program.AlgorithmAbsolute, |
| 73 | wantUnits: "celsius", |
| 74 | }, |
| 75 | } |
| 76 | |
| 77 | for name, tc := range tests { |
| 78 | t.Run(name, func(t *testing.T) { |
| 79 | route, ok, err := buildScalarAutogenRoute( |
| 80 | tc.metricName, |
| 81 | sortedLabelView(tc.labels), |
| 82 | tc.meta, |
| 83 | AutogenPolicy{Enabled: true, MaxTypeIDLen: defaultMaxTypeIDLen}, |
| 84 | "", |
| 85 | ) |
| 86 | require.NoError(t, err) |
| 87 | require.True(t, ok) |
| 88 | |
| 89 | assert.Equal(t, tc.wantID, route.chartID) |
| 90 | assert.Equal(t, tc.wantDim, route.dimensionName) |
| 91 | assert.Equal(t, tc.wantAlg, route.algorithm) |
| 92 | assert.Equal(t, tc.wantUnits, route.units) |
| 93 | assert.True(t, route.staticDimension) |
| 94 | }) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func runTestBuildHistogramBucketAutogenRoute(t *testing.T) { |
| 99 | tests := map[string]struct { |
| 100 | metricName string |
| 101 | labels map[string]string |
| 102 | wantID string |
| 103 | wantDim string |
| 104 | }{ |
| 105 | "histogram bucket excludes le from chart id and uses bucket dimension": { |
| 106 | metricName: "svc.latency_seconds_bucket", |
| 107 | labels: map[string]string{ |
| 108 | "instance": "db1", |
| 109 | "le": "0.5", |
| 110 | "method": "GET", |
| 111 | }, |
| 112 | wantID: "svc.latency_seconds-instance=db1-method=GET", |
| 113 | wantDim: "bucket_0.5", |
| 114 | }, |
| 115 | } |
| 116 | |
| 117 | for name, tc := range tests { |
| 118 | t.Run(name, func(t *testing.T) { |
| 119 | route, ok, err := buildHistogramBucketAutogenRoute( |
| 120 | tc.metricName, |
| 121 | sortedLabelView(tc.labels), |
| 122 | AutogenPolicy{Enabled: true, MaxTypeIDLen: defaultMaxTypeIDLen}, |
| 123 | "", |
| 124 | ) |
| 125 | require.NoError(t, err) |
| 126 | require.True(t, ok) |
| 127 | |
| 128 | assert.Equal(t, tc.wantID, route.chartID) |
| 129 | assert.Equal(t, tc.wantDim, route.dimensionName) |
| 130 | assert.Equal(t, metrix.HistogramBucketLabel, route.dimensionKeyLabel) |
| 131 | assert.Equal(t, program.AlgorithmIncremental, route.algorithm) |
| 132 | assert.False(t, route.staticDimension) |
| 133 | }) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func runTestBuildSummaryQuantileAutogenRoute(t *testing.T) { |
| 138 | tests := map[string]struct { |
| 139 | metricName string |
| 140 | labels map[string]string |
| 141 | wantID string |
| 142 | wantDim string |
| 143 | wantUnits string |
| 144 | }{ |
| 145 | "summary quantile excludes quantile label and keeps absolute algorithm": { |
| 146 | metricName: "svc.request_duration_seconds", |
| 147 | labels: map[string]string{ |
| 148 | "instance": "db1", |
| 149 | "method": "GET", |
| 150 | "quantile": "0.99", |
| 151 | }, |
| 152 | wantID: "svc.request_duration_seconds-instance=db1-method=GET", |
| 153 | wantDim: "quantile_0.99", |
| 154 | wantUnits: "seconds", |
| 155 | }, |
| 156 | } |
| 157 | |
| 158 | for name, tc := range tests { |
| 159 | t.Run(name, func(t *testing.T) { |
| 160 | route, ok, err := buildSummaryQuantileAutogenRoute( |
| 161 | tc.metricName, |
| 162 | sortedLabelView(tc.labels), |
| 163 | AutogenPolicy{Enabled: true, MaxTypeIDLen: defaultMaxTypeIDLen}, |
| 164 | "", |
| 165 | ) |
| 166 | require.NoError(t, err) |
| 167 | require.True(t, ok) |
| 168 | |
| 169 | assert.Equal(t, tc.wantID, route.chartID) |
| 170 | assert.Equal(t, tc.wantDim, route.dimensionName) |
| 171 | assert.Equal(t, tc.wantUnits, route.units) |
| 172 | assert.Equal(t, metrix.SummaryQuantileLabel, route.dimensionKeyLabel) |
| 173 | assert.Equal(t, program.AlgorithmAbsolute, route.algorithm) |
| 174 | assert.False(t, route.staticDimension) |
| 175 | }) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | func runTestBuildStateSetAutogenRoute(t *testing.T) { |
| 180 | tests := map[string]struct { |
| 181 | metricName string |
| 182 | labels map[string]string |
| 183 | meta metrix.SeriesMeta |
| 184 | wantID string |
| 185 | wantDim string |
| 186 | }{ |
| 187 | "stateset uses metric-name label as dimension key and excludes it from chart id": { |
| 188 | metricName: "service_status", |
| 189 | labels: map[string]string{ |
| 190 | "instance": "db1", |
| 191 | "service_status": "ready", |
| 192 | }, |
| 193 | meta: metrix.SeriesMeta{FlattenRole: metrix.FlattenRoleStateSetState}, |
| 194 | wantID: "service_status-instance=db1", |
| 195 | wantDim: "ready", |
| 196 | }, |
| 197 | } |
| 198 | |
| 199 | for name, tc := range tests { |
| 200 | t.Run(name, func(t *testing.T) { |
| 201 | route, ok, err := buildStateSetAutogenRoute( |
| 202 | tc.metricName, |
| 203 | sortedLabelView(tc.labels), |
| 204 | tc.meta, |
| 205 | AutogenPolicy{Enabled: true, MaxTypeIDLen: defaultMaxTypeIDLen}, |
| 206 | "", |
| 207 | ) |
| 208 | require.NoError(t, err) |
| 209 | require.True(t, ok) |
| 210 | |
| 211 | assert.Equal(t, tc.wantID, route.chartID) |
| 212 | assert.Equal(t, tc.wantDim, route.dimensionName) |
| 213 | assert.Equal(t, "service_status", route.dimensionKeyLabel) |
| 214 | assert.Equal(t, "state", route.units) |
| 215 | assert.Equal(t, program.AlgorithmAbsolute, route.algorithm) |
| 216 | assert.False(t, route.staticDimension) |
| 217 | }) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | func runTestBuildMeasureSetAutogenRoute(t *testing.T) { |
| 222 | tests := map[string]struct { |
| 223 | metricName string |
| 224 | labels map[string]string |
| 225 | meta metrix.SeriesMeta |
| 226 | wantOK bool |
| 227 | wantID string |
| 228 | wantDim string |
| 229 | wantAlg program.Algorithm |
| 230 | wantUnits string |
| 231 | }{ |
| 232 | "MeasureSet gauge uses synthetic field label and absolute algorithm": { |
| 233 | metricName: "service_latency_seconds_value", |
| 234 | labels: map[string]string{ |
| 235 | "instance": "db1", |
| 236 | metrix.MeasureSetFieldLabel: "value", |
| 237 | }, |
| 238 | meta: metrix.SeriesMeta{ |
| 239 | Kind: metrix.MetricKindGauge, |
| 240 | SourceKind: metrix.MetricKindMeasureSet, |
| 241 | FlattenRole: metrix.FlattenRoleMeasureSetField, |
| 242 | }, |
| 243 | wantOK: true, |
| 244 | wantID: "service_latency_seconds-instance=db1", |
| 245 | wantDim: "value", |
| 246 | wantAlg: program.AlgorithmAbsolute, |
| 247 | wantUnits: "seconds", |
| 248 | }, |
| 249 | "MeasureSet counter uses synthetic field label and incremental algorithm": { |
| 250 | metricName: "svc_requests_total_ok", |
| 251 | labels: map[string]string{ |
| 252 | "instance": "db1", |
| 253 | metrix.MeasureSetFieldLabel: "ok", |
| 254 | }, |
| 255 | meta: metrix.SeriesMeta{ |
| 256 | Kind: metrix.MetricKindCounter, |
| 257 | SourceKind: metrix.MetricKindMeasureSet, |
| 258 | FlattenRole: metrix.FlattenRoleMeasureSetField, |
| 259 | }, |
| 260 | wantOK: true, |
| 261 | wantID: "svc_requests_total-instance=db1", |
| 262 | wantDim: "ok", |
| 263 | wantAlg: program.AlgorithmIncremental, |
| 264 | wantUnits: "requests/s", |
| 265 | }, |
| 266 | "MeasureSet ignores unrelated matching labels and uses reserved field label": { |
| 267 | metricName: "svc_requests_total_ok", |
| 268 | labels: map[string]string{ |
| 269 | "instance": "db1", |
| 270 | "svc_requests": "total_ok", |
| 271 | metrix.MeasureSetFieldLabel: "ok", |
| 272 | }, |
| 273 | meta: metrix.SeriesMeta{ |
| 274 | Kind: metrix.MetricKindCounter, |
| 275 | SourceKind: metrix.MetricKindMeasureSet, |
| 276 | FlattenRole: metrix.FlattenRoleMeasureSetField, |
| 277 | }, |
| 278 | wantOK: true, |
| 279 | wantID: "svc_requests_total-instance=db1-svc_requests=total_ok", |
| 280 | wantDim: "ok", |
| 281 | wantAlg: program.AlgorithmIncremental, |
| 282 | wantUnits: "requests/s", |
| 283 | }, |
| 284 | "MeasureSet without reserved field label does not route": { |
| 285 | metricName: "svc_requests_total_ok", |
| 286 | labels: map[string]string{ |
| 287 | "instance": "db1", |
| 288 | "svc_requests": "total_ok", |
| 289 | }, |
| 290 | meta: metrix.SeriesMeta{ |
| 291 | Kind: metrix.MetricKindCounter, |
| 292 | SourceKind: metrix.MetricKindMeasureSet, |
| 293 | FlattenRole: metrix.FlattenRoleMeasureSetField, |
| 294 | }, |
| 295 | wantOK: false, |
| 296 | }, |
| 297 | "MeasureSet with mismatched reserved field label does not route": { |
| 298 | metricName: "svc_requests_total_ok", |
| 299 | labels: map[string]string{ |
| 300 | "instance": "db1", |
| 301 | metrix.MeasureSetFieldLabel: "failed", |
| 302 | }, |
| 303 | meta: metrix.SeriesMeta{ |
| 304 | Kind: metrix.MetricKindCounter, |
| 305 | SourceKind: metrix.MetricKindMeasureSet, |
| 306 | FlattenRole: metrix.FlattenRoleMeasureSetField, |
| 307 | }, |
| 308 | wantOK: false, |
| 309 | }, |
| 310 | } |
| 311 | |
| 312 | for name, tc := range tests { |
| 313 | t.Run(name, func(t *testing.T) { |
| 314 | route, ok, err := buildMeasureSetAutogenRoute( |
| 315 | tc.metricName, |
| 316 | sortedLabelView(tc.labels), |
| 317 | tc.meta, |
| 318 | AutogenPolicy{Enabled: true, MaxTypeIDLen: defaultMaxTypeIDLen}, |
| 319 | "", |
| 320 | ) |
| 321 | require.NoError(t, err) |
| 322 | require.Equal(t, tc.wantOK, ok) |
| 323 | if !tc.wantOK { |
| 324 | return |
| 325 | } |
| 326 | |
| 327 | assert.Equal(t, tc.wantID, route.chartID) |
| 328 | assert.Equal(t, tc.wantDim, route.dimensionName) |
| 329 | assert.Equal(t, tc.wantAlg, route.algorithm) |
| 330 | assert.Equal(t, tc.wantUnits, route.units) |
| 331 | assert.Equal(t, metrix.MeasureSetFieldLabel, route.dimensionKeyLabel) |
| 332 | assert.False(t, route.staticDimension) |
| 333 | }) |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | func TestFitsTypeIDBudget(t *testing.T) { |
| 338 | tests := map[string]struct { |
| 339 | maxLen int |
| 340 | typeIDPrefix string |
| 341 | chartID string |
| 342 | want bool |
| 343 | }{ |
| 344 | "empty type id at exact limit passes": { |
| 345 | maxLen: 5, |
| 346 | chartID: "abcde", |
| 347 | want: true, |
| 348 | }, |
| 349 | "empty type id over limit fails": { |
| 350 | maxLen: 5, |
| 351 | chartID: "abcdef", |
| 352 | want: false, |
| 353 | }, |
| 354 | "type id includes separator in budget": { |
| 355 | maxLen: 16, |
| 356 | typeIDPrefix: "collector.job", |
| 357 | chartID: "abc", |
| 358 | want: false, // len("collector.job")+1+len("abc") == 17 > 16 |
| 359 | }, |
| 360 | "type id budget overflow fails": { |
| 361 | maxLen: 15, |
| 362 | typeIDPrefix: "collector.job", |
| 363 | chartID: "abc", |
| 364 | want: false, |
| 365 | }, |
| 366 | } |
| 367 | |
| 368 | for name, tc := range tests { |
| 369 | t.Run(name, func(t *testing.T) { |
| 370 | assert.Equal(t, tc.want, fitsTypeIDBudget(tc.maxLen, tc.typeIDPrefix, tc.chartID)) |
| 371 | }) |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | func TestGetAutogenChartContext(t *testing.T) { |
| 376 | tests := map[string]struct { |
| 377 | namespace string |
| 378 | metric string |
| 379 | want string |
| 380 | }{ |
| 381 | "empty namespace returns bare metric": {namespace: "", metric: "foo", want: "foo"}, |
| 382 | "single-segment namespace is prefixed": {namespace: "prometheus", metric: "foo", want: "prometheus.foo"}, |
| 383 | "multi-segment namespace composes with dot": {namespace: "prometheus.app", metric: "foo", want: "prometheus.app.foo"}, |
| 384 | "whitespace-only namespace is treated empty": {namespace: " ", metric: "foo", want: "foo"}, |
| 385 | "empty metric falls back to metric": {namespace: "", metric: "", want: "metric"}, |
| 386 | "namespace with empty metric": {namespace: "prometheus", metric: "", want: "prometheus.metric"}, |
| 387 | } |
| 388 | |
| 389 | for name, tc := range tests { |
| 390 | t.Run(name, func(t *testing.T) { |
| 391 | assert.Equal(t, tc.want, getAutogenChartContext(tc.namespace, tc.metric)) |
| 392 | }) |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | func sortedLabelView(labels map[string]string) metrix.LabelView { |
| 397 | items := make([]metrix.Label, 0, len(labels)) |
| 398 | for key, value := range labels { |
| 399 | items = append(items, metrix.Label{Key: key, Value: value}) |
| 400 | } |
| 401 | sort.Slice(items, func(i, j int) bool { |
| 402 | return items[i].Key < items[j].Key |
| 403 | }) |
| 404 | return labelSliceView{items: items} |
| 405 | } |
| 406 | |
| 407 | func TestBuildScalarAutogenRouteSanitizesDotLabelValues(t *testing.T) { |
| 408 | route, ok, err := buildScalarAutogenRoute( |
| 409 | "svc.requests_total", |
| 410 | sortedLabelView(map[string]string{ |
| 411 | "instance": "db1.eu", |
| 412 | "job": "mysql.prod", |
| 413 | }), |
| 414 | metrix.SeriesMeta{Kind: metrix.MetricKindCounter}, |
| 415 | AutogenPolicy{Enabled: true, MaxTypeIDLen: defaultMaxTypeIDLen}, |
| 416 | "", |
| 417 | ) |
| 418 | require.NoError(t, err) |
| 419 | require.True(t, ok) |
| 420 | assert.Equal(t, "svc.requests_total-instance=db1_eu-job=mysql_prod", route.chartID) |
| 421 | } |
| 422 | |
| 423 | func TestBuildScalarAutogenRouteSanitizesLegacyLabelChars(t *testing.T) { |
| 424 | tests := []struct { |
| 425 | name string |
| 426 | value string |
| 427 | want string |
| 428 | }{ |
| 429 | {name: "space", value: "a b", want: "a_b"}, |
| 430 | {name: "backslash", value: "a\\b", want: "a_b"}, |
| 431 | {name: "apostrophe", value: "a'b", want: "ab"}, |
| 432 | } |
| 433 | |
| 434 | for _, tc := range tests { |
| 435 | t.Run(tc.name, func(t *testing.T) { |
| 436 | route, ok, err := buildScalarAutogenRoute( |
| 437 | "svc.requests_total", |
| 438 | sortedLabelView(map[string]string{"instance": tc.value}), |
| 439 | metrix.SeriesMeta{Kind: metrix.MetricKindCounter}, |
| 440 | AutogenPolicy{Enabled: true, MaxTypeIDLen: defaultMaxTypeIDLen}, |
| 441 | "", |
| 442 | ) |
| 443 | require.NoError(t, err) |
| 444 | require.True(t, ok) |
| 445 | assert.Equal(t, "svc.requests_total-instance="+tc.want, route.chartID) |
| 446 | }) |
| 447 | } |
| 448 | } |