loki-reader-core 0.2.0__tar.gz → 0.2.1__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.1}/PKG-INFO +1 -1
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/pyproject.toml +1 -1
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/__init__.py +1 -1
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/client.py +48 -4
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/tests/test_client.py +117 -7
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/.gitignore +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/README.md +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/build-publish.sh +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/dev-requirements.txt +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/exceptions.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/__init__.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/log_entry.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/log_stream.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/metric_sample.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/metric_series.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/query_result.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/query_stats.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/utils.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/tests/__init__.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/tests/test_models.py +0 -0
- {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/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.1
|
|
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.1"
|
|
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.
|
|
@@ -319,9 +357,10 @@ class LokiClient:
|
|
|
319
357
|
if logql is None and app is None:
|
|
320
358
|
raise ValueError("Must provide either 'logql' or 'app'")
|
|
321
359
|
|
|
360
|
+
app_label = None
|
|
322
361
|
if app is not None:
|
|
323
|
-
|
|
324
|
-
selector = f'{
|
|
362
|
+
app_label = self._find_app_label(app)
|
|
363
|
+
selector = f'{app_label}="{app}"'
|
|
325
364
|
if severity is not None:
|
|
326
365
|
sev_label = self._find_severity_label()
|
|
327
366
|
if sev_label:
|
|
@@ -344,7 +383,7 @@ class LokiClient:
|
|
|
344
383
|
start = end - (30 * 24 * NANOSECONDS_PER_HOUR)
|
|
345
384
|
time_range = (start, end)
|
|
346
385
|
|
|
347
|
-
|
|
386
|
+
result = self.query_range(
|
|
348
387
|
logql=logql,
|
|
349
388
|
start=time_range[0],
|
|
350
389
|
end=time_range[1],
|
|
@@ -352,6 +391,11 @@ class LokiClient:
|
|
|
352
391
|
direction="backward",
|
|
353
392
|
)
|
|
354
393
|
|
|
394
|
+
if app_label is not None:
|
|
395
|
+
result = _merge_streams(result, app_label, app)
|
|
396
|
+
|
|
397
|
+
return result
|
|
398
|
+
|
|
355
399
|
def query_range(
|
|
356
400
|
self,
|
|
357
401
|
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
|
|
|
@@ -509,16 +511,101 @@ class TestLabelDiscovery:
|
|
|
509
511
|
mock_glv.assert_called_once_with("level")
|
|
510
512
|
|
|
511
513
|
|
|
514
|
+
class TestMergeStreams:
|
|
515
|
+
"""Test _merge_streams helper function."""
|
|
516
|
+
|
|
517
|
+
def test_single_stream_unchanged(self) -> None:
|
|
518
|
+
stream = LogStream(
|
|
519
|
+
labels={"application": "myapp", "logger": "root"},
|
|
520
|
+
entries=[LogEntry(timestamp=100, message="hello")],
|
|
521
|
+
)
|
|
522
|
+
result = QueryResult(status="success", streams=[stream], stats=None)
|
|
523
|
+
|
|
524
|
+
merged = _merge_streams(result, "application", "myapp")
|
|
525
|
+
|
|
526
|
+
assert len(merged.streams) == 1
|
|
527
|
+
assert merged.streams[0] is stream # same object, not rebuilt
|
|
528
|
+
|
|
529
|
+
def test_empty_streams_unchanged(self) -> None:
|
|
530
|
+
result = QueryResult(status="success", streams=[], stats=None)
|
|
531
|
+
merged = _merge_streams(result, "application", "myapp")
|
|
532
|
+
assert len(merged.streams) == 0
|
|
533
|
+
|
|
534
|
+
def test_multiple_streams_merged(self) -> None:
|
|
535
|
+
stream1 = LogStream(
|
|
536
|
+
labels={"application": "myapp", "logger": "root", "severity": "info"},
|
|
537
|
+
entries=[
|
|
538
|
+
LogEntry(timestamp=300, message="root msg 1"),
|
|
539
|
+
LogEntry(timestamp=100, message="root msg 2"),
|
|
540
|
+
],
|
|
541
|
+
)
|
|
542
|
+
stream2 = LogStream(
|
|
543
|
+
labels={"application": "myapp", "logger": "__main__", "severity": "info"},
|
|
544
|
+
entries=[
|
|
545
|
+
LogEntry(timestamp=200, message="main msg 1"),
|
|
546
|
+
],
|
|
547
|
+
)
|
|
548
|
+
result = QueryResult(
|
|
549
|
+
status="success", streams=[stream1, stream2], stats=None,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
merged = _merge_streams(result, "application", "myapp")
|
|
553
|
+
|
|
554
|
+
assert len(merged.streams) == 1
|
|
555
|
+
assert merged.streams[0].labels == {"application": "myapp"}
|
|
556
|
+
assert len(merged.streams[0].entries) == 3
|
|
557
|
+
# Sorted descending by timestamp
|
|
558
|
+
timestamps = [e.timestamp for e in merged.streams[0].entries]
|
|
559
|
+
assert timestamps == [300, 200, 100]
|
|
560
|
+
|
|
561
|
+
def test_merged_preserves_stats(self) -> None:
|
|
562
|
+
from loki_reader_core import QueryStats
|
|
563
|
+
stats = QueryStats(
|
|
564
|
+
exec_time_seconds=0.05, bytes_processed=1024,
|
|
565
|
+
lines_processed=10,
|
|
566
|
+
)
|
|
567
|
+
stream1 = LogStream(
|
|
568
|
+
labels={"app": "x", "logger": "a"},
|
|
569
|
+
entries=[LogEntry(timestamp=2, message="a")],
|
|
570
|
+
)
|
|
571
|
+
stream2 = LogStream(
|
|
572
|
+
labels={"app": "x", "logger": "b"},
|
|
573
|
+
entries=[LogEntry(timestamp=1, message="b")],
|
|
574
|
+
)
|
|
575
|
+
result = QueryResult(
|
|
576
|
+
status="success", streams=[stream1, stream2], stats=stats,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
merged = _merge_streams(result, "app", "x")
|
|
580
|
+
|
|
581
|
+
assert merged.stats is stats
|
|
582
|
+
assert merged.status == "success"
|
|
583
|
+
|
|
584
|
+
|
|
512
585
|
class TestLokiClientQueryRedesign:
|
|
513
586
|
"""Test redesigned query() method."""
|
|
514
587
|
|
|
588
|
+
def _make_multi_stream_result(self) -> QueryResult:
|
|
589
|
+
"""Helper to create a QueryResult with multiple streams."""
|
|
590
|
+
stream1 = LogStream(
|
|
591
|
+
labels={"application": "myapp", "logger": "root", "severity": "info"},
|
|
592
|
+
entries=[LogEntry(timestamp=300, message="root log")],
|
|
593
|
+
)
|
|
594
|
+
stream2 = LogStream(
|
|
595
|
+
labels={"application": "myapp", "logger": "__main__", "severity": "info"},
|
|
596
|
+
entries=[LogEntry(timestamp=200, message="main log")],
|
|
597
|
+
)
|
|
598
|
+
return QueryResult(
|
|
599
|
+
status="success", streams=[stream1, stream2], stats=None,
|
|
600
|
+
)
|
|
601
|
+
|
|
515
602
|
def test_query_app_only(self) -> None:
|
|
516
603
|
client = LokiClient(base_url="http://localhost:3100")
|
|
517
604
|
with patch.object(client, "_find_app_label", return_value="application"), \
|
|
518
605
|
patch.object(client, "query_range") as mock_qr:
|
|
519
|
-
mock_qr.return_value =
|
|
606
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
520
607
|
|
|
521
|
-
client.query(app="myapp")
|
|
608
|
+
result = client.query(app="myapp")
|
|
522
609
|
|
|
523
610
|
mock_qr.assert_called_once()
|
|
524
611
|
args = mock_qr.call_args
|
|
@@ -526,12 +613,35 @@ class TestLokiClientQueryRedesign:
|
|
|
526
613
|
assert args.kwargs["limit"] == 100
|
|
527
614
|
assert args.kwargs["direction"] == "backward"
|
|
528
615
|
|
|
616
|
+
def test_query_app_merges_streams(self) -> None:
|
|
617
|
+
client = LokiClient(base_url="http://localhost:3100")
|
|
618
|
+
with patch.object(client, "_find_app_label", return_value="application"), \
|
|
619
|
+
patch.object(client, "query_range") as mock_qr:
|
|
620
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
621
|
+
|
|
622
|
+
result = client.query(app="myapp")
|
|
623
|
+
|
|
624
|
+
assert len(result.streams) == 1
|
|
625
|
+
assert result.streams[0].labels == {"application": "myapp"}
|
|
626
|
+
assert len(result.streams[0].entries) == 2
|
|
627
|
+
timestamps = [e.timestamp for e in result.streams[0].entries]
|
|
628
|
+
assert timestamps == [300, 200]
|
|
629
|
+
|
|
630
|
+
def test_query_logql_does_not_merge(self) -> None:
|
|
631
|
+
client = LokiClient(base_url="http://localhost:3100")
|
|
632
|
+
with patch.object(client, "query_range") as mock_qr:
|
|
633
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
634
|
+
|
|
635
|
+
result = client.query(logql='{application="myapp"}')
|
|
636
|
+
|
|
637
|
+
assert len(result.streams) == 2
|
|
638
|
+
|
|
529
639
|
def test_query_app_with_severity(self) -> None:
|
|
530
640
|
client = LokiClient(base_url="http://localhost:3100")
|
|
531
641
|
with patch.object(client, "_find_app_label", return_value="application"), \
|
|
532
642
|
patch.object(client, "_find_severity_label", return_value="level"), \
|
|
533
643
|
patch.object(client, "query_range") as mock_qr:
|
|
534
|
-
mock_qr.return_value =
|
|
644
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
535
645
|
|
|
536
646
|
client.query(app="myapp", severity="error")
|
|
537
647
|
|
|
@@ -542,7 +652,7 @@ class TestLokiClientQueryRedesign:
|
|
|
542
652
|
client = LokiClient(base_url="http://localhost:3100")
|
|
543
653
|
with patch.object(client, "_find_app_label", return_value="application"), \
|
|
544
654
|
patch.object(client, "query_range") as mock_qr:
|
|
545
|
-
mock_qr.return_value =
|
|
655
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
546
656
|
|
|
547
657
|
client.query(app="myapp", since_minutes=10)
|
|
548
658
|
|
|
@@ -590,7 +700,7 @@ class TestLokiClientQueryRedesign:
|
|
|
590
700
|
client = LokiClient(base_url="http://localhost:3100")
|
|
591
701
|
with patch.object(client, "_find_app_label", return_value="job"), \
|
|
592
702
|
patch.object(client, "query_range") as mock_qr:
|
|
593
|
-
mock_qr.return_value =
|
|
703
|
+
mock_qr.return_value = self._make_multi_stream_result()
|
|
594
704
|
|
|
595
705
|
client.query(app="myapp")
|
|
596
706
|
|
|
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.1}/src/loki_reader_core/models/metric_sample.py
RENAMED
|
File without changes
|
{loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/metric_series.py
RENAMED
|
File without changes
|
{loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/query_result.py
RENAMED
|
File without changes
|
{loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/query_stats.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|