From 46b2b2ead15762819604b80e4acd8283574024f3 Mon Sep 17 00:00:00 2001 From: christianjedro Date: Thu, 12 Feb 2026 14:19:12 +0100 Subject: [PATCH 1/2] feat: add reservation state and expiry time metrics --- README.md | 2 +- example.yaml | 2 + go.mod | 1 + go.sum | 2 + metrics_azurerm_reservation.go | 156 ++++++++++++++++++++++++++++++++- 5 files changed, 161 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c5e8ec2..8ee4fa0 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ This exporter is using Azure ResourceGraph queries and not wasting Azure API cal ## Azure permissions -This exporter needs `Reader` permissions on subscription level. +This exporter needs `Reader` permissions on subscription level. For Metrics about Reservation State and Expiry Time `Reservation Reader` is required. ## Metrics diff --git a/example.yaml b/example.yaml index 8f3a081..ed9e761 100644 --- a/example.yaml +++ b/example.yaml @@ -146,6 +146,8 @@ collectors: # for BillingProfile scope (modern). # # see https://learn.microsoft.com/en-us/rest/api/consumption/reservations-summaries/list?view=rest-consumption-2023-05-01&tabs=HTTP + # + # If no scopes are set, the exporter will only collect Expiry Time and Provisioning State metrics scopes: [] granularity: daily # or monthly diff --git a/go.mod b/go.mod index f22d7fc..faca230 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect diff --git a/go.sum b/go.sum index fa36a93..80cd246 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanage github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 h1:QM6sE5k2ZT/vI5BEe0r7mqjsUSnhVBFbOsVkEuaEfiA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0/go.mod h1:243D9iHbcQXoFUtgHJwL7gl2zx1aDuDMjvBZVGr2uW0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0 h1:0OO/3K+SKt45gXiOU4gHRILOLeNOUZdqeNO47Mq6iN8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0/go.mod h1:2FDnHkGwh1BFK1ZQt+iDJU47Cg9Y28F0W3FSI389NOA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 h1:zLzoX5+W2l95UJoVwiyNS4dX8vHyQ6x2xRLoBBL9wMk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcehealth/armresourcehealth v1.3.0 h1:hz+ZQ21PKZ6TBEiVMq8zqWUzA5DGj087lYC8OCm6wuY= diff --git a/metrics_azurerm_reservation.go b/metrics_azurerm_reservation.go index bc9e005..13ba813 100644 --- a/metrics_azurerm_reservation.go +++ b/metrics_azurerm_reservation.go @@ -2,14 +2,33 @@ package main import ( "log/slog" + "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/consumption/armconsumption" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations" "github.com/prometheus/client_golang/prometheus" "github.com/webdevops/go-common/prometheus/collector" "github.com/webdevops/go-common/utils/to" ) +// provisioningStateToNumber maps reservation provisioningState to a numeric value for the metric. +var provisioningStateToNumber = map[string]float64{ + "Creating": 0, + "PendingResourceHold": 1, + "ConfirmedResourceHold": 2, + "PendingBilling": 3, + "ConfirmedBilling": 4, + "Created": 5, + "Succeeded": 6, + "Cancelled": 7, + "Expired": 8, + "BillingFailed": 9, + "Failed": 10, + "Split": 11, + "Merged": 12, +} + // Define MetricsCollectorAzureRmReservation struct type MetricsCollectorAzureRmReservation struct { collector.Processor @@ -22,6 +41,8 @@ type MetricsCollectorAzureRmReservation struct { reservationUsedHours *prometheus.GaugeVec reservationReservedHours *prometheus.GaugeVec reservationTotalReservedQuantity *prometheus.GaugeVec + reservationProvisioningState *prometheus.GaugeVec + reservationExpiryTimestamp *prometheus.GaugeVec } } @@ -100,14 +121,51 @@ func (m *MetricsCollectorAzureRmReservation) Setup(collector *collector.Collecto commonLabels, ) m.Collector.RegisterMetricList("reservationTotalReservedQuantity", m.prometheus.reservationTotalReservedQuantity, true) + + // Metrics from management.azure.com/providers/Microsoft.Capacity/reservations (List All, no scopes required) + reservationListAllLabels := []string{ + "reservationOrderID", + "reservationID", + "skuName", + "kind", + "displayName", + "purchaseDate", + "reservedResourceType", + "skuDescription", + } + m.prometheus.reservationProvisioningState = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "azurerm_reservation_provisioning_state", + Help: "Azure ResourceManager Reservation provisioning state (Creating=0, PendingResourceHold=1, ConfirmedResourceHold=2, PendingBilling=3, ConfirmedBilling=4, Created=5, Succeeded=6, Cancelled=7, Expired=8, BillingFailed=9, Failed=10, Split=11, Merged=12, -1=Unknown)", + }, + reservationListAllLabels, + ) + m.Collector.RegisterMetricList("reservationProvisioningState", m.prometheus.reservationProvisioningState, true) + + m.prometheus.reservationExpiryTimestamp = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "azurerm_reservation_expiry_timestamp", + Help: "Azure ResourceManager Reservation expiry date as Unix timestamp", + }, + reservationListAllLabels, + ) + m.Collector.RegisterMetricList("reservationExpiryTimestamp", m.prometheus.reservationExpiryTimestamp, true) } func (m *MetricsCollectorAzureRmReservation) Reset() {} func (m *MetricsCollectorAzureRmReservation) Collect(callback chan<- func()) { - for _, scope := range Config.Collectors.Reservation.Scopes { + scopes := Config.Collectors.Reservation.Scopes + if len(scopes) == 0 { + // No scopes: only produce metrics from List All (management.azure.com/providers/Microsoft.Capacity/reservations) + m.collectReservationListAll(m.Logger(), callback) + return + } + // Scopes set: produce scope-based usage metrics and List All metrics + for _, scope := range scopes { m.collectReservationUsage(m.Logger(), scope, callback) } + m.collectReservationListAll(m.Logger(), callback) } func (m *MetricsCollectorAzureRmReservation) collectReservationUsage(logger *slog.Logger, scope string, callback chan<- func()) { @@ -167,3 +225,99 @@ func (m *MetricsCollectorAzureRmReservation) collectReservationUsage(logger *slo } } } + +// parseReservationID extracts reservationOrderID and reservationID from the reservation resource ID. +func parseReservationID(id string) (reservationOrderID, reservationID string) { + if id == "" { + return "", "" + } + parts := strings.Split(strings.TrimPrefix(id, "/"), "/") + for i := 0; i < len(parts)-1; i++ { + switch strings.ToLower(parts[i]) { + case "reservationorders": + if i+1 < len(parts) { + reservationOrderID = parts[i+1] + } + case "reservations": + if i+1 < len(parts) { + reservationID = parts[i+1] + } + } + } + return reservationOrderID, reservationID +} + +func (m *MetricsCollectorAzureRmReservation) collectReservationListAll(logger *slog.Logger, callback chan<- func()) { + provisioningStateMetric := m.Collector.GetMetricList("reservationProvisioningState") + expiryTimestampMetric := m.Collector.GetMetricList("reservationExpiryTimestamp") + + client, err := armreservations.NewReservationClient(AzureClient.GetCred(), AzureClient.NewArmClientOptions()) + if err != nil { + logger.Error("failed to create reservations client", slog.Any("error", err)) + return + } + + // Filter out archived reservations; API expects URL-encoded filter: (properties/archived eq false) + pager := client.NewListAllPager(&armreservations.ReservationClientListAllOptions{ + Filter: to.Ptr("(properties/archived eq false)"), + }) + + for pager.More() { + page, err := pager.NextPage(m.Context()) + if err != nil { + logger.Error("failed to get next reservations page", slog.Any("error", err)) + return + } + + for _, item := range page.Value { + if item == nil || item.Properties == nil { + continue + } + reservationOrderID, reservationID := parseReservationID(to.String(item.ID)) + if reservationOrderID == "" || reservationID == "" { + continue + } + + skuName := "" + if item.SKU != nil && item.SKU.Name != nil { + skuName = *item.SKU.Name + } + kind := to.String(item.Kind) + + displayName := to.String(item.Properties.DisplayName) + purchaseDate := "" + if item.Properties.PurchaseDate != nil { + purchaseDate = item.Properties.PurchaseDate.Format("2006-01-02") + } + reservedResourceType := "" + if item.Properties.ReservedResourceType != nil { + reservedResourceType = string(*item.Properties.ReservedResourceType) + } + skuDescription := to.String(item.Properties.SKUDescription) + + labels := prometheus.Labels{ + "reservationOrderID": reservationOrderID, + "reservationID": reservationID, + "skuName": skuName, + "kind": kind, + "displayName": displayName, + "purchaseDate": purchaseDate, + "reservedResourceType": reservedResourceType, + "skuDescription": skuDescription, + } + + if item.Properties.ProvisioningState != nil { + stateStr := string(*item.Properties.ProvisioningState) + if num, ok := provisioningStateToNumber[stateStr]; ok { + provisioningStateMetric.Add(labels, num) + } else { + provisioningStateMetric.Add(labels, -1) + } + } + + if item.Properties.ExpiryDate != nil { + expiryTimestampMetric.Add(labels, float64(item.Properties.ExpiryDate.Unix())) + } + } + } +} From f74192e3c6d70c4d18f169ab6dcea3e79b661517 Mon Sep 17 00:00:00 2001 From: christianjedro Date: Tue, 17 Mar 2026 14:58:07 +0100 Subject: [PATCH 2/2] feat: change provisioningState metric --- metrics_azurerm_reservation.go | 59 ++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/metrics_azurerm_reservation.go b/metrics_azurerm_reservation.go index 13ba813..9de6097 100644 --- a/metrics_azurerm_reservation.go +++ b/metrics_azurerm_reservation.go @@ -12,21 +12,20 @@ import ( "github.com/webdevops/go-common/utils/to" ) -// provisioningStateToNumber maps reservation provisioningState to a numeric value for the metric. -var provisioningStateToNumber = map[string]float64{ - "Creating": 0, - "PendingResourceHold": 1, - "ConfirmedResourceHold": 2, - "PendingBilling": 3, - "ConfirmedBilling": 4, - "Created": 5, - "Succeeded": 6, - "Cancelled": 7, - "Expired": 8, - "BillingFailed": 9, - "Failed": 10, - "Split": 11, - "Merged": 12, +var provisioningStates = []string{ + "Creating", + "PendingResourceHold", + "ConfirmedResourceHold", + "PendingBilling", + "ConfirmedBilling", + "Created", + "Succeeded", + "Cancelled", + "Expired", + "BillingFailed", + "Failed", + "Split", + "Merged", } // Define MetricsCollectorAzureRmReservation struct @@ -133,12 +132,13 @@ func (m *MetricsCollectorAzureRmReservation) Setup(collector *collector.Collecto "reservedResourceType", "skuDescription", } + reservationListAllLabelsWithState := append(append([]string{}, reservationListAllLabels...), "provisioningState") m.prometheus.reservationProvisioningState = prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "azurerm_reservation_provisioning_state", - Help: "Azure ResourceManager Reservation provisioning state (Creating=0, PendingResourceHold=1, ConfirmedResourceHold=2, PendingBilling=3, ConfirmedBilling=4, Created=5, Succeeded=6, Cancelled=7, Expired=8, BillingFailed=9, Failed=10, Split=11, Merged=12, -1=Unknown)", + Help: "Azure ResourceManager Reservation provisioning state as a label (value 1 for current state, 0 otherwise)", }, - reservationListAllLabels, + reservationListAllLabelsWithState, ) m.Collector.RegisterMetricList("reservationProvisioningState", m.prometheus.reservationProvisioningState, true) @@ -295,7 +295,7 @@ func (m *MetricsCollectorAzureRmReservation) collectReservationListAll(logger *s } skuDescription := to.String(item.Properties.SKUDescription) - labels := prometheus.Labels{ + baseLabels := prometheus.Labels{ "reservationOrderID": reservationOrderID, "reservationID": reservationID, "skuName": skuName, @@ -306,17 +306,28 @@ func (m *MetricsCollectorAzureRmReservation) collectReservationListAll(logger *s "skuDescription": skuDescription, } + currentState := "" if item.Properties.ProvisioningState != nil { - stateStr := string(*item.Properties.ProvisioningState) - if num, ok := provisioningStateToNumber[stateStr]; ok { - provisioningStateMetric.Add(labels, num) - } else { - provisioningStateMetric.Add(labels, -1) + currentState = string(*item.Properties.ProvisioningState) + } + + for _, state := range provisioningStates { + labels := prometheus.Labels{} + for k, v := range baseLabels { + labels[k] = v } + labels["provisioningState"] = state + + value := 0.0 + if state == currentState { + value = 1.0 + } + + provisioningStateMetric.Add(labels, value) } if item.Properties.ExpiryDate != nil { - expiryTimestampMetric.Add(labels, float64(item.Properties.ExpiryDate.Unix())) + expiryTimestampMetric.Add(baseLabels, float64(item.Properties.ExpiryDate.Unix())) } } }