From 06b420355cbf75895051c06d6cfd999cc55734be Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 14:31:51 -0500 Subject: [PATCH 01/21] refactor: rebuild metrics parser --- alpacloud/promls/fetch.py | 234 ++++++++++++++++++++++----------- alpacloud/promls/fetch_test.py | 30 +++-- 2 files changed, 182 insertions(+), 82 deletions(-) diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index 15e7ab3..5fb43e3 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -31,17 +31,98 @@ def fetch(self): class ParseError(Exception): """Error parsing Prometheus metrics endpoint.""" - def __init__(self, value, line: str): + def __init__(self, value, line: str, cursor: int | None = None): self.line = line + self.cursor = cursor super().__init__(value) def __str__(self) -> str: - return super().__str__() + f" line={self.line}" + msg = super().__str__() + f" line={self.line}" + if self.cursor is not None: + msg += f" cursor={self.cursor}" + return msg -class Parser: - """Parse metrics from Prometheus metrics endpoint.""" +whitespace = re.compile(r"\s+") +name = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*") + + +class LineReader: + def __init__(self, line: str): + self.line = line + self.cursor = 0 + + def err(self, msg: str) -> None: + raise ParseError(msg, self.line, self.cursor) + + def peek(self): + return self.line[self.cursor] + + def peek_for(self, char: str) -> bool: + return self.peek() == char + + def consume_for(self, char: str) -> str: + if self.peek_for(char): + self.cursor += 1 + return char + return "" + + def restore(self, cursor: int): + self.cursor = cursor + + def consume_whitespace(self) -> bool: + match = whitespace.match(self.line, self.cursor) + if match: + self.cursor = match.end() + return True + return False + + def read_name(self): + match = name.match(self.line, self.cursor) + if match: + self.cursor = match.end() + return match.group() + return None + + def read_escaped(self, until: str): + out = "" + while self.line[self.cursor] != until: + # TODO: can optimise to add in slices until escaped char is reached + if self.line[self.cursor] == "\\": + # TODO: ensure valid escape sequences + char_at = self.cursor + 1 + self.cursor += 2 + out += self.line[char_at] + else: + char_at = self.cursor + self.cursor += 1 + out += self.line[char_at] + + if self.cursor >= len(self.line): + self.err("Unterminated string literal") + self.cursor += 1 # TODO: use consume_for? + return out + + def read_label_value(self): + return self.read_escaped('"') + + 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): + return self.line[self.cursor :] + +class Parser: @dataclass class DataLine: """Data line from Prometheus metrics endpoint.""" @@ -66,86 +147,91 @@ 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 group_lines(self, lines: list[str]): - """Group lines by the metric name.""" - name2data = defaultdict(list) - for line in lines: - # escape empty lines - if not line.strip(): - continue - - is_data = not line.startswith("#") - - d: Parser.DataLine | Parser.MetaLine - if is_data: - d = self.parse_data_line(line) - else: - d = self.parse_meta_line(line) - - name2data[d.name].append(d) - return name2data + def __init__(self, r: LineReader): + self.r = r @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) - - name_and_labels, data = x - data = data.strip() + def assemble(lines: list[Parser.DataLine | Parser.MetaLine]): + """Assemble parsed lines into metrics""" + # TODO: gather comments + meta = defaultdict(list) + for line in lines: + if isinstance(line, Parser.MetaLine): + meta[line.name].append(line) - if line.count("{") != line.count("}"): - raise ParseError(r"Invalid data line, unmatched `{}` pair", line) + metrics = [] + for line in lines: + if isinstance(line, Parser.DataLine): + metrics.append(Parser.parse_metric(line.name, [line, *meta[line.name]])) - 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) + return metrics - name = pieces[0].strip() + def p_anyline(self) -> Parser.DataLine | Parser.MetaLine | None: + if not self.r.line.strip(): + return None - 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()) + if self.r.consume_for("#"): + return self.p_comment() else: - value, timestamp = data, None - - return Parser.DataLine(name, labels, value.strip(), timestamp) - - @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] + return self.p_metric() + + def p_comment(self): + """Parse a comment line""" + 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: - kind = Parser.MetaKind.COMMENT - tail = line + 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` - 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) + def p_metric(self): + """Parse a metric line""" + name = self.r.read_name() + if name is None: + self.r.err("Invalid metric name") - return Parser.MetaLine(name, kind, data) + 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 + + return Parser.DataLine(name, labels, value, timestamp) + + def p_label(self): + 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 `=`') + value = self.r.read_label_value() + return name, value @staticmethod def parse_metric(name, statements: list[Parser.DataLine | Parser.MetaLine]) -> Metric: diff --git a/alpacloud/promls/fetch_test.py b/alpacloud/promls/fetch_test.py index 7636967..ae1cd19 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -1,7 +1,8 @@ from pathlib import Path from alpacloud.lens.conftest import ResourceLoader -from alpacloud.promls.fetch import Parser +from alpacloud.promls.fetch import LineReader, Parser, Parser2 +from alpacloud.promls.metrics import Metric class TestParserDataline: @@ -59,18 +60,17 @@ def test_comment(self): 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 = """\ +# 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,8 +84,22 @@ 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 + r = [Parser2(LineReader(l)).p_anyline() for l in l.split("\n")] + r = list(filter(None, r)) + r = Parser2.assemble(r) + assert r == [ + Metric(name="msdos_file_access_time_ms", help="", type=""), + Metric(name="api_http_request_count", help="The total number of HTTP requests.", type="counter"), + Metric(name="api_http_request_count", help="The total number of HTTP requests.", type="counter"), + Metric(name="metric_without_timestamp_and_labels", help="", type=""), + Metric(name="telemetry_requests_metrics_latency_microseconds", help="A histogram of the response latency.", type="summary"), + Metric(name="telemetry_requests_metrics_latency_microseconds", help="A histogram of the response latency.", type="summary"), + Metric(name="telemetry_requests_metrics_latency_microseconds", help="A histogram of the response latency.", type="summary"), + Metric(name="telemetry_requests_metrics_latency_microseconds", help="A histogram of the response latency.", type="summary"), + Metric(name="telemetry_requests_metrics_latency_microseconds", help="A histogram of the response latency.", type="summary"), + Metric(name="telemetry_requests_metrics_latency_microseconds_sum", help="", type=""), + Metric(name="telemetry_requests_metrics_latency_microseconds_count", help="", type=""), + ] def test_certmanager_sample(self): r = ResourceLoader(Path(__file__).parent / "test_resources").load_raw("certmanager.prom") From 710996a45fa3d9128483219d009056d6b1a2d782 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 14:33:06 -0500 Subject: [PATCH 02/21] refactor: simplify parse_metric --- alpacloud/promls/fetch.py | 18 ++++++------ alpacloud/promls/fetch_test.py | 50 ++++++++++++++++++---------------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index 5fb43e3..7d7f024 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -162,7 +162,7 @@ def assemble(lines: list[Parser.DataLine | Parser.MetaLine]): metrics = [] for line in lines: if isinstance(line, Parser.DataLine): - metrics.append(Parser.parse_metric(line.name, [line, *meta[line.name]])) + metrics.append(Parser.parse_metric(line.name, meta[line.name], line)) return metrics @@ -170,13 +170,14 @@ def p_anyline(self) -> Parser.DataLine | Parser.MetaLine | None: if not self.r.line.strip(): return None - if self.r.consume_for("#"): + if self.r.peek_for("#"): return self.p_comment() else: return self.p_metric() def p_comment(self): """Parse a comment line""" + self.r.consume_for("#") self.r.consume_whitespace() restore_cursor = self.r.cursor kind = self.r.read_name() @@ -234,18 +235,17 @@ def p_label(self): return name, value @staticmethod - def parse_metric(name, statements: list[Parser.DataLine | Parser.MetaLine]) -> Metric: + def parse_metric(name, meta: list[Parser.MetaLine], data: Parser.DataLine) -> Metric: """Subpaarser for an actual metric.""" # TODO: label sets # TODO: sample values 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 line in meta: + if line.kind == Parser.MetaKind.HELP: + help = line.data + elif line.kind == Parser.MetaKind.TYPE: + type = line.data return Metric(name, help, type) diff --git a/alpacloud/promls/fetch_test.py b/alpacloud/promls/fetch_test.py index ae1cd19..a3b1c19 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -1,60 +1,60 @@ from pathlib import Path from alpacloud.lens.conftest import ResourceLoader -from alpacloud.promls.fetch import LineReader, Parser, Parser2 +from alpacloud.promls.fetch import LineReader, Parser, 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_metric() 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_metric() + 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_metric() 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_metric() + assert r == Parser.DataLine("telemetry_requests_metrics_latency_microseconds_sum", {}, 1.7560473e+07) 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_comment() 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_comment() 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_comment() assert r == Parser.MetaLine("COMMENT", Parser.MetaKind.COMMENT, "Finally a summary, which has a pretty complex representation in the text format:") @@ -84,9 +84,9 @@ def test_doc_sample(self): telemetry_requests_metrics_latency_microseconds_sum 1.7560473e+07 telemetry_requests_metrics_latency_microseconds_count 2693 """ - r = [Parser2(LineReader(l)).p_anyline() for l in l.split("\n")] + r = [Parser(LineReader(l)).p_anyline() for l in l.split("\n")] r = list(filter(None, r)) - r = Parser2.assemble(r) + r = Parser.assemble(r) assert r == [ Metric(name="msdos_file_access_time_ms", help="", type=""), Metric(name="api_http_request_count", help="The total number of HTTP requests.", type="counter"), @@ -102,5 +102,9 @@ def test_doc_sample(self): ] 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") + r = [Parser(LineReader(l)).p_anyline() for l in l.split("\n")] + r = list(filter(None, r)) + r = Parser.assemble(r) + + assert len(r) == 61 From 24d0b618608044fe8f7f2ceb1320ff646e776708 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 15:00:31 -0500 Subject: [PATCH 03/21] refactor: simplify parsing all metrics --- alpacloud/promls/fetch.py | 7 +++++++ alpacloud/promls/fetch_test.py | 8 ++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index 7d7f024..d21bb54 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -150,6 +150,13 @@ class MetaLine: def __init__(self, r: LineReader): self.r = r + @classmethod + def parse_all(cls, text: str) -> list[Metric]: + r = [Parser(LineReader(l)).p_anyline() for l in text.split("\n")] + r = list(filter(None, r)) + r = Parser.assemble(r) + return r + @staticmethod def assemble(lines: list[Parser.DataLine | Parser.MetaLine]): """Assemble parsed lines into metrics""" diff --git a/alpacloud/promls/fetch_test.py b/alpacloud/promls/fetch_test.py index a3b1c19..8812c42 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -84,9 +84,7 @@ def test_doc_sample(self): telemetry_requests_metrics_latency_microseconds_sum 1.7560473e+07 telemetry_requests_metrics_latency_microseconds_count 2693 """ - r = [Parser(LineReader(l)).p_anyline() for l in l.split("\n")] - r = list(filter(None, r)) - r = Parser.assemble(r) + r = Parser.parse_all(l) assert r == [ Metric(name="msdos_file_access_time_ms", help="", type=""), Metric(name="api_http_request_count", help="The total number of HTTP requests.", type="counter"), @@ -103,8 +101,6 @@ def test_doc_sample(self): def test_certmanager_sample(self): l = ResourceLoader(Path(__file__).parent / "test_resources").load_raw("certmanager.prom") - r = [Parser(LineReader(l)).p_anyline() for l in l.split("\n")] - r = list(filter(None, r)) - r = Parser.assemble(r) + r = Parser.parse_all(l) assert len(r) == 61 From 9fec00f713a64813898478b075f1683d1d285805 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 15:31:49 -0500 Subject: [PATCH 04/21] fix: proper escaping --- alpacloud/promls/fetch.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index d21bb54..10a0dcd 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -89,10 +89,16 @@ def read_escaped(self, until: str): while self.line[self.cursor] != until: # TODO: can optimise to add in slices until escaped char is reached if self.line[self.cursor] == "\\": - # TODO: ensure valid escape sequences - char_at = self.cursor + 1 self.cursor += 2 - out += self.line[char_at] + char_at = self.line[(self.cursor + 1)] + if char_at == "n": + out += "\n" + elif char_at == "\\": + out += "\\" + elif char_at == '"': + out += '"' + else: + self.err(f"Invalid escape sequence \\{char_at}") else: char_at = self.cursor self.cursor += 1 From 91dc39069d7a07cd4a554c7bb548487a6b25a2df Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 15:32:13 -0500 Subject: [PATCH 05/21] feature: collect label sets for metrics --- alpacloud/promls/fetch.py | 17 +++++++++++----- alpacloud/promls/fetch_test.py | 37 +++++++++++++++++++--------------- alpacloud/promls/metrics.py | 1 + 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index 10a0dcd..ce65621 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -172,10 +172,14 @@ def assemble(lines: list[Parser.DataLine | Parser.MetaLine]): if isinstance(line, Parser.MetaLine): meta[line.name].append(line) - metrics = [] + data = defaultdict(list) for line in lines: if isinstance(line, Parser.DataLine): - metrics.append(Parser.parse_metric(line.name, meta[line.name], line)) + data[line.name].append(line) + + metrics = [] + for k, vs in data.items(): + metrics.append(Parser.build_metric(k, meta[k], vs)) return metrics @@ -248,9 +252,8 @@ def p_label(self): return name, value @staticmethod - def parse_metric(name, meta: list[Parser.MetaLine], data: Parser.DataLine) -> Metric: + def build_metric(name, meta: list[Parser.MetaLine], data: list[Parser.DataLine]) -> Metric: """Subpaarser for an actual metric.""" - # TODO: label sets # TODO: sample values help = "" @@ -261,4 +264,8 @@ def parse_metric(name, meta: list[Parser.MetaLine], data: Parser.DataLine) -> Me elif line.kind == Parser.MetaKind.TYPE: type = line.data - return Metric(name, help, type) + label_sets = [] + for line in data: + label_sets.append(line.labels) + + return Metric(name, help, type, label_sets) diff --git a/alpacloud/promls/fetch_test.py b/alpacloud/promls/fetch_test.py index 8812c42..a348f97 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -1,7 +1,7 @@ from pathlib import Path from alpacloud.lens.conftest import ResourceLoader -from alpacloud.promls.fetch import LineReader, Parser, Parser +from alpacloud.promls.fetch import LineReader, Parser from alpacloud.promls.metrics import Metric @@ -38,7 +38,7 @@ def test_histogram_quantile(self): def test_histogram_sum(self): l = LineReader(r"telemetry_requests_metrics_latency_microseconds_sum 1.7560473e+07") r = Parser(l).p_metric() - assert r == Parser.DataLine("telemetry_requests_metrics_latency_microseconds_sum", {}, 1.7560473e+07) + assert r == Parser.DataLine("telemetry_requests_metrics_latency_microseconds_sum", {}, 1.7560473e07) class TestParserMetaLine: @@ -64,9 +64,9 @@ def test_doc_sample(self): # # 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 +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 api_http_request_count{method="post",code="200"} 1027 1395066363000 @@ -86,21 +86,26 @@ def test_doc_sample(self): """ r = Parser.parse_all(l) assert r == [ - Metric(name="msdos_file_access_time_ms", help="", type=""), - Metric(name="api_http_request_count", help="The total number of HTTP requests.", type="counter"), - Metric(name="api_http_request_count", help="The total number of HTTP requests.", type="counter"), - Metric(name="metric_without_timestamp_and_labels", help="", type=""), - Metric(name="telemetry_requests_metrics_latency_microseconds", help="A histogram of the response latency.", type="summary"), - Metric(name="telemetry_requests_metrics_latency_microseconds", help="A histogram of the response latency.", type="summary"), - Metric(name="telemetry_requests_metrics_latency_microseconds", help="A histogram of the response latency.", type="summary"), - Metric(name="telemetry_requests_metrics_latency_microseconds", help="A histogram of the response latency.", type="summary"), - Metric(name="telemetry_requests_metrics_latency_microseconds", help="A histogram of the response latency.", type="summary"), - Metric(name="telemetry_requests_metrics_latency_microseconds_sum", help="", type=""), - Metric(name="telemetry_requests_metrics_latency_microseconds_count", help="", type=""), + 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"}], + ), + Metric(name="telemetry_requests_metrics_latency_microseconds_sum", help="", type="", labels=[{}]), + Metric(name="telemetry_requests_metrics_latency_microseconds_count", help="", type="", labels=[{}]), ] def test_certmanager_sample(self): l = ResourceLoader(Path(__file__).parent / "test_resources").load_raw("certmanager.prom") r = Parser.parse_all(l) - assert len(r) == 61 + assert len(r) == 48 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]] From 52a9eee3dcf949ea5e279eaaa9fbc2bd4bf3f686 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 15:37:14 -0500 Subject: [PATCH 06/21] fixup! fix: proper escaping --- alpacloud/promls/fetch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index ce65621..6208f15 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -89,8 +89,8 @@ def read_escaped(self, until: str): while self.line[self.cursor] != until: # TODO: can optimise to add in slices until escaped char is reached if self.line[self.cursor] == "\\": - self.cursor += 2 char_at = self.line[(self.cursor + 1)] + self.cursor += 2 if char_at == "n": out += "\n" elif char_at == "\\": From 892186540cdb0c5d2901491e69ea7dc31915b856 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 15:49:38 -0500 Subject: [PATCH 07/21] refactor: split text outside of parser --- alpacloud/promls/cli.py | 2 +- alpacloud/promls/fetch.py | 4 ++-- alpacloud/promls/fetch_test.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/alpacloud/promls/cli.py b/alpacloud/promls/cli.py index 7f63fe4..ef46150 100644 --- a/alpacloud/promls/cli.py +++ b/alpacloud/promls/cli.py @@ -57,7 +57,7 @@ def decorator(f): def do_fetch(url: str): """Do the fetch and parse.""" - return MetricsTree(Parser().parse(FetcherURL(url).fetch())) + return MetricsTree({e.name: e for e in Parser.parse_all(FetcherURL(url).fetch())}) def mk_indent(i: int, s: str) -> str: diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index 6208f15..9d04d98 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -157,8 +157,8 @@ def __init__(self, r: LineReader): self.r = r @classmethod - def parse_all(cls, text: str) -> list[Metric]: - r = [Parser(LineReader(l)).p_anyline() for l in text.split("\n")] + def parse_all(cls, text: list[str]) -> list[Metric]: + r = [Parser(LineReader(l)).p_anyline() for l in text] r = list(filter(None, r)) r = Parser.assemble(r) return r diff --git a/alpacloud/promls/fetch_test.py b/alpacloud/promls/fetch_test.py index a348f97..89d41e6 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -84,7 +84,7 @@ def test_doc_sample(self): telemetry_requests_metrics_latency_microseconds_sum 1.7560473e+07 telemetry_requests_metrics_latency_microseconds_count 2693 """ - r = Parser.parse_all(l) + r = Parser.parse_all(l.split("\n")) 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( @@ -106,6 +106,6 @@ def test_doc_sample(self): def test_certmanager_sample(self): l = ResourceLoader(Path(__file__).parent / "test_resources").load_raw("certmanager.prom") - r = Parser.parse_all(l) + r = Parser.parse_all(l.split("\n")) assert len(r) == 48 From c98c9e9798ed90d90d824a6c69cabbeabe2fd6e2 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 15:50:00 -0500 Subject: [PATCH 08/21] feature: display labels for metrics --- alpacloud/promls/promls.css | 5 +++++ alpacloud/promls/vis.py | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) 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/vis.py b/alpacloud/promls/vis.py index 46510bf..ca8982a 100644 --- a/alpacloud/promls/vis.py +++ b/alpacloud/promls/vis.py @@ -2,10 +2,10 @@ from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import Container, Horizontal, Vertical +from textual.containers import Container, Horizontal, Vertical, VerticalScroll from textual.reactive import Reactive, reactive 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.filter import MetricsTree, PredicateFactory, filter_any, filter_name from alpacloud.promls.metrics import Metric @@ -41,6 +41,10 @@ def compose(self) -> ComposeResult: yield Container(Label(self.metric.type, variant="accent"), classes="right") yield Static(self.metric.help) + with Collapsible(title="Labels"): + for labels in self.metric.labels: + yield Static(str(labels)) + class PromlsVisApp(App): """A Textual app to visualize Prometheus Metrics.""" @@ -48,6 +52,13 @@ 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), From 1927a866ab3bdf975568b00259cc6c8e06a2f015 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 16:35:35 -0500 Subject: [PATCH 09/21] feature: collect parse errors and display them --- alpacloud/promls/cli.py | 26 +++++++++++++++++------ alpacloud/promls/fetch.py | 32 ++++++++++++++++++++-------- alpacloud/promls/fetch_test.py | 4 ++-- alpacloud/promls/vis.py | 39 +++++++++++++++++++++++++++++++++- 4 files changed, 83 insertions(+), 18 deletions(-) diff --git a/alpacloud/promls/cli.py b/alpacloud/promls/cli.py index ef46150..0948e99 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 FetcherURL, Parser, ParseError 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 @@ -57,7 +57,9 @@ def decorator(f): def do_fetch(url: str): """Do the fetch and parse.""" - return MetricsTree({e.name: e for e in Parser.parse_all(FetcherURL(url).fetch())}) + values, errors = Parser.parse_all(FetcherURL(url).fetch()) + + return MetricsTree({e.name: e for e in values}), errors def mk_indent(i: int, s: str) -> str: @@ -104,6 +106,14 @@ def do_print(tree: MetricsTree, mode: PrintMode): return txt +def print_errors(errors: list[ParseError]): + 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""" @@ -113,7 +123,8 @@ def search(): @common_args() def name(url, filter: str, display: PrintMode): """Filter metrics by their name""" - tree = do_fetch(url) + tree, errors = do_fetch(url) + print_errors(errors) filtered = tree.filter(filter_name(re.compile(filter))) click.echo(do_print(filtered, display)) @@ -122,7 +133,8 @@ def name(url, filter: str, display: PrintMode): @common_args() def any(url, filter: str, display: PrintMode): """Filter metrics by any of their properties""" - tree = do_fetch(url) + tree, errors = do_fetch(url) + print_errors(errors) filtered = tree.filter(filter_any(re.compile(filter))) click.echo(do_print(filtered, display)) @@ -131,7 +143,8 @@ def any(url, filter: str, display: PrintMode): @common_args() def path(url, filter: str, display: PrintMode): """Filter metrics by their path""" - tree = do_fetch(url) + tree, errors = do_fetch(url) + print_errors(errors) filtered = tree.filter(filter_path(filter.split("_"))) click.echo(do_print(filtered, display)) @@ -142,4 +155,5 @@ def path(url, filter: str, display: PrintMode): def browse(url, filter: str): """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) + PromlsVisApp(results, errors, real_filter, lambda s: filter_any(re.compile(s))).run() diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index 9d04d98..ec38d14 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -31,15 +31,20 @@ def fetch(self): class ParseError(Exception): """Error parsing Prometheus metrics endpoint.""" - def __init__(self, value, line: str, cursor: int | None = None): + 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: - msg = 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 @@ -48,12 +53,13 @@ def __str__(self) -> str: class LineReader: - def __init__(self, line: str): + 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) -> None: - raise ParseError(msg, self.line, self.cursor) + raise ParseError(msg, self.line, self.cursor, self.line_number) def peek(self): return self.line[self.cursor] @@ -157,11 +163,19 @@ def __init__(self, r: LineReader): self.r = r @classmethod - def parse_all(cls, text: list[str]) -> list[Metric]: - r = [Parser(LineReader(l)).p_anyline() for l in text] - r = list(filter(None, r)) - r = Parser.assemble(r) - return r + def parse_all(cls, text: list[str]) -> tuple[list[Metric], list[ParseError]]: + 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) + + r = Parser.assemble(o) + return r, errs @staticmethod def assemble(lines: list[Parser.DataLine | Parser.MetaLine]): diff --git a/alpacloud/promls/fetch_test.py b/alpacloud/promls/fetch_test.py index 89d41e6..4e81f9d 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -84,7 +84,7 @@ def test_doc_sample(self): telemetry_requests_metrics_latency_microseconds_sum 1.7560473e+07 telemetry_requests_metrics_latency_microseconds_count 2693 """ - r = Parser.parse_all(l.split("\n")) + r, _ = Parser.parse_all(l.split("\n")) 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( @@ -106,6 +106,6 @@ def test_doc_sample(self): def test_certmanager_sample(self): l = ResourceLoader(Path(__file__).parent / "test_resources").load_raw("certmanager.prom") - r = Parser.parse_all(l.split("\n")) + r, _ = Parser.parse_all(l.split("\n")) assert len(r) == 48 diff --git a/alpacloud/promls/vis.py b/alpacloud/promls/vis.py index ca8982a..725558c 100644 --- a/alpacloud/promls/vis.py +++ b/alpacloud/promls/vis.py @@ -4,9 +4,11 @@ from textual.binding import Binding from textual.containers import Container, Horizontal, Vertical, VerticalScroll from textual.reactive import Reactive, reactive +from textual.screen import Screen from textual.widget import Widget 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 @@ -26,6 +28,29 @@ def action_clear(self): 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): + with Vertical(): + yield Label("Parse Errors") + for i, err in enumerate(self.errors): + yield Static(f"error {i}: {str(err)}", classes="error") + + def action_dismiss(self): + """Dismiss the modal.""" + self.dismiss() + + class MetricInfoBox(Widget): """A widget to display information about the selected Metric.""" @@ -62,13 +87,15 @@ class PromlsVisApp(App): 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 @@ -78,6 +105,9 @@ def compose(self) -> ComposeResult: 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: self.load_metrics() @@ -141,3 +171,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)) \ No newline at end of file From 05cc0e5151ad0dad889d22f7b25a5a48d27422f5 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 22:56:27 -0500 Subject: [PATCH 10/21] feature: combine histogram and summary into common metric --- alpacloud/promls/fetch.py | 36 ++++++++++++++++++++++++++++------ alpacloud/promls/fetch_test.py | 4 +--- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index ec38d14..1f8c4e9 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -177,6 +177,16 @@ def parse_all(cls, text: list[str]) -> tuple[list[Metric], list[ParseError]]: r = Parser.assemble(o) return r, errs + @staticmethod + def basename(name: str) -> tuple[str, str | None]: + 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 + + @staticmethod def assemble(lines: list[Parser.DataLine | Parser.MetaLine]): """Assemble parsed lines into metrics""" @@ -184,16 +194,16 @@ def assemble(lines: list[Parser.DataLine | Parser.MetaLine]): meta = defaultdict(list) for line in lines: if isinstance(line, Parser.MetaLine): - meta[line.name].append(line) + meta[Parser.basename(line.name)[0]].append(line) data = defaultdict(list) for line in lines: if isinstance(line, Parser.DataLine): - data[line.name].append(line) + data[Parser.basename(line.name)[0]].append(line) metrics = [] for k, vs in data.items(): - metrics.append(Parser.build_metric(k, meta[k], vs)) + metrics.extend(Parser.build_metric(k, meta[k], vs)) return metrics @@ -266,8 +276,8 @@ def p_label(self): return name, value @staticmethod - def build_metric(name, meta: list[Parser.MetaLine], data: list[Parser.DataLine]) -> Metric: - """Subpaarser for an actual metric.""" + def build_metric(base_name, meta: list[Parser.MetaLine], data: list[Parser.DataLine], combine: bool = True) -> list[Metric]: + """Subparser for an actual metric.""" # TODO: sample values help = "" @@ -280,6 +290,20 @@ def build_metric(name, meta: list[Parser.MetaLine], data: list[Parser.DataLine]) label_sets = [] for line in data: + _, terminal = Parser.basename(line.name) + if combine 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, label_sets) + if combine 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 4e81f9d..61bf05f 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -100,12 +100,10 @@ def test_doc_sample(self): type="summary", labels=[{"quantile": "0.01"}, {"quantile": "0.05"}, {"quantile": "0.5"}, {"quantile": "0.9"}, {"quantile": "0.99"}], ), - Metric(name="telemetry_requests_metrics_latency_microseconds_sum", help="", type="", labels=[{}]), - Metric(name="telemetry_requests_metrics_latency_microseconds_count", help="", type="", labels=[{}]), ] def test_certmanager_sample(self): l = ResourceLoader(Path(__file__).parent / "test_resources").load_raw("certmanager.prom") r, _ = Parser.parse_all(l.split("\n")) - assert len(r) == 48 + assert len(r) == 46 From 3d8ab2506385ce18a498c4e855b4b2a13aa1c651 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 23:02:05 -0500 Subject: [PATCH 11/21] refactor: move assembling lines into metrics to own class --- alpacloud/promls/cli.py | 5 ++- alpacloud/promls/fetch.py | 74 ++++++++++++++++++---------------- alpacloud/promls/fetch_test.py | 9 +++-- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/alpacloud/promls/cli.py b/alpacloud/promls/cli.py index 0948e99..17b9925 100644 --- a/alpacloud/promls/cli.py +++ b/alpacloud/promls/cli.py @@ -7,7 +7,7 @@ import click -from alpacloud.promls.fetch import FetcherURL, Parser, ParseError +from alpacloud.promls.fetch import FetcherURL, Parser, ParseError, Collector 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 @@ -57,7 +57,8 @@ def decorator(f): def do_fetch(url: str): """Do the fetch and parse.""" - values, errors = Parser.parse_all(FetcherURL(url).fetch()) + lines, errors = Parser.parse_all(FetcherURL(url).fetch()) + values = Collector(lines).assemble() return MetricsTree({e.name: e for e in values}), errors diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index 1f8c4e9..49c8b2d 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -163,7 +163,7 @@ def __init__(self, r: LineReader): self.r = r @classmethod - def parse_all(cls, text: list[str]) -> tuple[list[Metric], list[ParseError]]: + def parse_all(cls, text: list[str]) -> tuple[list[Parser.DataLine | Parser.MetaLine | None], list[ParseError]]: o = [] errs = [] for i, line in enumerate(text): @@ -174,38 +174,7 @@ def parse_all(cls, text: list[str]) -> tuple[list[Metric], list[ParseError]]: except ParseError as e: errs.append(e) - r = Parser.assemble(o) - return r, errs - - @staticmethod - def basename(name: str) -> tuple[str, str | None]: - 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 - - - @staticmethod - def assemble(lines: list[Parser.DataLine | Parser.MetaLine]): - """Assemble parsed lines into metrics""" - # TODO: gather comments - meta = defaultdict(list) - for line in lines: - if isinstance(line, Parser.MetaLine): - meta[Parser.basename(line.name)[0]].append(line) - - data = defaultdict(list) - for line in lines: - if isinstance(line, Parser.DataLine): - data[Parser.basename(line.name)[0]].append(line) - - metrics = [] - for k, vs in data.items(): - metrics.extend(Parser.build_metric(k, meta[k], vs)) - - return metrics + return o, errs def p_anyline(self) -> Parser.DataLine | Parser.MetaLine | None: if not self.r.line.strip(): @@ -275,6 +244,43 @@ def p_label(self): value = self.r.read_label_value() return name, value + +@dataclass +class Collector: + """Collect metric lines into Metrics""" + + lines: list[Parser.DataLine| Parser.MetaLine| None] + + @staticmethod + def basename(name: str) -> tuple[str, str | None]: + 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) + + data = defaultdict(list) + for line in self.lines: + if isinstance(line, Parser.DataLine): + data[self.basename(line.name)[0]].append(line) + + metrics = [] + for k, vs in data.items(): + metrics.extend(self.build_metric(k, meta[k], vs)) + + return metrics + + @staticmethod def build_metric(base_name, meta: list[Parser.MetaLine], data: list[Parser.DataLine], combine: bool = True) -> list[Metric]: """Subparser for an actual metric.""" @@ -290,7 +296,7 @@ def build_metric(base_name, meta: list[Parser.MetaLine], data: list[Parser.DataL label_sets = [] for line in data: - _, terminal = Parser.basename(line.name) + _, terminal = Collector.basename(line.name) if combine 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 diff --git a/alpacloud/promls/fetch_test.py b/alpacloud/promls/fetch_test.py index 61bf05f..fddc88c 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -1,7 +1,7 @@ from pathlib import Path from alpacloud.lens.conftest import ResourceLoader -from alpacloud.promls.fetch import LineReader, Parser +from alpacloud.promls.fetch import LineReader, Parser, Collector from alpacloud.promls.metrics import Metric @@ -84,7 +84,8 @@ def test_doc_sample(self): telemetry_requests_metrics_latency_microseconds_sum 1.7560473e+07 telemetry_requests_metrics_latency_microseconds_count 2693 """ - r, _ = Parser.parse_all(l.split("\n")) + 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( @@ -104,6 +105,8 @@ def test_doc_sample(self): def test_certmanager_sample(self): l = ResourceLoader(Path(__file__).parent / "test_resources").load_raw("certmanager.prom") - r, _ = Parser.parse_all(l.split("\n")) + vs, _ = Parser.parse_all(l.split("\n")) + r = Collector(vs).assemble() assert len(r) == 46 + From 90781235f3a8fa5ec37fa2b80048cda6f59a206e Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 23:04:52 -0500 Subject: [PATCH 12/21] refactor: move assembling lines into metrics to own class --- alpacloud/promls/cli.py | 2 +- alpacloud/promls/fetch.py | 35 +++++++++++++++++++++++++--------- alpacloud/promls/fetch_test.py | 3 +-- alpacloud/promls/vis.py | 7 +++---- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/alpacloud/promls/cli.py b/alpacloud/promls/cli.py index 17b9925..acbbbcf 100644 --- a/alpacloud/promls/cli.py +++ b/alpacloud/promls/cli.py @@ -7,7 +7,7 @@ import click -from alpacloud.promls.fetch import FetcherURL, Parser, ParseError, Collector +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 diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index 49c8b2d..c2cdeca 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -249,18 +249,23 @@ def p_label(self): class Collector: """Collect metric lines into Metrics""" - lines: list[Parser.DataLine| Parser.MetaLine| None] + lines: list[Parser.DataLine | Parser.MetaLine | None] + combine_submetrics: bool = True @staticmethod def basename(name: str) -> tuple[str, str | None]: maybe_base = name.rsplit("_", 1) if len(maybe_base) == 2: base, terminal = maybe_base - if terminal in {"bucket", "quantile", "sum", "count",}: + if terminal in { + "bucket", + "quantile", + "sum", + "count", + }: return base, terminal return name, None - def assemble(self): """Assemble parsed lines into metrics""" # TODO: gather comments @@ -280,11 +285,8 @@ def assemble(self): return metrics - - @staticmethod - def build_metric(base_name, meta: list[Parser.MetaLine], data: list[Parser.DataLine], combine: bool = True) -> list[Metric]: + def build_metric(self, base_name, meta: list[Parser.MetaLine], data: list[Parser.DataLine]) -> list[Metric]: """Subparser for an actual metric.""" - # TODO: sample values help = "" type = "" @@ -297,13 +299,28 @@ def build_metric(base_name, meta: list[Parser.MetaLine], data: list[Parser.DataL label_sets = [] for line in data: _, terminal = Collector.basename(line.name) - if combine and type in {"summary", "histogram",} and terminal in {"sum", "count",}: + 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) - if combine and type in {"summary", "histogram",}: + if self.combine_submetrics and type in { + "summary", + "histogram", + }: return [Metric(base_name, help, type, label_sets)] else: names = set() diff --git a/alpacloud/promls/fetch_test.py b/alpacloud/promls/fetch_test.py index fddc88c..0aa7c25 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -1,7 +1,7 @@ from pathlib import Path from alpacloud.lens.conftest import ResourceLoader -from alpacloud.promls.fetch import LineReader, Parser, Collector +from alpacloud.promls.fetch import Collector, LineReader, Parser from alpacloud.promls.metrics import Metric @@ -109,4 +109,3 @@ def test_certmanager_sample(self): r = Collector(vs).assemble() assert len(r) == 46 - diff --git a/alpacloud/promls/vis.py b/alpacloud/promls/vis.py index 725558c..d5af8e5 100644 --- a/alpacloud/promls/vis.py +++ b/alpacloud/promls/vis.py @@ -2,7 +2,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import Container, Horizontal, Vertical, VerticalScroll +from textual.containers import Container, Horizontal, Vertical from textual.reactive import Reactive, reactive from textual.screen import Screen from textual.widget import Widget @@ -49,7 +49,7 @@ def compose(self): def action_dismiss(self): """Dismiss the modal.""" self.dismiss() - + class MetricInfoBox(Widget): """A widget to display information about the selected Metric.""" @@ -108,7 +108,6 @@ def compose(self) -> ComposeResult: 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: self.load_metrics() self.focus_findbox() @@ -177,4 +176,4 @@ def action_errors(self): if not self.errors: self.notify("No errors to show", severity="information") return - self.push_screen(ErrorsModal(self.errors)) \ No newline at end of file + self.push_screen(ErrorsModal(self.errors)) From 29378043c661e94cd9778cefeee513471638a581 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 23:12:21 -0500 Subject: [PATCH 13/21] feature: plumb option for combining submetrics --- alpacloud/promls/cli.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/alpacloud/promls/cli.py b/alpacloud/promls/cli.py index acbbbcf..e16cc89 100644 --- a/alpacloud/promls/cli.py +++ b/alpacloud/promls/cli.py @@ -41,6 +41,7 @@ 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,15 +51,16 @@ 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.""" lines, errors = Parser.parse_all(FetcherURL(url).fetch()) - values = Collector(lines).assemble() + values = Collector(lines, combine_submetrics).assemble() return MetricsTree({e.name: e for e in values}), errors @@ -122,9 +124,9 @@ 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, errors = 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)) @@ -132,9 +134,9 @@ def name(url, filter: str, display: PrintMode): @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, errors = 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)) @@ -142,9 +144,9 @@ def any(url, filter: str, display: PrintMode): @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, errors = 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)) @@ -153,8 +155,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 ".*" - results, errors = do_fetch(url) + results, errors = do_fetch(url, combine_submetrics) PromlsVisApp(results, errors, real_filter, lambda s: filter_any(re.compile(s))).run() From 63a1e9622d246ae3e605dbb3d23bc08d7017be23 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 23:14:17 -0500 Subject: [PATCH 14/21] test: add test case from coredns --- alpacloud/promls/fetch_test.py | 7 + alpacloud/promls/test_resources/coredns.prom | 298 +++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 alpacloud/promls/test_resources/coredns.prom diff --git a/alpacloud/promls/fetch_test.py b/alpacloud/promls/fetch_test.py index 0aa7c25..b5188b7 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -109,3 +109,10 @@ def test_certmanager_sample(self): 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 \ No newline at end of file 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 From ccfd3b5933b52fa625eba37f64e5a548008bff15 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 23:55:30 -0500 Subject: [PATCH 15/21] ui: leaf nodes are no longer expandable --- alpacloud/promls/vis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alpacloud/promls/vis.py b/alpacloud/promls/vis.py index d5af8e5..8534655 100644 --- a/alpacloud/promls/vis.py +++ b/alpacloud/promls/vis.py @@ -152,7 +152,7 @@ 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(): From 1f4c440891a92f7aac08854c155980ff9df96318 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Sun, 4 Jan 2026 23:56:14 -0500 Subject: [PATCH 16/21] ui: keep labels expanded status between changing metrics --- alpacloud/promls/vis.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/alpacloud/promls/vis.py b/alpacloud/promls/vis.py index 8534655..f9f6307 100644 --- a/alpacloud/promls/vis.py +++ b/alpacloud/promls/vis.py @@ -55,6 +55,7 @@ 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: with Vertical(): @@ -66,10 +67,15 @@ def compose(self) -> ComposeResult: yield Container(Label(self.metric.type, variant="accent"), classes="right") yield Static(self.metric.help) - with Collapsible(title="Labels"): + 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: + self.expand_labels = False + + def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None: + self.expand_labels = True class PromlsVisApp(App): """A Textual app to visualize Prometheus Metrics.""" From 998eb43aaa541a9b501a4d42ebf2db997048620b Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Mon, 5 Jan 2026 00:03:31 -0500 Subject: [PATCH 17/21] ui: skip "__value__" in tree redundant, only to make traversal easier in jsonlike tree structure --- alpacloud/promls/vis.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/alpacloud/promls/vis.py b/alpacloud/promls/vis.py index f9f6307..e1015aa 100644 --- a/alpacloud/promls/vis.py +++ b/alpacloud/promls/vis.py @@ -162,7 +162,10 @@ def _add_node(self, parent_node, m: TreeT | Metric): 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): tree = self.query_one(Tree) From a722010e2064883a03f7f56046f49d154b9b2ec3 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Mon, 5 Jan 2026 00:12:36 -0500 Subject: [PATCH 18/21] doc: update image of browser --- alpacloud/promls/browse.png | Bin 114329 -> 0 bytes alpacloud/promls/browse.svg | 237 ++++++++++++++++++++++++++++++++++++ alpacloud/promls/readme.md | 15 ++- 3 files changed, 249 insertions(+), 3 deletions(-) delete mode 100644 alpacloud/promls/browse.png create mode 100644 alpacloud/promls/browse.svg diff --git a/alpacloud/promls/browse.png b/alpacloud/promls/browse.png deleted file mode 100644 index abdc9648a033f59db2f238265c8b057eff2e48ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 114329 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%0As?-I3}4@Y2)^EtCc#af0IULC#jqnpLeryJdb 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/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 From 53a548a46f112a5f48d6cfa580b92c4197a01808 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Mon, 5 Jan 2026 00:59:33 -0500 Subject: [PATCH 19/21] task: lint --- alpacloud/promls/cli.py | 8 ++++- alpacloud/promls/cli_test.py | 19 +++++----- alpacloud/promls/fetch.py | 61 +++++++++++++++++++-------------- alpacloud/promls/fetch_test.py | 18 +++++----- alpacloud/promls/filter.py | 5 ++- alpacloud/promls/filter_test.py | 58 +++++++++++++++++++------------ alpacloud/promls/util_test.py | 2 +- alpacloud/promls/vis.py | 21 ++++++++++-- 8 files changed, 123 insertions(+), 69 deletions(-) diff --git a/alpacloud/promls/cli.py b/alpacloud/promls/cli.py index e16cc89..4e32123 100644 --- a/alpacloud/promls/cli.py +++ b/alpacloud/promls/cli.py @@ -41,7 +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) +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(): @@ -110,6 +115,7 @@ def do_print(tree: MetricsTree, mode: PrintMode): def print_errors(errors: list[ParseError]): + """Print parse errors""" if not errors: return click.echo(f"warning: parse errors: {len(errors)}", err=True) 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 c2cdeca..f275de0 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,7 +20,8 @@ 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): @@ -48,52 +44,62 @@ def __str__(self) -> str: return msg -whitespace = re.compile(r"\s+") -name = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*") +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) -> None: + 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): + 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: - match = whitespace.match(self.line, self.cursor) + """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): - match = name.match(self.line, self.cursor) + 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: - # TODO: can optimise to add in slices until escaped char is reached + # 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 @@ -112,12 +118,9 @@ def read_escaped(self, until: str): if self.cursor >= len(self.line): self.err("Unterminated string literal") - self.cursor += 1 # TODO: use consume_for? + self.consume_for('"') return out - def read_label_value(self): - return self.read_escaped('"') - def read_value(self): """Read a value from the line. Value must be a valid float, or NaN or Inf.""" start = self.cursor @@ -131,10 +134,13 @@ def read_value(self): self.err("Invalid numeric value") def read_remaining(self): + """Read the remaining characters on the line""" return self.line[self.cursor :] class Parser: + """Extract meaningful lines from Prometheus metrics text.""" + @dataclass class DataLine: """Data line from Prometheus metrics endpoint.""" @@ -164,6 +170,7 @@ def __init__(self, r: LineReader): @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): @@ -177,15 +184,16 @@ def parse_all(cls, text: list[str]) -> tuple[list[Parser.DataLine | Parser.MetaL return o, errs 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 if self.r.peek_for("#"): - return self.p_comment() + return self.p_metaline() else: - return self.p_metric() + return self.p_dataline() - def p_comment(self): + def p_metaline(self) -> Parser.MetaLine: """Parse a comment line""" self.r.consume_for("#") self.r.consume_whitespace() @@ -203,7 +211,7 @@ def p_comment(self): 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` - def p_metric(self): + def p_dataline(self): """Parse a metric line""" name = self.r.read_name() if name is None: @@ -236,12 +244,14 @@ def p_metric(self): return Parser.DataLine(name, labels, value, timestamp) 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 `=`') - value = self.r.read_label_value() + reader = self.r + value = reader.read_escaped('"') return name, value @@ -254,6 +264,7 @@ class Collector: @staticmethod 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 @@ -286,7 +297,7 @@ def assemble(self): return metrics def build_metric(self, base_name, meta: list[Parser.MetaLine], data: list[Parser.DataLine]) -> list[Metric]: - """Subparser for an actual metric.""" + """Collect lines into Metric objects""" help = "" type = "" diff --git a/alpacloud/promls/fetch_test.py b/alpacloud/promls/fetch_test.py index b5188b7..1d3de71 100644 --- a/alpacloud/promls/fetch_test.py +++ b/alpacloud/promls/fetch_test.py @@ -1,3 +1,5 @@ +# pylint: disable=missing-module-docstring,missing-class-docstring + from pathlib import Path from alpacloud.lens.conftest import ResourceLoader @@ -8,7 +10,7 @@ class TestParserDataline: def test_counter(self): l = LineReader(r'http_request_count{method="post",code="200"} 1027 1395066363000') - r = Parser(l).p_metric() + r = Parser(l).p_dataline() assert r == Parser.DataLine( "http_request_count", { @@ -21,12 +23,12 @@ def test_counter(self): def test_no_labels(self): l = LineReader(r"metric_without_timestamp_and_labels 12.47") - r = Parser(l).p_metric() + r = Parser(l).p_dataline() assert r == Parser.DataLine("metric_without_timestamp_and_labels", {}, 12.47) def test_histogram_quantile(self): l = LineReader(r'telemetry_requests_metrics_latency_microseconds{quantile="0.05"} 3272') - r = Parser(l).p_metric() + r = Parser(l).p_dataline() assert r == Parser.DataLine( "telemetry_requests_metrics_latency_microseconds", { @@ -37,24 +39,24 @@ def test_histogram_quantile(self): def test_histogram_sum(self): l = LineReader(r"telemetry_requests_metrics_latency_microseconds_sum 1.7560473e+07") - r = Parser(l).p_metric() + r = Parser(l).p_dataline() assert r == Parser.DataLine("telemetry_requests_metrics_latency_microseconds_sum", {}, 1.7560473e07) class TestParserMetaLine: def test_help(self): l = LineReader("# HELP telemetry_requests_metrics_latency_microseconds A histogram of the response latency.") - r = Parser(l).p_comment() + 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 = LineReader("# TYPE telemetry_requests_metrics_latency_microseconds summary") - r = Parser(l).p_comment() + r = Parser(l).p_metaline() assert r == Parser.MetaLine("telemetry_requests_metrics_latency_microseconds", Parser.MetaKind.TYPE, "summary") def test_comment(self): l = LineReader("# Finally a summary, which has a pretty complex representation in the text format:") - r = Parser(l).p_comment() + 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:") @@ -115,4 +117,4 @@ def test_coredns_sample(self): vs, _ = Parser.parse_all(l.split("\n")) r = Collector(vs).assemble() - assert len(r) == 61 \ No newline at end of file + 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/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 e1015aa..b99ed87 100644 --- a/alpacloud/promls/vis.py +++ b/alpacloud/promls/vis.py @@ -1,10 +1,12 @@ +"""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 +from textual.screen import Screen, ScreenResultType from textual.widget import Widget from textual.widgets import Collapsible, Footer, Header, Input, Label, Static, Tree @@ -25,6 +27,7 @@ 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() @@ -41,14 +44,15 @@ def __init__(self, errors: list[ParseError], *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") - def action_dismiss(self): + async def action_dismiss(self, result: ScreenResultType | None = None): """Dismiss the modal.""" - self.dismiss() + await self.dismiss() class MetricInfoBox(Widget): @@ -58,6 +62,7 @@ class MetricInfoBox(Widget): expand_labels: bool = False def compose(self) -> ComposeResult: + """Textual compose""" with Vertical(): if not self.metric: yield Label("Metric Info") @@ -72,11 +77,14 @@ def compose(self) -> ComposeResult: 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.""" @@ -106,6 +114,7 @@ def __init__(self, metrics: MetricsTree, errors: list[ParseError], query: str, p self.predicate_factory = predicate_factory def compose(self) -> ComposeResult: + """Textual compose""" yield Header() yield Tree("Prometheus Metrics") yield MetricInfoBox() @@ -115,22 +124,27 @@ def compose(self) -> ComposeResult: 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() @@ -168,6 +182,7 @@ def _add_node(self, parent_node, m: TreeT | Metric): 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 From ad74b0876ebd44729ae1a5debd036feaac806933 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Mon, 5 Jan 2026 01:03:24 -0500 Subject: [PATCH 20/21] task: typecheck --- alpacloud/promls/fetch.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/alpacloud/promls/fetch.py b/alpacloud/promls/fetch.py index f275de0..9051f6a 100644 --- a/alpacloud/promls/fetch.py +++ b/alpacloud/promls/fetch.py @@ -112,9 +112,8 @@ def read_escaped(self, until: str): else: self.err(f"Invalid escape sequence \\{char_at}") else: - char_at = self.cursor + out += self.line[self.cursor] self.cursor += 1 - out += self.line[char_at] if self.cursor >= len(self.line): self.err("Unterminated string literal") @@ -301,11 +300,11 @@ def build_metric(self, base_name, meta: list[Parser.MetaLine], data: list[Parser help = "" type = "" - for line in meta: - 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: From ace01233c120af92919c24d819ec6eb273e1c1a4 Mon Sep 17 00:00:00 2001 From: Daniel Goldman Date: Mon, 5 Jan 2026 01:07:39 -0500 Subject: [PATCH 21/21] task: prep for release --- alpacloud/promls/BUILD | 2 +- alpacloud/promls/changelog.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) 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/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