Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ The Metrics Operator is a powerful tool designed to monitor and provide insights
- [Managed Metric](#managed-metric)
- [Federated Metric](#federated-metric)
- [Federated Managed Metric](#federated-managed-metric)
- [Projection Selector Syntax Overview](#projection-selector-syntax-overview)
- [Remote Cluster Access](#remote-cluster-access)
- [Remote Cluster Access](#remote-cluster-access-1)
- [Federated Cluster Access](#federated-cluster-access)
Expand Down Expand Up @@ -211,7 +212,8 @@ To get a full list of the supported tasks, you can run the `task` command with n
### Metric

Metrics have additional capabilities, such as projections. Projections allow you to extract specific fields from the target resource and include them in the metric data.
This can be useful for tracking additional dimensions of the resource, such as fields, labels or annotations. It uses the dot notation to access nested fields.
This can be useful for tracking additional dimensions of the resource, such as fields, labels or annotations. It uses the dot notation and supports [JSONPath selectors](#projection-selector-syntax-overview) to access nested fields.
Note that a single projection has to select a primitive value, collection type results are not supported.
The projections are then translated to dimensions in the metric.

```yaml
Expand Down Expand Up @@ -298,6 +300,25 @@ spec:
---
```

### Projection Selector Syntax Overview

The following examples demonstrate the usage of different [JSONPath selectors](https://www.rfc-editor.org/rfc/rfc9535.html#name-selectors):

```yaml
projections:
- name: pod-namespace
# name selector: selects the namespace value
fieldPath: "metadata.namespace"
- name: pod-condition-ready-status
# filter selector: selects the status value of the conditions with type='Ready'
fieldPath: "status.conditions[?(@.type='Ready')].status"
- name: pod-condition-last-transition-time
# index selector: selects the lastTransitionTime value of the first condition
fieldPath: "status.conditions[0].lastTransitionTime"
```

Note: Array slice `start:end:step` syntax and wildcard selectors are technically supported but left out in the examples due to the restriction that a projection is expected to result in a single primitive value. It is also important to point out that even though `projections` is an array type, the operator evaluates only the first projection of a metric for now. Support for multiple projections will be added in a future release.

## Remote Cluster Access


Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ require (
k8s.io/client-go v0.33.2
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
sigs.k8s.io/controller-runtime v0.21.0
sigs.k8s.io/yaml v1.6.0
)

require (
Expand Down Expand Up @@ -84,5 +85,4 @@ require (
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect
sigs.k8s.io/yaml v1.5.0 // indirect
)
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -228,5 +228,5 @@ sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxO
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI=
sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ=
sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
4 changes: 1 addition & 3 deletions internal/orchestrator/federatedhandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,7 @@ func (h *FederatedHandler) extractProjectionGroupsFrom(list *unstructured.Unstru

if projection.Name != "" && projection.FieldPath != "" {
name := projection.Name
fieldPath := projection.FieldPath
fields := strings.Split(fieldPath, ".")
value, found, err := unstructured.NestedString(obj.Object, fields...)
value, found, err := nestedPrimitiveValue(obj, projection.FieldPath)
collection = append(collection, projectedField{name: name, value: value, found: found, error: err})
}
}
Expand Down
4 changes: 1 addition & 3 deletions internal/orchestrator/metrichandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,7 @@ func (h *MetricHandler) extractProjectionGroupsFrom(list *unstructured.Unstructu

if projection.Name != "" && projection.FieldPath != "" {
name := projection.Name
fieldPath := projection.FieldPath
fields := strings.Split(fieldPath, ".")
value, found, err := unstructured.NestedString(obj.Object, fields...)
value, found, err := nestedPrimitiveValue(obj, projection.FieldPath)
collection = append(collection, projectedField{name: name, value: value, found: found, error: err})
}
}
Expand Down
40 changes: 40 additions & 0 deletions internal/orchestrator/projectionhelper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package orchestrator

import (
"errors"
"fmt"

"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/util/jsonpath"
)

// nestedPrimitiveValue returns a string value based on the result of the client-go JSONPath parser.
// Returns false if the value is not found.
// Returns an error if the value is ambiguous or a collection type.
// Returns an error if the given path can't be parsed.
//
// String conversion of non-string primitives relies on the default format when printing the value.
// The input path is expected to be passed in dot-notation without brackets or a leading dot.
// The implementation is based on similar internal client-go jsonpath usages, like kubectl
func nestedPrimitiveValue(obj unstructured.Unstructured, path string) (string, bool, error) {
jp := jsonpath.New("projection").AllowMissingKeys(true)
if err := jp.Parse(fmt.Sprintf("{.%s}", path)); err != nil {
return "", false, fmt.Errorf("failed to parse path: %v", err)
}
results, err := jp.FindResults(obj.UnstructuredContent())
if err != nil {
return "", false, fmt.Errorf("failed to find results: %v", err)
}
if len(results) == 0 || len(results[0]) == 0 {
return "", false, nil
}
if len(results) > 1 || len(results[0]) > 1 {
return "", true, errors.New("fieldPath matches more than one value which is not supported")
}
value := results[0][0]
switch value.Interface().(type) {
case map[string]interface{}, []interface{}:
return "", true, errors.New("fieldPath results in collection type which is not supported")
}
return fmt.Sprintf("%v", value.Interface()), true, nil
}
164 changes: 164 additions & 0 deletions internal/orchestrator/projectionhelper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package orchestrator

import (
"testing"

"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/yaml"
)

const subaccountCR = `
apiVersion: account.btp.sap.crossplane.io/v1alpha1
kind: Subaccount
metadata:
annotations:
crossplane.io/external-name: test-subaccount
name: test-subaccount
spec:
deletionPolicy: Delete
status:
conditions:
- lastTransitionTime: "2025-09-12T15:57:41Z"
observedGeneration: 1
reason: ReconcileSuccess
status: "True"
type: Synced
- lastTransitionTime: "2025-09-09T14:33:38Z"
reason: Available
status: "True"
type: Ready
`

func TestNestedPrimitiveValue(t *testing.T) {
tests := []struct {
name string
resourceYaml string
path string
wantValue string
wantFound bool
wantError bool
}{
{
name: "top level value retrieval",
resourceYaml: subaccountCR,
path: "kind",
wantValue: "Subaccount",
wantFound: true,
wantError: false,
},
{
name: "nested value retrieval with name selector",
resourceYaml: subaccountCR,
path: "spec.deletionPolicy",
wantValue: "Delete",
wantFound: true,
wantError: false,
},
{
name: "nested value retrieval with escaped name selector",
resourceYaml: subaccountCR,
path: "metadata.annotations.crossplane\\.io/external-name",
wantValue: "test-subaccount",
wantFound: true,
wantError: false,
},
{
name: "nested value retrieval with index selector",
resourceYaml: subaccountCR,
path: "status.conditions[1].status",
wantValue: "True",
wantFound: true,
wantError: false,
},
{
name: "nested value retrieval with filter selector",
resourceYaml: subaccountCR,
path: "status.conditions[?(@.type=='Ready')].status",
wantValue: "True",
wantFound: true,
wantError: false,
},
{
name: "nested value retrieval with array slice selector",
resourceYaml: subaccountCR,
path: "status.conditions[0:1].status",
wantValue: "True",
wantFound: true,
wantError: false,
},
{
name: "nested value retrieval with wildcard selector; collection results are not supported",
resourceYaml: subaccountCR,
path: "status.conditions[*].status",
wantValue: "",
wantFound: true,
wantError: true,
},
{
name: "non-existent value",
resourceYaml: subaccountCR,
path: "metadata.labels.app",
wantValue: "",
wantFound: false,
wantError: false,
},
{
name: "nested non-string value retrieval with default print format",
resourceYaml: subaccountCR,
path: "status.conditions[0].observedGeneration",
wantValue: "1",
wantFound: true,
wantError: false,
},
{
name: "retrieval of collection types is not supported",
resourceYaml: subaccountCR,
path: "status.conditions[0]",
wantValue: "",
wantFound: true,
wantError: true,
},
{
name: "invalid array index returns an error",
resourceYaml: subaccountCR,
path: "status.conditions[abc].status",
wantValue: "",
wantFound: false,
wantError: true,
},
{
name: "invalid path syntax returns an error",
resourceYaml: subaccountCR,
path: "$.[status.conditions[0].status]",
wantValue: "",
wantFound: false,
wantError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
obj := toUnstructured(t, tt.resourceYaml)
value, ok, err := nestedPrimitiveValue(obj, tt.path)

if (err != nil) != tt.wantError {
t.Errorf("unexpected error: got %v, wantErr %v", err, tt.wantError)
}
if ok != tt.wantFound {
t.Errorf("unexpected ok result: got %v, want %v", ok, tt.wantFound)
}
if value != tt.wantValue {
t.Errorf("unexpected value: got %v, want %v", value, tt.wantValue)
}
})
}
}

func toUnstructured(t *testing.T, resourceYaml string) unstructured.Unstructured {
t.Helper()
var object map[string]interface{}
if err := yaml.Unmarshal([]byte(resourceYaml), &object); err != nil {
t.Fatalf("failed to unmarshal YAML: %v", err)
}
return unstructured.Unstructured{Object: object}
}