From d4758b1b9bf3c77464f5d76ea0ae47a6faca1e0f Mon Sep 17 00:00:00 2001 From: Rui Santos Date: Fri, 25 Sep 2026 19:58:16 +0100 Subject: [PATCH] Add a component label to attribute metrics registerComponentMetrics() labels every attribute with deviceId and componentId, but the map it iterates over is keyed by capability, not by component, so componentId holds the capability name and the real component id is never exported. A device with several components sharing a capability - a four button remote exposes button1..button4, each with the "button" capability - therefore produces identical series for every component. client_golang rejects the duplicates and the whole /metrics request fails with HTTP 500, so a single such device stops metrics for every device on the account. Pass the component id down and export it as a new "component" label. componentId keeps its current value so existing queries and dashboards continue to work. Note that the extra label changes the label set of every smartthings_attribute_* metric, so Prometheus starts a new series for each of them on upgrade. History is split across the old and new series: graphs spanning the upgrade show both, and range functions such as increase() over a window crossing it only see part of the data. Queries can use "without (component)" to aggregate the label away. --- collector.go | 10 ++++--- collector_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 collector_test.go diff --git a/collector.go b/collector.go index cbe2013..b90d1f1 100644 --- a/collector.go +++ b/collector.go @@ -39,7 +39,7 @@ func (collector *Collector) Collect(metrics chan<- prometheus.Metric) { for _, component := range device.Components { componentStatus, err := collector.client.GetDeviceComponentStatus(ctx, device.DeviceID, component.ID) if err == nil { - registerComponentMetrics(device.DeviceID, componentStatus, metrics) + registerComponentMetrics(device.DeviceID, component.ID, componentStatus, metrics) } else { log.Println("getDeviceComponentStatus deviceID:", device.DeviceID, "componentID:", component.ID, "failed, error:", err) } @@ -73,11 +73,13 @@ func registerDeviceMetrics(device *smartthings.Device, metrics chan<- prometheus } } -func registerComponentMetrics(deviceId string, componentStatus smartthings.ComponentStatus, metrics chan<- prometheus.Metric) { +// componentStatus is keyed by capability, which is exported as "componentId" +// for backwards compatibility; the real component goes in "component". +func registerComponentMetrics(deviceId, component string, componentStatus smartthings.ComponentStatus, metrics chan<- prometheus.Metric) { for componentId, attributes := range componentStatus { for attributeId, properties := range attributes { - labels := []string{"deviceId", "componentId"} - values := []string{deviceId, componentId} + labels := []string{"deviceId", "component", "componentId"} + values := []string{deviceId, component, componentId} var extras map[string]string metricValue := float64(0) diff --git a/collector_test.go b/collector_test.go new file mode 100644 index 0000000..c003a98 --- /dev/null +++ b/collector_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/setheck/smartthings-exporter/smartthings" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeClient struct { + devices []*smartthings.Device + status map[string]smartthings.ComponentStatus +} + +func (client *fakeClient) ListDevices(ctx context.Context) ([]*smartthings.Device, error) { + return client.devices, nil +} + +func (client *fakeClient) GetDeviceComponentStatus(ctx context.Context, deviceId, componentId string) (smartthings.ComponentStatus, error) { + return client.status[componentId], nil +} + +// A device with several components sharing a capability (e.g. a 4 button +// remote) must not produce duplicate series, or the whole scrape fails. +func TestCollectMultiComponentDevice(t *testing.T) { + buttonStatus := smartthings.ComponentStatus{ + "button": smartthings.ComponentAttributes{ + "button": smartthings.ComponentProperties{"value": "pushed"}, + "numberOfButtons": smartthings.ComponentProperties{"value": float64(1)}, + }, + } + client := &fakeClient{ + devices: []*smartthings.Device{{ + DeviceID: "dev1", + Components: []*smartthings.Component{ + {ID: "main"}, {ID: "button1"}, {ID: "button2"}, + }, + }}, + status: map[string]smartthings.ComponentStatus{ + "main": { + "battery": smartthings.ComponentAttributes{ + "battery": smartthings.ComponentProperties{"value": float64(87)}, + }, + }, + "button1": buttonStatus, + "button2": buttonStatus, + }, + } + + registry := prometheus.NewRegistry() + require.NoError(t, registry.Register(NewCollector(client))) + + families, err := registry.Gather() + require.NoError(t, err) + + components := map[string][]string{} + for _, family := range families { + for _, metric := range family.GetMetric() { + for _, label := range metric.GetLabel() { + if label.GetName() == "component" { + components[family.GetName()] = append(components[family.GetName()], label.GetValue()) + } + } + } + } + assert.ElementsMatch(t, []string{"button1", "button2"}, components["smartthings_attribute_button"]) + assert.ElementsMatch(t, []string{"button1", "button2"}, components["smartthings_attribute_numberOfButtons"]) + assert.ElementsMatch(t, []string{"main"}, components["smartthings_attribute_battery"]) +}