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 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
+ )
@@ -0,0 +1,3 @@
1
+ from .evaluator import PromQLEngine as PromQLEngine
2
+ from .parser import parse_duration as parse_duration
3
+ from .parser import parse_promql as parse_promql