Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions source/Canopy-Core-Tests/CanopyAccessorTest.class.st
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,14 @@ CanopyAccessorTest >> testTwoAccessorsThatDeclareNothingStillConflict [
one case the repeat-tolerance above must not swallow."
self should: [ CanopyAccessor new merge: CanopyAccessor new ] raise: Error
]

{ #category : 'as yet unclassified' }
CanopyAccessorTest >> testEveryBranchMethodContributes [
"Two methods carrying <canopyBranch> and both of them build. Until 2026-09-21 the builder
ran the marked method only when there was exactly one, so a second one did not add a
branch - it removed the first, silently and without an else."
| tree |
tree := CanopyTwoBranchExample new canopyMapping.
self assert: (tree / #first) value equals: 1.
self assert: (tree / #second) value equals: 2
]
19 changes: 19 additions & 0 deletions source/Canopy-Core-Tests/CanopyRaisingLeafExample.class.st
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"
A component whose reader raises, which is what a live one does on a bad day -
the servers read their values out of blocks, and AGSoilMetrics reduces over every
open database while databases open and close underneath it.

Here to hold the exporter to its promise that such a leaf costs that leaf and not
the whole scrape.
"
Class {
#name : 'CanopyRaisingLeafExample',
#superclass : 'Object',
#category : 'Canopy-Core-Tests',
#package : 'Canopy-Core-Tests'
}

{ #category : 'accessing' }
CanopyRaisingLeafExample >> brokenReading [
^ self error: 'this component cannot answer today'
]
26 changes: 26 additions & 0 deletions source/Canopy-Core-Tests/CanopyTwoBranchExample.class.st
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"
An object that marks two methods with <canopyBranch>, which until 2026-09-21 was
a way to end up with no composed tree at all: the builder ran the marked method
only when there was exactly one of them, and said nothing otherwise.

Two rather than one is not exotic. It is what a subclass gets the moment it adds
a branch of its own to the one it inherits.
"
Class {
#name : 'CanopyTwoBranchExample',
#superclass : 'Object',
#category : 'Canopy-Core-Tests',
#package : 'Canopy-Core-Tests'
}

{ #category : 'building' }
CanopyTwoBranchExample >> canopyBranch: aBuilder [
<canopyBranch>
aBuilder map at: #first put: (CanopyCell new value: 1)
]

{ #category : 'building' }
CanopyTwoBranchExample >> canopySecondBranch: aBuilder [
<canopyBranch>
aBuilder map at: #second put: (CanopyCell new value: 2)
]
26 changes: 17 additions & 9 deletions source/Canopy-Core/CanopyMappingBuilder.class.st
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,23 @@ CanopyMappingBuilder >> build [

{ #category : 'as yet unclassified' }
CanopyMappingBuilder >> buildComplexProperties [
| pragmas |
pragmas := Pragma
allNamed: #canopyBranch
from: object class
to: Object.
(pragmas size = 1) ifTrue: [
object
perform: pragmas first selector asMutator
with: self ]
"Every method that carries the marker, each sent once, in the order the pragmas come in.

This used to run only when there was exactly one and do nothing at all otherwise, with no
else and no complaint: a subclass that added a branch method of its own lost the one it
inherited as well, and the whole composed half of the tree simply was not there.

Sent by the method's own selector rather than by #canopyBranch: derived from the pragma
name. They are the same for every declaration there is today, but only one of the two is
an answer to the question 'which method did I mark'. An override is in the list twice,
the subclass's and the superclass's, and both name that same selector - so taking distinct
selectors runs the override once and never the method it replaces."
| selectors |
selectors := OrderedCollection new.
(Pragma allNamed: #canopyBranch from: object class to: Object) do: [ :each |
(selectors includes: each methodSelector) ifFalse: [
selectors add: each methodSelector ] ].
selectors do: [ :each | object perform: each with: self ]
]

{ #category : 'as yet unclassified' }
Expand Down
25 changes: 25 additions & 0 deletions source/Canopy-Metrics-Tests/CanopyMetricsVisitorTest.class.st
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,28 @@ CanopyMetricsVisitorTest >> testExportWithoutPrefixWritesNameAsIs [
self assert: (output includesSubstring: 'apptiveGrid_memorySize').
self deny: (output includesSubstring: 'pharo')
]

{ #category : 'as yet unclassified' }
CanopyMetricsVisitorTest >> testALeafThatRaisesCostsThatLeafAndNotTheScrape [
"Reading a leaf is a live call into a component, and one that fails used to end the export
where it stood: the error left #format: altogether, so the answer was not a short scrape but
no scrape - which looks like a server that stopped reporting rather than like one value that
could not be read.

Which of the two the tree hands the visitor first does not matter, and the test does not
depend on it: the old code raised out of #format: whichever way round they came."
| tree output |
tree := CanopyBranchNode new.
tree at: #brokenReading put: (CanopyAccessor new
object: CanopyRaisingLeafExample new;
readSelector: #brokenReading;
yourself).
tree at: #workingReading put: (CanopyAccessor reading: [
CanopyMetric new
type: #gauge;
description: 'a reading that answers';
value: 42 ]).
output := CanopyPrometheusVisitor new format: tree.
self deny: (output includesSubstring: 'brokenReading').
self assert: (output includesSubstring: 'workingReading 42 ')
]
16 changes: 14 additions & 2 deletions source/Canopy-Metrics/CanopyPrometheusVisitor.class.st
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,21 @@ CanopyPrometheusVisitor >> visitCell: aCanopyCell [
CanopyPrometheusVisitor >> visitMetric: aNode [
"Ask the value what it contributes and write one line per reading. A value that is not a
metric answers nothing, so nothing is written - not even the HELP and TYPE header. That is
why a config value in an exported tree now costs no output instead of ending the scrape."
why a config value in an exported tree now costs no output instead of ending the scrape.

And a leaf that raises while it is read costs that leaf rather than the scrape. Reading is
a live call into a component - the servers' values are blocks, AGSoilMetrics reduces over
every open database - and one of them failing used to end the export where it stood, so
everything after it in the tree silently left the scrape as well. Nothing is written for it
now, which reads as a gap in one series instead of a flat line in all of them.

Asking is what evaluates, so the guard sits on the ask rather than around the writing: once
the holder is in hand the readings are too, and the stream cannot be left carrying a header
whose lines never came."
| holder header |
holder := aNode valueHolder.
holder := [ aNode valueHolder ]
on: Error
do: [ :error | ^ self ].
header := false.
holder readingsDo: [ :labels :value |
header ifFalse: [
Expand Down
Loading