-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add heat metrics #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package heat | ||
|
|
||
| import ( | ||
| "log/slog" | ||
|
|
||
| "github.com/prometheus/client_golang/prometheus" | ||
| "github.com/vexxhost/openstack_database_exporter/internal/db" | ||
| ) | ||
|
|
||
| const ( | ||
| Namespace = "openstack" | ||
| Subsystem = "orchestration" | ||
| ) | ||
|
|
||
| func RegisterCollectors(registry *prometheus.Registry, databaseURL string, logger *slog.Logger) { | ||
| if databaseURL == "" { | ||
| logger.Info("Collector not loaded", "service", "heat", "reason", "database URL not configured") | ||
| return | ||
| } | ||
|
|
||
| conn, err := db.Connect(databaseURL) | ||
| if err != nil { | ||
| logger.Error("Failed to connect to database", "service", "heat", "error", err) | ||
| return | ||
| } | ||
|
|
||
| registry.MustRegister(NewOrchestrationCollector(conn, logger)) | ||
|
|
||
| logger.Info("Registered collectors", "service", "heat") | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package heat | ||
|
|
||
| import ( | ||
| "database/sql" | ||
| "log/slog" | ||
|
|
||
| "github.com/prometheus/client_golang/prometheus" | ||
| ) | ||
|
|
||
| var ( | ||
| orchestrationUpDesc = prometheus.NewDesc( | ||
| prometheus.BuildFQName(Namespace, Subsystem, "up"), | ||
| "up", | ||
| nil, | ||
| nil, | ||
| ) | ||
| ) | ||
|
|
||
| type OrchestrationCollector struct { | ||
| db *sql.DB | ||
| logger *slog.Logger | ||
| stacksCollector *StacksCollector | ||
| } | ||
|
|
||
| func NewOrchestrationCollector(db *sql.DB, logger *slog.Logger) *OrchestrationCollector { | ||
| return &OrchestrationCollector{ | ||
| db: db, | ||
| logger: logger, | ||
| stacksCollector: NewStacksCollector(db, logger), | ||
| } | ||
| } | ||
|
|
||
| func (c *OrchestrationCollector) Describe(ch chan<- *prometheus.Desc) { | ||
| ch <- orchestrationUpDesc | ||
| c.stacksCollector.Describe(ch) | ||
| } | ||
|
|
||
| func (c *OrchestrationCollector) Collect(ch chan<- prometheus.Metric) { | ||
| var hasError bool | ||
|
|
||
| // Collect metrics from stacks collector and track errors | ||
| if err := c.stacksCollector.Collect(ch); err != nil { | ||
| c.logger.Error("stacks collector failed", "error", err) | ||
| hasError = true | ||
| } | ||
|
|
||
| // Emit single up metric based on overall success | ||
| upValue := float64(1) | ||
| if hasError { | ||
| upValue = float64(0) | ||
| } | ||
| ch <- prometheus.MustNewConstMetric(orchestrationUpDesc, prometheus.GaugeValue, upValue) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| package heat | ||
|
|
||
| import ( | ||
| "database/sql" | ||
| "log/slog" | ||
| "regexp" | ||
| "testing" | ||
|
|
||
| "github.com/DATA-DOG/go-sqlmock" | ||
| heatdb "github.com/vexxhost/openstack_database_exporter/internal/db/heat" | ||
| "github.com/vexxhost/openstack_database_exporter/internal/testutil" | ||
| ) | ||
|
|
||
| func TestOrchestrationCollector(t *testing.T) { | ||
| tests := []testutil.CollectorTestCase{ | ||
| { | ||
| Name: "successful collection with stacks working", | ||
| SetupMock: func(mock sqlmock.Sqlmock) { | ||
| rows := sqlmock.NewRows([]string{ | ||
| "id", "name", "status", "action", "tenant", "created_at", "updated_at", "deleted_at", "nested_depth", "disable_rollback", | ||
| }).AddRow( | ||
| "stack-1", "test-stack", "CREATE_COMPLETE", "CREATE", "project-1", nil, nil, nil, 0, false, | ||
| ) | ||
|
|
||
| mock.ExpectQuery(regexp.QuoteMeta(heatdb.GetStackMetrics)).WillReturnRows(rows) | ||
| }, | ||
| ExpectedMetrics: `# HELP openstack_orchestration_stack_status stack_status | ||
| # TYPE openstack_orchestration_stack_status gauge | ||
| openstack_orchestration_stack_status{action="CREATE",id="stack-1",name="test-stack",project_id="project-1",status="CREATE_COMPLETE"} 5 | ||
| # HELP openstack_orchestration_stack_status_counter stack_status_counter | ||
| # TYPE openstack_orchestration_stack_status_counter gauge | ||
| openstack_orchestration_stack_status_counter{status="ADOPT_COMPLETE"} 0 | ||
| openstack_orchestration_stack_status_counter{status="ADOPT_FAILED"} 0 | ||
| openstack_orchestration_stack_status_counter{status="ADOPT_IN_PROGRESS"} 0 | ||
| openstack_orchestration_stack_status_counter{status="CHECK_COMPLETE"} 0 | ||
| openstack_orchestration_stack_status_counter{status="CHECK_FAILED"} 0 | ||
| openstack_orchestration_stack_status_counter{status="CHECK_IN_PROGRESS"} 0 | ||
| openstack_orchestration_stack_status_counter{status="CREATE_COMPLETE"} 1 | ||
| openstack_orchestration_stack_status_counter{status="CREATE_FAILED"} 0 | ||
| openstack_orchestration_stack_status_counter{status="CREATE_IN_PROGRESS"} 0 | ||
| openstack_orchestration_stack_status_counter{status="DELETE_COMPLETE"} 0 | ||
| openstack_orchestration_stack_status_counter{status="DELETE_FAILED"} 0 | ||
| openstack_orchestration_stack_status_counter{status="DELETE_IN_PROGRESS"} 0 | ||
| openstack_orchestration_stack_status_counter{status="INIT_COMPLETE"} 0 | ||
| openstack_orchestration_stack_status_counter{status="INIT_FAILED"} 0 | ||
| openstack_orchestration_stack_status_counter{status="INIT_IN_PROGRESS"} 0 | ||
| openstack_orchestration_stack_status_counter{status="RESUME_COMPLETE"} 0 | ||
| openstack_orchestration_stack_status_counter{status="RESUME_FAILED"} 0 | ||
| openstack_orchestration_stack_status_counter{status="RESUME_IN_PROGRESS"} 0 | ||
| openstack_orchestration_stack_status_counter{status="ROLLBACK_COMPLETE"} 0 | ||
| openstack_orchestration_stack_status_counter{status="ROLLBACK_FAILED"} 0 | ||
| openstack_orchestration_stack_status_counter{status="ROLLBACK_IN_PROGRESS"} 0 | ||
| openstack_orchestration_stack_status_counter{status="SNAPSHOT_COMPLETE"} 0 | ||
| openstack_orchestration_stack_status_counter{status="SNAPSHOT_FAILED"} 0 | ||
| openstack_orchestration_stack_status_counter{status="SNAPSHOT_IN_PROGRESS"} 0 | ||
| openstack_orchestration_stack_status_counter{status="SUSPEND_COMPLETE"} 0 | ||
| openstack_orchestration_stack_status_counter{status="SUSPEND_FAILED"} 0 | ||
| openstack_orchestration_stack_status_counter{status="SUSPEND_IN_PROGRESS"} 0 | ||
| openstack_orchestration_stack_status_counter{status="UPDATE_COMPLETE"} 0 | ||
| openstack_orchestration_stack_status_counter{status="UPDATE_FAILED"} 0 | ||
| openstack_orchestration_stack_status_counter{status="UPDATE_IN_PROGRESS"} 0 | ||
| # HELP openstack_orchestration_total_stacks total_stacks | ||
| # TYPE openstack_orchestration_total_stacks gauge | ||
| openstack_orchestration_total_stacks 1 | ||
| # HELP openstack_orchestration_up up | ||
| # TYPE openstack_orchestration_up gauge | ||
| openstack_orchestration_up 1 | ||
| `, | ||
| }, | ||
| { | ||
| Name: "stacks collector fails, up metric should be 0", | ||
| SetupMock: func(mock sqlmock.Sqlmock) { | ||
| mock.ExpectQuery(regexp.QuoteMeta(heatdb.GetStackMetrics)).WillReturnError(sql.ErrConnDone) | ||
| }, | ||
| ExpectedMetrics: `# HELP openstack_orchestration_up up | ||
| # TYPE openstack_orchestration_up gauge | ||
| openstack_orchestration_up 0 | ||
| `, | ||
| }, | ||
| } | ||
|
|
||
| testutil.RunCollectorTests(t, tests, func(db *sql.DB, logger *slog.Logger) *OrchestrationCollector { | ||
| return NewOrchestrationCollector(db, logger) | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,162 @@ | ||||||||||||||
| package heat | ||||||||||||||
|
|
||||||||||||||
| import ( | ||||||||||||||
| "context" | ||||||||||||||
| "database/sql" | ||||||||||||||
| "log/slog" | ||||||||||||||
|
|
||||||||||||||
| "github.com/prometheus/client_golang/prometheus" | ||||||||||||||
| heatdb "github.com/vexxhost/openstack_database_exporter/internal/db/heat" | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| var ( | ||||||||||||||
| // Known stack statuses from the original openstack-exporter | ||||||||||||||
| knownStackStatuses = []string{ | ||||||||||||||
| "INIT_IN_PROGRESS", | ||||||||||||||
| "INIT_FAILED", | ||||||||||||||
| "INIT_COMPLETE", | ||||||||||||||
| "CREATE_IN_PROGRESS", | ||||||||||||||
| "CREATE_FAILED", | ||||||||||||||
| "CREATE_COMPLETE", | ||||||||||||||
| "DELETE_IN_PROGRESS", | ||||||||||||||
| "DELETE_FAILED", | ||||||||||||||
| "DELETE_COMPLETE", | ||||||||||||||
| "UPDATE_IN_PROGRESS", | ||||||||||||||
| "UPDATE_FAILED", | ||||||||||||||
| "UPDATE_COMPLETE", | ||||||||||||||
| "ROLLBACK_IN_PROGRESS", | ||||||||||||||
| "ROLLBACK_FAILED", | ||||||||||||||
| "ROLLBACK_COMPLETE", | ||||||||||||||
| "SUSPEND_IN_PROGRESS", | ||||||||||||||
| "SUSPEND_FAILED", | ||||||||||||||
| "SUSPEND_COMPLETE", | ||||||||||||||
| "RESUME_IN_PROGRESS", | ||||||||||||||
| "RESUME_FAILED", | ||||||||||||||
| "RESUME_COMPLETE", | ||||||||||||||
| "ADOPT_IN_PROGRESS", | ||||||||||||||
| "ADOPT_FAILED", | ||||||||||||||
| "ADOPT_COMPLETE", | ||||||||||||||
| "SNAPSHOT_IN_PROGRESS", | ||||||||||||||
| "SNAPSHOT_FAILED", | ||||||||||||||
| "SNAPSHOT_COMPLETE", | ||||||||||||||
| "CHECK_IN_PROGRESS", | ||||||||||||||
| "CHECK_FAILED", | ||||||||||||||
| "CHECK_COMPLETE", | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| stackStatusDesc = prometheus.NewDesc( | ||||||||||||||
| prometheus.BuildFQName(Namespace, Subsystem, "stack_status"), | ||||||||||||||
| "stack_status", | ||||||||||||||
| []string{ | ||||||||||||||
| "id", | ||||||||||||||
| "name", | ||||||||||||||
| "project_id", | ||||||||||||||
| "status", | ||||||||||||||
| "action", | ||||||||||||||
| }, | ||||||||||||||
| nil, | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| stackStatusCounterDesc = prometheus.NewDesc( | ||||||||||||||
| prometheus.BuildFQName(Namespace, Subsystem, "stack_status_counter"), | ||||||||||||||
| "stack_status_counter", | ||||||||||||||
| []string{ | ||||||||||||||
| "status", | ||||||||||||||
| }, | ||||||||||||||
| nil, | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| totalStacksDesc = prometheus.NewDesc( | ||||||||||||||
| prometheus.BuildFQName(Namespace, Subsystem, "total_stacks"), | ||||||||||||||
| "total_stacks", | ||||||||||||||
| nil, | ||||||||||||||
| nil, | ||||||||||||||
| ) | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| type StacksCollector struct { | ||||||||||||||
| db *sql.DB | ||||||||||||||
| queries *heatdb.Queries | ||||||||||||||
| logger *slog.Logger | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| func NewStacksCollector(db *sql.DB, logger *slog.Logger) *StacksCollector { | ||||||||||||||
| return &StacksCollector{ | ||||||||||||||
| db: db, | ||||||||||||||
| queries: heatdb.New(db), | ||||||||||||||
| logger: logger.With( | ||||||||||||||
| "namespace", Namespace, | ||||||||||||||
| "subsystem", Subsystem, | ||||||||||||||
| "collector", "stacks", | ||||||||||||||
| ), | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| func (c *StacksCollector) Describe(ch chan<- *prometheus.Desc) { | ||||||||||||||
| ch <- stackStatusDesc | ||||||||||||||
| ch <- stackStatusCounterDesc | ||||||||||||||
| ch <- totalStacksDesc | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| func (c *StacksCollector) Collect(ch chan<- prometheus.Metric) error { | ||||||||||||||
| ctx := context.Background() | ||||||||||||||
|
|
||||||||||||||
| stacks, err := c.queries.GetStackMetrics(ctx) | ||||||||||||||
| if err != nil { | ||||||||||||||
| c.logger.Error("failed to query stacks", "error", err) | ||||||||||||||
| return err | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Initialize status counters | ||||||||||||||
| stackStatusCounter := make(map[string]int, len(knownStackStatuses)) | ||||||||||||||
| for _, status := range knownStackStatuses { | ||||||||||||||
| stackStatusCounter[status] = 0 | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+110
to
+114
|
||||||||||||||
|
|
||||||||||||||
| // total_stacks count | ||||||||||||||
| ch <- prometheus.MustNewConstMetric( | ||||||||||||||
| totalStacksDesc, | ||||||||||||||
| prometheus.GaugeValue, | ||||||||||||||
| float64(len(stacks)), | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| // Individual stack status metrics and count status occurrences | ||||||||||||||
| for _, stack := range stacks { | ||||||||||||||
| // Count status occurrences | ||||||||||||||
| stackStatusCounter[stack.Status]++ | ||||||||||||||
|
Comment on lines
+125
to
+126
|
||||||||||||||
| // Count status occurrences | |
| stackStatusCounter[stack.Status]++ | |
| // Count status occurrences only for known statuses | |
| if _, ok := stackStatusCounter[stack.Status]; ok { | |
| stackStatusCounter[stack.Status]++ | |
| } |
Copilot
AI
Feb 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Emitting stack_status_counter by iterating over a map produces non-deterministic metric output order, which can make scrapes and unit tests flaky. Prefer emitting counters in a stable order (e.g., iterate over knownStackStatuses and read stackStatusCounter[status]).
Copilot
AI
Feb 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mapStackStatusValue performs a linear scan of knownStackStatuses for every stack. For large numbers of stacks this becomes unnecessary overhead; consider building a map[string]int (status -> value) once (e.g., in init() or as a package-level var) and doing O(1) lookups.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using
context.Background()means the DB query cannot be cancelled and has no upper bound on runtime during a scrape. Consider usingcontext.WithTimeout(with a reasonable scrape-aligned timeout) so a slow/bad DB doesn’t hang collection.