Skip to content

Commit 6736631

Browse files
ottermataJenkins
authored andcommitted
graphing: build a combined graph's expression per matched object
A combined graph substituted its cross-service fan-out at each metric name, so a template drawing an arithmetic operation handed the operation two fan-outs as operands - which the engine refuses. Build what a graph draws once per matched object and combine the results instead, the way the legacy combined path resolves a template per object. An expression is now parsed against the one object it is built for, so a nested threshold resolves against that object rather than the first matched one, and a rule is no longer built where no object names it. CMK-37904 Change-Id: If7801985660de86232fe12facc34606f918255df JIRA-Ref: CMK-37904
1 parent e80070b commit 6736631

3 files changed

Lines changed: 155 additions & 50 deletions

File tree

packages/cmk-graphing-engine/cmk/graphing_engine/_from_api.py

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@
3737

3838

3939
class QuantityBuilderProtocol(Protocol):
40-
def __call__(self, metrics: Sequence[RRDMetric]) -> QuantityProtocol: ...
40+
"""Combines what a graph draws for each matched object into the one quantity drawing them."""
4141

42+
def __call__(self, per_object: Sequence[QuantityProtocol]) -> QuantityProtocol: ...
4243

43-
def build_single_quantity(metrics: Sequence[RRDMetric]) -> QuantityProtocol:
44-
(metric,) = metrics
45-
return metric
44+
45+
def build_single_quantity(per_object: Sequence[QuantityProtocol]) -> QuantityProtocol:
46+
(quantity,) = per_object
47+
return quantity
4648

4749

4850
def drawn_quantity(
@@ -53,21 +55,49 @@ def drawn_quantity(
5355
return quantity_builder([rrd_metric_of(service, metric_name) for service in services])
5456

5557

58+
@dataclass(frozen=True)
59+
class _ObjectContext:
60+
"""One of the objects a graph was matched on, to build what it draws for that object."""
61+
62+
service: Service
63+
localizer: Callable[[str], str]
64+
registered_metrics: Mapping[str, metrics_v1.Metric]
65+
66+
def metric(self, metric_name: str) -> RRDMetric:
67+
return rrd_metric_of(self.service, metric_name)
68+
69+
5670
@dataclass(frozen=True)
5771
class _ParseContext:
5872
services: Sequence[Service]
5973
quantity_builder: QuantityBuilderProtocol
6074
localizer: Callable[[str], str]
6175
registered_metrics: Mapping[str, metrics_v1.Metric]
6276

63-
def drawn(self, metric_name: str) -> QuantityProtocol:
64-
return drawn_quantity(metric_name, self.services, self.quantity_builder)
65-
66-
def scalar(self, metric_name: str) -> RRDMetric:
67-
return rrd_metric_of(self.services[0], metric_name)
77+
def _of(self, service: Service) -> _ObjectContext:
78+
return _ObjectContext(service, self.localizer, self.registered_metrics)
79+
80+
def single_object(self) -> _ObjectContext | None:
81+
# A rule names one object's thresholds; over several matched ones there is none to name them
82+
# against, so no rule is built.
83+
return self._of(self.services[0]) if len(self.services) == 1 else None
84+
85+
def object_for(self, bound: ApiQuantity) -> _ObjectContext | None:
86+
# A bound naming a metric is read from one object; over several matched ones there is none, so
87+
# that end is left to auto-scale. A bound of constants alone is read without one.
88+
if any(metric_names_in_quantity(bound)):
89+
return self.single_object()
90+
return self._of(self.services[0])
91+
92+
def drawn(self, quantity: ApiQuantity) -> QuantityProtocol:
93+
# Built once per matched object and combined, so an operation only ever takes that object's
94+
# operands - never a quantity spanning the objects, which cannot be an operand.
95+
return self.quantity_builder(
96+
[_parse_quantity(quantity, self._of(service)) for service in self.services]
97+
)
6898

6999

70-
def _curve_display(quantity: ApiQuantity, context: _ParseContext) -> CurveAttributes:
100+
def _curve_display(quantity: ApiQuantity, context: _ObjectContext) -> CurveAttributes:
71101
match quantity:
72102
case str():
73103
return metric_display_attributes(
@@ -109,39 +139,39 @@ def _curve_display(quantity: ApiQuantity, context: _ParseContext) -> CurveAttrib
109139
assert_never(quantity)
110140

111141

112-
def _parse_quantity(quantity: ApiQuantity, context: _ParseContext) -> QuantityProtocol:
142+
def _parse_quantity(quantity: ApiQuantity, context: _ObjectContext) -> QuantityProtocol:
113143
match quantity:
114144
case str():
115-
return context.drawn(quantity)
145+
return context.metric(quantity)
116146
case metrics_v1.Constant():
117147
return Constant(quantity.value, display=_curve_display(quantity, context))
118148
case metrics_v2_unstable.LowerWarningOf():
119149
return ScalarOf(
120-
metric=context.scalar(quantity.metric_name),
150+
metric=context.metric(quantity.metric_name),
121151
scalar_kind=ScalarKind.LOWER_WARNING,
122152
)
123153
case metrics_v2_unstable.LowerCriticalOf():
124154
return ScalarOf(
125-
metric=context.scalar(quantity.metric_name),
155+
metric=context.metric(quantity.metric_name),
126156
scalar_kind=ScalarKind.LOWER_CRITICAL,
127157
)
128158
case metrics_v1.WarningOf():
129159
return ScalarOf(
130-
metric=context.scalar(quantity.metric_name), scalar_kind=ScalarKind.WARNING
160+
metric=context.metric(quantity.metric_name), scalar_kind=ScalarKind.WARNING
131161
)
132162
case metrics_v1.CriticalOf():
133163
return ScalarOf(
134-
metric=context.scalar(quantity.metric_name), scalar_kind=ScalarKind.CRITICAL
164+
metric=context.metric(quantity.metric_name), scalar_kind=ScalarKind.CRITICAL
135165
)
136166
case metrics_v1.MinimumOf():
137167
return ScalarOf(
138-
metric=context.scalar(quantity.metric_name),
168+
metric=context.metric(quantity.metric_name),
139169
scalar_kind=ScalarKind.MINIMUM,
140170
color=parse_color(quantity.color),
141171
)
142172
case metrics_v1.MaximumOf():
143173
return ScalarOf(
144-
metric=context.scalar(quantity.metric_name),
174+
metric=context.metric(quantity.metric_name),
145175
scalar_kind=ScalarKind.MAXIMUM,
146176
color=parse_color(quantity.color),
147177
)
@@ -174,11 +204,8 @@ def _parse_quantity(quantity: ApiQuantity, context: _ParseContext) -> QuantityPr
174204
def _parse_bound(bound: int | float | ApiQuantity, context: _ParseContext) -> Bound | None:
175205
if isinstance(bound, int | float):
176206
return bound
177-
# A bound naming a metric is read from one object; over several matched ones there is none, so
178-
# that end is left to auto-scale. A bound of constants alone is read without one.
179-
if len(context.services) > 1 and any(metric_names_in_quantity(bound)):
180-
return None
181-
return _parse_quantity(bound, context)
207+
object_context = context.object_for(bound)
208+
return None if object_context is None else _parse_quantity(bound, object_context)
182209

183210

184211
def _parse_range(
@@ -242,19 +269,26 @@ def _parse_lines(
242269
*,
243270
inverse: bool,
244271
) -> tuple[Sequence[Stack], Sequence[Line], Sequence[Rule]]:
245-
def _curve(q: ApiQuantity) -> Curve:
246-
return build_curve(
247-
_parse_quantity(q, context), context.localizer, context.registered_metrics
248-
)
272+
def _curve(quantity: QuantityProtocol) -> Curve:
273+
return build_curve(quantity, context.localizer, context.registered_metrics)
249274

250-
stack_members = [_curve(q) for q in graph.compound_lines if not is_scalar(q)]
275+
stack_members = [_curve(context.drawn(q)) for q in graph.compound_lines if not is_scalar(q)]
251276
stacks = [Stack(members=stack_members, inverse=inverse)] if stack_members else []
252-
lines = [Line(curve=_curve(q), inverse=inverse) for q in graph.simple_lines if not is_scalar(q)]
253-
rules = [
254-
Rule(curve=_curve(q), inverse=inverse)
255-
for q in (*graph.compound_lines, *graph.simple_lines)
256-
if is_scalar(q)
277+
lines = [
278+
Line(curve=_curve(context.drawn(q)), inverse=inverse)
279+
for q in graph.simple_lines
280+
if not is_scalar(q)
257281
]
282+
single_object = context.single_object()
283+
rules: Sequence[Rule] = (
284+
()
285+
if single_object is None
286+
else [
287+
Rule(curve=_curve(_parse_quantity(q, single_object)), inverse=inverse)
288+
for q in (*graph.compound_lines, *graph.simple_lines)
289+
if is_scalar(q)
290+
]
291+
)
258292
return stacks, lines, rules
259293

260294

packages/cmk-graphing-engine/cmk/graphing_engine/_matching.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -159,19 +159,10 @@ def _collect(base: Graph) -> None:
159159
# work, rather than building every graph and filtering afterwards.
160160
if graph_name is not None and not _matches_graph_name(base, graph_name):
161161
return
162-
# Rules and predictive lines are single-service concepts; a graph over multiple services
163-
# drops them.
162+
# A predictive line is a single-service concept; a graph over multiple services has none (nor
163+
# rules or a quantity range bound, which the parse already left out).
164164
if single_service is None:
165-
matched_graphs.append(
166-
Graph(
167-
name=base.name,
168-
title=base.title,
169-
kind=base.kind,
170-
vertical_range=base.vertical_range,
171-
stacks=base.stacks,
172-
lines=base.lines,
173-
)
174-
)
165+
matched_graphs.append(base)
175166
return
176167
graph, predictive_names = _add_predictive_lines(
177168
base, single_service, available, localizer, registered_metrics
@@ -181,7 +172,7 @@ def _collect(base: Graph) -> None:
181172

182173
def _fallback_rules(name: MetricName) -> Sequence[Rule]:
183174
if single_service is None:
184-
return []
175+
return ()
185176
metric = rrd_metric_of(single_service, name)
186177
return [
187178
Rule(

packages/cmk-graphing-engine/tests/test_matching.py

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
build_matched_graphs,
1616
ConsolidationFunction,
1717
Constant,
18+
Difference,
1819
evaluate_graphs,
1920
EvaluatedGraph,
2021
FetchedData,
@@ -645,10 +646,10 @@ def _rrd_on(service: Service, name: MetricName) -> RRDMetric:
645646

646647

647648
class _SumQuantityBuilder:
648-
# Stand-in for a real aggregating QuantityBuilderProtocol (e.g. the pro _Aggregation): wraps the per-service
649-
# RRDMetrics of one drawn metric in the engine's own Sum.
650-
def __call__(self, metrics: Sequence[RRDMetric]) -> QuantityProtocol:
651-
return Sum(summands=list(metrics))
649+
# Stand-in for a real aggregating QuantityBuilderProtocol (e.g. the pro CombinedAggregation):
650+
# wraps what one drawn quantity is per service in the engine's own Sum.
651+
def __call__(self, per_object: Sequence[QuantityProtocol]) -> QuantityProtocol:
652+
return Sum(summands=list(per_object))
652653

653654

654655
def _discover_combined(
@@ -688,6 +689,85 @@ def test_build_matched_graphs_aggregates_a_drawn_metric_across_services() -> Non
688689
assert [line.curve.value for line in _evaluate(discovered, fetch_data).lines] == [2.0]
689690

690691

692+
def test_build_matched_graphs_builds_a_drawn_operation_per_service() -> None:
693+
h1, h2 = _services()
694+
cpu_user = MetricName("cpu_user")
695+
cpu_system = MetricName("cpu_system")
696+
plugin = graphs_v1.Graph(
697+
name="cpu",
698+
title=Title("CPU"),
699+
simple_lines=[
700+
metrics_v1.Difference(
701+
Title("Difference"),
702+
metrics_v1.Color.GREEN,
703+
minuend="cpu_user",
704+
subtrahend="cpu_system",
705+
)
706+
],
707+
)
708+
fetch_data = _FakeRRDFetchData(
709+
performance_response={
710+
h1: _perf_data(_perf(cpu_user), _perf(cpu_system)),
711+
h2: _perf_data(_perf(cpu_user), _perf(cpu_system)),
712+
}
713+
)
714+
715+
[discovered] = _discover_combined([plugin], fetch_data=fetch_data)
716+
717+
# The builder combines one difference per service, rather than the difference taking two
718+
# cross-service quantities as its operands - which no operation can have.
719+
[line] = discovered.lines
720+
quantity = line.curve.quantity
721+
assert isinstance(quantity, Sum)
722+
operands = []
723+
for summand in quantity.summands:
724+
assert isinstance(summand, Difference)
725+
operands.append((summand.minuend, summand.subtrahend))
726+
assert operands == [
727+
(_rrd_on(h1, cpu_user), _rrd_on(h1, cpu_system)),
728+
(_rrd_on(h2, cpu_user), _rrd_on(h2, cpu_system)),
729+
]
730+
assert [line.curve.value for line in _evaluate(discovered, fetch_data).lines] == [0.0]
731+
732+
733+
def test_build_matched_graphs_resolves_a_nested_scalar_per_service() -> None:
734+
h1, h2 = _services()
735+
cpu_user = MetricName("cpu_user")
736+
plugin = graphs_v1.Graph(
737+
name="cpu",
738+
title=Title("CPU"),
739+
simple_lines=[
740+
metrics_v1.Difference(
741+
Title("Headroom"),
742+
metrics_v1.Color.GREEN,
743+
minuend=metrics_v1.MaximumOf("cpu_user", metrics_v1.Color.GREEN),
744+
subtrahend="cpu_user",
745+
)
746+
],
747+
)
748+
fetch_data = _FakeRRDFetchData(
749+
performance_response={
750+
h1: _perf_data(_perf(cpu_user)),
751+
h2: _perf_data(_perf(cpu_user)),
752+
}
753+
)
754+
755+
[discovered] = _discover_combined([plugin], fetch_data=fetch_data)
756+
757+
# A threshold names the metric of the service its expression was built for, not of the first
758+
# matched one.
759+
[line] = discovered.lines
760+
quantity = line.curve.quantity
761+
assert isinstance(quantity, Sum)
762+
thresholds = []
763+
for summand in quantity.summands:
764+
assert isinstance(summand, Difference)
765+
assert isinstance(summand.minuend, ScalarOf)
766+
assert summand.minuend.scalar_kind is ScalarKind.MAXIMUM
767+
thresholds.append(summand.minuend.metric)
768+
assert thresholds == [_rrd_on(h1, cpu_user), _rrd_on(h2, cpu_user)]
769+
770+
691771
def test_build_matched_graphs_drops_rules_and_predictive_for_multiple_services() -> None:
692772
h1, h2 = _services()
693773
cpu_user = MetricName("cpu_user")

0 commit comments

Comments
 (0)