Fix: rrdtool DS names must be <= 19 chars

sanitizeName now keeps the first 15 chars plus a 3-hex hash suffix
for names longer than 19 chars (rrdtool's hard limit on DS name
length). The previous replace-dots-only version produced names up to
31 chars, which rrdtool rejected with 'invalid DS format'.
This commit is contained in:
2026-09-07 23:09:42 +03:00
parent e30a28c9ef
commit d3518e8ff2
2 changed files with 37 additions and 8 deletions
+11 -1
View File
@@ -15,11 +15,13 @@ module HomeAssistant.Runtime.Metrics
import Control.Concurrent (threadDelay)
import Control.Monad (forever, unless)
import Data.Char (ord)
import Data.Int (Int64)
import Data.List (intercalate, sortBy)
import Data.Ord (comparing)
import Data.Text (Text)
import Data.Void (Void)
import Numeric (showHex)
import qualified Data.Text as T
import qualified Data.HashMap.Strict as HM
import qualified System.Metrics as M (Value (..), Sample, Store, sampleAll)
@@ -41,8 +43,16 @@ dsTypeOf (M.Counter _) = Just Derive
dsTypeOf (M.Gauge _) = Just Gauge
dsTypeOf _ = Nothing
-- | Maps an ekg metric label to a valid rrd DS name (≤19 chars, [A-Za-z0-9_]).
-- Long names keep the first 15 chars plus a 3-hex hash suffix for uniqueness.
sanitizeName :: Text -> String
sanitizeName = T.unpack . T.replace "." "_"
sanitizeName name
| length sanitized <= 19 = sanitized
| otherwise = take 15 sanitized ++ "_" ++ paddedHash
where
sanitized = T.unpack (T.replace "." "_" name)
paddedHash = let h = showHex (sum (map ord sanitized) `mod` 4096) ""
in replicate (3 - length h) '0' ++ h
buildSchema :: M.Sample -> [DsSpec]
buildSchema sample =
+26 -7
View File
@@ -41,22 +41,41 @@ spec = do
dsTypeOf (M.Label "hello") `shouldBe` Nothing
describe "sanitizeName" $ do
it "replaces dots with underscores" $
sanitizeName ("rts.gc.bytes_allocated" :: Text) `shouldBe` "rts_gc_bytes_allocated"
it "replaces dots with underscores for short names" $
sanitizeName ("rts.gc.cpu_ms" :: Text) `shouldBe` "rts_gc_cpu_ms"
it "shortens names longer than 19 chars to 15 chars + _ + 3 hex" $ do
let result = sanitizeName ("rts.gc.par_balanced_bytes_copied" :: Text)
length result `shouldBe` 19
take 15 result `shouldBe` "rts_gc_par_bala"
drop 15 result `shouldBe` "_" ++ drop 16 result
it "is deterministic (same input -> same output)" $
sanitizeName ("rts.gc.peak_megabytes_allocated" :: Text)
`shouldBe` sanitizeName ("rts.gc.peak_megabytes_allocated" :: Text)
describe "buildSchema" $ do
it "builds sorted DsSpecs from counters and gauges, skipping labels" $
let sample :: HashMap Text M.Value
sample = HM.fromList
[ ("rts.gc.bytes_allocated", M.Counter 1000)
, ("rts.gc.max_bytes_used", M.Gauge 500)
, ("rts.gc.label_thing", M.Label "irrelevant")
[ ("x.allocated", M.Counter 1000)
, ("a.bytes_used", M.Gauge 500)
, ("c.label_thing", M.Label "irrelevant")
]
in buildSchema sample `shouldBe`
[ DsSpec "rts.gc.bytes_allocated" "rts_gc_bytes_allocated" Derive
, DsSpec "rts.gc.max_bytes_used" "rts_gc_max_bytes_used" Gauge
[ DsSpec "a.bytes_used" "a_bytes_used" Gauge
, DsSpec "x.allocated" "x_allocated" Derive
]
it "produces dsName <= 19 chars for long ekg GC metric names" $
let sample :: HashMap Text M.Value
sample = HM.fromList
[ ("rts.gc.par_balanced_bytes_copied", M.Gauge 1)
, ("rts.gc.peak_megabytes_allocated", M.Gauge 2)
, ("rts.gc.cumulative_bytes_used", M.Counter 3)
]
in map (length . dsName) (buildSchema sample) `shouldSatisfy` all (<= 19)
describe "buildCreateArgs" $ do
it "builds create argv with mixed DERIVE and GAUGE DSes and RRAs" $
buildCreateArgs "test.rrd" 10