From 4d0d471ecbaa60e9786f698cd0d8438e6731ccd1 Mon Sep 17 00:00:00 2001 From: Norbert Hartl Date: Fri, 25 Sep 2026 10:57:05 +0200 Subject: [PATCH] Declare a runtime metric on its branch, and leave out a value that is not known Every server spelled out the same shape by hand: an accessor whose block builds a CanopyMetric, sets its type and description and reads its value. Base, account, scheduler, AGSoilMetrics and now the metabase each had their own copy. CanopyBranchNode now says it once - gauge:description:reading:, counter:description:reading: and gauge:label:description:reading: for a map - and a server says only its name, what it means and where the number comes from. A metric whose value is nil answers no reading. A number that is not known yet is a gap in the series, not the word nil on the scrape and not a zero that claims to know. Co-Authored-By: Claude Opus 5.5 --- .../CanopyDeclaredMetricTest.class.st | 74 +++++++++++++++++++ .../CanopyBranchNode.extension.st | 63 ++++++++++++++++ source/Canopy-Metrics/CanopyMetric.class.st | 7 +- .../Canopy-Metrics/CanopyMetricMap.class.st | 4 +- 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 source/Canopy-Metrics-Tests/CanopyDeclaredMetricTest.class.st create mode 100644 source/Canopy-Metrics/CanopyBranchNode.extension.st diff --git a/source/Canopy-Metrics-Tests/CanopyDeclaredMetricTest.class.st b/source/Canopy-Metrics-Tests/CanopyDeclaredMetricTest.class.st new file mode 100644 index 0000000..1e3cfe3 --- /dev/null +++ b/source/Canopy-Metrics-Tests/CanopyDeclaredMetricTest.class.st @@ -0,0 +1,74 @@ +" +The runtime metrics a server declares on a branch - #gauge:description:reading: and its +siblings - and what a scrape makes of them. +" +Class { + #name : 'CanopyDeclaredMetricTest', + #superclass : 'TestCase', + #instVars : [ + 'root', + 'branch' + ], + #category : 'Canopy-Metrics-Tests', + #package : 'Canopy-Metrics-Tests' +} + +{ #category : 'running' } +CanopyDeclaredMetricTest >> scrape [ + ^ CanopyPrometheusVisitor new format: root +] + +{ #category : 'running' } +CanopyDeclaredMetricTest >> setUp [ + super setUp. + root := CanopyBranchNode new. + branch := root /+ #server +] + +{ #category : 'tests' } +CanopyDeclaredMetricTest >> testACounterIsWrittenAsOne [ + branch counter: #requests description: 'requests served' reading: [ 3 ]. + self assert: (self scrape includesSubstring: '# TYPE server_requests counter'). + self assert: (self scrape includesSubstring: 'server_requests 3 ') +] + +{ #category : 'tests' } +CanopyDeclaredMetricTest >> testAGaugeIsReadAtEveryScrape [ + | count | + count := 1. + branch gauge: #open description: 'open databases' reading: [ count ]. + self assert: (self scrape includesSubstring: '# HELP server_open open databases'). + self assert: (self scrape includesSubstring: 'server_open 1 '). + count := 2. + self assert: (self scrape includesSubstring: 'server_open 2 ') +] + +{ #category : 'tests' } +CanopyDeclaredMetricTest >> testALabelledGaugeWritesALinePerKey [ + branch + gauge: #owing + label: #task + description: 'databases owing a task' + reading: [ { #backup -> 4. #repair -> 0 } asDictionary ]. + self assert: (self scrape includesSubstring: 'server_owing{task="backup"} 4 '). + self assert: (self scrape includesSubstring: 'server_owing{task="repair"} 0 ') +] + +{ #category : 'tests' } +CanopyDeclaredMetricTest >> testAValueThatIsNotKnownIsLeftOut [ + "a gap, not a line that says nil or a zero that says something is known" + branch gauge: #counted description: 'not counted yet' reading: [ nil ]. + branch gauge: #owing label: #task description: 'not counted yet' reading: [ nil ]. + branch gauge: #known description: 'known' reading: [ 7 ]. + self deny: (self scrape includesSubstring: 'server_counted'). + self deny: (self scrape includesSubstring: 'server_owing'). + self assert: (self scrape includesSubstring: 'server_known 7 ') +] + +{ #category : 'tests' } +CanopyDeclaredMetricTest >> testDeclaringAgainReplaces [ + branch gauge: #open description: 'open databases' reading: [ 1 ]. + branch gauge: #open description: 'open databases' reading: [ 2 ]. + self deny: (self scrape includesSubstring: 'server_open 1 '). + self assert: (self scrape includesSubstring: 'server_open 2 ') +] diff --git a/source/Canopy-Metrics/CanopyBranchNode.extension.st b/source/Canopy-Metrics/CanopyBranchNode.extension.st new file mode 100644 index 0000000..7e01814 --- /dev/null +++ b/source/Canopy-Metrics/CanopyBranchNode.extension.st @@ -0,0 +1,63 @@ +Extension { #name : 'CanopyBranchNode' } + +{ #category : '*Canopy-Metrics' } +CanopyBranchNode >> counter: aKey description: aString reading: aBlock [ + "A counter under aKey, read by evaluating aBlock at every scrape. See + #metric:at:reading:" + ^ self + metric: (CanopyMetric new + type: #counter; + description: aString; + yourself) + at: aKey + reading: aBlock +] + +{ #category : '*Canopy-Metrics' } +CanopyBranchNode >> gauge: aKey description: aString reading: aBlock [ + "A gauge under aKey, read by evaluating aBlock at every scrape. See + #metric:at:reading:" + ^ self + metric: (CanopyMetric new + type: #gauge; + description: aString; + yourself) + at: aKey + reading: aBlock +] + +{ #category : '*Canopy-Metrics' } +CanopyBranchNode >> gauge: aKey label: aSymbol description: aString reading: aBlock [ + "A gauge with one line per key of the dictionary aBlock answers, the key written under + the label aSymbol. See #metric:at:reading:" + ^ self + metric: (CanopyMetricMap new + type: #gauge; + description: aString; + labelName: aSymbol; + yourself) + at: aKey + reading: aBlock +] + +{ #category : '*Canopy-Metrics' } +CanopyBranchNode >> metric: aMetric at: aKey reading: aBlock [ + "The one shape every runtime metric of the servers had been spelled out in by hand: an + accessor whose block builds the metric and reads its value. Here it is said once, and a + server says only what is its own - the name, what it means, and where the number comes + from. + + The metric is copied for every read rather than filled in place: two scrapes at once would + otherwise write into the same holder. A block that answers nil leaves the leaf out of the + scrape - see CanopyMetric>>readingsDo: - and one that raises costs this leaf and not the + scrape, see CanopyPrometheusVisitor>>visitMetric:. + + Declaring the same key again replaces the leaf, so a server that is set up twice does not + have to know it was set up before." + ^ self + at: aKey + put: (CanopyAccessor reading: [ + aMetric copy + value: aBlock value; + yourself ]) +] diff --git a/source/Canopy-Metrics/CanopyMetric.class.st b/source/Canopy-Metrics/CanopyMetric.class.st index f91201a..c15e6dc 100644 --- a/source/Canopy-Metrics/CanopyMetric.class.st +++ b/source/Canopy-Metrics/CanopyMetric.class.st @@ -41,7 +41,12 @@ CanopyMetric >> labels [ { #category : 'enumerating' } CanopyMetric >> readingsDo: aBlock [ "One reading: the labels this metric knows itself, and its value. Whatever the tree adds is - merged in by the export, which is the only place that knows both sides." + merged in by the export, which is the only place that knows both sides. + + None when there is no value. A number that is not known yet - not counted since the image + started, not read because what it is read from is closed - is a gap in the series, and + writing it would put the word nil on the scrape, or a zero that says something is known." + self value ifNil: [ ^ self ]. aBlock value: labels value: self value ] diff --git a/source/Canopy-Metrics/CanopyMetricMap.class.st b/source/Canopy-Metrics/CanopyMetricMap.class.st index a227544..27c8e51 100644 --- a/source/Canopy-Metrics/CanopyMetricMap.class.st +++ b/source/Canopy-Metrics/CanopyMetricMap.class.st @@ -22,7 +22,9 @@ CanopyMetricMap >> labelName: aSymbol [ { #category : 'enumerating' } CanopyMetricMap >> readingsDo: aBlock [ - "One reading per key: the labels this metric carries, plus the key under the declared name." + "One reading per key: the labels this metric carries, plus the key under the declared name. + None at all when there is no map, for the reason CanopyMetric>>readingsDo: gives" + self value ifNil: [ ^ self ]. self value keysAndValuesDo: [ :key :each | aBlock value: (self labels copy