prombed 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prombed/__init__.py +50 -0
- prombed/errors.py +20 -0
- prombed/exposition.py +192 -0
- prombed/prombed.py +435 -0
- prombed/promql/__init__.py +3 -0
- prombed/promql/evaluator.py +577 -0
- prombed/promql/parser.py +400 -0
- prombed/storage.py +196 -0
- prombed/types.py +85 -0
- prombed/value.py +3 -0
- prombed-0.1.0.dist-info/METADATA +66 -0
- prombed-0.1.0.dist-info/RECORD +15 -0
- prombed-0.1.0.dist-info/WHEEL +4 -0
- prombed-0.1.0.dist-info/licenses/LICENSE +19 -0
- prombed-0.1.0.dist-info/licenses/NOTICE +8 -0
prombed/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from .errors import PrombedError as PrombedError
|
|
2
|
+
from .errors import PrombedErrorCode as PrombedErrorCode
|
|
3
|
+
from .exposition import parse_exposition as parse_exposition
|
|
4
|
+
from .prombed import Fetch as Fetch
|
|
5
|
+
from .prombed import Prombed as Prombed
|
|
6
|
+
from .promql import PromQLEngine as PromQLEngine
|
|
7
|
+
from .promql import parse_duration as parse_duration
|
|
8
|
+
from .promql import parse_promql as parse_promql
|
|
9
|
+
from .storage import AppendResult as AppendResult
|
|
10
|
+
from .storage import InMemoryStorage as InMemoryStorage
|
|
11
|
+
from .storage import StorageAppender as StorageAppender
|
|
12
|
+
from .storage import StoredSeries as StoredSeries
|
|
13
|
+
from .storage import label_key as label_key
|
|
14
|
+
from .types import (
|
|
15
|
+
ExpositionSample as ExpositionSample,
|
|
16
|
+
)
|
|
17
|
+
from .types import (
|
|
18
|
+
LabelMatcher as LabelMatcher,
|
|
19
|
+
)
|
|
20
|
+
from .types import (
|
|
21
|
+
LabelMatcherOperator as LabelMatcherOperator,
|
|
22
|
+
)
|
|
23
|
+
from .types import (
|
|
24
|
+
LabelSet as LabelSet,
|
|
25
|
+
)
|
|
26
|
+
from .types import (
|
|
27
|
+
MetricMetadata as MetricMetadata,
|
|
28
|
+
)
|
|
29
|
+
from .types import (
|
|
30
|
+
MetricType as MetricType,
|
|
31
|
+
)
|
|
32
|
+
from .types import (
|
|
33
|
+
ParsedExposition as ParsedExposition,
|
|
34
|
+
)
|
|
35
|
+
from .types import (
|
|
36
|
+
Sample as Sample,
|
|
37
|
+
)
|
|
38
|
+
from .types import (
|
|
39
|
+
ScrapeResult as ScrapeResult,
|
|
40
|
+
)
|
|
41
|
+
from .types import (
|
|
42
|
+
ScrapeTarget as ScrapeTarget,
|
|
43
|
+
)
|
|
44
|
+
from .types import (
|
|
45
|
+
ScrapeTargetHealth as ScrapeTargetHealth,
|
|
46
|
+
)
|
|
47
|
+
from .types import (
|
|
48
|
+
ScrapeTargetStatus as ScrapeTargetStatus,
|
|
49
|
+
)
|
|
50
|
+
from .value import STALE_NAN_BITS as STALE_NAN_BITS
|
prombed/errors.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
PrombedErrorCode = Literal[
|
|
4
|
+
"invalid_exposition",
|
|
5
|
+
"invalid_query",
|
|
6
|
+
"unsupported_promql_feature",
|
|
7
|
+
"query_execution_error",
|
|
8
|
+
"out_of_order_sample",
|
|
9
|
+
"duplicate_sample",
|
|
10
|
+
"storage_capacity_exceeded",
|
|
11
|
+
"scrape_failed",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PrombedError(Exception):
|
|
16
|
+
"""A stable, machine-readable failure from Prombed."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, code: PrombedErrorCode, message: str) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.code = code
|
prombed/exposition.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from .errors import PrombedError
|
|
7
|
+
from .types import ExpositionSample, MetricMetadata, ParsedExposition
|
|
8
|
+
|
|
9
|
+
_METRIC_NAME = re.compile(r"^[A-Za-z_:][A-Za-z0-9_:]*$")
|
|
10
|
+
_LABEL_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
11
|
+
_FLOAT = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$")
|
|
12
|
+
_METRIC_TYPES: set[str] = {
|
|
13
|
+
"counter",
|
|
14
|
+
"gauge",
|
|
15
|
+
"histogram",
|
|
16
|
+
"summary",
|
|
17
|
+
"untyped",
|
|
18
|
+
"info",
|
|
19
|
+
"stateset",
|
|
20
|
+
"gaugehistogram",
|
|
21
|
+
"unknown",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _invalid(line_number: int, message: str) -> None:
|
|
26
|
+
raise PrombedError(
|
|
27
|
+
"invalid_exposition",
|
|
28
|
+
f"invalid Prometheus exposition at line {line_number}: {message}",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _skip_whitespace(value: str, start: int) -> int:
|
|
33
|
+
index = start
|
|
34
|
+
while index < len(value) and value[index].isspace():
|
|
35
|
+
index += 1
|
|
36
|
+
return index
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_escaped_string(value: str, start: int, line_number: int) -> tuple[str, int]:
|
|
40
|
+
if start >= len(value) or value[start] != '"':
|
|
41
|
+
_invalid(line_number, "expected quoted label value")
|
|
42
|
+
output: list[str] = []
|
|
43
|
+
index = start + 1
|
|
44
|
+
while index < len(value):
|
|
45
|
+
char = value[index]
|
|
46
|
+
if char == '"':
|
|
47
|
+
return "".join(output), index + 1
|
|
48
|
+
if char != "\\":
|
|
49
|
+
output.append(char)
|
|
50
|
+
index += 1
|
|
51
|
+
continue
|
|
52
|
+
index += 1
|
|
53
|
+
if index >= len(value):
|
|
54
|
+
_invalid(line_number, "unterminated quoted label value")
|
|
55
|
+
escaped = value[index]
|
|
56
|
+
replacement = {"\\": "\\", '"': '"', "n": "\n"}.get(escaped)
|
|
57
|
+
if replacement is None:
|
|
58
|
+
_invalid(line_number, f"unsupported escape sequence \\{escaped}")
|
|
59
|
+
output.append(replacement)
|
|
60
|
+
index += 1
|
|
61
|
+
_invalid(line_number, "unterminated quoted label value")
|
|
62
|
+
raise AssertionError("unreachable")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_labels(value: str, start: int, line_number: int) -> tuple[dict[str, str], int]:
|
|
66
|
+
labels: dict[str, str] = {}
|
|
67
|
+
index = start + 1
|
|
68
|
+
while True:
|
|
69
|
+
index = _skip_whitespace(value, index)
|
|
70
|
+
if index < len(value) and value[index] == "}":
|
|
71
|
+
return labels, index + 1
|
|
72
|
+
name_start = index
|
|
73
|
+
while index < len(value) and (value[index].isalnum() or value[index] == "_"):
|
|
74
|
+
index += 1
|
|
75
|
+
name = value[name_start:index]
|
|
76
|
+
if not _LABEL_NAME.fullmatch(name):
|
|
77
|
+
_invalid(line_number, f"invalid label name {name!r}")
|
|
78
|
+
if name in labels:
|
|
79
|
+
_invalid(line_number, f"duplicate label {name!r}")
|
|
80
|
+
index = _skip_whitespace(value, index)
|
|
81
|
+
if index >= len(value) or value[index] != "=":
|
|
82
|
+
_invalid(line_number, f"expected '=' after label {name!r}")
|
|
83
|
+
index = _skip_whitespace(value, index + 1)
|
|
84
|
+
parsed, index = _parse_escaped_string(value, index, line_number)
|
|
85
|
+
labels[name] = parsed
|
|
86
|
+
index = _skip_whitespace(value, index)
|
|
87
|
+
if index < len(value) and value[index] == ",":
|
|
88
|
+
index += 1
|
|
89
|
+
continue
|
|
90
|
+
if index < len(value) and value[index] == "}":
|
|
91
|
+
return labels, index + 1
|
|
92
|
+
_invalid(line_number, "expected ',' or '}' after label value")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_float(token: str, line_number: int) -> float:
|
|
96
|
+
if token == "NaN":
|
|
97
|
+
return math.nan
|
|
98
|
+
if token in {"+Inf", "Inf"}:
|
|
99
|
+
return math.inf
|
|
100
|
+
if token == "-Inf":
|
|
101
|
+
return -math.inf
|
|
102
|
+
if not _FLOAT.fullmatch(token):
|
|
103
|
+
_invalid(line_number, f"invalid sample value {token!r}")
|
|
104
|
+
return float(token)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_sample_line(line: str, line_number: int) -> ExpositionSample:
|
|
108
|
+
index = 0
|
|
109
|
+
while index < len(line) and not line[index].isspace() and line[index] != "{":
|
|
110
|
+
index += 1
|
|
111
|
+
name = line[:index]
|
|
112
|
+
if not _METRIC_NAME.fullmatch(name):
|
|
113
|
+
_invalid(line_number, f"invalid metric name {name!r}")
|
|
114
|
+
labels: dict[str, str] = {}
|
|
115
|
+
if index < len(line) and line[index] == "{":
|
|
116
|
+
labels, index = _parse_labels(line, index, line_number)
|
|
117
|
+
index = _skip_whitespace(line, index)
|
|
118
|
+
value_start = index
|
|
119
|
+
while index < len(line) and not line[index].isspace():
|
|
120
|
+
index += 1
|
|
121
|
+
sample_value = _parse_float(line[value_start:index], line_number)
|
|
122
|
+
index = _skip_whitespace(line, index)
|
|
123
|
+
timestamp: int | None = None
|
|
124
|
+
if index < len(line) and line[index] != "#":
|
|
125
|
+
timestamp_start = index
|
|
126
|
+
while index < len(line) and not line[index].isspace():
|
|
127
|
+
index += 1
|
|
128
|
+
token = line[timestamp_start:index]
|
|
129
|
+
if not re.fullmatch(r"-?\d+", token):
|
|
130
|
+
_invalid(line_number, f"invalid sample timestamp {token!r}")
|
|
131
|
+
timestamp = int(token)
|
|
132
|
+
if abs(timestamp) > 2**53 - 1:
|
|
133
|
+
_invalid(line_number, f"sample timestamp is outside the safe integer range: {token}")
|
|
134
|
+
index = _skip_whitespace(line, index)
|
|
135
|
+
if index < len(line) and line[index] != "#":
|
|
136
|
+
_invalid(line_number, f"unexpected trailing content {line[index:]!r}")
|
|
137
|
+
return ExpositionSample(name, labels, sample_value, timestamp)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _unescape_help(value: str, line_number: int) -> str:
|
|
141
|
+
output: list[str] = []
|
|
142
|
+
index = 0
|
|
143
|
+
while index < len(value):
|
|
144
|
+
char = value[index]
|
|
145
|
+
if char != "\\":
|
|
146
|
+
output.append(char)
|
|
147
|
+
index += 1
|
|
148
|
+
continue
|
|
149
|
+
index += 1
|
|
150
|
+
if index >= len(value):
|
|
151
|
+
_invalid(line_number, "unsupported HELP escape sequence \\")
|
|
152
|
+
escaped = value[index]
|
|
153
|
+
if escaped == "\\":
|
|
154
|
+
output.append("\\")
|
|
155
|
+
elif escaped == "n":
|
|
156
|
+
output.append("\n")
|
|
157
|
+
else:
|
|
158
|
+
_invalid(line_number, f"unsupported HELP escape sequence \\{escaped}")
|
|
159
|
+
index += 1
|
|
160
|
+
return "".join(output)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_exposition(value: str) -> ParsedExposition:
|
|
164
|
+
"""Parse Prometheus text format 0.0.4 and its classic OpenMetrics subset."""
|
|
165
|
+
|
|
166
|
+
samples: list[ExpositionSample] = []
|
|
167
|
+
metadata: dict[str, MetricMetadata] = {}
|
|
168
|
+
descriptor_pattern = re.compile(r"^#\s*(HELP|TYPE|UNIT)\s+([^\s]+)\s+(.+)$")
|
|
169
|
+
for line_number, raw in enumerate(value.splitlines(), 1):
|
|
170
|
+
line = raw.strip()
|
|
171
|
+
if not line or line == "# EOF":
|
|
172
|
+
continue
|
|
173
|
+
if not line.startswith("#"):
|
|
174
|
+
samples.append(_parse_sample_line(line, line_number))
|
|
175
|
+
continue
|
|
176
|
+
match = descriptor_pattern.fullmatch(line)
|
|
177
|
+
if match is None:
|
|
178
|
+
continue
|
|
179
|
+
kind, name, raw_value = match.groups()
|
|
180
|
+
if not _METRIC_NAME.fullmatch(name):
|
|
181
|
+
_invalid(line_number, f"invalid metadata metric name {name!r}")
|
|
182
|
+
entry = metadata.setdefault(name, MetricMetadata())
|
|
183
|
+
if kind == "TYPE":
|
|
184
|
+
metric_type = raw_value.lower()
|
|
185
|
+
if metric_type not in _METRIC_TYPES:
|
|
186
|
+
_invalid(line_number, f"unsupported metric type {raw_value!r}")
|
|
187
|
+
entry.type = metric_type # type: ignore[assignment]
|
|
188
|
+
elif kind == "HELP":
|
|
189
|
+
entry.help = _unescape_help(raw_value, line_number)
|
|
190
|
+
else:
|
|
191
|
+
entry.unit = raw_value
|
|
192
|
+
return ParsedExposition(samples, metadata)
|
prombed/prombed.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
import time
|
|
6
|
+
import urllib.request
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from .errors import PrombedError
|
|
12
|
+
from .exposition import parse_exposition
|
|
13
|
+
from .promql.evaluator import PromQLEngine
|
|
14
|
+
from .storage import InMemoryStorage, StorageAppender, label_key
|
|
15
|
+
from .types import (
|
|
16
|
+
ExpositionSample,
|
|
17
|
+
LabelSet,
|
|
18
|
+
Sample,
|
|
19
|
+
ScrapeResult,
|
|
20
|
+
ScrapeTarget,
|
|
21
|
+
ScrapeTargetStatus,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
Fetch = Callable[[str, dict[str, str], float, int], Awaitable[bytes]]
|
|
25
|
+
ScrapeErrorHandler = Callable[[PrombedError], None]
|
|
26
|
+
|
|
27
|
+
_DEFAULT_SCRAPE_INTERVAL_MS = 5_000
|
|
28
|
+
_DEFAULT_SCRAPE_TIMEOUT_MS = 5_000
|
|
29
|
+
_DEFAULT_MAX_SCRAPE_BYTES = 16 * 1024 * 1024
|
|
30
|
+
_FAMILY_SUFFIXES = ("_bucket", "_sum", "_count", "_created")
|
|
31
|
+
_EXPORTED_LABEL_PREFIX = "exported_"
|
|
32
|
+
_SCRAPE_REPORT_METRICS = {
|
|
33
|
+
"up": "up",
|
|
34
|
+
"duration": "scrape_duration_seconds",
|
|
35
|
+
"samples": "scrape_samples_scraped",
|
|
36
|
+
"samples_after_filter": "scrape_samples_post_metric_relabeling",
|
|
37
|
+
"series_added": "scrape_series_added",
|
|
38
|
+
"body_size": "scrape_body_size_bytes",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _now_ms() -> int:
|
|
43
|
+
return time.time_ns() // 1_000_000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _allowed_metric(name: str, allowlist: tuple[str, ...]) -> bool:
|
|
47
|
+
if not allowlist:
|
|
48
|
+
return True
|
|
49
|
+
return any(
|
|
50
|
+
name == family or name in {family + suffix for suffix in _FAMILY_SUFFIXES}
|
|
51
|
+
for family in allowlist
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _scrape_error(target: ScrapeTarget, error: Exception) -> PrombedError:
|
|
56
|
+
if isinstance(error, PrombedError):
|
|
57
|
+
return error
|
|
58
|
+
return PrombedError("scrape_failed", f"failed to scrape {target.url}: {error}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_url(url: str, headers: dict[str, str], timeout: float, max_bytes: int) -> bytes:
|
|
62
|
+
if max_bytes <= 0:
|
|
63
|
+
raise PrombedError("scrape_failed", "scrape body limit must be positive")
|
|
64
|
+
request = urllib.request.Request(url, headers=headers)
|
|
65
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
66
|
+
declared = response.headers.get("content-length")
|
|
67
|
+
if declared is not None and declared.isdigit() and int(declared) > max_bytes:
|
|
68
|
+
raise PrombedError(
|
|
69
|
+
"scrape_failed",
|
|
70
|
+
f"metrics response declares {declared} bytes, exceeding the limit of {max_bytes}",
|
|
71
|
+
)
|
|
72
|
+
body = response.read(max_bytes + 1)
|
|
73
|
+
if len(body) > max_bytes:
|
|
74
|
+
raise PrombedError(
|
|
75
|
+
"scrape_failed", f"metrics response exceeds the limit of {max_bytes} bytes"
|
|
76
|
+
)
|
|
77
|
+
return body
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _default_fetch(
|
|
81
|
+
url: str, headers: dict[str, str], timeout: float, max_bytes: int
|
|
82
|
+
) -> bytes:
|
|
83
|
+
return await asyncio.to_thread(_read_url, url, headers, timeout, max_bytes)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Port of Prometheus scrape.mutateSampleLabels with honor_labels=false as the default.
|
|
87
|
+
def _mutate_sample_labels(exposed: LabelSet, target: LabelSet, honor: bool) -> LabelSet:
|
|
88
|
+
labels = dict(exposed)
|
|
89
|
+
if honor:
|
|
90
|
+
for name, value in target.items():
|
|
91
|
+
labels.setdefault(name, value)
|
|
92
|
+
return labels
|
|
93
|
+
conflicts: list[tuple[str, str]] = []
|
|
94
|
+
for name, value in target.items():
|
|
95
|
+
existing = labels.get(name, "")
|
|
96
|
+
if existing:
|
|
97
|
+
conflicts.append((name, existing))
|
|
98
|
+
labels[name] = value
|
|
99
|
+
conflicts.sort(key=lambda item: len(item[0]))
|
|
100
|
+
for name, value in conflicts:
|
|
101
|
+
exported = name
|
|
102
|
+
while labels.get(exported, ""):
|
|
103
|
+
exported = _EXPORTED_LABEL_PREFIX + exported
|
|
104
|
+
labels[exported] = value
|
|
105
|
+
return labels
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _target_key(target: ScrapeTarget) -> str:
|
|
109
|
+
return label_key({**target.labels, "__url__": target.url})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _report_labels(target_labels: LabelSet, name: str) -> LabelSet:
|
|
113
|
+
return {**target_labels, "__name__": name}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Prombed:
|
|
117
|
+
"""Embedded scrape, short-retention storage, and PromQL query runtime."""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
targets: list[ScrapeTarget] | None = None,
|
|
123
|
+
retention_ms: int = 10 * 60_000,
|
|
124
|
+
max_series: int = 20_000,
|
|
125
|
+
max_samples_per_series: int = 10_000,
|
|
126
|
+
lookback_delta_ms: int = 5 * 60_000,
|
|
127
|
+
max_range_points: int = 11_000,
|
|
128
|
+
max_samples: int = 50_000_000,
|
|
129
|
+
scrape_interval_ms: int = _DEFAULT_SCRAPE_INTERVAL_MS,
|
|
130
|
+
scrape_timeout_ms: int = _DEFAULT_SCRAPE_TIMEOUT_MS,
|
|
131
|
+
max_scrape_bytes: int = _DEFAULT_MAX_SCRAPE_BYTES,
|
|
132
|
+
fetch: Fetch | None = None,
|
|
133
|
+
on_scrape_error: ScrapeErrorHandler | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
if scrape_interval_ms <= 0 or scrape_timeout_ms <= 0 or max_scrape_bytes <= 0:
|
|
136
|
+
raise PrombedError(
|
|
137
|
+
"scrape_failed", "scrape interval, timeout, and body limit must be positive"
|
|
138
|
+
)
|
|
139
|
+
self.storage = InMemoryStorage(
|
|
140
|
+
retention_ms=retention_ms,
|
|
141
|
+
max_series=max_series,
|
|
142
|
+
max_samples_per_series=max_samples_per_series,
|
|
143
|
+
)
|
|
144
|
+
self._engine = PromQLEngine(
|
|
145
|
+
self.storage,
|
|
146
|
+
lookback_delta_ms=lookback_delta_ms,
|
|
147
|
+
max_range_points=max_range_points,
|
|
148
|
+
max_samples=max_samples,
|
|
149
|
+
)
|
|
150
|
+
self._targets = list(targets or [])
|
|
151
|
+
self._scrape_interval_ms = scrape_interval_ms
|
|
152
|
+
self._scrape_timeout_ms = scrape_timeout_ms
|
|
153
|
+
self._max_scrape_bytes = max_scrape_bytes
|
|
154
|
+
self._fetch = fetch or _default_fetch
|
|
155
|
+
self._on_scrape_error = on_scrape_error
|
|
156
|
+
self._target_series: dict[str, dict[str, LabelSet]] = {}
|
|
157
|
+
self._target_statuses: dict[str, ScrapeTargetStatus] = {}
|
|
158
|
+
self._watch_task: asyncio.Task | None = None
|
|
159
|
+
for target in self._targets:
|
|
160
|
+
self._ensure_target(target)
|
|
161
|
+
|
|
162
|
+
def ingest(
|
|
163
|
+
self,
|
|
164
|
+
exposition: str,
|
|
165
|
+
*,
|
|
166
|
+
timestamp_ms: int | None = None,
|
|
167
|
+
labels: LabelSet | None = None,
|
|
168
|
+
metric_names: tuple[str, ...] = (),
|
|
169
|
+
) -> int:
|
|
170
|
+
parsed = parse_exposition(exposition)
|
|
171
|
+
timestamp = timestamp_ms if timestamp_ms is not None else _now_ms()
|
|
172
|
+
appender = self.storage.appender(timestamp)
|
|
173
|
+
count = 0
|
|
174
|
+
try:
|
|
175
|
+
for sample in parsed.samples:
|
|
176
|
+
if not _allowed_metric(sample.name, metric_names):
|
|
177
|
+
continue
|
|
178
|
+
appender.append(
|
|
179
|
+
{**(labels or {}), **sample.labels, "__name__": sample.name},
|
|
180
|
+
Sample(
|
|
181
|
+
sample.timestamp if sample.timestamp is not None else timestamp,
|
|
182
|
+
sample.value,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
count += 1
|
|
186
|
+
appender.commit()
|
|
187
|
+
return count
|
|
188
|
+
except Exception:
|
|
189
|
+
appender.rollback()
|
|
190
|
+
raise
|
|
191
|
+
|
|
192
|
+
async def scrape_once(self, target: ScrapeTarget | None = None) -> list[ScrapeResult]:
|
|
193
|
+
selected = self._targets if target is None else [target]
|
|
194
|
+
return list(await asyncio.gather(*(self._scrape_target(item) for item in selected)))
|
|
195
|
+
|
|
196
|
+
async def start(self) -> list[ScrapeResult]:
|
|
197
|
+
if self._watch_task is not None:
|
|
198
|
+
return []
|
|
199
|
+
initial = await self.scrape_once()
|
|
200
|
+
self._watch_task = asyncio.create_task(self._watch(), name="prombed-scrape")
|
|
201
|
+
return initial
|
|
202
|
+
|
|
203
|
+
def stop(self) -> None:
|
|
204
|
+
if self._watch_task is not None:
|
|
205
|
+
self._watch_task.cancel()
|
|
206
|
+
self._watch_task = None
|
|
207
|
+
|
|
208
|
+
def targets(self) -> list[ScrapeTargetStatus]:
|
|
209
|
+
return [
|
|
210
|
+
replace(
|
|
211
|
+
status,
|
|
212
|
+
target=replace(
|
|
213
|
+
status.target,
|
|
214
|
+
labels=dict(status.target.labels),
|
|
215
|
+
headers=dict(status.target.headers),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
for status in self._target_statuses.values()
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
def query(self, expression: str, time_ms: int | None = None) -> dict:
|
|
222
|
+
return self._engine.query(expression, time_ms)
|
|
223
|
+
|
|
224
|
+
def query_range(self, expression: str, start_ms: int, end_ms: int, step_ms: int) -> dict:
|
|
225
|
+
return self._engine.query_range(expression, start_ms, end_ms, step_ms)
|
|
226
|
+
|
|
227
|
+
async def _watch(self) -> None:
|
|
228
|
+
try:
|
|
229
|
+
while True:
|
|
230
|
+
await asyncio.sleep(self._scrape_interval_ms / 1_000)
|
|
231
|
+
try:
|
|
232
|
+
await self.scrape_once()
|
|
233
|
+
except Exception as exc: # each target status already records the concrete failure
|
|
234
|
+
if self._on_scrape_error is not None:
|
|
235
|
+
failure = (
|
|
236
|
+
exc
|
|
237
|
+
if isinstance(exc, PrombedError)
|
|
238
|
+
else PrombedError("scrape_failed", str(exc))
|
|
239
|
+
)
|
|
240
|
+
self._on_scrape_error(failure)
|
|
241
|
+
except asyncio.CancelledError:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
async def _scrape_target(self, target: ScrapeTarget) -> ScrapeResult:
|
|
245
|
+
started_at = _now_ms()
|
|
246
|
+
scraped_at = started_at
|
|
247
|
+
key = self._ensure_target(target)
|
|
248
|
+
target_labels = dict(target.labels)
|
|
249
|
+
body_size = 0
|
|
250
|
+
try:
|
|
251
|
+
parsed_url = urlsplit(target.url)
|
|
252
|
+
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
|
253
|
+
raise ValueError(f"invalid target URL {target.url!r}")
|
|
254
|
+
target_labels = {"instance": parsed_url.netloc, **target.labels}
|
|
255
|
+
timeout_ms = target.timeout_ms or self._scrape_timeout_ms
|
|
256
|
+
max_bytes = target.max_body_bytes or self._max_scrape_bytes
|
|
257
|
+
body = await self._fetch(
|
|
258
|
+
target.url,
|
|
259
|
+
{
|
|
260
|
+
"Accept": "text/plain;version=0.0.4;q=1.0,application/openmetrics-text;q=0.5",
|
|
261
|
+
**target.headers,
|
|
262
|
+
},
|
|
263
|
+
timeout_ms / 1_000,
|
|
264
|
+
max_bytes,
|
|
265
|
+
)
|
|
266
|
+
body_size = len(body)
|
|
267
|
+
if body_size > max_bytes:
|
|
268
|
+
raise PrombedError(
|
|
269
|
+
"scrape_failed", f"metrics response exceeds the limit of {max_bytes} bytes"
|
|
270
|
+
)
|
|
271
|
+
parsed = parse_exposition(body.decode())
|
|
272
|
+
added = self._commit_scrape(
|
|
273
|
+
target, key, target_labels, parsed.samples, scraped_at, body_size
|
|
274
|
+
)
|
|
275
|
+
duration_ms = _now_ms() - started_at
|
|
276
|
+
self._report_target(target, key, started_at, duration_ms)
|
|
277
|
+
return ScrapeResult(target, scraped_at, added, duration_ms)
|
|
278
|
+
except Exception as exc:
|
|
279
|
+
failure = _scrape_error(target, exc)
|
|
280
|
+
duration_ms = _now_ms() - started_at
|
|
281
|
+
try:
|
|
282
|
+
self._commit_failed_scrape(key, target_labels, scraped_at, body_size, duration_ms)
|
|
283
|
+
except Exception as report_error:
|
|
284
|
+
failure = PrombedError(
|
|
285
|
+
"scrape_failed",
|
|
286
|
+
f"{failure}; additionally failed to record scrape failure: {report_error}",
|
|
287
|
+
)
|
|
288
|
+
self._report_target(target, key, started_at, duration_ms, failure)
|
|
289
|
+
if failure is exc:
|
|
290
|
+
raise
|
|
291
|
+
raise failure from exc
|
|
292
|
+
|
|
293
|
+
def _ensure_target(self, target: ScrapeTarget) -> str:
|
|
294
|
+
key = _target_key(target)
|
|
295
|
+
self._target_statuses.setdefault(key, ScrapeTargetStatus(target, "unknown"))
|
|
296
|
+
self._target_series.setdefault(key, {})
|
|
297
|
+
return key
|
|
298
|
+
|
|
299
|
+
def _commit_scrape(
|
|
300
|
+
self,
|
|
301
|
+
target: ScrapeTarget,
|
|
302
|
+
key: str,
|
|
303
|
+
target_labels: LabelSet,
|
|
304
|
+
samples: list[ExpositionSample],
|
|
305
|
+
timestamp: int,
|
|
306
|
+
body_size: int,
|
|
307
|
+
) -> int:
|
|
308
|
+
appender = self.storage.appender(timestamp)
|
|
309
|
+
current: dict[str, LabelSet] = {}
|
|
310
|
+
added = 0
|
|
311
|
+
series_added = 0
|
|
312
|
+
try:
|
|
313
|
+
for sample in samples:
|
|
314
|
+
if not _allowed_metric(sample.name, target.metric_names):
|
|
315
|
+
continue
|
|
316
|
+
labels = _mutate_sample_labels(
|
|
317
|
+
{**sample.labels, "__name__": sample.name},
|
|
318
|
+
target_labels,
|
|
319
|
+
target.honor_labels,
|
|
320
|
+
)
|
|
321
|
+
result = appender.append(
|
|
322
|
+
labels,
|
|
323
|
+
Sample(
|
|
324
|
+
sample.timestamp if sample.timestamp is not None else timestamp,
|
|
325
|
+
sample.value,
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
series_added += int(result.created)
|
|
329
|
+
added += 1
|
|
330
|
+
if sample.timestamp is None or target.track_timestamps_staleness:
|
|
331
|
+
current[label_key(labels)] = labels
|
|
332
|
+
self._append_stale_markers(
|
|
333
|
+
appender, self._target_series.get(key, {}), current, timestamp
|
|
334
|
+
)
|
|
335
|
+
self._append_scrape_report(
|
|
336
|
+
appender,
|
|
337
|
+
target_labels,
|
|
338
|
+
timestamp,
|
|
339
|
+
_now_ms() - timestamp,
|
|
340
|
+
len(samples),
|
|
341
|
+
added,
|
|
342
|
+
series_added,
|
|
343
|
+
body_size,
|
|
344
|
+
healthy=True,
|
|
345
|
+
)
|
|
346
|
+
appender.commit()
|
|
347
|
+
self._target_series[key] = current
|
|
348
|
+
return added
|
|
349
|
+
except Exception:
|
|
350
|
+
appender.rollback()
|
|
351
|
+
raise
|
|
352
|
+
|
|
353
|
+
def _commit_failed_scrape(
|
|
354
|
+
self,
|
|
355
|
+
key: str,
|
|
356
|
+
target_labels: LabelSet,
|
|
357
|
+
timestamp: int,
|
|
358
|
+
body_size: int,
|
|
359
|
+
duration_ms: int,
|
|
360
|
+
) -> None:
|
|
361
|
+
appender = self.storage.appender(timestamp)
|
|
362
|
+
try:
|
|
363
|
+
self._append_stale_markers(appender, self._target_series.get(key, {}), {}, timestamp)
|
|
364
|
+
self._append_scrape_report(
|
|
365
|
+
appender,
|
|
366
|
+
target_labels,
|
|
367
|
+
timestamp,
|
|
368
|
+
duration_ms,
|
|
369
|
+
0,
|
|
370
|
+
0,
|
|
371
|
+
0,
|
|
372
|
+
body_size,
|
|
373
|
+
healthy=False,
|
|
374
|
+
)
|
|
375
|
+
appender.commit()
|
|
376
|
+
self._target_series[key] = {}
|
|
377
|
+
except Exception:
|
|
378
|
+
appender.rollback()
|
|
379
|
+
raise
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _append_stale_markers(
|
|
383
|
+
appender: StorageAppender,
|
|
384
|
+
previous: dict[str, LabelSet],
|
|
385
|
+
current: dict[str, LabelSet],
|
|
386
|
+
timestamp: int,
|
|
387
|
+
) -> None:
|
|
388
|
+
for key, labels in previous.items():
|
|
389
|
+
if key in current:
|
|
390
|
+
continue
|
|
391
|
+
try:
|
|
392
|
+
appender.append(labels, Sample(timestamp, math.nan, stale=True))
|
|
393
|
+
except PrombedError as exc:
|
|
394
|
+
if exc.code not in {"out_of_order_sample", "duplicate_sample"}:
|
|
395
|
+
raise
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def _append_scrape_report(
|
|
399
|
+
appender: StorageAppender,
|
|
400
|
+
target_labels: LabelSet,
|
|
401
|
+
timestamp: int,
|
|
402
|
+
duration_ms: int,
|
|
403
|
+
scraped: int,
|
|
404
|
+
added: int,
|
|
405
|
+
series_added: int,
|
|
406
|
+
body_size: int,
|
|
407
|
+
*,
|
|
408
|
+
healthy: bool,
|
|
409
|
+
) -> None:
|
|
410
|
+
values = (
|
|
411
|
+
(_SCRAPE_REPORT_METRICS["up"], 1.0 if healthy else 0.0),
|
|
412
|
+
(_SCRAPE_REPORT_METRICS["duration"], duration_ms / 1_000),
|
|
413
|
+
(_SCRAPE_REPORT_METRICS["samples"], float(scraped)),
|
|
414
|
+
(_SCRAPE_REPORT_METRICS["samples_after_filter"], float(added)),
|
|
415
|
+
(_SCRAPE_REPORT_METRICS["series_added"], float(series_added)),
|
|
416
|
+
(_SCRAPE_REPORT_METRICS["body_size"], float(body_size)),
|
|
417
|
+
)
|
|
418
|
+
for name, value in values:
|
|
419
|
+
appender.append(_report_labels(target_labels, name), Sample(timestamp, value))
|
|
420
|
+
|
|
421
|
+
def _report_target(
|
|
422
|
+
self,
|
|
423
|
+
target: ScrapeTarget,
|
|
424
|
+
key: str,
|
|
425
|
+
started_at: int,
|
|
426
|
+
duration_ms: int,
|
|
427
|
+
error: PrombedError | None = None,
|
|
428
|
+
) -> None:
|
|
429
|
+
self._target_statuses[key] = ScrapeTargetStatus(
|
|
430
|
+
target,
|
|
431
|
+
"down" if error else "up",
|
|
432
|
+
str(error) if error else None,
|
|
433
|
+
started_at,
|
|
434
|
+
duration_ms,
|
|
435
|
+
)
|