Skip to content

Add a component label so multi-component devices don't break /metrics - #8

Open
RuiSantos76 wants to merge 1 commit into
Setheck:mainfrom
RuiSantos76:fix-multi-component-duplicates
Open

RuiSantos76 wants to merge 1 commit into
Setheck:mainfrom
RuiSantos76:fix-multi-component-duplicates

Conversation

@RuiSantos76

Copy link
Copy Markdown

Problem

Adding a four button remote (a Zigbee "zigbee-thing" with components main, button1..button4) to a SmartThings account made the exporter return HTTP 500 on every scrape, so no metrics were exported for any device on the account:

An error has occurred while serving metrics:

9 error(s) occurred:
* collected metric "smartthings_attribute_button" { label:{name:"componentId" value:"button"} label:{name:"deviceId" value:"42e878bc-..."} label:{name:"value" value:"pushed"} gauge:{value:0}} was collected before with the same name and label values
* collected metric "smartthings_attribute_numberOfButtons" { ... } was collected before with the same name and label values
...

GetDeviceComponentStatus() returns a map keyed by capability, but registerComponentMetrics() exports that key as componentId, and the actual component id is never exported. Every component that shares a capability with another component of the same device therefore produces identical series. client_golang rejects the duplicates and fails the whole request, so one such device is enough to stop metrics for every device.

Change

  • Pass the component id into registerComponentMetrics() and export it as a new component label.
  • componentId keeps its current value (the capability), so existing queries and dashboards that filter on it, e.g. componentId="tamperAlert", keep working. Single-component devices simply gain component="main".
  • New collector_test.go builds a device with two components sharing the button capability and gathers through a registry. It fails on main with the error above and passes with the change.

Example output after the change:

smartthings_attribute_button{component="button1",componentId="button",deviceId="42e878bc-...",value="pushed"} 0
smartthings_attribute_button{component="button2",componentId="button",deviceId="42e878bc-...",value="pushed"} 0
smartthings_attribute_temperature{component="main",componentId="temperatureMeasurement",deviceId="0a8f2477-...",unit="C"} 24

Important

Every smartthings_attribute_* metric becomes a new series in Prometheus. A series is identified by its full label set, so adding component gives every existing attribute a new identity, even on devices with only a main component. After upgrading:

  • The old series (without component) is marked stale at the first scrape after the upgrade and the new one starts there. History is not lost, but it is split across two series.
  • Graphs that span the upgrade show two lines per attribute, one ending and one starting at the upgrade, with the same legend if the legend doesn't use component.
  • rate(), increase(), delta() and *_over_time() over a window that crosses the upgrade are computed separately for each series, so each result covers only part of the window. For example, increase(smartthings_attribute_energy[1d]) under-reports for the first day.
  • Alerts or recording rules that match exact label sets need updating. For example, one-to-one vector matching against another metric without on(...)/ignoring(...) now also has to match component. Rules built on range vectors (absent_over_time(), changes(), count_over_time()) see two partial series until the window has moved past the upgrade.

Queries that should ignore the change can aggregate the label away, e.g. max without (component) (smartthings_attribute_temperature). Series count stays the same for single-component devices (one old series is replaced by one new series). The only new series come from multi-component devices, which could not be scraped at all before.

If you would rather avoid the churn, an alternative is to add component only when it isn't main. Existing single-component series would then keep their identity, at the cost of an inconsistent label set within a metric. I'm happy to switch to that if you prefer.

Tested with

go test ./... and go vet ./.... The change has also been running against a real account with 31 devices since 2026-09-24: Samsung Room A/C units, Aeotec Smart Switch 7 and MultiSensor 7, Zigbee temperature/humidity sensors, the four button remote, SmartTags, a TV, a dishwasher and a Sonos speaker. Scrapes return HTTP 200 with 865 series and no errors, and existing Grafana dashboards work unchanged.

Diagnosis and patch were developed with the help of an AI assistant (Claude Code). The results above are from the real deployment.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant