Skip to content

Commit bbd2b42

Browse files
committed
Add Azure Storage cost query for blob collector
1 parent ecafd51 commit bbd2b42

File tree

6 files changed

+375
-7
lines changed

6 files changed

+375
-7
lines changed

docs/metrics/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@
2222
## Azure Services
2323

2424
- **[AKS](./azure/aks.md)** - Azure Kubernetes Service VM instances and managed disks
25-
- **[Blob](./azure/blob.md)** - Azure Blob Storage (cost metrics registered; no series until Cost Management)
25+
- **[Blob](./azure/blob.md)** - Azure Storage cost metrics via Cost Management (subscription scope; see blob.md for scope of meters)

docs/metrics/azure/blob.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Status
44

5-
The `BLOB` service is registered when you pass `BLOB` in `--azure.services`. `Collect` applies the **last successful** cost rows to the storage gauge. It calls **`StorageCostQuerier.QueryBlobStorage`** (see `pkg/azure/blob/cost_query.go`) only when **`ScrapeInterval`** has elapsed since the last successful query—same pattern as S3 billing refresh (`pkg/aws/s3/s3.go`). The default querier is a **no-op** (returns no rows), and **`azure.go` does not inject a real implementation yet**, so production scrapes still show **no samples** until Cost Management is wired to `Config.CostQuerier`. Lookback for each query is **30 days** (`defaultQueryLookback`), similar in spirit to the S3 billing window; **interval** controls refetch frequency only.
5+
The `BLOB` service is registered when you pass `BLOB` in `--azure.services`. `Collect` applies the **last successful** cost rows to the storage gauge. It calls **`StorageCostQuerier.QueryBlobStorage`** (see `pkg/azure/blob/cost_query.go`) only when **`ScrapeInterval`** has elapsed since the last successful query—same pattern as S3 billing refresh (`pkg/aws/s3/s3.go`). **`pkg/azure/azure.go` wires `client.BlobStorageCostQuerier`**, which queries **Azure Cost Management** (subscription scope) for **`ServiceName` = Storage**, grouped by **`ResourceLocation`** and **`Meter`**, with **daily** granularity merged over the lookback window. Rates use the same shape as S3 TimedStorage: **`(PreTaxCost / HoursInMonth) / UsageQuantity`** per merged row (`pkg/azure/client/storage_cost_query.go`). That includes **non-blob** Storage SKUs (files, queues, etc.); **`class`** is the Azure **meter** name. Lookback for each query is **30 days** (`defaultQueryLookback`); **interval** controls refetch frequency only.
66

77
## Planned behavior (see issue #54):
88

@@ -25,6 +25,6 @@ The `BLOB` service is registered when you pass `BLOB` in `--azure.services`. `Co
2525

2626
| Metric name | Type | Labels | Samples |
2727
|-------------|------|--------|---------|
28-
| `cloudcost_azure_blob_storage_by_location_usd_per_gibyte_hour` | Gauge | `region`, `class` | None with default no-op querier; populated when `StorageCostQuerier` returns rows |
28+
| `cloudcost_azure_blob_storage_by_location_usd_per_gibyte_hour` | Gauge | `region`, `class` | Populated when Cost Management returns rows; `class` is the **Meter** dimension |
2929

3030
**Planned future work:** `cloudcost_azure_blob_operation_by_location_usd_per_krequest` (Gauge; `region`, `class`, `tier`) — parity with S3/GCS operation metrics; commented out in `pkg/azure/blob/blob.go` until operation pricing is implemented. How we group cost rows into labels is TBD when we add the API integration.

pkg/azure/azure.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,16 @@ func New(ctx context.Context, config *Config) (*Azure, error) {
104104
}
105105
collectors = append(collectors, collector)
106106
case "BLOB":
107+
blobQuerier, err := client.NewBlobStorageCostQuerier(creds, nil)
108+
if err != nil {
109+
logger.LogAttrs(ctx, slog.LevelError, "failed to create blob cost querier", slog.String("err", err.Error()))
110+
return nil, err
111+
}
107112
collector, err := blob.New(&blob.Config{
108113
Logger: logger,
109114
SubscriptionId: config.SubscriptionId,
110115
ScrapeInterval: config.ScrapeInterval,
116+
CostQuerier: blobQuerier,
111117
})
112118
if err != nil {
113119
return nil, err

pkg/azure/client/storage_cost_querier.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package client
22

33
import (
44
"context"
5+
"fmt"
6+
"strings"
57
"time"
68

79
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
@@ -14,7 +16,6 @@ import (
1416
var _ blob.StorageCostQuerier = (*BlobStorageCostQuerier)(nil)
1517

1618
// BlobStorageCostQuerier implements blob.StorageCostQuerier using Azure Cost Management QueryClient.
17-
// QueryBlobStorage returns no rows until a subscription-scoped Usage query is implemented.
1819
type BlobStorageCostQuerier struct {
1920
query *armcostmanagement.QueryClient
2021
}
@@ -30,10 +31,30 @@ func NewBlobStorageCostQuerier(credential azcore.TokenCredential, options *arm.C
3031

3132
// CostQueryClient returns the underlying Cost Management client for subscription-scoped Usage calls.
3233
func (q *BlobStorageCostQuerier) CostQueryClient() *armcostmanagement.QueryClient {
34+
if q == nil {
35+
return nil
36+
}
3337
return q.query
3438
}
3539

36-
// QueryBlobStorage implements blob.StorageCostQuerier.
37-
func (*BlobStorageCostQuerier) QueryBlobStorage(context.Context, string, time.Duration) ([]blob.StorageCostRow, error) {
38-
return nil, nil
40+
// QueryBlobStorage runs a subscription-scoped Usage query (ServiceName = Storage, grouped by
41+
// ResourceLocation and Meter) and returns USD/(GiB·h) rates. See buildBlobStorageCostQuery.
42+
func (q *BlobStorageCostQuerier) QueryBlobStorage(ctx context.Context, subscriptionID string, lookback time.Duration) ([]blob.StorageCostRow, error) {
43+
if q == nil || q.query == nil {
44+
return nil, nil
45+
}
46+
subscriptionID = strings.TrimSpace(subscriptionID)
47+
if subscriptionID == "" {
48+
return nil, fmt.Errorf("subscription id required for cost management query")
49+
}
50+
51+
def := buildBlobStorageCostQuery(lookback)
52+
resp, err := q.query.Usage(ctx, subscriptionQueryScope(subscriptionID), def, nil)
53+
if err != nil {
54+
return nil, err
55+
}
56+
if resp.Properties == nil {
57+
return nil, nil
58+
}
59+
return blobStorageRowsFromProperties(resp.Properties)
3960
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package client
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
"time"
8+
9+
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/costmanagement/armcostmanagement"
10+
"github.com/Azure/go-autorest/autorest/to"
11+
12+
"github.com/grafana/cloudcost-exporter/pkg/azure/blob"
13+
"github.com/grafana/cloudcost-exporter/pkg/utils"
14+
)
15+
16+
// buildBlobStorageCostQuery returns a Cost Management Usage query for Microsoft.Storage-style
17+
// capacity meters: ServiceName Storage, grouped by ResourceLocation and Meter, daily granularity
18+
// (aggregated client-side). Matches the S3 TimedStorage rate shape: (cost / HoursInMonth) / usage.
19+
func buildBlobStorageCostQuery(lookback time.Duration) armcostmanagement.QueryDefinition {
20+
if lookback < 24*time.Hour {
21+
lookback = 24 * time.Hour
22+
}
23+
end := time.Now().UTC().Truncate(24 * time.Hour)
24+
start := end.Add(-lookback)
25+
26+
dimType := armcostmanagement.QueryColumnTypeDimension
27+
opIn := armcostmanagement.QueryOperatorTypeIn
28+
exportType := armcostmanagement.ExportTypeActualCost
29+
timeframe := armcostmanagement.TimeframeTypeCustom
30+
granularity := armcostmanagement.GranularityTypeDaily
31+
sumCostFn := armcostmanagement.FunctionTypeSum
32+
sumUsageFn := armcostmanagement.FunctionTypeSum
33+
34+
return armcostmanagement.QueryDefinition{
35+
Type: &exportType,
36+
Timeframe: &timeframe,
37+
TimePeriod: &armcostmanagement.QueryTimePeriod{
38+
From: &start,
39+
To: &end,
40+
},
41+
Dataset: &armcostmanagement.QueryDataset{
42+
Granularity: &granularity,
43+
Aggregation: map[string]*armcostmanagement.QueryAggregation{
44+
"cost": {
45+
Name: to.StringPtr("PreTaxCost"),
46+
Function: &sumCostFn,
47+
},
48+
"usageQty": {
49+
Name: to.StringPtr("UsageQuantity"),
50+
Function: &sumUsageFn,
51+
},
52+
},
53+
Grouping: []*armcostmanagement.QueryGrouping{
54+
{Type: &dimType, Name: to.StringPtr("ResourceLocation")},
55+
{Type: &dimType, Name: to.StringPtr("Meter")},
56+
},
57+
Filter: &armcostmanagement.QueryFilter{
58+
Dimensions: &armcostmanagement.QueryComparisonExpression{
59+
Name: to.StringPtr("ServiceName"),
60+
Operator: &opIn,
61+
Values: []*string{to.StringPtr("Storage")},
62+
},
63+
},
64+
},
65+
}
66+
}
67+
68+
func subscriptionQueryScope(subscriptionID string) string {
69+
return fmt.Sprintf("/subscriptions/%s/", strings.TrimSpace(subscriptionID))
70+
}
71+
72+
// blobStorageRowsFromProperties turns Cost Management Usage rows into blob.StorageCostRow rates.
73+
// Daily rows for the same (location, meter) are summed before rate calculation.
74+
func blobStorageRowsFromProperties(props *armcostmanagement.QueryProperties) ([]blob.StorageCostRow, error) {
75+
if props == nil || len(props.Columns) == 0 {
76+
return nil, nil
77+
}
78+
idx := columnNameIndex(props.Columns)
79+
locIdx := firstColumnIndex(idx, "resourcelocation")
80+
meterIdx := firstColumnIndex(idx, "meter")
81+
costIdx := firstColumnIndex(idx, "cost", "pretaxcost", "totalcost")
82+
usageIdx := firstColumnIndex(idx, "usageqty", "usagequantity")
83+
84+
if locIdx < 0 || meterIdx < 0 || costIdx < 0 || usageIdx < 0 {
85+
return nil, fmt.Errorf("cost management query columns missing (need ResourceLocation, Meter, cost, usageQty); got %v", columnNames(props.Columns))
86+
}
87+
88+
type agg struct {
89+
cost float64
90+
qty float64
91+
}
92+
merged := make(map[string]agg)
93+
94+
need := max(locIdx, meterIdx, costIdx, usageIdx) + 1
95+
for _, row := range props.Rows {
96+
if len(row) < need {
97+
continue
98+
}
99+
loc := normalizeLocation(cellString(row[locIdx]))
100+
meter := strings.TrimSpace(cellString(row[meterIdx]))
101+
if meter == "" {
102+
continue
103+
}
104+
cost, okC := cellFloat(row[costIdx])
105+
qty, okQ := cellFloat(row[usageIdx])
106+
if !okC || !okQ {
107+
continue
108+
}
109+
key := loc + "\x00" + meter
110+
a := merged[key]
111+
a.cost += cost
112+
a.qty += qty
113+
merged[key] = a
114+
}
115+
116+
out := make([]blob.StorageCostRow, 0, len(merged))
117+
for key, a := range merged {
118+
if a.qty == 0 {
119+
continue
120+
}
121+
parts := strings.SplitN(key, "\x00", 2)
122+
loc := parts[0]
123+
meter := parts[1]
124+
// S3 TimedStorage-style: USD/(GiB*h) from billing cost and GB-mo style usage totals.
125+
rate := (a.cost / utils.HoursInMonth) / a.qty
126+
out = append(out, blob.StorageCostRow{
127+
Region: loc,
128+
Class: meter,
129+
Rate: rate,
130+
})
131+
}
132+
return out, nil
133+
}
134+
135+
func columnNames(cols []*armcostmanagement.QueryColumn) []string {
136+
var names []string
137+
for _, c := range cols {
138+
if c != nil && c.Name != nil {
139+
names = append(names, *c.Name)
140+
}
141+
}
142+
return names
143+
}
144+
145+
func columnNameIndex(cols []*armcostmanagement.QueryColumn) map[string]int {
146+
m := make(map[string]int)
147+
for i, c := range cols {
148+
if c == nil || c.Name == nil {
149+
continue
150+
}
151+
m[strings.ToLower(strings.TrimSpace(*c.Name))] = i
152+
}
153+
return m
154+
}
155+
156+
func firstColumnIndex(idx map[string]int, names ...string) int {
157+
for _, n := range names {
158+
if i, ok := idx[strings.ToLower(n)]; ok {
159+
return i
160+
}
161+
}
162+
return -1
163+
}
164+
165+
func max(vals ...int) int {
166+
m := vals[0]
167+
for _, v := range vals[1:] {
168+
if v > m {
169+
m = v
170+
}
171+
}
172+
return m
173+
}
174+
175+
func normalizeLocation(s string) string {
176+
s = strings.TrimSpace(s)
177+
if s == "" || strings.EqualFold(s, "unassigned") {
178+
return utils.RegionUnknown
179+
}
180+
return s
181+
}
182+
183+
func cellString(v any) string {
184+
if v == nil {
185+
return ""
186+
}
187+
switch x := v.(type) {
188+
case string:
189+
return x
190+
default:
191+
return fmt.Sprint(x)
192+
}
193+
}
194+
195+
func cellFloat(v any) (float64, bool) {
196+
switch x := v.(type) {
197+
case float64:
198+
return x, true
199+
case float32:
200+
return float64(x), true
201+
case int:
202+
return float64(x), true
203+
case int64:
204+
return float64(x), true
205+
case string:
206+
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
207+
return f, err == nil
208+
default:
209+
return 0, false
210+
}
211+
}

0 commit comments

Comments
 (0)