From d3518e8ff242679393678dafb18c517cbd5f7a6e Mon Sep 17 00:00:00 2001 From: Mats Rauhala Date: Mon, 7 Sep 2026 23:09:42 +0300 Subject: [PATCH] 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'. --- src/HomeAssistant/Runtime/Metrics.hs | 12 +++++++++- test/MetricsSpec.hs | 33 ++++++++++++++++++++++------ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/HomeAssistant/Runtime/Metrics.hs b/src/HomeAssistant/Runtime/Metrics.hs index c5d411e..0e07597 100644 --- a/src/HomeAssistant/Runtime/Metrics.hs +++ b/src/HomeAssistant/Runtime/Metrics.hs @@ -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 = diff --git a/test/MetricsSpec.hs b/test/MetricsSpec.hs index 91cb934..2b3c6b2 100644 --- a/test/MetricsSpec.hs +++ b/test/MetricsSpec.hs @@ -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