diff --git a/alpacloud/promls/BUILD b/alpacloud/promls/BUILD index 0d63da7..57920a7 100644 --- a/alpacloud/promls/BUILD +++ b/alpacloud/promls/BUILD @@ -14,7 +14,7 @@ python_distribution( long_description_path="alpacloud/promls/readme.md", provides=python_artifact( name="alpacloud_promls", - version="0.2.0", + version="0.3.0", description="A visualiser for available Prometheus metrics", author="Daniel Goldman", entry_points={"console_scripts": ["promls = alpacloud.promls.cli:search"]}, diff --git a/alpacloud/promls/browse.png b/alpacloud/promls/browse.png deleted file mode 100644 index abdc964..0000000 Binary files a/alpacloud/promls/browse.png and /dev/null differ diff --git a/alpacloud/promls/browse.svg b/alpacloud/promls/browse.svg new file mode 100644 index 0000000..30a0cdd --- /dev/null +++ b/alpacloud/promls/browse.svg @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Promls + + + + + + + + + + Promls +▼ Prometheus Metrics +├── ▼ coredns +│   ├── ▼ build▄▄ +│   │   └── ▼ info +│   │   └── coredns_build_info +│   ├── ▼ cache +│   │   ├── ▼ entries +│   │   │   └── coredns_cache_entries +│   │   ├── ▼ misses +│   │   │   └── ▼ total +│   │   │   └── coredns_cache_misses_total +│   │   └── ▼ requests +│   │   └── ▼ total +│   │   └── coredns_cache_requests_total +│   ├── ▼ dns +│   │   ├── ▼ request +│   │   │   ├── ▼ duration +│   │   │   │   └── ▼ seconds +│   │   │   │   └── coredns_dns_request_duration_seconds +│   │   │   └── ▼ size +│   │   │   └── ▼ bytes +│   │   │   └── coredns_dns_request_size_bytes +│   │   ├── ▼ requests +│   │   │   └── ▼ total +coredns_dns_request_duration_secondshistogram +Histogram of the time (in seconds) each request took per zone. +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +▼ Labels + +{'server': 'dns://:53', 'view': '', 'zone': '.', 'le': '0.00025'} +{'server': 'dns://:53', 'view': '', 'zone': '.', 'le': '0.0005'} +{'server': 'dns://:53', 'view': '', 'zone': '.', 'le': '0.001'} +{'server': 'dns://:53', 'view': '', 'zone': '.', 'le': '0.002'}▅▅ +{'server': 'dns://:53', 'view': '', 'zone': '.', 'le': '0.004'} +{'server': 'dns://:53', 'view': '', 'zone': '.', 'le': '0.008'} +{'server': 'dns://:53', 'view': '', 'zone': '.', 'le': '0.016'} +{'server': 'dns://:53', 'view': '', 'zone': '.', 'le': '0.032'} + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Find... +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ^f find  ^g goto ^p palette + + + diff --git a/alpacloud/promls/changelog.md b/alpacloud/promls/changelog.md index 87ad17c..fe4f8b4 100644 --- a/alpacloud/promls/changelog.md +++ b/alpacloud/promls/changelog.md @@ -1,9 +1,23 @@ # 0 +# 0.3 + +# 0.3.0 + +- fix : parser can parse escaped sequences +- feature : parser can report errors and is more robust +- feature : show labels in browser +- ui : tweaks for browser tree +- feature : combine submetrics of summaries and histograms + # 0.2 +## 0.2.0 + - refactor : drop jank fuzzy finder # 0.1 +## 0.1.0 + - release \ No newline at end of file diff --git a/alpacloud/promls/cli.py b/alpacloud/promls/cli.py index 7f63fe4..4e32123 100644 --- a/alpacloud/promls/cli.py +++ b/alpacloud/promls/cli.py @@ -7,7 +7,7 @@ import click -from alpacloud.promls.fetch import FetcherURL, Parser +from alpacloud.promls.fetch import Collector, FetcherURL, ParseError, Parser from alpacloud.promls.filter import MetricsTree, filter_any, filter_name, filter_path from alpacloud.promls.metrics import Metric from alpacloud.promls.util import paths_to_tree @@ -41,6 +41,12 @@ def parse(ctx, param, value): help=f"Display mode: {', '.join(m.value for m in PrintMode)}", ) opt_filter = click.option("--filter") +opt_combine = click.option( + "--combine-submetrics", + is_flag=True, + help="Combine submetrics into their parent. For example, combine a histogram's `sum` and `count` into the main histogram metric.", + default=True, +) def common_args(): @@ -50,14 +56,18 @@ def decorator(f): f = opt_filter(f) f = opt_mode(f) f = arg_url(f) + f = opt_combine(f) return f return decorator -def do_fetch(url: str): +def do_fetch(url: str, combine_submetrics: bool): """Do the fetch and parse.""" - return MetricsTree(Parser().parse(FetcherURL(url).fetch())) + lines, errors = Parser.parse_all(FetcherURL(url).fetch()) + values = Collector(lines, combine_submetrics).assemble() + + return MetricsTree({e.name: e for e in values}), errors def mk_indent(i: int, s: str) -> str: @@ -104,6 +114,15 @@ def do_print(tree: MetricsTree, mode: PrintMode): return txt +def print_errors(errors: list[ParseError]): + """Print parse errors""" + if not errors: + return + click.echo(f"warning: parse errors: {len(errors)}", err=True) + for err in errors: + click.echo(str(err), err=True) + + @click.group() def search(): """Search metrics""" @@ -111,27 +130,30 @@ def search(): @search.command() @common_args() -def name(url, filter: str, display: PrintMode): +def name(url, filter: str, display: PrintMode, combine_submetrics: bool): """Filter metrics by their name""" - tree = do_fetch(url) + tree, errors = do_fetch(url, combine_submetrics) + print_errors(errors) filtered = tree.filter(filter_name(re.compile(filter))) click.echo(do_print(filtered, display)) @search.command() @common_args() -def any(url, filter: str, display: PrintMode): +def any(url, filter: str, display: PrintMode, combine_submetrics: bool): """Filter metrics by any of their properties""" - tree = do_fetch(url) + tree, errors = do_fetch(url, combine_submetrics) + print_errors(errors) filtered = tree.filter(filter_any(re.compile(filter))) click.echo(do_print(filtered, display)) @search.command() @common_args() -def path(url, filter: str, display: PrintMode): +def path(url, filter: str, display: PrintMode, combine_submetrics: bool): """Filter metrics by their path""" - tree = do_fetch(url) + tree, errors = do_fetch(url, combine_submetrics) + print_errors(errors) filtered = tree.filter(filter_path(filter.split("_"))) click.echo(do_print(filtered, display)) @@ -139,7 +161,9 @@ def path(url, filter: str, display: PrintMode): @search.command() @arg_url @opt_filter -def browse(url, filter: str): +@opt_combine +def browse(url, filter: str, combine_submetrics: bool): """Browse metrics in an interactive visualizer""" real_filter = filter or ".*" - PromlsVisApp(do_fetch(url), real_filter, lambda s: filter_any(re.compile(s))).run() + results, errors = do_fetch(url, combine_submetrics) + PromlsVisApp(results, errors, real_filter, lambda s: filter_any(re.compile(s))).run() diff --git a/alpacloud/promls/cli_test.py b/alpacloud/promls/cli_test.py index 43ffe94..40ad709 100644 --- a/alpacloud/promls/cli_test.py +++ b/alpacloud/promls/cli_test.py @@ -1,4 +1,5 @@ """Tests for CLI functions.""" +# pylint: disable=redefined-outer-name, import json @@ -13,11 +14,11 @@ def test_metrics(): """Common test data for all PrintMode tests.""" return [ - Metric(name="http_requests_total", help="Total HTTP requests", type="counter"), - Metric(name="http_requests_failed", help="Failed HTTP requests", type="counter"), - Metric(name="http_response_time_seconds", help="HTTP response time", type="histogram"), - Metric(name="database_connections_active", help="Active database connections", type="gauge"), - Metric(name="database_queries_total", help="Total database queries", type="counter"), + Metric(name="http_requests_total", help="Total HTTP requests", type="counter", labels=[]), + Metric(name="http_requests_failed", help="Failed HTTP requests", type="counter", labels=[]), + Metric(name="http_response_time_seconds", help="HTTP response time", type="histogram", labels=[]), + Metric(name="database_connections_active", help="Active database connections", type="gauge", labels=[]), + Metric(name="database_queries_total", help="Total database queries", type="counter", labels=[]), ] @@ -72,7 +73,7 @@ def test_flat_empty_tree(self): def test_flat_metric_without_help(self): """Test flat mode with metric without help text.""" - metric = Metric(name="test_metric", help="", type="counter") + metric = Metric(name="test_metric", help="", type="counter", labels=[]) tree = MetricsTree.mk_tree([metric]) result = do_print(tree, PrintMode.flat) @@ -121,7 +122,7 @@ def test_full_empty_tree(self): def test_full_metric_without_help(self): """Test full mode with metric without help text.""" - metric = Metric(name="test_metric", help="", type="counter") + metric = Metric(name="test_metric", help="", type="counter", labels=[]) tree = MetricsTree.mk_tree([metric]) result = do_print(tree, PrintMode.full) @@ -178,7 +179,7 @@ def test_tree_empty_tree(self): def test_tree_metric_without_underscore(self): """Test tree mode with metric without underscore.""" - metric = Metric(name="simplemetric", help="A simple metric", type="gauge") + metric = Metric(name="simplemetric", help="A simple metric", type="gauge", labels=[]) tree = MetricsTree.mk_tree([metric]) result = do_print(tree, PrintMode.tree) @@ -234,7 +235,7 @@ def test_json_empty_tree(self): def test_json_metric_structure(self): """Test json mode metric structure.""" - metric = Metric(name="test_metric", help="Test help", type="counter") + metric = Metric(name="test_metric", help="Test help", type="counter", labels=[]) tree = MetricsTree.mk_tree([metric]) result = do_print(tree, PrintMode.json) parsed = json.loads(result) diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index 15e7ab3..9051f6a 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -3,21 +3,16 @@ from __future__ import annotations import re -from abc import ABC from collections import defaultdict from dataclasses import dataclass from enum import Enum -from typing import Any +from typing import Any, NoReturn import requests from alpacloud.promls.metrics import Metric -class Fetcher(ABC): - """""" - - @dataclass class FetcherURL: """Fetch metrics from Prometheus metrics endpoint.""" @@ -25,22 +20,125 @@ class FetcherURL: url: str def fetch(self): - return requests.get(self.url).text.split("\n") + """Fetch metrics from url""" + return requests.get(self.url, timeout=10).text.split("\n") class ParseError(Exception): """Error parsing Prometheus metrics endpoint.""" - def __init__(self, value, line: str): + def __init__(self, value, line: str, cursor: int | None = None, line_number: int | None = None): self.line = line + self.cursor = cursor + self.line_number = line_number super().__init__(value) def __str__(self) -> str: - return super().__str__() + f" line={self.line}" + msg = super().__str__() + if self.line_number is not None: + msg += f" line_number={self.line_number}" + msg += f" line={self.line}" + if self.cursor is not None: + msg += f" cursor={self.cursor}" + + return msg + + +tok_whitespace = re.compile(r"\s+") +tok_name = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*") + + +class LineReader: + """Parse elements from a line of Prometheus metrics text.""" + + def __init__(self, line: str, line_number: int | None = None): + self.line = line + self.cursor = 0 + self.line_number = line_number + + def err(self, msg: str) -> NoReturn: + """Raise an error with the current state""" + raise ParseError(msg, self.line, self.cursor, self.line_number) + + def peek(self) -> str: + """Peek at the next character""" + return self.line[self.cursor] + + def peek_for(self, char: str) -> bool: + """Peek to check for a specific character""" + return self.peek() == char + + def consume_for(self, char: str) -> str: + """Consume a character if it matches, otherwise return empty string""" + if self.peek_for(char): + self.cursor += 1 + return char + return "" + + def restore(self, cursor: int): + """Restore the cursor to a previous position""" + self.cursor = cursor + + def consume_whitespace(self) -> bool: + """Consume whitespace and return True if there was any""" + match = tok_whitespace.match(self.line, self.cursor) + if match: + self.cursor = match.end() + return True + return False + + def read_name(self) -> str | None: + """Read a name token from the input line""" + match = tok_name.match(self.line, self.cursor) + if match: + self.cursor = match.end() + return match.group() + return None + + def read_escaped(self, until: str): + """Read an escaped string literal""" + out = "" + while self.line[self.cursor] != until: + # there's an optimisation opportunity to add in slices until escaped char is reached + if self.line[self.cursor] == "\\": + char_at = self.line[(self.cursor + 1)] + self.cursor += 2 + if char_at == "n": + out += "\n" + elif char_at == "\\": + out += "\\" + elif char_at == '"': + out += '"' + else: + self.err(f"Invalid escape sequence \\{char_at}") + else: + out += self.line[self.cursor] + self.cursor += 1 + + if self.cursor >= len(self.line): + self.err("Unterminated string literal") + self.consume_for('"') + return out + + def read_value(self): + """Read a value from the line. Value must be a valid float, or NaN or Inf.""" + start = self.cursor + end = start + while len(self.line) > self.cursor and self.line[self.cursor] != " ": + self.cursor += 1 + end = self.cursor + try: + return float(self.line[start:end]) + except ValueError: + self.err("Invalid numeric value") + + def read_remaining(self): + """Read the remaining characters on the line""" + return self.line[self.cursor :] class Parser: - """Parse metrics from Prometheus metrics endpoint.""" + """Extract meaningful lines from Prometheus metrics text.""" @dataclass class DataLine: @@ -66,100 +164,179 @@ class MetaLine: kind: Parser.MetaKind data: str - def parse(self, lines: list[str]) -> dict[str, Metric]: - """Parse metrics from Prometheus metrics endpoint.""" - name2data = self.group_lines(lines) - return {k: self.parse_metric(k, v) for k, v in name2data.items()} + def __init__(self, r: LineReader): + self.r = r - def group_lines(self, lines: list[str]): - """Group lines by the metric name.""" - name2data = defaultdict(list) - for line in lines: - # escape empty lines + @classmethod + def parse_all(cls, text: list[str]) -> tuple[list[Parser.DataLine | Parser.MetaLine | None], list[ParseError]]: + """Parse all lines from Prometheus metrics endpoint""" + o = [] + errs = [] + for i, line in enumerate(text): if not line.strip(): continue + try: + o.append(Parser(LineReader(line, i)).p_anyline()) + except ParseError as e: + errs.append(e) - is_data = not line.startswith("#") + return o, errs - d: Parser.DataLine | Parser.MetaLine - if is_data: - d = self.parse_data_line(line) - else: - d = self.parse_meta_line(line) + def p_anyline(self) -> Parser.DataLine | Parser.MetaLine | None: + """Parse any kind of line from Prometheus metrics endpoint. Returns None for blank lines.""" + if not self.r.line.strip(): + return None - name2data[d.name].append(d) - return name2data + if self.r.peek_for("#"): + return self.p_metaline() + else: + return self.p_dataline() + + def p_metaline(self) -> Parser.MetaLine: + """Parse a comment line""" + self.r.consume_for("#") + self.r.consume_whitespace() + restore_cursor = self.r.cursor + kind = self.r.read_name() + if kind == "HELP" or kind == "TYPE": + if not self.r.consume_whitespace(): + self.r.err(f"Expected whitespace after comment type {kind}") + metric_name = self.r.read_name() + if metric_name is None: + self.r.err(f"Invalid metric name in {kind} comment") + self.r.consume_whitespace() + return Parser.MetaLine(metric_name, Parser.MetaKind(kind), self.r.read_remaining()) + else: + self.r.restore(restore_cursor) + return Parser.MetaLine("COMMENT", Parser.MetaKind.COMMENT, self.r.read_remaining()) # TODO: model comment so we don't have an arbitrary value for `name` - @staticmethod - def parse_data_line(line: str) -> DataLine: - """Subparser for a line of data.""" - # TODO: handle escaping in lines - x = line.split(" ", 1) - if len(x) != 2: - raise ParseError("Could not identify name in line", line) + def p_dataline(self): + """Parse a metric line""" + name = self.r.read_name() + if name is None: + self.r.err("Invalid metric name") - name_and_labels, data = x - data = data.strip() + labels = {} + if self.r.consume_for("{"): + label_name, label_value = self.p_label() + labels[label_name] = label_value + + # consume all labels + while self.r.consume_for(","): + if self.r.peek_for("}"): # We peek here because of potential trailing comma + break + label_name, label_value = self.p_label() + labels[label_name] = label_value + + if not self.r.consume_for("}"): + self.r.err("Expected closing brace after labels") + + if not self.r.consume_whitespace(): + self.r.err("Expected whitespace after labels") + value = self.r.read_value() + + if self.r.consume_whitespace(): + timestamp = self.r.read_value() + else: + timestamp = None - if line.count("{") != line.count("}"): - raise ParseError(r"Invalid data line, unmatched `{}` pair", line) + return Parser.DataLine(name, labels, value, timestamp) - pieces = list(filter(None, re.split(r"[{}]", name_and_labels))) - if len(pieces) > 2: - # TODO: better error - raise ParseError(f"Invalid data line, split into incorrect number of pieces pieces: {len(pieces)}", line) + def p_label(self): + """Parse a label""" + name = self.r.read_name() + if not self.r.consume_for("="): + self.r.err("Expected `=` after label name") + if not self.r.consume_for('"'): + self.r.err('Expected `"` after `=`') + reader = self.r + value = reader.read_escaped('"') + return name, value - name = pieces[0].strip() - labels = {} - if len(pieces) > 1: - for label_pair in pieces[1].split(","): - label_key, label_value = label_pair.split("=") - labels[label_key.strip()] = label_value.strip('"') - - if " " in data: - # if it's a counter, it will also contain a timestamp - value, timestamp_raw = data.split(" ", 1) - timestamp = int(timestamp_raw.strip()) - else: - value, timestamp = data, None +@dataclass +class Collector: + """Collect metric lines into Metrics""" - return Parser.DataLine(name, labels, value.strip(), timestamp) + lines: list[Parser.DataLine | Parser.MetaLine | None] + combine_submetrics: bool = True @staticmethod - def parse_meta_line(line: str) -> MetaLine: - """Subparser for a line of metadata.""" - if not line.startswith("#"): - raise ParseError(r"Invalid metadata line, did not start with #", line) - - line = line.strip("#").strip() - maybe_kind = line.split(" ", maxsplit=1) - if maybe_kind[0] in Parser.MetaKind.__members__: - kind = Parser.MetaKind(maybe_kind[0]) - tail = maybe_kind[1] - else: - kind = Parser.MetaKind.COMMENT - tail = line + def basename(name: str) -> tuple[str, str | None]: + """Extract the base name from a submetric. For example, a histogram my_metric has submetrics like my_metric_bucket, my_metric_sum, my_metric_count""" + maybe_base = name.rsplit("_", 1) + if len(maybe_base) == 2: + base, terminal = maybe_base + if terminal in { + "bucket", + "quantile", + "sum", + "count", + }: + return base, terminal + return name, None + + def assemble(self): + """Assemble parsed lines into metrics""" + # TODO: gather comments + meta = defaultdict(list) + for line in self.lines: + if isinstance(line, Parser.MetaLine): + meta[self.basename(line.name)[0]].append(line) - if kind == Parser.MetaKind.COMMENT: - return Parser.MetaLine("COMMENT", kind, tail) # TODO: model comment so we don't have an arbitrary value for `name` - [name, data] = tail.split(" ", maxsplit=1) + data = defaultdict(list) + for line in self.lines: + if isinstance(line, Parser.DataLine): + data[self.basename(line.name)[0]].append(line) - return Parser.MetaLine(name, kind, data) + metrics = [] + for k, vs in data.items(): + metrics.extend(self.build_metric(k, meta[k], vs)) - @staticmethod - def parse_metric(name, statements: list[Parser.DataLine | Parser.MetaLine]) -> Metric: - """Subpaarser for an actual metric.""" - # TODO: label sets - # TODO: sample values + return metrics + + def build_metric(self, base_name, meta: list[Parser.MetaLine], data: list[Parser.DataLine]) -> list[Metric]: + """Collect lines into Metric objects""" help = "" type = "" - for line in statements: - if isinstance(line, Parser.MetaLine): - if line.kind == Parser.MetaKind.HELP: - help = line.data - elif line.kind == Parser.MetaKind.TYPE: - type = line.data + for meta_line in meta: + if meta_line.kind == Parser.MetaKind.HELP: + help = meta_line.data + elif meta_line.kind == Parser.MetaKind.TYPE: + type = meta_line.data + + label_sets = [] + for line in data: + _, terminal = Collector.basename(line.name) + if ( + self.combine_submetrics + and type + in { + "summary", + "histogram", + } + and terminal + in { + "sum", + "count", + } + ): + # sum and count have no labels, so there's no need to collect them + continue + + label_sets.append(line.labels) - return Metric(name, help, type) + if self.combine_submetrics and type in { + "summary", + "histogram", + }: + return [Metric(base_name, help, type, label_sets)] + else: + names = set() + for line in data: + names.add(line.name) + metrics = [] + for name in names: + metrics.append(Metric(name, help, type, label_sets)) + return metrics diff --git a/alpacloud/promls/fetch_test.py b/alpacloud/promls/fetch_test.py index 7636967..1d3de71 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -1,76 +1,78 @@ +# pylint: disable=missing-module-docstring,missing-class-docstring + from pathlib import Path from alpacloud.lens.conftest import ResourceLoader -from alpacloud.promls.fetch import Parser +from alpacloud.promls.fetch import Collector, LineReader, Parser +from alpacloud.promls.metrics import Metric class TestParserDataline: def test_counter(self): - l = r'http_request_count{method="post",code="200"} 1027 1395066363000' - r = Parser.parse_data_line(l) + l = LineReader(r'http_request_count{method="post",code="200"} 1027 1395066363000') + r = Parser(l).p_dataline() assert r == Parser.DataLine( "http_request_count", { "method": "post", "code": "200", }, - "1027", + 1027.0, 1395066363000, ) def test_no_labels(self): - l = r"metric_without_timestamp_and_labels 12.47" - r = Parser.parse_data_line(l) - assert r == Parser.DataLine("metric_without_timestamp_and_labels", {}, "12.47") + l = LineReader(r"metric_without_timestamp_and_labels 12.47") + r = Parser(l).p_dataline() + assert r == Parser.DataLine("metric_without_timestamp_and_labels", {}, 12.47) def test_histogram_quantile(self): - l = r'telemetry_requests_metrics_latency_microseconds{quantile="0.05"} 3272' - r = Parser.parse_data_line(l) + l = LineReader(r'telemetry_requests_metrics_latency_microseconds{quantile="0.05"} 3272') + r = Parser(l).p_dataline() assert r == Parser.DataLine( "telemetry_requests_metrics_latency_microseconds", { "quantile": "0.05", }, - "3272", + 3272, ) def test_histogram_sum(self): - l = r"telemetry_requests_metrics_latency_microseconds_sum 1.7560473e+07" - r = Parser.parse_data_line(l) - assert r == Parser.DataLine("telemetry_requests_metrics_latency_microseconds_sum", {}, "1.7560473e+07") + l = LineReader(r"telemetry_requests_metrics_latency_microseconds_sum 1.7560473e+07") + r = Parser(l).p_dataline() + assert r == Parser.DataLine("telemetry_requests_metrics_latency_microseconds_sum", {}, 1.7560473e07) class TestParserMetaLine: def test_help(self): - l = "# HELP telemetry_requests_metrics_latency_microseconds A histogram of the response latency." - r = Parser.parse_meta_line(l) + l = LineReader("# HELP telemetry_requests_metrics_latency_microseconds A histogram of the response latency.") + r = Parser(l).p_metaline() assert r == Parser.MetaLine("telemetry_requests_metrics_latency_microseconds", Parser.MetaKind.HELP, "A histogram of the response latency.") def test_type(self): - l = "# TYPE telemetry_requests_metrics_latency_microseconds summary" - r = Parser.parse_meta_line(l) + l = LineReader("# TYPE telemetry_requests_metrics_latency_microseconds summary") + r = Parser(l).p_metaline() assert r == Parser.MetaLine("telemetry_requests_metrics_latency_microseconds", Parser.MetaKind.TYPE, "summary") def test_comment(self): - l = "# Finally a summary, which has a pretty complex representation in the text format:" - r = Parser.parse_meta_line(l) + l = LineReader("# Finally a summary, which has a pretty complex representation in the text format:") + r = Parser(l).p_metaline() assert r == Parser.MetaLine("COMMENT", Parser.MetaKind.COMMENT, "Finally a summary, which has a pretty complex representation in the text format:") class TestParseAll: def test_doc_sample(self): - # TODO: support for escaped values # """ - # # Escaping in label values: - # msdos_file_access_time_ms{path="C:\\DIR\\FILE.TXT",error="Cannot find file:\n\"FILE.TXT\""} 1.234e3 # # A weird metric from before the epoch: # something_weird{problem="division by zero"} +Inf -3982045 # """ - l = """\ + l = r""" +# Escaping in label values: +msdos_file_access_time_ms{path="C:\\DIR\\FILE.TXT",error="Cannot find file:\n\"FILE.TXT\""} 1.234e3 # HELP api_http_request_count The total number of HTTP requests. # TYPE api_http_request_count counter -http_request_count{method="post",code="200"} 1027 1395066363000 -http_request_count{method="post",code="400"} 3 1395066363000 +api_http_request_count{method="post",code="200"} 1027 1395066363000 +api_http_request_count{method="post",code="400"} 3 1395066363000 # Minimalistic line: metric_without_timestamp_and_labels 12.47 # Finally a summary, which has a pretty complex representation in the text format: @@ -84,9 +86,35 @@ def test_doc_sample(self): telemetry_requests_metrics_latency_microseconds_sum 1.7560473e+07 telemetry_requests_metrics_latency_microseconds_count 2693 """ - r = Parser().parse(l.split("\n")) - assert len(r) == 7 + vs, _ = Parser.parse_all(l.split("\n")) + r = Collector(vs).assemble() + assert r == [ + Metric(name="msdos_file_access_time_ms", help="", type="", labels=[{"path": "C:\\DIR\\FILE.TXT", "error": 'Cannot find file:\n"FILE.TXT"'}]), + Metric( + name="api_http_request_count", + help="The total number of HTTP requests.", + type="counter", + labels=[{"method": "post", "code": "200"}, {"method": "post", "code": "400"}], + ), + Metric(name="metric_without_timestamp_and_labels", help="", type="", labels=[{}]), + Metric( + name="telemetry_requests_metrics_latency_microseconds", + help="A histogram of the response latency.", + type="summary", + labels=[{"quantile": "0.01"}, {"quantile": "0.05"}, {"quantile": "0.5"}, {"quantile": "0.9"}, {"quantile": "0.99"}], + ), + ] def test_certmanager_sample(self): - r = ResourceLoader(Path(__file__).parent / "test_resources").load_raw("certmanager.prom") - assert len(Parser().parse(r.split("\n"))) == 48 + l = ResourceLoader(Path(__file__).parent / "test_resources").load_raw("certmanager.prom") + vs, _ = Parser.parse_all(l.split("\n")) + r = Collector(vs).assemble() + + assert len(r) == 46 + + def test_coredns_sample(self): + l = ResourceLoader(Path(__file__).parent / "test_resources").load_raw("coredns.prom") + vs, _ = Parser.parse_all(l.split("\n")) + r = Collector(vs).assemble() + + assert len(r) == 61 diff --git a/alpacloud/promls/filter.py b/alpacloud/promls/filter.py index 856ba0e..3055cd3 100644 --- a/alpacloud/promls/filter.py +++ b/alpacloud/promls/filter.py @@ -47,8 +47,11 @@ def predicate(metric: Metric) -> bool: def filter_any(pattern: re.Pattern) -> Predicate: """Filter metrics for any field""" + def _match_label_set(labels: dict[str, str]) -> bool: + return any(pattern.search(k) or pattern.search(v) for k, v in labels.items()) + def predicate(metric: Metric) -> bool: - return pattern.search(metric.name) is not None or pattern.search(metric.help) is not None + return pattern.search(metric.name) is not None or pattern.search(metric.help) is not None or any(_match_label_set(labels) for labels in metric.labels) return predicate diff --git a/alpacloud/promls/filter_test.py b/alpacloud/promls/filter_test.py index e43b272..312d06c 100644 --- a/alpacloud/promls/filter_test.py +++ b/alpacloud/promls/filter_test.py @@ -1,3 +1,4 @@ +# pylint: disable=missing-module-docstring,missing-class-docstring import re from alpacloud.promls.filter import filter_any, filter_name, filter_path @@ -7,88 +8,103 @@ class TestFilterName: def test_matches_when_pattern_in_name(self): predicate = filter_name(re.compile("foo")) - metric = Metric("my_foo_metric", "", "counter") + metric = Metric("my_foo_metric", "", "counter", []) assert predicate(metric) is True def test_does_not_match_when_not_in_name(self): predicate = filter_name(re.compile("foo")) - metric = Metric("bar_baz", "", "gauge") + metric = Metric("bar_baz", "", "gauge", []) assert predicate(metric) is False def test_case_sensitive_by_default(self): predicate = filter_name(re.compile("FOO")) - metric = Metric("foo_metric", "", "counter") + metric = Metric("foo_metric", "", "counter", []) assert predicate(metric) is False def test_case_insensitive_with_flag(self): predicate = filter_name(re.compile("FOO", re.IGNORECASE)) - metric = Metric("foo_metric", "", "counter") + metric = Metric("foo_metric", "", "counter", []) assert predicate(metric) is True def test_regex_special_characters(self): # unescaped dot matches any char predicate_any = filter_name(re.compile(r"foo.bar")) - assert predicate_any(Metric("fooXbar", "", "counter")) is True - assert predicate_any(Metric("foobar", "", "counter")) is False + assert predicate_any(Metric("fooXbar", "", "counter", [])) is True + assert predicate_any(Metric("foobar", "", "counter", [])) is False # escaped dot matches literal dot predicate_literal = filter_name(re.compile(r"foo\.bar")) - assert predicate_literal(Metric("foo.bar", "", "counter")) is True - assert predicate_literal(Metric("fooXbar", "", "counter")) is False + assert predicate_literal(Metric("foo.bar", "", "counter", [])) is True + assert predicate_literal(Metric("fooXbar", "", "counter", [])) is False def test_empty_pattern_matches_everything(self): predicate = filter_name(re.compile("")) - assert predicate(Metric("anything_goes", "", "counter")) is True + assert predicate(Metric("anything_goes", "", "counter", [])) is True def test_search_ignores_helptext(self): predicate = filter_name(re.compile("foo")) - metric = Metric("no", "foo", "counter") + metric = Metric("no", "foo", "counter", []) + assert predicate(metric) is False + + def test_search_ignores_labels(self): + predicate = filter_name(re.compile("foo")) + metric = Metric("no", "", "counter", [{"foo": "bar"}, {"bar": "foo"}]) assert predicate(metric) is False class TestFilterAny: def test_matches_when_pattern_in_name(self): predicate = filter_any(re.compile("latency")) - assert predicate(Metric("http_latency_seconds", "HTTP request latency", "histogram")) is True + assert predicate(Metric("http_latency_seconds", "HTTP request latency", "histogram", [])) is True def test_matches_when_pattern_in_help(self): predicate = filter_any(re.compile(r"request latency")) - assert predicate(Metric("http_seconds", "HTTP request latency", "histogram")) is True + assert predicate(Metric("http_seconds", "HTTP request latency", "histogram", [])) is True def test_does_not_match_when_neither_name_nor_help_match(self): predicate = filter_any(re.compile("throughput")) - assert predicate(Metric("http_latency_seconds", "HTTP request latency", "histogram")) is False + assert predicate(Metric("http_latency_seconds", "HTTP request latency", "histogram", [])) is False def test_case_insensitive_with_flag(self): predicate = filter_any(re.compile("LATENCY", re.IGNORECASE)) - assert predicate(Metric("http_latency_seconds", "HTTP request latency", "histogram")) is True + assert predicate(Metric("http_latency_seconds", "HTTP request latency", "histogram", [])) is True def test_empty_pattern_matches_everything(self): predicate = filter_any(re.compile("")) - assert predicate(Metric("anything", "and everything", "counter")) is True + assert predicate(Metric("anything", "and everything", "counter", [])) is True + + def test_in_label_key(self): + predicate = filter_any(re.compile("foo")) + metric = Metric("http_seconds", "HTTP request latency", "histogram", [{"foo": "bar"}]) + assert predicate(metric) is True + + def test_in_label_value(self): + predicate = filter_any(re.compile("foo")) + metric = Metric("http_seconds", "HTTP request latency", "histogram", [{"bar": "foo"}]) + assert predicate(metric) is True class TestFilterPath: def test_matches_prefix_path(self): predicate = filter_path(["http", "server"]) - assert predicate(Metric("http_server_requests_total", "Total HTTP server requests", "counter")) is True + assert predicate(Metric("http_server_requests_total", "Total HTTP server requests", "counter", [])) is True def test_does_not_match_when_prefix_not_at_start(self): predicate = filter_path(["http", "server"]) - assert predicate(Metric("xhttp_server_requests_total", "prefixed with x", "counter")) is False + assert predicate(Metric("xhttp_server_requests_total", "prefixed with x", "counter", [])) is False def test_does_not_match_different_prefix(self): predicate = filter_path(["http", "server"]) - assert predicate(Metric("http_client_requests_total", "client metric", "counter")) is False + assert predicate(Metric("http_client_requests_total", "client metric", "counter", [])) is False def test_exact_match(self): predicate = filter_path(["http", "server"]) - assert predicate(Metric("http_server", "exact match", "gauge")) is True + assert predicate(Metric("http_server", "exact match", "gauge", [])) is True def test_longer_path_matches(self): predicate = filter_path(["a", "b", "c"]) - assert predicate(Metric("a_b_c_d", "longer metric name", "counter")) is True + assert predicate(Metric("a_b_c_d", "longer metric name", "counter", [])) is True def test_case_sensitive_by_default(self): predicate = filter_path(["HTTP", "server"]) - assert predicate(Metric("http_server_requests_total", "lowercase name", "counter")) is False + assert predicate(Metric("http_server_requests_total", "lowercase name", "counter", [])) is False diff --git a/alpacloud/promls/metrics.py b/alpacloud/promls/metrics.py index a3766c0..aafccc1 100644 --- a/alpacloud/promls/metrics.py +++ b/alpacloud/promls/metrics.py @@ -10,3 +10,4 @@ class Metric: name: str help: str type: str + labels: list[dict[str, str]] diff --git a/alpacloud/promls/promls.css b/alpacloud/promls/promls.css index d715591..99079bd 100644 --- a/alpacloud/promls/promls.css +++ b/alpacloud/promls/promls.css @@ -15,6 +15,11 @@ MetricInfoBox Label{ height: auto; } +MetricInfoBox Collapsible{ + max-height: 30vh; + overflow-y: auto; +} + .left { align: left middle; } diff --git a/alpacloud/promls/readme.md b/alpacloud/promls/readme.md index 0791a76..087f8be 100644 --- a/alpacloud/promls/readme.md +++ b/alpacloud/promls/readme.md @@ -2,11 +2,19 @@ `promls` lets you explore prometheus metrics for your services. Target the metrics endpoint of your service and search for relevant metrics. +## Installation + +The `promls` cli is available in a Python package. You can install it directly with pipx: + +```shell +pipx install alpacloud-promls +``` + ## Usage ### CLI -`promls` has cli modes. Filter for metrics by name, path, any field, or a fuzzy match. +`promls` has cli modes. Filter for metrics by name, path, or any field. ```shell > promls name --filter cpu http://localhost:9402/metrics @@ -46,8 +54,9 @@ You can output the metrics in several formats: ### TUI Interactively filter and explore the metrics. -![browse.png](browse.png) +![browse.svg](browse.svg) ## Bibliography -- [Prometheus metric format](https://docs.google.com/document/d/1ZjyKiKxZV83VI9ZKAXRGKaUKK2BIWCT7oiGBKDBpjEY/mobilebasic) : Actual grammar for Prometheus metrics +- [Prometheus metric format](https://docs.google.com/document/d/1ZjyKiKxZV83VI9ZKAXRGKaUKK2BIWCT7oiGBKDBpjEY/mobilebasic) : Old grammar for Prometheus metrics +- [Prometheus metric format](https://github.com/prometheus/docs/blob/main/docs/instrumenting/exposition_formats.md#text-format-details) : the actual grammar diff --git a/alpacloud/promls/test_resources/coredns.prom b/alpacloud/promls/test_resources/coredns.prom new file mode 100644 index 0000000..1c78839 --- /dev/null +++ b/alpacloud/promls/test_resources/coredns.prom @@ -0,0 +1,298 @@ +# HELP coredns_build_info A metric with a constant '1' value labeled by version, revision, and goversion from which CoreDNS was built. +# TYPE coredns_build_info gauge +coredns_build_info{goversion="go1.23.3",revision="51e11f1",version="1.12.0"} 1 +# HELP coredns_cache_entries The number of elements in the cache. +# TYPE coredns_cache_entries gauge +coredns_cache_entries{server="dns://:53",type="denial",view="",zones="."} 1 +coredns_cache_entries{server="dns://:53",type="success",view="",zones="."} 0 +# HELP coredns_cache_misses_total The count of cache misses. Deprecated, derive misses from cache hits/requests counters. +# TYPE coredns_cache_misses_total counter +coredns_cache_misses_total{server="dns://:53",view="",zones="."} 1 +# HELP coredns_cache_requests_total The count of cache requests. +# TYPE coredns_cache_requests_total counter +coredns_cache_requests_total{server="dns://:53",view="",zones="."} 1 +# HELP coredns_dns_request_duration_seconds Histogram of the time (in seconds) each request took per zone. +# TYPE coredns_dns_request_duration_seconds histogram +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.00025"} 0 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.0005"} 0 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.001"} 0 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.002"} 0 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.004"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.008"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.016"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.032"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.064"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.128"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.256"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="0.512"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="1.024"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="2.048"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="4.096"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="8.192"} 1 +coredns_dns_request_duration_seconds_bucket{server="dns://:53",view="",zone=".",le="+Inf"} 1 +coredns_dns_request_duration_seconds_sum{server="dns://:53",view="",zone="."} 0.00205817 +coredns_dns_request_duration_seconds_count{server="dns://:53",view="",zone="."} 1 +# HELP coredns_dns_request_size_bytes Size of the EDNS0 UDP buffer in bytes (64K for TCP) per zone and protocol. +# TYPE coredns_dns_request_size_bytes histogram +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="0"} 0 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="100"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="200"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="300"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="400"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="511"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="1023"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="2047"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="4095"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="8291"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="16000"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="32000"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="48000"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="64000"} 1 +coredns_dns_request_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="+Inf"} 1 +coredns_dns_request_size_bytes_sum{proto="udp",server="dns://:53",view="",zone="."} 57 +coredns_dns_request_size_bytes_count{proto="udp",server="dns://:53",view="",zone="."} 1 +# HELP coredns_dns_requests_total Counter of DNS requests made per zone, protocol and family. +# TYPE coredns_dns_requests_total counter +coredns_dns_requests_total{family="1",proto="udp",server="dns://:53",type="other",view="",zone="."} 1 +# HELP coredns_dns_response_size_bytes Size of the returned response in bytes. +# TYPE coredns_dns_response_size_bytes histogram +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="0"} 0 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="100"} 0 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="200"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="300"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="400"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="511"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="1023"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="2047"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="4095"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="8291"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="16000"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="32000"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="48000"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="64000"} 1 +coredns_dns_response_size_bytes_bucket{proto="udp",server="dns://:53",view="",zone=".",le="+Inf"} 1 +coredns_dns_response_size_bytes_sum{proto="udp",server="dns://:53",view="",zone="."} 132 +coredns_dns_response_size_bytes_count{proto="udp",server="dns://:53",view="",zone="."} 1 +# HELP coredns_dns_responses_total Counter of response status codes. +# TYPE coredns_dns_responses_total counter +coredns_dns_responses_total{plugin="loadbalance",rcode="NXDOMAIN",server="dns://:53",view="",zone="."} 1 +# HELP coredns_forward_healthcheck_broken_total Counter of the number of complete failures of the healthchecks. +# TYPE coredns_forward_healthcheck_broken_total counter +coredns_forward_healthcheck_broken_total 0 +# HELP coredns_forward_max_concurrent_rejects_total Counter of the number of queries rejected because the concurrent queries were at maximum. +# TYPE coredns_forward_max_concurrent_rejects_total counter +coredns_forward_max_concurrent_rejects_total 0 +# HELP coredns_health_request_duration_seconds Histogram of the time (in seconds) each request took. +# TYPE coredns_health_request_duration_seconds histogram +coredns_health_request_duration_seconds_bucket{le="0.00025"} 114 +coredns_health_request_duration_seconds_bucket{le="0.0025"} 12300 +coredns_health_request_duration_seconds_bucket{le="0.025"} 12304 +coredns_health_request_duration_seconds_bucket{le="0.25"} 12304 +coredns_health_request_duration_seconds_bucket{le="2.5"} 12304 +coredns_health_request_duration_seconds_bucket{le="+Inf"} 12304 +coredns_health_request_duration_seconds_sum 4.899982981000013 +coredns_health_request_duration_seconds_count 12304 +# HELP coredns_health_request_failures_total The number of times the health check failed. +# TYPE coredns_health_request_failures_total counter +coredns_health_request_failures_total 0 +# HELP coredns_hosts_reload_timestamp_seconds The timestamp of the last reload of hosts file. +# TYPE coredns_hosts_reload_timestamp_seconds gauge +coredns_hosts_reload_timestamp_seconds 0 +# HELP coredns_kubernetes_rest_client_rate_limiter_duration_seconds Client side rate limiter latency in seconds. Broken down by verb and host. +# TYPE coredns_kubernetes_rest_client_rate_limiter_duration_seconds histogram +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.005"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.01"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.025"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.05"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.1"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.25"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.5"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="1"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="2.5"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="5"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="10"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="+Inf"} 6 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_sum{host="10.96.0.1:443",verb="GET"} 9.759e-06 +coredns_kubernetes_rest_client_rate_limiter_duration_seconds_count{host="10.96.0.1:443",verb="GET"} 6 +# HELP coredns_kubernetes_rest_client_request_duration_seconds Request latency in seconds. Broken down by verb and host. +# TYPE coredns_kubernetes_rest_client_request_duration_seconds histogram +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.005"} 1 +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.01"} 1 +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.025"} 3 +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.05"} 3 +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.1"} 3 +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.25"} 3 +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="0.5"} 3 +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="1"} 3 +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="2.5"} 3 +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="5"} 3 +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="10"} 3 +coredns_kubernetes_rest_client_request_duration_seconds_bucket{host="10.96.0.1:443",verb="GET",le="+Inf"} 6 +coredns_kubernetes_rest_client_request_duration_seconds_sum{host="10.96.0.1:443",verb="GET"} 90.03795677499998 +coredns_kubernetes_rest_client_request_duration_seconds_count{host="10.96.0.1:443",verb="GET"} 6 +# HELP coredns_kubernetes_rest_client_requests_total Number of HTTP requests, partitioned by status code, method, and host. +# TYPE coredns_kubernetes_rest_client_requests_total counter +coredns_kubernetes_rest_client_requests_total{code="200",host="10.96.0.1:443",method="GET"} 89 +coredns_kubernetes_rest_client_requests_total{code="",host="10.96.0.1:443",method="GET"} 3 +# HELP coredns_local_localhost_requests_total Counter of localhost. requests. +# TYPE coredns_local_localhost_requests_total counter +coredns_local_localhost_requests_total 0 +# HELP coredns_panics_total A metrics that counts the number of panics. +# TYPE coredns_panics_total counter +coredns_panics_total 0 +# HELP coredns_plugin_enabled A metric that indicates whether a plugin is enabled on per server and zone basis. +# TYPE coredns_plugin_enabled gauge +coredns_plugin_enabled{name="cache",server="dns://:53",view="",zone="."} 1 +coredns_plugin_enabled{name="errors",server="dns://:53",view="",zone="."} 1 +coredns_plugin_enabled{name="forward",server="dns://:53",view="",zone="."} 1 +coredns_plugin_enabled{name="kubernetes",server="dns://:53",view="",zone="."} 1 +coredns_plugin_enabled{name="loadbalance",server="dns://:53",view="",zone="."} 1 +coredns_plugin_enabled{name="loop",server="dns://:53",view="",zone="."} 1 +coredns_plugin_enabled{name="prometheus",server="dns://:53",view="",zone="."} 1 +# HELP coredns_proxy_conn_cache_misses_total Counter of connection cache misses per upstream and protocol. +# TYPE coredns_proxy_conn_cache_misses_total counter +coredns_proxy_conn_cache_misses_total{proto="udp",proxy_name="forward",to="172.18.0.1:53"} 1 +# HELP coredns_proxy_request_duration_seconds Histogram of the time each request took. +# TYPE coredns_proxy_request_duration_seconds histogram +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.00025"} 0 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.0005"} 0 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.001"} 0 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.002"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.004"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.008"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.016"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.032"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.064"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.128"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.256"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="0.512"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="1.024"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="2.048"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="4.096"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="8.192"} 1 +coredns_proxy_request_duration_seconds_bucket{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53",le="+Inf"} 1 +coredns_proxy_request_duration_seconds_sum{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53"} 0.001936832 +coredns_proxy_request_duration_seconds_count{proxy_name="forward",rcode="NXDOMAIN",to="172.18.0.1:53"} 1 +# HELP coredns_reload_failed_total Counter of the number of failed reload attempts. +# TYPE coredns_reload_failed_total counter +coredns_reload_failed_total 0 +# HELP go_gc_duration_seconds A summary of the wall-time pause (stop-the-world) duration in garbage collection cycles. +# TYPE go_gc_duration_seconds summary +go_gc_duration_seconds{quantile="0"} 3.3644e-05 +go_gc_duration_seconds{quantile="0.25"} 4.0107e-05 +go_gc_duration_seconds{quantile="0.5"} 4.4167e-05 +go_gc_duration_seconds{quantile="0.75"} 5.7922e-05 +go_gc_duration_seconds{quantile="1"} 0.004205507 +go_gc_duration_seconds_sum 0.010077544 +go_gc_duration_seconds_count 106 +# HELP go_gc_gogc_percent Heap size target percentage configured by the user, otherwise 100. This value is set by the GOGC environment variable, and the runtime/debug.SetGCPercent function. Sourced from /gc/gogc:percent +# TYPE go_gc_gogc_percent gauge +go_gc_gogc_percent 100 +# HELP go_gc_gomemlimit_bytes Go runtime memory limit configured by the user, otherwise math.MaxInt64. This value is set by the GOMEMLIMIT environment variable, and the runtime/debug.SetMemoryLimit function. Sourced from /gc/gomemlimit:bytes +# TYPE go_gc_gomemlimit_bytes gauge +go_gc_gomemlimit_bytes 9.223372036854776e+18 +# HELP go_goroutines Number of goroutines that currently exist. +# TYPE go_goroutines gauge +go_goroutines 40 +# HELP go_info Information about the Go environment. +# TYPE go_info gauge +go_info{version="go1.23.3"} 1 +# HELP go_memstats_alloc_bytes Number of bytes allocated in heap and currently in use. Equals to /memory/classes/heap/objects:bytes. +# TYPE go_memstats_alloc_bytes gauge +go_memstats_alloc_bytes 7.25096e+06 +# HELP go_memstats_alloc_bytes_total Total number of bytes allocated in heap until now, even if released already. Equals to /gc/heap/allocs:bytes. +# TYPE go_memstats_alloc_bytes_total counter +go_memstats_alloc_bytes_total 2.491742e+08 +# HELP go_memstats_buck_hash_sys_bytes Number of bytes used by the profiling bucket hash table. Equals to /memory/classes/profiling/buckets:bytes. +# TYPE go_memstats_buck_hash_sys_bytes gauge +go_memstats_buck_hash_sys_bytes 1.480556e+06 +# HELP go_memstats_frees_total Total number of heap objects frees. Equals to /gc/heap/frees:objects + /gc/heap/tiny/allocs:objects. +# TYPE go_memstats_frees_total counter +go_memstats_frees_total 1.797767e+06 +# HELP go_memstats_gc_sys_bytes Number of bytes used for garbage collection system metadata. Equals to /memory/classes/metadata/other:bytes. +# TYPE go_memstats_gc_sys_bytes gauge +go_memstats_gc_sys_bytes 3.428688e+06 +# HELP go_memstats_heap_alloc_bytes Number of heap bytes allocated and currently in use, same as go_memstats_alloc_bytes. Equals to /memory/classes/heap/objects:bytes. +# TYPE go_memstats_heap_alloc_bytes gauge +go_memstats_heap_alloc_bytes 7.25096e+06 +# HELP go_memstats_heap_idle_bytes Number of heap bytes waiting to be used. Equals to /memory/classes/heap/released:bytes + /memory/classes/heap/free:bytes. +# TYPE go_memstats_heap_idle_bytes gauge +go_memstats_heap_idle_bytes 5.103616e+06 +# HELP go_memstats_heap_inuse_bytes Number of heap bytes that are in use. Equals to /memory/classes/heap/objects:bytes + /memory/classes/heap/unused:bytes +# TYPE go_memstats_heap_inuse_bytes gauge +go_memstats_heap_inuse_bytes 1.0084352e+07 +# HELP go_memstats_heap_objects Number of currently allocated objects. Equals to /gc/heap/objects:objects. +# TYPE go_memstats_heap_objects gauge +go_memstats_heap_objects 31448 +# HELP go_memstats_heap_released_bytes Number of heap bytes released to OS. Equals to /memory/classes/heap/released:bytes. +# TYPE go_memstats_heap_released_bytes gauge +go_memstats_heap_released_bytes 2.940928e+06 +# HELP go_memstats_heap_sys_bytes Number of heap bytes obtained from system. Equals to /memory/classes/heap/objects:bytes + /memory/classes/heap/unused:bytes + /memory/classes/heap/released:bytes + /memory/classes/heap/free:bytes. +# TYPE go_memstats_heap_sys_bytes gauge +go_memstats_heap_sys_bytes 1.5187968e+07 +# HELP go_memstats_last_gc_time_seconds Number of seconds since 1970 of last garbage collection. +# TYPE go_memstats_last_gc_time_seconds gauge +go_memstats_last_gc_time_seconds 1.7675641765341887e+09 +# HELP go_memstats_mallocs_total Total number of heap objects allocated, both live and gc-ed. Semantically a counter version for go_memstats_heap_objects gauge. Equals to /gc/heap/allocs:objects + /gc/heap/tiny/allocs:objects. +# TYPE go_memstats_mallocs_total counter +go_memstats_mallocs_total 1.829215e+06 +# HELP go_memstats_mcache_inuse_bytes Number of bytes in use by mcache structures. Equals to /memory/classes/metadata/mcache/inuse:bytes. +# TYPE go_memstats_mcache_inuse_bytes gauge +go_memstats_mcache_inuse_bytes 19200 +# HELP go_memstats_mcache_sys_bytes Number of bytes used for mcache structures obtained from system. Equals to /memory/classes/metadata/mcache/inuse:bytes + /memory/classes/metadata/mcache/free:bytes. +# TYPE go_memstats_mcache_sys_bytes gauge +go_memstats_mcache_sys_bytes 31200 +# HELP go_memstats_mspan_inuse_bytes Number of bytes in use by mspan structures. Equals to /memory/classes/metadata/mspan/inuse:bytes. +# TYPE go_memstats_mspan_inuse_bytes gauge +go_memstats_mspan_inuse_bytes 280480 +# HELP go_memstats_mspan_sys_bytes Number of bytes used for mspan structures obtained from system. Equals to /memory/classes/metadata/mspan/inuse:bytes + /memory/classes/metadata/mspan/free:bytes. +# TYPE go_memstats_mspan_sys_bytes gauge +go_memstats_mspan_sys_bytes 326400 +# HELP go_memstats_next_gc_bytes Number of heap bytes when next garbage collection will take place. Equals to /gc/heap/goal:bytes. +# TYPE go_memstats_next_gc_bytes gauge +go_memstats_next_gc_bytes 1.4345904e+07 +# HELP go_memstats_other_sys_bytes Number of bytes used for other system allocations. Equals to /memory/classes/other:bytes. +# TYPE go_memstats_other_sys_bytes gauge +go_memstats_other_sys_bytes 2.701164e+06 +# HELP go_memstats_stack_inuse_bytes Number of bytes obtained from system for stack allocator in non-CGO environments. Equals to /memory/classes/heap/stacks:bytes. +# TYPE go_memstats_stack_inuse_bytes gauge +go_memstats_stack_inuse_bytes 1.572864e+06 +# HELP go_memstats_stack_sys_bytes Number of bytes obtained from system for stack allocator. Equals to /memory/classes/heap/stacks:bytes + /memory/classes/os-stacks:bytes. +# TYPE go_memstats_stack_sys_bytes gauge +go_memstats_stack_sys_bytes 1.572864e+06 +# HELP go_memstats_sys_bytes Number of bytes obtained from system. Equals to /memory/classes/total:byte. +# TYPE go_memstats_sys_bytes gauge +go_memstats_sys_bytes 2.472884e+07 +# HELP go_sched_gomaxprocs_threads The current runtime.GOMAXPROCS setting, or the number of operating system threads that can execute user-level Go code simultaneously. Sourced from /sched/gomaxprocs:threads +# TYPE go_sched_gomaxprocs_threads gauge +go_sched_gomaxprocs_threads 16 +# HELP go_threads Number of OS threads created. +# TYPE go_threads gauge +go_threads 19 +# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds. +# TYPE process_cpu_seconds_total counter +process_cpu_seconds_total 13.56 +# HELP process_max_fds Maximum number of open file descriptors. +# TYPE process_max_fds gauge +process_max_fds 1.048576e+06 +# HELP process_network_receive_bytes_total Number of bytes received by the process over the network. +# TYPE process_network_receive_bytes_total counter +process_network_receive_bytes_total 1.0321463e+07 +# HELP process_network_transmit_bytes_total Number of bytes sent by the process over the network. +# TYPE process_network_transmit_bytes_total counter +process_network_transmit_bytes_total 1.0276151e+07 +# HELP process_open_fds Number of open file descriptors. +# TYPE process_open_fds gauge +process_open_fds 13 +# HELP process_resident_memory_bytes Resident memory size in bytes. +# TYPE process_resident_memory_bytes gauge +process_resident_memory_bytes 5.9408384e+07 +# HELP process_start_time_seconds Start time of the process since unix epoch in seconds. +# TYPE process_start_time_seconds gauge +process_start_time_seconds 1.7675518993e+09 +# HELP process_virtual_memory_bytes Virtual memory size in bytes. +# TYPE process_virtual_memory_bytes gauge +process_virtual_memory_bytes 1.327259648e+09 +# HELP process_virtual_memory_max_bytes Maximum amount of virtual memory available in bytes. +# TYPE process_virtual_memory_max_bytes gauge +process_virtual_memory_max_bytes 1.8446744073709552e+19 diff --git a/alpacloud/promls/util_test.py b/alpacloud/promls/util_test.py index a4ab5ea..391cf22 100644 --- a/alpacloud/promls/util_test.py +++ b/alpacloud/promls/util_test.py @@ -41,7 +41,7 @@ def test_empty_mapping(self): """Test with empty input.""" mapping = {} tree = paths_to_tree(mapping) - assert tree == {} + assert tree == {} # pylint: disable=use-implicit-booleaness-not-comparison def test_custom_separator(self): """Test with custom separator.""" diff --git a/alpacloud/promls/vis.py b/alpacloud/promls/vis.py index 46510bf..b99ed87 100644 --- a/alpacloud/promls/vis.py +++ b/alpacloud/promls/vis.py @@ -1,12 +1,16 @@ +"""Promls metrics visualizer.""" + import re from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Container, Horizontal, Vertical from textual.reactive import Reactive, reactive +from textual.screen import Screen, ScreenResultType from textual.widget import Widget -from textual.widgets import Footer, Header, Input, Label, Static, Tree +from textual.widgets import Collapsible, Footer, Header, Input, Label, Static, Tree +from alpacloud.promls.fetch import ParseError from alpacloud.promls.filter import MetricsTree, PredicateFactory, filter_any, filter_name from alpacloud.promls.metrics import Metric from alpacloud.promls.util import TreeT, paths_to_tree @@ -23,15 +27,42 @@ def __init__(self, placeholder: str, id: str = "find-box") -> None: super().__init__(placeholder=placeholder, id=id) def action_clear(self): + """Clear the text area.""" self.clear() +class ErrorsModal(Screen): + """A modal widget to display errors.""" + + BINDINGS = [ + Binding("ctrl+e", "dismiss", "Close", show=False), + Binding("escape", "dismiss", "Close", show=False), + ] + + def __init__(self, errors: list[ParseError], *args, **kwargs): + super().__init__(*args, **kwargs) + self.errors = errors + + def compose(self): + """Textual compose""" + with Vertical(): + yield Label("Parse Errors") + for i, err in enumerate(self.errors): + yield Static(f"error {i}: {str(err)}", classes="error") + + async def action_dismiss(self, result: ScreenResultType | None = None): + """Dismiss the modal.""" + await self.dismiss() + + class MetricInfoBox(Widget): """A widget to display information about the selected Metric.""" metric: Reactive[Metric | None] = reactive(None, recompose=True) + expand_labels: bool = False def compose(self) -> ComposeResult: + """Textual compose""" with Vertical(): if not self.metric: yield Label("Metric Info") @@ -41,6 +72,18 @@ def compose(self) -> ComposeResult: yield Container(Label(self.metric.type, variant="accent"), classes="right") yield Static(self.metric.help) + with Collapsible(title="Labels", collapsed=not self.expand_labels): + for labels in self.metric.labels: + yield Static(str(labels)) + + def on_collapsible_collapsed(self, event: Collapsible.Collapsed) -> None: + """Synchronise expand_labels state""" + self.expand_labels = False + + def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None: + """Synchronise expand_labels state""" + self.expand_labels = True + class PromlsVisApp(App): """A Textual app to visualize Prometheus Metrics.""" @@ -48,43 +91,60 @@ class PromlsVisApp(App): TITLE = "Promls" CSS_PATH = "promls.css" + # Add inline CSS for labels container height constraint + CSS = """ + .labels-scroll { + max-height: 33vh; + } + """ + BINDINGS = [ Binding("ctrl+f", "find", "find", priority=True), Binding("ctrl+g", "goto", "goto", priority=True), + Binding("ctrl+e", "errors", "Errors", show=False), Binding("greater_than_sign", "expand_all", "Expand all", show=False), Binding("less_than_sign", "collapse_all", "Collapse all", show=False), ] - def __init__(self, metrics: MetricsTree, query: str, predicate_factory: PredicateFactory, *args, **kwargs): + def __init__(self, metrics: MetricsTree, errors: list[ParseError], query: str, predicate_factory: PredicateFactory, *args, **kwargs): super().__init__(*args, **kwargs) self.metrics = metrics + self.errors = errors self.query_str = query self.predicate_factory = predicate_factory def compose(self) -> ComposeResult: + """Textual compose""" yield Header() yield Tree("Prometheus Metrics") yield MetricInfoBox() yield FindBox(placeholder="Find...", id="find-box") yield Footer() + if self.errors: + self.notify(f"Warning: {len(self.errors)} parse errors. use `ctrl+e` to show errors", severity="warning") def on_mount(self) -> None: + """Textual on_mount""" self.load_metrics() self.focus_findbox() def focus_findbox(self): + """Focus the find box.""" self.query_one(FindBox).focus() def on_input_changed(self, event: Input.Changed) -> None: + """Filter the tree when the query changes.""" self.query_str = event.value self.load_metrics() async def action_find(self) -> None: + """Filter with `filter_any`""" self.predicate_factory = lambda s: filter_any(re.compile(s)) self.load_metrics() self.focus_findbox() async def action_goto(self): + """Filter with `filter_name`""" self.predicate_factory = lambda s: filter_name(re.compile(s)) self.load_metrics() self.focus_findbox() @@ -112,13 +172,17 @@ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: def _add_node(self, parent_node, m: TreeT | Metric): if isinstance(m, Metric): - new_node = parent_node.add(m.name) + new_node = parent_node.add_leaf(m.name) new_node.data = m else: for k, v in m.items(): - self._add_node(parent_node.add(k), v) + if k == "__value__": + self._add_node(parent_node, v) + else: + self._add_node(parent_node.add(k), v) def load_metrics(self): + """Load metrics into the tree.""" tree = self.query_one(Tree) tree.clear() root = tree.root @@ -130,3 +194,10 @@ def load_metrics(self): self._add_node(root, paths_to_tree(filtered.metrics, sep="_")) root.expand_all() + + def action_errors(self): + """Show errors modal.""" + if not self.errors: + self.notify("No errors to show", severity="information") + return + self.push_screen(ErrorsModal(self.errors))