Skip to content

Commit 60c2aa9

Browse files
committed
Cache Azure blob cost query on scrape interval
1 parent e0f21c7 commit 60c2aa9

File tree

3 files changed

+76
-8
lines changed

3 files changed

+76
-8
lines changed

docs/metrics/azure/blob.md

Lines changed: 1 addition & 1 deletion
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` calls **`StorageCostQuerier.QueryBlobStorage`** (see `pkg/azure/blob/cost_query.go`) and **`Set`s** the storage gauge per row. 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 the query is **30 days** (`defaultQueryLookback`), similar in spirit to the S3 billing window.
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.
66

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

pkg/azure/blob/blob.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package blob
33
import (
44
"context"
55
"log/slog"
6+
"sync"
67
"time"
78

89
"github.com/grafana/cloudcost-exporter/pkg/provider"
@@ -48,6 +49,10 @@ type Collector struct {
4849
querier StorageCostQuerier
4950
subscriptionID string
5051
scrapeInterval time.Duration
52+
53+
mu sync.Mutex
54+
cachedRows []StorageCostRow
55+
nextRefresh time.Time // QueryBlobStorage when time.Now is on or after this (S3 billing refresh pattern).
5156
}
5257

5358
// Config holds settings for the blob collector.
@@ -75,20 +80,33 @@ func New(cfg *Config) (*Collector, error) {
7580
querier: q,
7681
subscriptionID: cfg.SubscriptionId,
7782
scrapeInterval: interval,
83+
// First Collect runs a query immediately (same idea as pkg/aws/s3 nextScrape).
84+
nextRefresh: time.Now().Add(-interval),
7885
}, nil
7986
}
8087

8188
// Collect satisfies provider.Collector.
8289
func (c *Collector) Collect(ctx context.Context, _ chan<- prometheus.Metric) error {
8390
c.logger.LogAttrs(ctx, slog.LevelInfo, "collecting metrics")
84-
rows, err := c.querier.QueryBlobStorage(ctx, c.subscriptionID, defaultQueryLookback)
85-
if err != nil {
86-
return err
91+
c.mu.Lock()
92+
defer c.mu.Unlock()
93+
now := time.Now()
94+
if !now.Before(c.nextRefresh) {
95+
rows, err := c.querier.QueryBlobStorage(ctx, c.subscriptionID, defaultQueryLookback)
96+
if err != nil {
97+
return err
98+
}
99+
c.cachedRows = rows
100+
c.nextRefresh = now.Add(c.scrapeInterval)
87101
}
102+
c.applyRowsToGauge(c.cachedRows)
103+
return nil
104+
}
105+
106+
func (c *Collector) applyRowsToGauge(rows []StorageCostRow) {
88107
for _, row := range rows {
89108
c.metrics.StorageGauge.WithLabelValues(row.Region, row.Class).Set(row.Rate)
90109
}
91-
return nil
92110
}
93111

94112
// Describe satisfies provider.Collector.

pkg/azure/blob/blob_test.go

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"log/slog"
77
"os"
88
"strings"
9+
"sync"
910
"testing"
1011
"time"
1112

@@ -30,14 +31,63 @@ func (s stubCostQuerier) QueryBlobStorage(context.Context, string, time.Duration
3031
return s.rows, s.err
3132
}
3233

33-
func TestCollector_Collect_queryError(t *testing.T) {
34+
type countingCostQuerier struct {
35+
mu sync.Mutex
36+
n int
37+
rows []StorageCostRow
38+
err error
39+
}
40+
41+
func (c *countingCostQuerier) QueryBlobStorage(context.Context, string, time.Duration) ([]StorageCostRow, error) {
42+
c.mu.Lock()
43+
defer c.mu.Unlock()
44+
c.n++
45+
return c.rows, c.err
46+
}
47+
48+
func (c *countingCostQuerier) calls() int {
49+
c.mu.Lock()
50+
defer c.mu.Unlock()
51+
return c.n
52+
}
53+
54+
func newCollectorWithCountingQuerier(t *testing.T, rows []StorageCostRow, querierErr error) (*Collector, *countingCostQuerier) {
55+
t.Helper()
56+
q := &countingCostQuerier{rows: rows, err: querierErr}
3457
c, err := New(&Config{
3558
Logger: testLogger,
3659
SubscriptionId: "sub",
37-
CostQuerier: stubCostQuerier{err: errors.New("query failed")},
60+
ScrapeInterval: time.Hour,
61+
CostQuerier: q,
3862
})
3963
require.NoError(t, err)
40-
assert.Error(t, c.Collect(t.Context(), nil))
64+
return c, q
65+
}
66+
67+
func TestCollector_Collect_costQueryRefresh(t *testing.T) {
68+
sampleRows := []StorageCostRow{{Region: "eastus", Class: "Hot", Rate: 0.001}}
69+
70+
t.Run("skips_until_interval", func(t *testing.T) {
71+
c, q := newCollectorWithCountingQuerier(t, sampleRows, nil)
72+
require.NoError(t, c.Collect(t.Context(), nil))
73+
require.NoError(t, c.Collect(t.Context(), nil))
74+
assert.Equal(t, 1, q.calls(), "second scrape within interval should not call querier")
75+
})
76+
77+
t.Run("refetches_when_next_refresh_elapsed", func(t *testing.T) {
78+
c, q := newCollectorWithCountingQuerier(t, sampleRows, nil)
79+
require.NoError(t, c.Collect(t.Context(), nil))
80+
c.nextRefresh = time.Now().Add(-time.Second)
81+
require.NoError(t, c.Collect(t.Context(), nil))
82+
assert.Equal(t, 2, q.calls())
83+
})
84+
85+
t.Run("retries_after_error", func(t *testing.T) {
86+
c, q := newCollectorWithCountingQuerier(t, nil, errors.New("query failed"))
87+
assert.Error(t, c.Collect(t.Context(), nil))
88+
assert.Error(t, c.Collect(t.Context(), nil))
89+
assert.Equal(t, 2, q.calls(), "errors do not advance nextRefresh; querier should run again")
90+
})
4191
}
4292

4393
func TestCollector_Collect_setsGaugeFromQuerier(t *testing.T) {

0 commit comments

Comments
 (0)