Skip to content

Commit 87587eb

Browse files
authored
Azure: Add blob collector stub (#893)
1 parent 736ef7c commit 87587eb

8 files changed

Lines changed: 217 additions & 9 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ cmd/exporter/exporter.go # Entrypoint: flags, provider selection, HT
2121
pkg/provider/provider.go # Provider, Collector, Registry interfaces
2222
pkg/aws/aws.go # AWS: S3, EC2, RDS, NATGATEWAY, ELB, VPC
2323
pkg/google/gcp.go # GCP: GCS, GKE, CLB, SQL, VPC
24-
pkg/azure/azure.go # Azure: AKS
24+
pkg/azure/azure.go # Azure: AKS, blob
2525
pkg/gatherer/gatherer.go # Wraps Collect(): duration, errors, metadata metrics
2626
pkg/utils/consts.go # Shared metric suffixes, HoursInMonth, GenerateDesc()
2727
cmd/dashboards/main.go # Dashboard generation (grafana-foundation-sdk)
@@ -58,7 +58,8 @@ Rule: Never push to `main`.
5858
```bash
5959
go run cmd/exporter/exporter.go -provider gcp -project-id=$GCP_PROJECT_ID -gcp.services GKE,GCS
6060
go run cmd/exporter/exporter.go -provider aws -aws.profile $AWS_PROFILE -aws.services EC2,S3
61-
go run cmd/exporter/exporter.go -provider azure -azure.subscription-id $AZ_SUBSCRIPTION_ID
61+
go run cmd/exporter/exporter.go -provider azure -azure.subscription-id $AZ_SUBSCRIPTION_ID -azure.services AKS
62+
go run cmd/exporter/exporter.go -provider azure -azure.subscription-id $AZ_SUBSCRIPTION_ID -azure.services blob
6263
```
6364

6465
### Adding a collector

cmd/exporter/exporter.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func providerFlags(fs *flag.FlagSet, cfg *config.Config) {
7575
fs.Var(config.NewDeprecatedStringSliceFlag(&cfg.Providers.GCP.Projects, &cfg.Providers.GCP.BucketProjectsDeprecated), "gcp.bucket-projects", "GCP project(s). (deprecated: use --gcp.projects instead)")
7676
fs.Var(&cfg.Providers.AWS.Services, "aws.services", "AWS service(s).")
7777
fs.Var(&cfg.Providers.AWS.ExcludeRegions, "aws.exclude-regions", "AWS region(s) to exclude from cost collection.")
78-
fs.Var(&cfg.Providers.Azure.Services, "azure.services", "Azure service(s).")
78+
fs.Var(&cfg.Providers.Azure.Services, "azure.services", "Azure service(s): AKS, blob (comma-separated and/or repeat flag; case-insensitive).")
7979
fs.Var(&cfg.Providers.GCP.Services, "gcp.services", "GCP service(s).")
8080
flag.StringVar(&cfg.Providers.AWS.Region, "aws.region", "", "AWS region")
8181
flag.StringVar(&cfg.Providers.AWS.RoleARN, "aws.roleARN", "", "Optional AWS role ARN to assume for cross-account access.")
@@ -242,7 +242,8 @@ func selectProviderWith(
242242
return newAzure(ctx, &azure.Config{
243243
Logger: cfg.Logger,
244244
SubscriptionId: cfg.Providers.Azure.SubscriptionId,
245-
Services: cfg.Providers.Azure.Services,
245+
ScrapeInterval: cfg.Collector.ScrapeInterval,
246+
Services: strings.Split(cfg.Providers.Azure.Services.String(), ","),
246247
CollectorTimeout: collectorTimeout,
247248
})
248249
case "aws":

docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@
1111
- [Providers](metrics/providers.md)
1212
- **AWS:** [EC2](metrics/aws/ec2.md), [S3](metrics/aws/s3.md), [RDS](metrics/aws/rds.md), [MSK](metrics/aws/msk.md), [ELB](metrics/aws/elb.md), [NAT Gateway](metrics/aws/natgateway.md), [VPC](metrics/aws/vpc.md)
1313
- **GCP:** [GKE](metrics/gcp/gke.md), [GCS](metrics/gcp/gcs.md), [Cloud SQL](metrics/gcp/cloudsql.md), [Managed Kafka](metrics/gcp/managedkafka.md), [CLB](metrics/gcp/clb.md), [VPC](metrics/gcp/vpc.md)
14-
- **Azure:** [AKS](metrics/azure/aks.md)
14+
- **Azure:** [AKS](metrics/azure/aks.md), [blob](metrics/azure/blob.md)
1515
- [Deploying](deploying/aws/README.md) - Run the exporter
1616
- [AWS](deploying/aws/README.md) — IRSA, Helm, cross-account access

docs/metrics/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +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)

docs/metrics/azure/blob.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Azure Blob Storage metrics
2+
3+
Pass `blob` in `--azure.services` to enable this collector. Matching is case-insensitive.
4+
5+
The collector defines a storage cost `GaugeVec` that the Azure provider includes in its `Describe` and `Collect` fan-out (same gatherer pattern as `azure_aks`). The parent Azure collector forwards `StorageGauge.Collect(ch)` so blob cost metrics share one registration path with the rest of the Azure exporter. Scrape instrumentation publishes `cloudcost_exporter_collector_*` with label `collector="azure_blob"`.
6+
7+
## Cost metrics
8+
9+
| Metric name | Metric type | Description | Labels |
10+
|-------------|-------------|-------------|--------|
11+
| cloudcost_azure_blob_storage_by_location_usd_per_gibyte_hour | Gauge | Storage cost rate for Blob Storage by region and class. Cost represented in USD/(GiB*h) | `region`=&lt;Azure region&gt; <br/> `class`=&lt;Azure Blob storage class or access tier&gt; |

pkg/azure/azure.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/prometheus/client_golang/prometheus"
1313

1414
"github.com/grafana/cloudcost-exporter/pkg/azure/aks"
15+
"github.com/grafana/cloudcost-exporter/pkg/azure/blob"
1516
"github.com/grafana/cloudcost-exporter/pkg/azure/client"
1617
"github.com/grafana/cloudcost-exporter/pkg/collectormetrics"
1718
"github.com/grafana/cloudcost-exporter/pkg/provider"
@@ -65,6 +66,7 @@ type Config struct {
6566
Region string
6667

6768
SubscriptionId string
69+
ScrapeInterval time.Duration
6870

6971
CollectorTimeout time.Duration
7072
Services []string
@@ -90,15 +92,35 @@ func New(ctx context.Context, config *Config) (*Azure, error) {
9092
return nil, err
9193
}
9294

93-
// Collector Registration
95+
// Collector Registration (--azure.services matching is case-insensitive).
9496
for _, svc := range config.Services {
95-
switch strings.ToUpper(svc) {
96-
case "AKS":
97+
svc = strings.TrimSpace(svc)
98+
if svc == "" {
99+
continue
100+
}
101+
switch {
102+
case strings.EqualFold(svc, "AKS"):
97103
collector, err := aks.New(ctx, &aks.Config{
98104
Logger: logger,
99105
}, azClientWrapper)
100106
if err != nil {
101-
return nil, err
107+
logger.LogAttrs(ctx, slog.LevelError, "Error creating collector",
108+
slog.String("service", svc),
109+
slog.String("message", err.Error()))
110+
continue
111+
}
112+
collectors = append(collectors, collector)
113+
case strings.EqualFold(svc, "blob"):
114+
collector, err := blob.New(&blob.Config{
115+
Logger: logger,
116+
SubscriptionId: config.SubscriptionId,
117+
ScrapeInterval: config.ScrapeInterval,
118+
})
119+
if err != nil {
120+
logger.LogAttrs(ctx, slog.LevelError, "Error creating collector",
121+
slog.String("service", svc),
122+
slog.String("message", err.Error()))
123+
continue
102124
}
103125
collectors = append(collectors, collector)
104126
default:

pkg/azure/blob/blob.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package blob
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"time"
7+
8+
"github.com/grafana/cloudcost-exporter/pkg/provider"
9+
"github.com/prometheus/client_golang/prometheus"
10+
11+
cloudcost_exporter "github.com/grafana/cloudcost-exporter"
12+
)
13+
14+
const subsystem = "azure_blob"
15+
16+
// metrics holds Prometheus collectors for blob cost rates. Vectors are not registered on the root registry;
17+
// Azure's top-level Collector gathers them via Collect → GaugeVec.Collect (same pattern as pkg/azure/aks).
18+
type metrics struct {
19+
StorageGauge *prometheus.GaugeVec
20+
}
21+
22+
func newMetrics() metrics {
23+
m := metrics{
24+
StorageGauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{
25+
Name: prometheus.BuildFQName(cloudcost_exporter.MetricPrefix, subsystem, "storage_by_location_usd_per_gibyte_hour"),
26+
Help: "Storage cost of blob objects by region and class. Cost represented in USD/(GiB*h). No samples until Cost Management is integrated.",
27+
},
28+
[]string{"region", "class"},
29+
),
30+
}
31+
32+
return m
33+
}
34+
35+
// Collector implements provider.Collector for Azure Blob Storage cost metrics.
36+
// Cost Management integration is not implemented yet; there are no labeled series until Collect calls Set on the vec.
37+
type Collector struct {
38+
logger *slog.Logger
39+
metrics metrics
40+
subscriptionID string
41+
scrapeInterval time.Duration
42+
}
43+
44+
// Config holds settings for the blob collector.
45+
type Config struct {
46+
Logger *slog.Logger
47+
SubscriptionId string
48+
ScrapeInterval time.Duration
49+
}
50+
51+
// New builds a blob collector. It does not call Azure APIs yet; subscription and interval are stored for Cost Management integration.
52+
func New(cfg *Config) (*Collector, error) {
53+
interval := cfg.ScrapeInterval
54+
if interval <= 0 {
55+
interval = time.Hour
56+
}
57+
return &Collector{
58+
logger: cfg.Logger.With("collector", "blob"),
59+
metrics: newMetrics(),
60+
subscriptionID: cfg.SubscriptionId,
61+
scrapeInterval: interval,
62+
}, nil
63+
}
64+
65+
// Collect satisfies provider.Collector. Does not call Set on cost vectors yet; still forwards the vec on ch for the parent gatherer.
66+
func (c *Collector) Collect(ctx context.Context, ch chan<- prometheus.Metric) error {
67+
c.logger.LogAttrs(ctx, slog.LevelInfo, "collecting metrics")
68+
c.metrics.StorageGauge.Collect(ch)
69+
return nil
70+
}
71+
72+
// Describe satisfies provider.Collector.
73+
func (c *Collector) Describe(ch chan<- *prometheus.Desc) error {
74+
c.metrics.StorageGauge.Describe(ch)
75+
return nil
76+
}
77+
78+
// Name returns the collector subsystem name for operational metrics.
79+
func (c *Collector) Name() string {
80+
return subsystem
81+
}
82+
83+
// Register satisfies provider.Collector. Does not register cost metrics on the registry (avoids duplicate Desc
84+
// with Azure's Describe fan-out; metrics are collected via Collect → StorageGauge.Collect).
85+
func (c *Collector) Register(_ provider.Registry) error {
86+
c.logger.LogAttrs(context.Background(), slog.LevelInfo, "registering collector")
87+
return nil
88+
}

pkg/azure/blob/blob_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package blob
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"os"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/prometheus/client_golang/prometheus"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
cloudcost_exporter "github.com/grafana/cloudcost-exporter"
16+
)
17+
18+
var testLogger = slog.New(slog.NewTextHandler(os.Stdout, nil))
19+
20+
func testCollectSink() chan prometheus.Metric {
21+
return make(chan prometheus.Metric, 8)
22+
}
23+
24+
func TestCollector_Describe(t *testing.T) {
25+
c, err := New(&Config{Logger: testLogger})
26+
require.NoError(t, err)
27+
ch := make(chan *prometheus.Desc, 4)
28+
require.NoError(t, c.Describe(ch))
29+
close(ch)
30+
var got []string
31+
for d := range ch {
32+
got = append(got, d.String())
33+
}
34+
require.Len(t, got, 1)
35+
joined := strings.Join(got, " ")
36+
prefix := cloudcost_exporter.MetricPrefix + "_azure_blob_"
37+
assert.Contains(t, joined, prefix+"storage_by_location_usd_per_gibyte_hour")
38+
}
39+
40+
func TestCollector_Register(t *testing.T) {
41+
c, err := New(&Config{Logger: testLogger})
42+
require.NoError(t, err)
43+
// Register does not call registry.MustRegister on cost metrics (AKS pattern).
44+
require.NoError(t, c.Register(nil))
45+
}
46+
47+
func TestCollector_Collect_forwardsStorageGauge(t *testing.T) {
48+
c, err := New(&Config{Logger: testLogger})
49+
require.NoError(t, err)
50+
ctx := context.Background()
51+
require.NoError(t, c.Collect(ctx, testCollectSink()))
52+
}
53+
54+
func TestNew_configPlumbing(t *testing.T) {
55+
const subUUID = "11111111-1111-1111-1111-111111111111"
56+
tests := map[string]struct {
57+
subscriptionID string
58+
scrapeInterval time.Duration
59+
wantInterval time.Duration
60+
}{
61+
"zero scrape interval defaults to one hour": {
62+
subscriptionID: "sub-1",
63+
scrapeInterval: 0,
64+
wantInterval: time.Hour,
65+
},
66+
"explicit subscription and interval": {
67+
subscriptionID: subUUID,
68+
scrapeInterval: 30 * time.Minute,
69+
wantInterval: 30 * time.Minute,
70+
},
71+
}
72+
for name, tt := range tests {
73+
t.Run(name, func(t *testing.T) {
74+
c, err := New(&Config{
75+
Logger: testLogger,
76+
SubscriptionId: tt.subscriptionID,
77+
ScrapeInterval: tt.scrapeInterval,
78+
})
79+
require.NoError(t, err)
80+
assert.Equal(t, tt.subscriptionID, c.subscriptionID)
81+
assert.Equal(t, tt.wantInterval, c.scrapeInterval)
82+
})
83+
}
84+
}

0 commit comments

Comments
 (0)