From c7353a6a47937af65acbd10046c070554b9e96d5 Mon Sep 17 00:00:00 2001 From: SoumyaRaikwar Date: Wed, 15 Oct 2025 04:01:45 +0530 Subject: [PATCH] Add valueType field for explicit duration parsing in custom resources - Add valueType field to MetricGauge struct (duration, quantity, default) - Implement parseDurationValue() function using time.ParseDuration - Add duration parsing support in gauge value extraction - Add 18 comprehensive test cases covering all scenarios - Document valueType field with cert-manager examples This enables explicit parsing of Go duration strings (e.g., '2160h', '30m') to seconds for metrics, particularly useful for cert-manager Certificate resources and other CRs with duration fields. Signed-off-by: SoumyaRaikwar --- .../extend/customresourcestate-metrics.md | 100 ++++++++ .../config_metrics_types.go | 18 +- pkg/customresourcestate/registry_factory.go | 65 ++++- .../registry_factory_test.go | 231 ++++++++++++++++++ 4 files changed, 411 insertions(+), 3 deletions(-) diff --git a/docs/metrics/extend/customresourcestate-metrics.md b/docs/metrics/extend/customresourcestate-metrics.md index 9e493a9a2a..c63e742f70 100644 --- a/docs/metrics/extend/customresourcestate-metrics.md +++ b/docs/metrics/extend/customresourcestate-metrics.md @@ -623,6 +623,106 @@ Supported types are: * Percentages ending with a "%" are parsed to float * finally the string is parsed to float using which should support all common number formats. If that fails an error is yielded +##### Value Type Specification + +By default, kube-state-metrics automatically detects and converts values using the logic described above. However, you can **explicitly specify the value type** for more predictable parsing, especially for duration strings that would otherwise fail float parsing. + +###### Available Value Types + +The `valueType` field in gauge configuration accepts the following values: + +* `duration` - Explicitly parse Go duration strings (e.g., "1h", "30m", "1h30m45s") and convert them to seconds +* `quantity` - Explicitly parse Kubernetes resource quantities (e.g., "250m", "5Gi") +* (empty/omitted) - Use automatic type detection (default behavior) + +###### Duration Value Type + +The `duration` value type is particularly useful for custom resources that store time values as Go duration strings, such as cert-manager Certificates. + +**Example: cert-manager Certificate Duration** + +```yaml +kind: CustomResourceStateMetrics +spec: + resources: + - groupVersionKind: + group: cert-manager.io + version: v1 + kind: Certificate + labelsFromPath: + name: [metadata, name] + namespace: [metadata, namespace] + metrics: + - name: "certificate_duration_seconds" + help: "Certificate validity duration in seconds" + each: + type: Gauge + gauge: + path: [spec, duration] + valueType: duration # Explicitly parse as duration + + - name: "certificate_renew_before_seconds" + help: "Time before expiration when certificate should be renewed" + each: + type: Gauge + gauge: + path: [spec, renewBefore] + valueType: duration # Explicitly parse as duration +``` + +**Supported Duration Formats** + +The `duration` value type uses Go's `time.ParseDuration` format and supports: + +* Hours: `"1h"`, `"24h"`, `"2160h"` (90 days) +* Minutes: `"30m"`, `"90m"` +* Seconds: `"45s"` +* Milliseconds: `"500ms"` +* Microseconds: `"100us"`, `"1000µs"` +* Nanoseconds: `"1000ns"` +* Combined: `"1h30m45s"`, `"2h15m30s"` + +All durations are converted to **seconds as float64** for Prometheus compatibility. + +**Example Metrics Output** + +For a cert-manager Certificate with `spec.duration: "2160h"` and `spec.renewBefore: "720h"`: + +```prometheus +kube_customresource_certificate_duration_seconds{customresource_group="cert-manager.io", customresource_kind="Certificate", customresource_version="v1", name="example-cert", namespace="default"} 7776000 +kube_customresource_certificate_renew_before_seconds{customresource_group="cert-manager.io", customresource_kind="Certificate", customresource_version="v1", name="example-cert", namespace="default"} 2592000 +``` + +**When to Use valueType** + +Use `valueType: duration` when: +* The resource field contains Go duration strings (e.g., "72h", "30m") +* Auto-detection fails because the string isn't recognized as a number +* You want explicit, predictable parsing behavior + +Use `valueType: quantity` when: +* You want to ensure Kubernetes quantity parsing (even if auto-detection would work) +* You want to make the parsing behavior explicit in configuration + +###### valueType with valueFrom + +The `valueType` field works with both direct `path` and `valueFrom` configurations: + +```yaml +# Direct path +gauge: + path: [spec, duration] + valueType: duration + +# With valueFrom (nested extraction) +gauge: + path: [spec] + valueFrom: [duration] + valueType: duration + labelsFromPath: + name: [name] +``` + ##### Example for status conditions on Kubernetes Controllers ```yaml diff --git a/pkg/customresourcestate/config_metrics_types.go b/pkg/customresourcestate/config_metrics_types.go index 549d328b4b..8ff3cb4e0d 100644 --- a/pkg/customresourcestate/config_metrics_types.go +++ b/pkg/customresourcestate/config_metrics_types.go @@ -16,6 +16,20 @@ limitations under the License. package customresourcestate +// ValueType specifies how to parse the value from the resource field. +type ValueType string + +const ( + // ValueTypeDefault uses automatic type detection (current behavior). + // This is the default when ValueType is not specified. + ValueTypeDefault ValueType = "" + // ValueTypeDuration parses duration strings (e.g., "1h", "30m", "1h30m45s") using time.ParseDuration. + // The parsed duration is converted to seconds as a float64 for Prometheus compatibility. + ValueTypeDuration ValueType = "duration" + // ValueTypeQuantity parses Kubernetes resource quantities (e.g., "250m" for millicores, "1Gi" for memory). + ValueTypeQuantity ValueType = "quantity" +) + // MetricMeta are variables which may used for any metric type. type MetricMeta struct { // LabelsFromPath adds additional labels where the value of the label is taken from a field under Path. @@ -32,7 +46,9 @@ type MetricGauge struct { MetricMeta `yaml:",inline" json:",inline"` // ValueFrom is the path to a numeric field under Path that will be the metric value. - ValueFrom []string `yaml:"valueFrom" json:"valueFrom"` + ValueFrom []string `yaml:"valueFrom" json:"valueFrom"` + ValueType ValueType `yaml:"valueType,omitempty" json:"valueType,omitempty"` + // NilIsZero indicates that if a value is nil it will be treated as zero value. NilIsZero bool `yaml:"nilIsZero" json:"nilIsZero"` } diff --git a/pkg/customresourcestate/registry_factory.go b/pkg/customresourcestate/registry_factory.go index 2a2cb067fc..31ec492b0e 100644 --- a/pkg/customresourcestate/registry_factory.go +++ b/pkg/customresourcestate/registry_factory.go @@ -173,6 +173,7 @@ func newCompiledMetric(m Metric) (compiledMetric, error) { ValueFrom: valueFromPath, NilIsZero: m.Gauge.NilIsZero, labelFromKey: m.Gauge.LabelFromKey, + valueType: m.Gauge.ValueType, }, nil case metric.Info: if m.Info == nil { @@ -216,6 +217,7 @@ type compiledGauge struct { labelFromKey string ValueFrom valuePath NilIsZero bool + valueType ValueType } func (c *compiledGauge) Values(v interface{}) (result []eachValue, errs []error) { @@ -241,7 +243,21 @@ func (c *compiledGauge) Values(v interface{}) (result []eachValue, errs []error) len(sValueFrom) > 2 { extractedValueFrom := sValueFrom[1 : len(sValueFrom)-1] if key == extractedValueFrom { - gotFloat, err := toFloat64(it, c.NilIsZero) + // Check if explicit valueType is specified + var gotFloat float64 + var err error + + switch c.valueType { + case ValueTypeDuration: + gotFloat, err = parseDurationValue(it) + case ValueTypeQuantity: + gotFloat, err = toFloat64(it, c.NilIsZero) + case ValueTypeDefault: + gotFloat, err = toFloat64(it, c.NilIsZero) + default: + err = fmt.Errorf("unknown valueType: %s", c.valueType) + } + if err != nil { onError(fmt.Errorf("[%s]: %w", key, err)) continue @@ -462,7 +478,23 @@ func (c compiledGauge) value(it interface{}) (*eachValue, error) { // Don't error if there was not a type-casting issue (`toFloat64`). return nil, nil } - value, err := toFloat64(got, c.NilIsZero) + + // Check if explicit valueType is specified + var value float64 + var err error + switch c.valueType { + case ValueTypeDuration: + value, err = parseDurationValue(got) + case ValueTypeQuantity: + // Use existing quantity parsing from toFloat64 + value, err = toFloat64(got, c.NilIsZero) + case ValueTypeDefault: + // Fall through to auto-detection (existing logic) + value, err = toFloat64(got, c.NilIsZero) + default: + return nil, fmt.Errorf("unknown valueType: %s", c.valueType) + } + if err != nil { return nil, fmt.Errorf("%s: %w", c.ValueFrom, err) } @@ -710,6 +742,35 @@ func scrapeValuesFor(e compiledEach, obj map[string]interface{}) ([]eachValue, [ return result, errs } +// parseDurationValue converts a duration string to seconds as float64. +// It uses time.ParseDuration to parse strings like "1h", "30m", "1h30m45s". +// The result is converted to seconds for Prometheus compatibility. +func parseDurationValue(value interface{}) (float64, error) { + var durationStr string + + switch v := value.(type) { + case string: + durationStr = v + case nil: + return 0, fmt.Errorf("nil value cannot be parsed as duration") + default: + return 0, fmt.Errorf("value must be a string for duration parsing, got %T", value) + } + + // Handle empty string + if durationStr == "" { + return 0, fmt.Errorf("empty string cannot be parsed as duration") + } + + duration, err := time.ParseDuration(durationStr) + if err != nil { + return 0, fmt.Errorf("failed to parse duration '%s': %w", durationStr, err) + } + + // Convert to seconds as float64 for Prometheus compatibility + return duration.Seconds(), nil +} + // toFloat64 converts the value to a float64 which is the value type for any metric. func toFloat64(value interface{}, nilIsZero bool) (float64, error) { var v float64 diff --git a/pkg/customresourcestate/registry_factory_test.go b/pkg/customresourcestate/registry_factory_test.go index 7f5d1a0928..b8dacaf44d 100644 --- a/pkg/customresourcestate/registry_factory_test.go +++ b/pkg/customresourcestate/registry_factory_test.go @@ -554,3 +554,234 @@ func mustCompilePath(t *testing.T, path ...string) valuePath { } return out } + +// TestParseDurationValue tests the parseDurationValue function +func TestParseDurationValue(t *testing.T) { + tests := []struct { + name string + input interface{} + expected float64 + expectError bool + }{ + { + name: "simple hours", + input: "1h", + expected: 3600.0, + expectError: false, + }, + { + name: "simple minutes", + input: "30m", + expected: 1800.0, + expectError: false, + }, + { + name: "simple seconds", + input: "45s", + expected: 45.0, + expectError: false, + }, + { + name: "complex duration", + input: "1h30m45s", + expected: 5445.0, + expectError: false, + }, + { + name: "cert-manager style 90 days", + input: "2160h", + expected: 7776000.0, + expectError: false, + }, + { + name: "milliseconds", + input: "500ms", + expected: 0.5, + expectError: false, + }, + { + name: "zero duration", + input: "0s", + expected: 0.0, + expectError: false, + }, + { + name: "invalid format", + input: "invalid", + expected: 0, + expectError: true, + }, + { + name: "empty string", + input: "", + expected: 0, + expectError: true, + }, + { + name: "nil value", + input: nil, + expected: 0, + expectError: true, + }, + { + name: "non-string value", + input: 123, + expected: 0, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseDurationValue(tt.input) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +// TestDurationValueType tests gauge metrics with duration valueType +func TestDurationValueType(t *testing.T) { + tests := []struct { + name string + each compiledEach + resource map[string]interface{} + wantResult []eachValue + wantErrors []error + }{ + { + name: "duration hours", + each: &compiledGauge{ + compiledCommon: compiledCommon{ + path: mustCompilePath(t, "spec", "duration"), + }, + valueType: ValueTypeDuration, + }, + resource: map[string]interface{}{ + "spec": map[string]interface{}{ + "duration": "2160h", + }, + }, + wantResult: []eachValue{newEachValue(t, 7776000.0)}, + }, + { + name: "duration minutes", + each: &compiledGauge{ + compiledCommon: compiledCommon{ + path: mustCompilePath(t, "timeout"), + }, + valueType: ValueTypeDuration, + }, + resource: map[string]interface{}{ + "timeout": "30m", + }, + wantResult: []eachValue{newEachValue(t, 1800.0)}, + }, + { + name: "duration complex", + each: &compiledGauge{ + compiledCommon: compiledCommon{ + path: mustCompilePath(t, "spec", "renewBefore"), + }, + valueType: ValueTypeDuration, + }, + resource: map[string]interface{}{ + "spec": map[string]interface{}{ + "renewBefore": "1h30m", + }, + }, + wantResult: []eachValue{newEachValue(t, 5400.0)}, + }, + { + name: "duration with labels", + each: &compiledGauge{ + compiledCommon: compiledCommon{ + path: mustCompilePath(t, "spec"), + labelFromPath: map[string]valuePath{ + "name": mustCompilePath(t, "name"), + }, + }, + ValueFrom: mustCompilePath(t, "duration"), + valueType: ValueTypeDuration, + }, + resource: map[string]interface{}{ + "spec": map[string]interface{}{ + "name": "test-cert", + "duration": "720h", + }, + }, + wantResult: []eachValue{newEachValue(t, 2592000.0, "name", "test-cert")}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotResult, gotErrors := scrapeValuesFor(tt.each, tt.resource) + assert.Equal(t, tt.wantResult, gotResult) + assert.Equal(t, tt.wantErrors, gotErrors) + }) + } +} + +// TestValueTypeBackwardCompatibility tests that omitting valueType still works +func TestValueTypeBackwardCompatibility(t *testing.T) { + tests := []struct { + name string + each compiledEach + wantResult []eachValue + }{ + { + name: "numeric without valueType", + each: &compiledGauge{ + compiledCommon: compiledCommon{ + path: mustCompilePath(t, "spec", "replicas"), + }, + // valueType omitted (defaults to "") + }, + wantResult: []eachValue{newEachValue(t, 1)}, + }, + { + name: "quantity without explicit valueType", + each: &compiledGauge{ + compiledCommon: compiledCommon{ + path: mustCompilePath(t, "status", "quantity_milli"), + }, + // valueType omitted - should auto-detect as quantity + }, + wantResult: []eachValue{newEachValue(t, 0.25)}, + }, + { + name: "bool without valueType", + each: &compiledGauge{ + compiledCommon: compiledCommon{ + path: mustCompilePath(t, "spec", "order", "0", "value"), + }, + }, + wantResult: []eachValue{newEachValue(t, 1)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotResult, gotErrors := scrapeValuesFor(tt.each, cr) + assert.Equal(t, tt.wantResult, gotResult) + if len(gotErrors) > 0 { + t.Errorf("unexpected errors: %v", gotErrors) + } + }) + } +}