loki-reader-core 0.0.1__tar.gz → 0.2.0__tar.gz
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.
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/PKG-INFO +3 -9
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/README.md +1 -7
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/pyproject.toml +2 -2
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/__init__.py +4 -2
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/client.py +208 -23
- loki_reader_core-0.2.0/src/loki_reader_core/models/__init__.py +19 -0
- loki_reader_core-0.2.0/src/loki_reader_core/models/metric_sample.py +75 -0
- loki_reader_core-0.2.0/src/loki_reader_core/models/metric_series.py +90 -0
- loki_reader_core-0.2.0/src/loki_reader_core/models/query_result.py +134 -0
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/utils.py +3 -3
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/tests/test_client.py +260 -44
- loki_reader_core-0.2.0/tests/test_models.py +544 -0
- loki_reader_core-0.0.1/src/loki_reader_core/models/__init__.py +0 -10
- loki_reader_core-0.0.1/src/loki_reader_core/models/query_result.py +0 -88
- loki_reader_core-0.0.1/tests/test_models.py +0 -270
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/.gitignore +0 -0
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/build-publish.sh +0 -0
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/dev-requirements.txt +0 -0
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/exceptions.py +0 -0
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/models/log_entry.py +0 -0
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/models/log_stream.py +0 -0
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/models/query_stats.py +0 -0
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/tests/__init__.py +0 -0
- {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/tests/test_utils.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loki-reader-core
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python library for querying Grafana Loki logs via REST API
|
|
5
5
|
Project-URL: Homepage, https://github.com/jmazzahacks/loki-reader-core
|
|
6
6
|
Project-URL: Issues, https://github.com/jmazzahacks/loki-reader-core/issues
|
|
7
|
-
Author-email: Jason Byteforge <
|
|
7
|
+
Author-email: Jason Byteforge <jason@mzmail.me>
|
|
8
8
|
License: MIT License
|
|
9
9
|
Keywords: grafana,logging,logs,loki,observability
|
|
10
10
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -30,7 +30,7 @@ A lightweight Python library for querying Grafana Loki logs via REST API.
|
|
|
30
30
|
|
|
31
31
|
## Installation
|
|
32
32
|
|
|
33
|
-
From PyPI
|
|
33
|
+
From PyPI:
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
36
|
pip install loki-reader-core
|
|
@@ -158,12 +158,6 @@ source bin/activate
|
|
|
158
158
|
pytest tests/ -v
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
-
### Building and Publishing
|
|
162
|
-
|
|
163
|
-
```bash
|
|
164
|
-
./build-publish.sh
|
|
165
|
-
```
|
|
166
|
-
|
|
167
161
|
## License
|
|
168
162
|
|
|
169
163
|
MIT
|
|
@@ -14,7 +14,7 @@ A lightweight Python library for querying Grafana Loki logs via REST API.
|
|
|
14
14
|
|
|
15
15
|
## Installation
|
|
16
16
|
|
|
17
|
-
From PyPI
|
|
17
|
+
From PyPI:
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
pip install loki-reader-core
|
|
@@ -142,12 +142,6 @@ source bin/activate
|
|
|
142
142
|
pytest tests/ -v
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
-
### Building and Publishing
|
|
146
|
-
|
|
147
|
-
```bash
|
|
148
|
-
./build-publish.sh
|
|
149
|
-
```
|
|
150
|
-
|
|
151
145
|
## License
|
|
152
146
|
|
|
153
147
|
MIT
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "loki-reader-core"
|
|
3
|
-
version = "0.0
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
authors = [
|
|
5
|
-
{ name="Jason Byteforge", email="
|
|
5
|
+
{ name="Jason Byteforge", email="jason@mzmail.me" },
|
|
6
6
|
]
|
|
7
7
|
description = "Python library for querying Grafana Loki logs via REST API"
|
|
8
8
|
keywords = ["loki", "grafana", "logging", "logs", "observability"]
|
|
@@ -4,14 +4,16 @@ Python library for querying Grafana Loki logs via REST API
|
|
|
4
4
|
|
|
5
5
|
from .client import LokiClient
|
|
6
6
|
from .exceptions import LokiAuthError, LokiConnectionError, LokiError, LokiQueryError
|
|
7
|
-
from .models import LogEntry, LogStream, QueryResult, QueryStats
|
|
7
|
+
from .models import LogEntry, LogStream, MetricSample, MetricSeries, QueryResult, QueryStats
|
|
8
8
|
|
|
9
|
-
__version__ = "0.0
|
|
9
|
+
__version__ = "0.2.0"
|
|
10
10
|
|
|
11
11
|
__all__ = [
|
|
12
12
|
"LokiClient",
|
|
13
13
|
"LogEntry",
|
|
14
14
|
"LogStream",
|
|
15
|
+
"MetricSample",
|
|
16
|
+
"MetricSeries",
|
|
15
17
|
"QueryResult",
|
|
16
18
|
"QueryStats",
|
|
17
19
|
"LokiError",
|
|
@@ -8,7 +8,102 @@ import requests
|
|
|
8
8
|
|
|
9
9
|
from .exceptions import LokiAuthError, LokiConnectionError, LokiQueryError
|
|
10
10
|
from .models import QueryResult
|
|
11
|
-
from .utils import now_ns
|
|
11
|
+
from .utils import now_ns, NANOSECONDS_PER_MINUTE, NANOSECONDS_PER_HOUR
|
|
12
|
+
|
|
13
|
+
APP_LABEL_NAMES = ["application", "app", "job", "service", "service_name", "logger"]
|
|
14
|
+
SEVERITY_LABEL_NAMES = ["level", "severity", "log_level", "loglevel"]
|
|
15
|
+
SEVERITY_TIERS = ["trace", "debug", "info", "warn", "warning", "error", "fatal", "critical"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_metric_query(logql: str) -> bool:
|
|
19
|
+
"""Check if a LogQL query is a metric query (vs a log selector).
|
|
20
|
+
|
|
21
|
+
Metric queries start with aggregation functions and return numeric
|
|
22
|
+
results via the instant endpoint. Log selectors return log streams
|
|
23
|
+
and need the range endpoint.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
logql: LogQL query string.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
True if the query is a metric/aggregation query.
|
|
30
|
+
"""
|
|
31
|
+
metric_prefixes = [
|
|
32
|
+
"count_over_time", "rate", "bytes_over_time", "bytes_rate",
|
|
33
|
+
"sum", "avg", "min", "max", "stddev", "stdvar",
|
|
34
|
+
"quantile_over_time", "first_over_time", "last_over_time",
|
|
35
|
+
"absent_over_time", "rate_counter", "topk", "bottomk",
|
|
36
|
+
"sort", "sort_desc", "label_replace", "label_join",
|
|
37
|
+
]
|
|
38
|
+
stripped = logql.strip().lower()
|
|
39
|
+
for prefix in metric_prefixes:
|
|
40
|
+
if stripped.startswith(prefix + "(") or stripped.startswith(prefix + " ("):
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_severity_regex(min_severity: str) -> str:
|
|
46
|
+
"""Build regex matching min_severity and all higher severity tiers.
|
|
47
|
+
|
|
48
|
+
Canonical order (low to high): trace, debug, info, warn, error, fatal.
|
|
49
|
+
Aliases: "warning" maps to "warn", "critical" maps to "fatal".
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
min_severity: Minimum severity level to include.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Pipe-separated regex string (e.g. "error|fatal|critical").
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If min_severity is not a recognized level.
|
|
59
|
+
"""
|
|
60
|
+
normalized = min_severity.strip().lower()
|
|
61
|
+
|
|
62
|
+
if normalized == "warning":
|
|
63
|
+
normalized = "warn"
|
|
64
|
+
if normalized == "critical":
|
|
65
|
+
normalized = "fatal"
|
|
66
|
+
|
|
67
|
+
canonical = ["trace", "debug", "info", "warn", "error", "fatal"]
|
|
68
|
+
idx = canonical.index(normalized) # raises ValueError if invalid
|
|
69
|
+
selected = canonical[idx:]
|
|
70
|
+
|
|
71
|
+
expanded = []
|
|
72
|
+
for level in selected:
|
|
73
|
+
expanded.append(level)
|
|
74
|
+
if level == "warn":
|
|
75
|
+
expanded.append("warning")
|
|
76
|
+
elif level == "fatal":
|
|
77
|
+
expanded.append("critical")
|
|
78
|
+
|
|
79
|
+
return "|".join(expanded)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_since(
|
|
83
|
+
since_minutes: Optional[int],
|
|
84
|
+
since_hours: Optional[int],
|
|
85
|
+
since_days: Optional[int],
|
|
86
|
+
) -> Optional[tuple[int, int]]:
|
|
87
|
+
"""Resolve relative time params into a (start_ns, end_ns) tuple.
|
|
88
|
+
|
|
89
|
+
Priority: since_minutes > since_hours > since_days.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
since_minutes: Number of minutes to look back.
|
|
93
|
+
since_hours: Number of hours to look back.
|
|
94
|
+
since_days: Number of days to look back.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Tuple of (start_ns, end_ns) or None if all params are None.
|
|
98
|
+
"""
|
|
99
|
+
end = now_ns()
|
|
100
|
+
if since_minutes is not None:
|
|
101
|
+
return (end - since_minutes * NANOSECONDS_PER_MINUTE, end)
|
|
102
|
+
if since_hours is not None:
|
|
103
|
+
return (end - since_hours * NANOSECONDS_PER_HOUR, end)
|
|
104
|
+
if since_days is not None:
|
|
105
|
+
return (end - since_days * NANOSECONDS_PER_HOUR * 24, end)
|
|
106
|
+
return None
|
|
12
107
|
|
|
13
108
|
|
|
14
109
|
class LokiClient:
|
|
@@ -22,12 +117,7 @@ class LokiClient:
|
|
|
22
117
|
org_id="tenant-1"
|
|
23
118
|
)
|
|
24
119
|
|
|
25
|
-
result = client.
|
|
26
|
-
logql='{job="api"} |= "error"',
|
|
27
|
-
start=hours_ago_ns(1),
|
|
28
|
-
end=now_ns(),
|
|
29
|
-
limit=500
|
|
30
|
-
)
|
|
120
|
+
result = client.query(app="my-api", severity="error", since_hours=1)
|
|
31
121
|
"""
|
|
32
122
|
|
|
33
123
|
def __init__(
|
|
@@ -58,6 +148,8 @@ class LokiClient:
|
|
|
58
148
|
self.timeout = timeout
|
|
59
149
|
|
|
60
150
|
self._session: Optional[requests.Session] = None
|
|
151
|
+
self._app_label_cache: dict[str, str] = {}
|
|
152
|
+
self._severity_label_cache: Optional[str] = None
|
|
61
153
|
|
|
62
154
|
@property
|
|
63
155
|
def session(self) -> requests.Session:
|
|
@@ -139,33 +231,126 @@ class LokiClient:
|
|
|
139
231
|
|
|
140
232
|
return data
|
|
141
233
|
|
|
234
|
+
def _find_app_label(self, app_value: str) -> str:
|
|
235
|
+
"""Discover which label name contains the given app value.
|
|
236
|
+
|
|
237
|
+
Checks a prioritized list of common label names and returns the
|
|
238
|
+
first one that contains the app value. Results are cached.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
app_value: Application name to search for.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
The label name that contains the app value.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
ValueError: If app_value is not found in any common label.
|
|
248
|
+
"""
|
|
249
|
+
if app_value in self._app_label_cache:
|
|
250
|
+
return self._app_label_cache[app_value]
|
|
251
|
+
|
|
252
|
+
for label_name in APP_LABEL_NAMES:
|
|
253
|
+
values = self.get_label_values(label_name)
|
|
254
|
+
if app_value in values:
|
|
255
|
+
self._app_label_cache[app_value] = label_name
|
|
256
|
+
return label_name
|
|
257
|
+
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"Could not find '{app_value}' in any common label "
|
|
260
|
+
f"({', '.join(APP_LABEL_NAMES)}). Use logql param for custom labels."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _find_severity_label(self) -> Optional[str]:
|
|
264
|
+
"""Discover which label name is used for severity/level.
|
|
265
|
+
|
|
266
|
+
Checks a prioritized list of common severity label names and
|
|
267
|
+
returns the first one that has values. Result is cached.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
The severity label name, or None if not found.
|
|
271
|
+
"""
|
|
272
|
+
if self._severity_label_cache is not None:
|
|
273
|
+
return self._severity_label_cache
|
|
274
|
+
|
|
275
|
+
for label_name in SEVERITY_LABEL_NAMES:
|
|
276
|
+
values = self.get_label_values(label_name)
|
|
277
|
+
if values:
|
|
278
|
+
self._severity_label_cache = label_name
|
|
279
|
+
return label_name
|
|
280
|
+
|
|
281
|
+
return None
|
|
282
|
+
|
|
142
283
|
def query(
|
|
143
284
|
self,
|
|
144
|
-
logql: str,
|
|
145
|
-
|
|
146
|
-
|
|
285
|
+
logql: Optional[str] = None,
|
|
286
|
+
app: Optional[str] = None,
|
|
287
|
+
severity: Optional[str] = None,
|
|
288
|
+
limit: int = 100,
|
|
289
|
+
since_minutes: Optional[int] = None,
|
|
290
|
+
since_hours: Optional[int] = None,
|
|
291
|
+
since_days: Optional[int] = None,
|
|
147
292
|
) -> QueryResult:
|
|
148
293
|
"""
|
|
149
|
-
|
|
294
|
+
Query Loki logs by application name or LogQL expression.
|
|
295
|
+
|
|
296
|
+
For app-based queries, auto-discovers the correct label name and
|
|
297
|
+
builds the LogQL selector. For raw LogQL, passes through directly.
|
|
298
|
+
|
|
299
|
+
Metric queries (count_over_time, rate, etc.) use the instant endpoint.
|
|
300
|
+
All other queries use the range endpoint with a time window.
|
|
150
301
|
|
|
151
302
|
Args:
|
|
152
|
-
logql: LogQL query string
|
|
153
|
-
|
|
303
|
+
logql: Raw LogQL query string. Mutually exclusive with app.
|
|
304
|
+
app: Application name to search for. Auto-discovers the label.
|
|
305
|
+
severity: Minimum severity level (trace/debug/info/warn/error/fatal).
|
|
154
306
|
limit: Maximum number of entries to return.
|
|
307
|
+
since_minutes: Look back N minutes from now.
|
|
308
|
+
since_hours: Look back N hours from now.
|
|
309
|
+
since_days: Look back N days from now.
|
|
155
310
|
|
|
156
311
|
Returns:
|
|
157
|
-
QueryResult containing matching log streams.
|
|
158
|
-
"""
|
|
159
|
-
params = {
|
|
160
|
-
"query": logql,
|
|
161
|
-
"limit": limit
|
|
162
|
-
}
|
|
312
|
+
QueryResult containing matching log streams or metric results.
|
|
163
313
|
|
|
164
|
-
|
|
165
|
-
|
|
314
|
+
Raises:
|
|
315
|
+
ValueError: If both logql and app are provided, or neither.
|
|
316
|
+
"""
|
|
317
|
+
if logql is not None and app is not None:
|
|
318
|
+
raise ValueError("Cannot combine 'logql' with 'app'")
|
|
319
|
+
if logql is None and app is None:
|
|
320
|
+
raise ValueError("Must provide either 'logql' or 'app'")
|
|
321
|
+
|
|
322
|
+
if app is not None:
|
|
323
|
+
label_name = self._find_app_label(app)
|
|
324
|
+
selector = f'{label_name}="{app}"'
|
|
325
|
+
if severity is not None:
|
|
326
|
+
sev_label = self._find_severity_label()
|
|
327
|
+
if sev_label:
|
|
328
|
+
regex = _build_severity_regex(severity)
|
|
329
|
+
selector += f', {sev_label}=~"{regex}"'
|
|
330
|
+
logql = "{" + selector + "}"
|
|
331
|
+
|
|
332
|
+
has_since = any(
|
|
333
|
+
p is not None for p in [since_minutes, since_hours, since_days]
|
|
334
|
+
)
|
|
166
335
|
|
|
167
|
-
|
|
168
|
-
|
|
336
|
+
if _is_metric_query(logql) and not has_since:
|
|
337
|
+
params = {"query": logql, "limit": limit}
|
|
338
|
+
response = self._request("GET", "/loki/api/v1/query", params)
|
|
339
|
+
return QueryResult.from_loki_response(response)
|
|
340
|
+
|
|
341
|
+
time_range = _resolve_since(since_minutes, since_hours, since_days)
|
|
342
|
+
if time_range is None:
|
|
343
|
+
end = now_ns()
|
|
344
|
+
start = end - (30 * 24 * NANOSECONDS_PER_HOUR)
|
|
345
|
+
time_range = (start, end)
|
|
346
|
+
|
|
347
|
+
return self.query_range(
|
|
348
|
+
logql=logql,
|
|
349
|
+
start=time_range[0],
|
|
350
|
+
end=time_range[1],
|
|
351
|
+
limit=limit,
|
|
352
|
+
direction="backward",
|
|
353
|
+
)
|
|
169
354
|
|
|
170
355
|
def query_range(
|
|
171
356
|
self,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for loki-reader-core.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .log_entry import LogEntry
|
|
6
|
+
from .log_stream import LogStream
|
|
7
|
+
from .metric_sample import MetricSample
|
|
8
|
+
from .metric_series import MetricSeries
|
|
9
|
+
from .query_stats import QueryStats
|
|
10
|
+
from .query_result import QueryResult
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"LogEntry",
|
|
14
|
+
"LogStream",
|
|
15
|
+
"MetricSample",
|
|
16
|
+
"MetricSeries",
|
|
17
|
+
"QueryStats",
|
|
18
|
+
"QueryResult",
|
|
19
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MetricSample model representing a single numeric datapoint from a Loki metric query.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
from ..utils import NANOSECONDS_PER_SECOND
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class MetricSample:
|
|
14
|
+
"""
|
|
15
|
+
A single metric datapoint from a Loki metric query.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
timestamp: Unix timestamp in nanoseconds when the sample was recorded.
|
|
19
|
+
value: The numeric value of the metric at this timestamp.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
timestamp: int
|
|
23
|
+
value: float
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
"""
|
|
27
|
+
Convert to dictionary for serialization.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Dictionary with timestamp and value.
|
|
31
|
+
"""
|
|
32
|
+
return {
|
|
33
|
+
"timestamp": self.timestamp,
|
|
34
|
+
"value": self.value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_dict(cls, data: dict) -> "MetricSample":
|
|
39
|
+
"""
|
|
40
|
+
Create MetricSample from dictionary.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
data: Dictionary with timestamp and value keys.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
MetricSample instance.
|
|
47
|
+
"""
|
|
48
|
+
return cls(
|
|
49
|
+
timestamp=int(data["timestamp"]),
|
|
50
|
+
value=float(data["value"])
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_loki_value(cls, value: list) -> "MetricSample":
|
|
55
|
+
"""
|
|
56
|
+
Create MetricSample from Loki's [timestamp, value] format.
|
|
57
|
+
|
|
58
|
+
Loki returns metric values as [unix_seconds_float, "string_value"].
|
|
59
|
+
The timestamp is in seconds (possibly fractional) and the value is a string.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
value: List of [timestamp_seconds, value_string] from Loki API.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
MetricSample instance with timestamp converted to nanoseconds.
|
|
66
|
+
"""
|
|
67
|
+
timestamp_seconds = float(value[0])
|
|
68
|
+
whole_seconds = math.floor(timestamp_seconds)
|
|
69
|
+
fractional = timestamp_seconds - whole_seconds
|
|
70
|
+
timestamp_ns = (whole_seconds * NANOSECONDS_PER_SECOND
|
|
71
|
+
+ round(fractional * NANOSECONDS_PER_SECOND))
|
|
72
|
+
return cls(
|
|
73
|
+
timestamp=timestamp_ns,
|
|
74
|
+
value=float(value[1])
|
|
75
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MetricSeries model representing a series of metric datapoints with common labels.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .metric_sample import MetricSample
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class MetricSeries:
|
|
12
|
+
"""
|
|
13
|
+
A series of metric datapoints sharing common labels.
|
|
14
|
+
|
|
15
|
+
Returned by Loki for metric queries like count_over_time, rate, etc.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
labels: Dictionary of label key-value pairs identifying this series.
|
|
19
|
+
samples: List of MetricSample objects with timestamp/value pairs.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
labels: dict[str, str]
|
|
23
|
+
samples: list[MetricSample]
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
"""
|
|
27
|
+
Convert to dictionary for serialization.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Dictionary with labels and samples.
|
|
31
|
+
"""
|
|
32
|
+
return {
|
|
33
|
+
"labels": self.labels,
|
|
34
|
+
"samples": [sample.to_dict() for sample in self.samples]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_dict(cls, data: dict) -> "MetricSeries":
|
|
39
|
+
"""
|
|
40
|
+
Create MetricSeries from dictionary.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
data: Dictionary with labels and samples keys.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
MetricSeries instance.
|
|
47
|
+
"""
|
|
48
|
+
samples = [MetricSample.from_dict(s) for s in data.get("samples", [])]
|
|
49
|
+
return cls(
|
|
50
|
+
labels=data["labels"],
|
|
51
|
+
samples=samples
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_loki_matrix(cls, data: dict) -> "MetricSeries":
|
|
56
|
+
"""
|
|
57
|
+
Create MetricSeries from Loki matrix result format.
|
|
58
|
+
|
|
59
|
+
Matrix results have multiple timestamp/value pairs per series.
|
|
60
|
+
Format: {"metric": {labels}, "values": [[ts, val], ...]}
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
data: Dictionary with 'metric' (labels) and 'values' (datapoints).
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
MetricSeries instance.
|
|
67
|
+
"""
|
|
68
|
+
labels = data.get("metric", {})
|
|
69
|
+
values = data.get("values", [])
|
|
70
|
+
samples = [MetricSample.from_loki_value(v) for v in values]
|
|
71
|
+
return cls(labels=labels, samples=samples)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_loki_vector(cls, data: dict) -> "MetricSeries":
|
|
75
|
+
"""
|
|
76
|
+
Create MetricSeries from Loki vector result format.
|
|
77
|
+
|
|
78
|
+
Vector results have a single timestamp/value pair per series.
|
|
79
|
+
Format: {"metric": {labels}, "value": [ts, val]}
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
data: Dictionary with 'metric' (labels) and 'value' (single datapoint).
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
MetricSeries instance with one sample.
|
|
86
|
+
"""
|
|
87
|
+
labels = data.get("metric", {})
|
|
88
|
+
value = data.get("value", [])
|
|
89
|
+
samples = [MetricSample.from_loki_value(value)] if value else []
|
|
90
|
+
return cls(labels=labels, samples=samples)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QueryResult model representing the result of a Loki query.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .log_stream import LogStream
|
|
8
|
+
from .metric_series import MetricSeries
|
|
9
|
+
from .query_stats import QueryStats
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class QueryResult:
|
|
14
|
+
"""
|
|
15
|
+
Result of a Loki query containing streams, metric series, and statistics.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
status: Response status from Loki (typically "success").
|
|
19
|
+
streams: List of LogStream objects (populated for log queries).
|
|
20
|
+
stats: Optional QueryStats with execution statistics.
|
|
21
|
+
result_type: Loki result type ("streams", "matrix", or "vector").
|
|
22
|
+
metric_series: List of MetricSeries objects (populated for metric queries).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
status: str
|
|
26
|
+
streams: list[LogStream]
|
|
27
|
+
stats: QueryStats | None
|
|
28
|
+
result_type: str = "streams"
|
|
29
|
+
metric_series: list[MetricSeries] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
"""
|
|
33
|
+
Convert to dictionary for serialization.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Dictionary with status, streams, metric_series, and stats.
|
|
37
|
+
"""
|
|
38
|
+
return {
|
|
39
|
+
"status": self.status,
|
|
40
|
+
"result_type": self.result_type,
|
|
41
|
+
"streams": [stream.to_dict() for stream in self.streams],
|
|
42
|
+
"metric_series": [s.to_dict() for s in self.metric_series],
|
|
43
|
+
"stats": self.stats.to_dict() if self.stats else None
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: dict) -> "QueryResult":
|
|
48
|
+
"""
|
|
49
|
+
Create QueryResult from dictionary.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
data: Dictionary with status, streams, metric_series, and stats keys.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
QueryResult instance.
|
|
56
|
+
"""
|
|
57
|
+
streams = [LogStream.from_dict(s) for s in data.get("streams", [])]
|
|
58
|
+
metric_series = [
|
|
59
|
+
MetricSeries.from_dict(s) for s in data.get("metric_series", [])
|
|
60
|
+
]
|
|
61
|
+
stats = QueryStats.from_dict(data["stats"]) if data.get("stats") else None
|
|
62
|
+
return cls(
|
|
63
|
+
status=data["status"],
|
|
64
|
+
streams=streams,
|
|
65
|
+
stats=stats,
|
|
66
|
+
result_type=data.get("result_type", "streams"),
|
|
67
|
+
metric_series=metric_series,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_loki_response(cls, response_data: dict) -> "QueryResult":
|
|
72
|
+
"""
|
|
73
|
+
Create QueryResult from Loki API response format.
|
|
74
|
+
|
|
75
|
+
Handles all three Loki result types:
|
|
76
|
+
- streams: log lines with labels
|
|
77
|
+
- matrix: time series of numeric values (from rate, count_over_time, etc.)
|
|
78
|
+
- vector: instant numeric values
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
response_data: Full response from Loki query API.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
QueryResult instance.
|
|
85
|
+
"""
|
|
86
|
+
status = response_data.get("status", "unknown")
|
|
87
|
+
data = response_data.get("data", {})
|
|
88
|
+
result_type = data.get("resultType", "streams")
|
|
89
|
+
result_list = data.get("result", [])
|
|
90
|
+
|
|
91
|
+
stats_data = data.get("stats")
|
|
92
|
+
stats = QueryStats.from_loki_stats(stats_data) if stats_data else None
|
|
93
|
+
|
|
94
|
+
streams: list[LogStream] = []
|
|
95
|
+
metric_series: list[MetricSeries] = []
|
|
96
|
+
|
|
97
|
+
if result_type == "matrix":
|
|
98
|
+
metric_series = [
|
|
99
|
+
MetricSeries.from_loki_matrix(r) for r in result_list
|
|
100
|
+
]
|
|
101
|
+
elif result_type == "vector":
|
|
102
|
+
metric_series = [
|
|
103
|
+
MetricSeries.from_loki_vector(r) for r in result_list
|
|
104
|
+
]
|
|
105
|
+
else:
|
|
106
|
+
streams = [LogStream.from_loki_stream(s) for s in result_list]
|
|
107
|
+
|
|
108
|
+
return cls(
|
|
109
|
+
status=status,
|
|
110
|
+
streams=streams,
|
|
111
|
+
stats=stats,
|
|
112
|
+
result_type=result_type,
|
|
113
|
+
metric_series=metric_series,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def total_entries(self) -> int:
|
|
118
|
+
"""
|
|
119
|
+
Get total number of log entries across all streams.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Total entry count.
|
|
123
|
+
"""
|
|
124
|
+
return sum(len(stream.entries) for stream in self.streams)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def total_samples(self) -> int:
|
|
128
|
+
"""
|
|
129
|
+
Get total number of metric samples across all series.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Total sample count.
|
|
133
|
+
"""
|
|
134
|
+
return sum(len(series.samples) for series in self.metric_series)
|