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