Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions docs/metrics/extend/customresourcestate-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,106 @@ Supported types are:
* Percentages ending with a "%" are parsed to float
* finally the string is parsed to float using <https://pkg.go.dev/strconv#ParseFloat> 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
Expand Down
18 changes: 17 additions & 1 deletion pkg/customresourcestate/config_metrics_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure the difference between ValueTypeDefault and ValueTypeQuantity , Both cases call toFloat64(), which already includes automatic quantity detection.

)

// 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.
Expand All @@ -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"`
}
Expand Down
65 changes: 63 additions & 2 deletions pkg/customresourcestate/registry_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value type switching logic is duplicated in two places
Suggestion: Extract this into a helper function:

func (c *compiledGauge) parseValue(value interface{}) (float64, error) {
    switch c.valueType {
    case ValueTypeDuration:
        return parseDurationValue(value)
    case ValueTypeQuantity, ValueTypeDefault:
        return toFloat64(value, c.NilIsZero)
    default:
        return 0, fmt.Errorf("unknown valueType: %s", c.valueType)
    }
}

Then use it in both locations:
gotFloat, err := c.parseValue(it)

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
Expand Down Expand Up @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above.

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)
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading