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.
Files changed (21) hide show
  1. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/PKG-INFO +1 -1
  2. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/pyproject.toml +1 -1
  3. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/__init__.py +1 -1
  4. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/client.py +66 -6
  5. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/tests/test_client.py +142 -12
  6. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/.gitignore +0 -0
  7. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/README.md +0 -0
  8. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/build-publish.sh +0 -0
  9. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/dev-requirements.txt +0 -0
  10. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/exceptions.py +0 -0
  11. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/__init__.py +0 -0
  12. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/log_entry.py +0 -0
  13. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/log_stream.py +0 -0
  14. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/metric_sample.py +0 -0
  15. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/metric_series.py +0 -0
  16. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/query_result.py +0 -0
  17. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/models/query_stats.py +0 -0
  18. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/src/loki_reader_core/utils.py +0 -0
  19. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/tests/__init__.py +0 -0
  20. {loki_reader_core-0.2.0 → loki_reader_core-0.2.2}/tests/test_models.py +0 -0
  21. {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.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "loki-reader-core"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  authors = [
5
5
  { name="Jason Byteforge", email="jason@mzmail.me" },
6
6
  ]
@@ -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.0"
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
- label_name = self._find_app_label(app)
324
- selector = f'{label_name}="{app}"'
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
- return self.query_range(
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 _is_metric_query, _build_severity_regex, _resolve_since
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.assert_called_once_with("application")
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.assert_called_once_with("level")
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.assert_called_once_with("application")
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.assert_called_once_with("level")
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 = MagicMock()
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 = MagicMock()
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 = MagicMock()
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 = MagicMock()
723
+ mock_qr.return_value = self._make_multi_stream_result()
594
724
 
595
725
  client.query(app="myapp")
596
726