loki-reader-core 0.2.0__tar.gz → 0.2.2__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.2.0 → loki_reader_core-0.2.2}/PKG-INFO +1 -1
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/pyproject.toml +1 -1
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/__init__.py +1 -1
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/client.py +66 -6
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/tests/test_client.py +142 -12
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/.gitignore +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/README.md +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/build-publish.sh +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/dev-requirements.txt +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/exceptions.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/__init__.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/log_entry.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/log_stream.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/metric_sample.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/metric_series.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/query_result.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/query_stats.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/utils.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/tests/__init__.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/tests/test_models.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loki-reader-core
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -6,7 +6,7 @@ from .client import LokiClient
|
|
|
6
6
|
from .exceptions import LokiAuthError, LokiConnectionError, LokiError, LokiQueryError
|
|
7
7
|
from .models import LogEntry, LogStream, MetricSample, MetricSeries, QueryResult, QueryStats
|
|
8
8
|
|
|
9
|
-
__version__ = "0.2.
|
|
9
|
+
__version__ = "0.2.2"
|
|
10
10
|
|
|
11
11
|
__all__ = [
|
|
12
12
|
"LokiClient",
|
|
@@ -7,7 +7,7 @@ from typing import Optional
|
|
|
7
7
|
import requests
|
|
8
8
|
|
|
9
9
|
from .exceptions import LokiAuthError, LokiConnectionError, LokiQueryError
|
|
10
|
-
from .models import QueryResult
|
|
10
|
+
from .models import LogStream, QueryResult
|
|
11
11
|
from .utils import now_ns, NANOSECONDS_PER_MINUTE, NANOSECONDS_PER_HOUR
|
|
12
12
|
|
|
13
13
|
APP_LABEL_NAMES = ["application", "app", "job", "service", "service_name", "logger"]
|
|
@@ -106,6 +106,44 @@ def _resolve_since(
|
|
|
106
106
|
return None
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
def _merge_streams(result: QueryResult, app_label: str, app_value: str) -> QueryResult:
|
|
110
|
+
"""Merge multiple log streams into a single stream sorted by timestamp.
|
|
111
|
+
|
|
112
|
+
Used for app-based queries where Loki returns separate streams per
|
|
113
|
+
logger/severity combination. Combines all entries into one flat
|
|
114
|
+
chronological list.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
result: QueryResult with potentially multiple streams.
|
|
118
|
+
app_label: The label name used for the application (e.g. "application").
|
|
119
|
+
app_value: The application name (e.g. "materia-server").
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
QueryResult with a single merged stream.
|
|
123
|
+
"""
|
|
124
|
+
if len(result.streams) <= 1:
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
all_entries = []
|
|
128
|
+
for stream in result.streams:
|
|
129
|
+
all_entries.extend(stream.entries)
|
|
130
|
+
|
|
131
|
+
all_entries.sort(key=lambda e: e.timestamp, reverse=True)
|
|
132
|
+
|
|
133
|
+
merged_stream = LogStream(
|
|
134
|
+
labels={app_label: app_value},
|
|
135
|
+
entries=all_entries,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return QueryResult(
|
|
139
|
+
status=result.status,
|
|
140
|
+
streams=[merged_stream],
|
|
141
|
+
stats=result.stats,
|
|
142
|
+
result_type=result.result_type,
|
|
143
|
+
metric_series=result.metric_series,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
109
147
|
class LokiClient:
|
|
110
148
|
"""
|
|
111
149
|
Client for querying Grafana Loki logs.
|
|
@@ -231,6 +269,20 @@ class LokiClient:
|
|
|
231
269
|
|
|
232
270
|
return data
|
|
233
271
|
|
|
272
|
+
def _discovery_time_range(self) -> tuple[int, int]:
|
|
273
|
+
"""Get a 30-day time range for label discovery queries.
|
|
274
|
+
|
|
275
|
+
Without a time range, Loki's label API only returns labels for
|
|
276
|
+
recently active streams. Using a 30-day window ensures we find
|
|
277
|
+
apps that haven't logged in a while.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Tuple of (start_ns, end_ns) covering the last 30 days.
|
|
281
|
+
"""
|
|
282
|
+
end = now_ns()
|
|
283
|
+
start = end - (30 * 24 * NANOSECONDS_PER_HOUR)
|
|
284
|
+
return (start, end)
|
|
285
|
+
|
|
234
286
|
def _find_app_label(self, app_value: str) -> str:
|
|
235
287
|
"""Discover which label name contains the given app value.
|
|
236
288
|
|
|
@@ -249,8 +301,9 @@ class LokiClient:
|
|
|
249
301
|
if app_value in self._app_label_cache:
|
|
250
302
|
return self._app_label_cache[app_value]
|
|
251
303
|
|
|
304
|
+
start, end = self._discovery_time_range()
|
|
252
305
|
for label_name in APP_LABEL_NAMES:
|
|
253
|
-
values = self.get_label_values(label_name)
|
|
306
|
+
values = self.get_label_values(label_name, start=start, end=end)
|
|
254
307
|
if app_value in values:
|
|
255
308
|
self._app_label_cache[app_value] = label_name
|
|
256
309
|
return label_name
|
|
@@ -272,8 +325,9 @@ class LokiClient:
|
|
|
272
325
|
if self._severity_label_cache is not None:
|
|
273
326
|
return self._severity_label_cache
|
|
274
327
|
|
|
328
|
+
start, end = self._discovery_time_range()
|
|
275
329
|
for label_name in SEVERITY_LABEL_NAMES:
|
|
276
|
-
values = self.get_label_values(label_name)
|
|
330
|
+
values = self.get_label_values(label_name, start=start, end=end)
|
|
277
331
|
if values:
|
|
278
332
|
self._severity_label_cache = label_name
|
|
279
333
|
return label_name
|
|
@@ -319,9 +373,10 @@ class LokiClient:
|
|
|
319
373
|
if logql is None and app is None:
|
|
320
374
|
raise ValueError("Must provide either 'logql' or 'app'")
|
|
321
375
|
|
|
376
|
+
app_label = None
|
|
322
377
|
if app is not None:
|
|
323
|
-
|
|
324
|
-
selector = f'{
|
|
378
|
+
app_label = self._find_app_label(app)
|
|
379
|
+
selector = f'{app_label}="{app}"'
|
|
325
380
|
if severity is not None:
|
|
326
381
|
sev_label = self._find_severity_label()
|
|
327
382
|
if sev_label:
|
|
@@ -344,7 +399,7 @@ class LokiClient:
|
|
|
344
399
|
start = end - (30 * 24 * NANOSECONDS_PER_HOUR)
|
|
345
400
|
time_range = (start, end)
|
|
346
401
|
|
|
347
|
-
|
|
402
|
+
result = self.query_range(
|
|
348
403
|
logql=logql,
|
|
349
404
|
start=time_range[0],
|
|
350
405
|
end=time_range[1],
|
|
@@ -352,6 +407,11 @@ class LokiClient:
|
|
|
352
407
|
direction="backward",
|
|
353
408
|
)
|
|
354
409
|
|
|
410
|
+
if app_label is not None:
|
|
411
|
+
result = _merge_streams(result, app_label, app)
|
|
412
|
+
|
|
413
|
+
return result
|
|
414
|
+
|
|
355
415
|
def query_range(
|
|
356
416
|
self,
|
|
357
417
|
logql: str,
|
|
@@ -4,8 +4,10 @@ from unittest.mock import MagicMock, patch, call
|
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
|
-
from loki_reader_core import LokiClient
|
|
8
|
-
from loki_reader_core.client import
|
|
7
|
+
from loki_reader_core import LokiClient, LogEntry, LogStream, QueryResult
|
|
8
|
+
from loki_reader_core.client import (
|
|
9
|
+
_is_metric_query, _build_severity_regex, _resolve_since, _merge_streams,
|
|
10
|
+
)
|
|
9
11
|
from loki_reader_core.exceptions import LokiAuthError, LokiConnectionError, LokiQueryError
|
|
10
12
|
|
|
11
13
|
|
|
@@ -451,12 +453,16 @@ class TestLabelDiscovery:
|
|
|
451
453
|
mock_glv.return_value = ["myapp", "otherapp"]
|
|
452
454
|
result = client._find_app_label("myapp")
|
|
453
455
|
assert result == "application"
|
|
454
|
-
mock_glv.
|
|
456
|
+
mock_glv.assert_called_once()
|
|
457
|
+
args = mock_glv.call_args
|
|
458
|
+
assert args.args[0] == "application"
|
|
459
|
+
assert "start" in args.kwargs
|
|
460
|
+
assert "end" in args.kwargs
|
|
455
461
|
|
|
456
462
|
def test_find_app_label_job(self) -> None:
|
|
457
463
|
client = LokiClient(base_url="http://localhost:3100")
|
|
458
464
|
with patch.object(client, "get_label_values") as mock_glv:
|
|
459
|
-
def side_effect(label: str) -> list[str]:
|
|
465
|
+
def side_effect(label: str, start: int = None, end: int = None) -> list[str]:
|
|
460
466
|
if label == "job":
|
|
461
467
|
return ["myapp", "worker"]
|
|
462
468
|
return []
|
|
@@ -478,7 +484,11 @@ class TestLabelDiscovery:
|
|
|
478
484
|
mock_glv.return_value = ["info", "error", "warn"]
|
|
479
485
|
result = client._find_severity_label()
|
|
480
486
|
assert result == "level"
|
|
481
|
-
mock_glv.
|
|
487
|
+
mock_glv.assert_called_once()
|
|
488
|
+
args = mock_glv.call_args
|
|
489
|
+
assert args.args[0] == "level"
|
|
490
|
+
assert "start" in args.kwargs
|
|
491
|
+
assert "end" in args.kwargs
|
|
482
492
|
|
|
483
493
|
def test_find_severity_label_none(self) -> None:
|
|
484
494
|
client = LokiClient(base_url="http://localhost:3100")
|
|
@@ -496,7 +506,7 @@ class TestLabelDiscovery:
|
|
|
496
506
|
client._find_app_label("myapp")
|
|
497
507
|
|
|
498
508
|
# Only called once - second call uses cache
|
|
499
|
-
mock_glv.
|
|
509
|
+
mock_glv.assert_called_once()
|
|
500
510
|
|
|
501
511
|
def test_severity_label_cached(self) -> None:
|
|
502
512
|
client = LokiClient(base_url="http://localhost:3100")
|
|
@@ -506,19 +516,116 @@ class TestLabelDiscovery:
|
|
|
506
516
|
client._find_severity_label()
|
|
507
517
|
client._find_severity_label()
|
|
508
518
|
|
|
509
|
-
mock_glv.
|
|
519
|
+
mock_glv.assert_called_once()
|
|
520
|
+
|
|
521
|
+
def test_discovery_uses_30_day_range(self) -> None:
|
|
522
|
+
client = LokiClient(base_url="http://localhost:3100")
|
|
523
|
+
with patch.object(client, "get_label_values") as mock_glv:
|
|
524
|
+
mock_glv.return_value = ["myapp"]
|
|
525
|
+
client._find_app_label("myapp")
|
|
526
|
+
|
|
527
|
+
args = mock_glv.call_args
|
|
528
|
+
start = args.kwargs["start"]
|
|
529
|
+
end = args.kwargs["end"]
|
|
530
|
+
diff_days = (end - start) / (24 * 60 * 60 * 1_000_000_000)
|
|
531
|
+
assert diff_days == 30
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class TestMergeStreams:
|
|
535
|
+
"""Test _merge_streams helper function."""
|
|
536
|
+
|
|
537
|
+
def test_single_stream_unchanged(self) -> None:
|
|
538
|
+
stream = LogStream(
|
|
539
|
+
labels={"application": "myapp", "logger": "root"},
|
|
540
|
+
entries=[LogEntry(timestamp=100, message="hello")],
|
|
541
|
+
)
|
|
542
|
+
result = QueryResult(status="success", streams=[stream], stats=None)
|
|
543
|
+
|
|
544
|
+
merged = _merge_streams(result, "application", "myapp")
|
|
545
|
+
|
|
546
|
+
assert len(merged.streams) == 1
|
|
547
|
+
assert merged.streams[0] is stream # same object, not rebuilt
|
|
548
|
+
|
|
549
|
+
def test_empty_streams_unchanged(self) -> None:
|
|
550
|
+
result = QueryResult(status="success", streams=[], stats=None)
|
|
551
|
+
merged = _merge_streams(result, "application", "myapp")
|
|
552
|
+
assert len(merged.streams) == 0
|
|
553
|
+
|
|
554
|
+
def test_multiple_streams_merged(self) -> None:
|
|
555
|
+
stream1 = LogStream(
|
|
556
|
+
labels={"application": "myapp", "logger": "root", "severity": "info"},
|
|
557
|
+
entries=[
|
|
558
|
+
LogEntry(timestamp=300, message="root msg 1"),
|
|
559
|
+
LogEntry(timestamp=100, message="root msg 2"),
|
|
560
|
+
],
|
|
561
|
+
)
|
|
562
|
+
stream2 = LogStream(
|
|
563
|
+
labels={"application": "myapp", "logger": "__main__", "severity": "info"},
|
|
564
|
+
entries=[
|
|
565
|
+
LogEntry(timestamp=200, message="main msg 1"),
|
|
566
|
+
],
|
|
567
|
+
)
|
|
568
|
+
result = QueryResult(
|
|
569
|
+
status="success", streams=[stream1, stream2], stats=None,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
merged = _merge_streams(result, "application", "myapp")
|
|
573
|
+
|
|
574
|
+
assert len(merged.streams) == 1
|
|
575
|
+
assert merged.streams[0].labels == {"application": "myapp"}
|
|
576
|
+
assert len(merged.streams[0].entries) == 3
|
|
577
|
+
# Sorted descending by timestamp
|
|
578
|
+
timestamps = [e.timestamp for e in merged.streams[0].entries]
|
|
579
|
+
assert timestamps == [300, 200, 100]
|
|
580
|
+
|
|
581
|
+
def test_merged_preserves_stats(self) -> None:
|
|
582
|
+
from loki_reader_core import QueryStats
|
|
583
|
+
stats = QueryStats(
|
|
584
|
+
exec_time_seconds=0.05, bytes_processed=1024,
|
|
585
|
+
lines_processed=10,
|
|
586
|
+
)
|
|
587
|
+
stream1 = LogStream(
|
|
588
|
+
labels={"app": "x", "logger": "a"},
|
|
589
|
+
entries=[LogEntry(timestamp=2, message="a")],
|
|
590
|
+
)
|
|
591
|
+
stream2 = LogStream(
|
|
592
|
+
labels={"app": "x", "logger": "b"},
|
|
593
|
+
entries=[LogEntry(timestamp=1, message="b")],
|
|
594
|
+
)
|
|
595
|
+
result = QueryResult(
|
|
596
|
+
status="success", streams=[stream1, stream2], stats=stats,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
merged = _merge_streams(result, "app", "x")
|
|
600
|
+
|
|
601
|
+
assert merged.stats is stats
|
|
602
|
+
assert merged.status == "success"
|
|
510
603
|
|
|
511
604
|
|
|
512
605
|
class TestLokiClientQueryRedesign:
|
|
513
606
|
"""Test redesigned query() method."""
|
|
514
607
|
|
|
608
|
+
def _make_multi_stream_result(self) -> QueryResult:
|
|
609
|
+
"""Helper to create a QueryResult with multiple streams."""
|
|
610
|
+
stream1 = LogStream(
|
|
611
|
+
labels={"application": "myapp", "logger": "root", "severity": "info"},
|
|
612
|
+
entries=[LogEntry(timestamp=300, message="root log")],
|
|
613
|
+
)
|
|
614
|
+
stream2 = LogStream(
|
|
615
|
+
labels={"application": "myapp", "logger": "__main__", "severity": "info"},
|
|
616
|
+
entries=[LogEntry(timestamp=200, message="main log")],
|
|
617
|
+
)
|
|
618
|
+
return QueryResult(
|
|
619
|
+
status="success", streams=[stream1, stream2], stats=None,
|
|
620
|
+
)
|
|
621
|
+
|
|
515
622
|
def test_query_app_only(self) -> None:
|
|
516
623
|
client = LokiClient(base_url="http://localhost:3100")
|
|
517
624
|
with patch.object(client, "_find_app_label", return_value="application"), \
|
|
518
625
|
patch.object(client, "query_range") as mock_qr:
|
|
519
|
-
mock_qr.return_value =
|
|
626
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
520
627
|
|
|
521
|
-
client.query(app="myapp")
|
|
628
|
+
result = client.query(app="myapp")
|
|
522
629
|
|
|
523
630
|
mock_qr.assert_called_once()
|
|
524
631
|
args = mock_qr.call_args
|
|
@@ -526,12 +633,35 @@ class TestLokiClientQueryRedesign:
|
|
|
526
633
|
assert args.kwargs["limit"] == 100
|
|
527
634
|
assert args.kwargs["direction"] == "backward"
|
|
528
635
|
|
|
636
|
+
def test_query_app_merges_streams(self) -> None:
|
|
637
|
+
client = LokiClient(base_url="http://localhost:3100")
|
|
638
|
+
with patch.object(client, "_find_app_label", return_value="application"), \
|
|
639
|
+
patch.object(client, "query_range") as mock_qr:
|
|
640
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
641
|
+
|
|
642
|
+
result = client.query(app="myapp")
|
|
643
|
+
|
|
644
|
+
assert len(result.streams) == 1
|
|
645
|
+
assert result.streams[0].labels == {"application": "myapp"}
|
|
646
|
+
assert len(result.streams[0].entries) == 2
|
|
647
|
+
timestamps = [e.timestamp for e in result.streams[0].entries]
|
|
648
|
+
assert timestamps == [300, 200]
|
|
649
|
+
|
|
650
|
+
def test_query_logql_does_not_merge(self) -> None:
|
|
651
|
+
client = LokiClient(base_url="http://localhost:3100")
|
|
652
|
+
with patch.object(client, "query_range") as mock_qr:
|
|
653
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
654
|
+
|
|
655
|
+
result = client.query(logql='{application="myapp"}')
|
|
656
|
+
|
|
657
|
+
assert len(result.streams) == 2
|
|
658
|
+
|
|
529
659
|
def test_query_app_with_severity(self) -> None:
|
|
530
660
|
client = LokiClient(base_url="http://localhost:3100")
|
|
531
661
|
with patch.object(client, "_find_app_label", return_value="application"), \
|
|
532
662
|
patch.object(client, "_find_severity_label", return_value="level"), \
|
|
533
663
|
patch.object(client, "query_range") as mock_qr:
|
|
534
|
-
mock_qr.return_value =
|
|
664
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
535
665
|
|
|
536
666
|
client.query(app="myapp", severity="error")
|
|
537
667
|
|
|
@@ -542,7 +672,7 @@ class TestLokiClientQueryRedesign:
|
|
|
542
672
|
client = LokiClient(base_url="http://localhost:3100")
|
|
543
673
|
with patch.object(client, "_find_app_label", return_value="application"), \
|
|
544
674
|
patch.object(client, "query_range") as mock_qr:
|
|
545
|
-
mock_qr.return_value =
|
|
675
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
546
676
|
|
|
547
677
|
client.query(app="myapp", since_minutes=10)
|
|
548
678
|
|
|
@@ -590,7 +720,7 @@ class TestLokiClientQueryRedesign:
|
|
|
590
720
|
client = LokiClient(base_url="http://localhost:3100")
|
|
591
721
|
with patch.object(client, "_find_app_label", return_value="job"), \
|
|
592
722
|
patch.object(client, "query_range") as mock_qr:
|
|
593
|
-
mock_qr.return_value =
|
|
723
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
594
724
|
|
|
595
725
|
client.query(app="myapp")
|
|
596
726
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/metric_sample.py
RENAMED
|
File without changes
|
{loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/metric_series.py
RENAMED
|
File without changes
|
{loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/query_result.py
RENAMED
|
File without changes
|
{loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/query_stats.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|