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.
Files changed (24) hide show
  1. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/PKG-INFO +3 -9
  2. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/README.md +1 -7
  3. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/pyproject.toml +2 -2
  4. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/__init__.py +4 -2
  5. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/client.py +208 -23
  6. loki_reader_core-0.2.0/src/loki_reader_core/models/__init__.py +19 -0
  7. loki_reader_core-0.2.0/src/loki_reader_core/models/metric_sample.py +75 -0
  8. loki_reader_core-0.2.0/src/loki_reader_core/models/metric_series.py +90 -0
  9. loki_reader_core-0.2.0/src/loki_reader_core/models/query_result.py +134 -0
  10. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/utils.py +3 -3
  11. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/tests/test_client.py +260 -44
  12. loki_reader_core-0.2.0/tests/test_models.py +544 -0
  13. loki_reader_core-0.0.1/src/loki_reader_core/models/__init__.py +0 -10
  14. loki_reader_core-0.0.1/src/loki_reader_core/models/query_result.py +0 -88
  15. loki_reader_core-0.0.1/tests/test_models.py +0 -270
  16. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/.gitignore +0 -0
  17. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/build-publish.sh +0 -0
  18. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/dev-requirements.txt +0 -0
  19. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/exceptions.py +0 -0
  20. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/models/log_entry.py +0 -0
  21. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/models/log_stream.py +0 -0
  22. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/src/loki_reader_core/models/query_stats.py +0 -0
  23. {loki_reader_core-0.0.1 → loki_reader_core-0.2.0}/tests/__init__.py +0 -0
  24. {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.1
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 <jmazzahacks@gmail.com>
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 (coming soon):
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 (coming soon):
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.1"
3
+ version = "0.2.0"
4
4
  authors = [
5
- { name="Jason Byteforge", email="jmazzahacks@gmail.com" },
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.1"
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.query_range(
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
- time: Optional[int] = None,
146
- limit: int = 100
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
- Execute an instant query at a single point in time.
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 (e.g., '{job="api"} |= "error"').
153
- time: Optional timestamp in nanoseconds. Defaults to now.
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
- if time is not None:
165
- params["time"] = str(time)
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
- response = self._request("GET", "/loki/api/v1/query", params)
168
- return QueryResult.from_loki_response(response)
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)