loki-reader-core 0.0.1__py3-none-any.whl
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/__init__.py +21 -0
- loki_reader_core/client.py +294 -0
- loki_reader_core/exceptions.py +23 -0
- loki_reader_core/models/__init__.py +10 -0
- loki_reader_core/models/log_entry.py +63 -0
- loki_reader_core/models/log_stream.py +66 -0
- loki_reader_core/models/query_result.py +88 -0
- loki_reader_core/models/query_stats.py +69 -0
- loki_reader_core/utils.py +85 -0
- loki_reader_core-0.0.1.dist-info/METADATA +173 -0
- loki_reader_core-0.0.1.dist-info/RECORD +12 -0
- loki_reader_core-0.0.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python library for querying Grafana Loki logs via REST API
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .client import LokiClient
|
|
6
|
+
from .exceptions import LokiAuthError, LokiConnectionError, LokiError, LokiQueryError
|
|
7
|
+
from .models import LogEntry, LogStream, QueryResult, QueryStats
|
|
8
|
+
|
|
9
|
+
__version__ = "0.0.1"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"LokiClient",
|
|
13
|
+
"LogEntry",
|
|
14
|
+
"LogStream",
|
|
15
|
+
"QueryResult",
|
|
16
|
+
"QueryStats",
|
|
17
|
+
"LokiError",
|
|
18
|
+
"LokiConnectionError",
|
|
19
|
+
"LokiQueryError",
|
|
20
|
+
"LokiAuthError",
|
|
21
|
+
]
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LokiClient for querying Grafana Loki logs via REST API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .exceptions import LokiAuthError, LokiConnectionError, LokiQueryError
|
|
10
|
+
from .models import QueryResult
|
|
11
|
+
from .utils import now_ns
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LokiClient:
|
|
15
|
+
"""
|
|
16
|
+
Client for querying Grafana Loki logs.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
client = LokiClient(
|
|
20
|
+
base_url="https://loki.example.com",
|
|
21
|
+
auth=("user", "pass"),
|
|
22
|
+
org_id="tenant-1"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
result = client.query_range(
|
|
26
|
+
logql='{job="api"} |= "error"',
|
|
27
|
+
start=hours_ago_ns(1),
|
|
28
|
+
end=now_ns(),
|
|
29
|
+
limit=500
|
|
30
|
+
)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str,
|
|
36
|
+
auth: Optional[tuple[str, str]] = None,
|
|
37
|
+
org_id: Optional[str] = None,
|
|
38
|
+
ca_cert: Optional[str] = None,
|
|
39
|
+
verify_ssl: bool = True,
|
|
40
|
+
timeout: int = 30
|
|
41
|
+
):
|
|
42
|
+
"""
|
|
43
|
+
Initialize the Loki client.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
base_url: Base URL of the Loki server (e.g., "https://loki.example.com").
|
|
47
|
+
auth: Optional tuple of (username, password) for basic authentication.
|
|
48
|
+
org_id: Optional X-Scope-OrgID header for multi-tenant Loki setups.
|
|
49
|
+
ca_cert: Optional path to CA certificate PEM file for self-signed certs.
|
|
50
|
+
verify_ssl: Whether to verify SSL certificates. Set False to disable (insecure).
|
|
51
|
+
timeout: Request timeout in seconds.
|
|
52
|
+
"""
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self.auth = auth
|
|
55
|
+
self.org_id = org_id
|
|
56
|
+
self.ca_cert = ca_cert
|
|
57
|
+
self.verify_ssl = verify_ssl
|
|
58
|
+
self.timeout = timeout
|
|
59
|
+
|
|
60
|
+
self._session: Optional[requests.Session] = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def session(self) -> requests.Session:
|
|
64
|
+
"""
|
|
65
|
+
Get or create HTTP session with configured authentication and SSL settings.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Configured requests.Session instance.
|
|
69
|
+
"""
|
|
70
|
+
if self._session is None:
|
|
71
|
+
self._session = requests.Session()
|
|
72
|
+
|
|
73
|
+
if self.auth:
|
|
74
|
+
self._session.auth = self.auth
|
|
75
|
+
|
|
76
|
+
if self.org_id:
|
|
77
|
+
self._session.headers["X-Scope-OrgID"] = self.org_id
|
|
78
|
+
|
|
79
|
+
if self.ca_cert:
|
|
80
|
+
self._session.verify = self.ca_cert
|
|
81
|
+
else:
|
|
82
|
+
self._session.verify = self.verify_ssl
|
|
83
|
+
|
|
84
|
+
return self._session
|
|
85
|
+
|
|
86
|
+
def _request(self, method: str, endpoint: str, params: Optional[dict] = None) -> dict:
|
|
87
|
+
"""
|
|
88
|
+
Make HTTP request to Loki API.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
method: HTTP method (GET, POST, etc.).
|
|
92
|
+
endpoint: API endpoint path.
|
|
93
|
+
params: Optional query parameters.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
JSON response as dictionary.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
LokiConnectionError: If connection to Loki fails.
|
|
100
|
+
LokiAuthError: If authentication fails (401/403).
|
|
101
|
+
LokiQueryError: If the query fails or returns an error.
|
|
102
|
+
"""
|
|
103
|
+
url = f"{self.base_url}{endpoint}"
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
response = self.session.request(
|
|
107
|
+
method=method,
|
|
108
|
+
url=url,
|
|
109
|
+
params=params,
|
|
110
|
+
timeout=self.timeout
|
|
111
|
+
)
|
|
112
|
+
except requests.exceptions.SSLError as e:
|
|
113
|
+
raise LokiConnectionError(f"SSL error connecting to Loki: {e}") from e
|
|
114
|
+
except requests.exceptions.ConnectionError as e:
|
|
115
|
+
raise LokiConnectionError(f"Failed to connect to Loki at {url}: {e}") from e
|
|
116
|
+
except requests.exceptions.Timeout as e:
|
|
117
|
+
raise LokiConnectionError(f"Request to Loki timed out: {e}") from e
|
|
118
|
+
except requests.exceptions.RequestException as e:
|
|
119
|
+
raise LokiConnectionError(f"Request to Loki failed: {e}") from e
|
|
120
|
+
|
|
121
|
+
if response.status_code == 401:
|
|
122
|
+
raise LokiAuthError("Authentication failed: invalid credentials")
|
|
123
|
+
if response.status_code == 403:
|
|
124
|
+
raise LokiAuthError("Authorization failed: access denied")
|
|
125
|
+
|
|
126
|
+
if response.status_code != 200:
|
|
127
|
+
raise LokiQueryError(
|
|
128
|
+
f"Loki query failed with status {response.status_code}: {response.text}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
data = response.json()
|
|
133
|
+
except ValueError as e:
|
|
134
|
+
raise LokiQueryError(f"Invalid JSON response from Loki: {e}") from e
|
|
135
|
+
|
|
136
|
+
if data.get("status") == "error":
|
|
137
|
+
error_msg = data.get("error", "Unknown error")
|
|
138
|
+
raise LokiQueryError(f"Loki query error: {error_msg}")
|
|
139
|
+
|
|
140
|
+
return data
|
|
141
|
+
|
|
142
|
+
def query(
|
|
143
|
+
self,
|
|
144
|
+
logql: str,
|
|
145
|
+
time: Optional[int] = None,
|
|
146
|
+
limit: int = 100
|
|
147
|
+
) -> QueryResult:
|
|
148
|
+
"""
|
|
149
|
+
Execute an instant query at a single point in time.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
logql: LogQL query string (e.g., '{job="api"} |= "error"').
|
|
153
|
+
time: Optional timestamp in nanoseconds. Defaults to now.
|
|
154
|
+
limit: Maximum number of entries to return.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
QueryResult containing matching log streams.
|
|
158
|
+
"""
|
|
159
|
+
params = {
|
|
160
|
+
"query": logql,
|
|
161
|
+
"limit": limit
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if time is not None:
|
|
165
|
+
params["time"] = str(time)
|
|
166
|
+
|
|
167
|
+
response = self._request("GET", "/loki/api/v1/query", params)
|
|
168
|
+
return QueryResult.from_loki_response(response)
|
|
169
|
+
|
|
170
|
+
def query_range(
|
|
171
|
+
self,
|
|
172
|
+
logql: str,
|
|
173
|
+
start: int,
|
|
174
|
+
end: int,
|
|
175
|
+
limit: int = 1000,
|
|
176
|
+
direction: str = "backward"
|
|
177
|
+
) -> QueryResult:
|
|
178
|
+
"""
|
|
179
|
+
Execute a range query across a time period.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
logql: LogQL query string (e.g., '{job="api"} |= "error"').
|
|
183
|
+
start: Start timestamp in nanoseconds.
|
|
184
|
+
end: End timestamp in nanoseconds.
|
|
185
|
+
limit: Maximum number of entries to return.
|
|
186
|
+
direction: Sort direction - "forward" (oldest first) or "backward" (newest first).
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
QueryResult containing matching log streams.
|
|
190
|
+
"""
|
|
191
|
+
params = {
|
|
192
|
+
"query": logql,
|
|
193
|
+
"start": str(start),
|
|
194
|
+
"end": str(end),
|
|
195
|
+
"limit": limit,
|
|
196
|
+
"direction": direction
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
response = self._request("GET", "/loki/api/v1/query_range", params)
|
|
200
|
+
return QueryResult.from_loki_response(response)
|
|
201
|
+
|
|
202
|
+
def get_labels(
|
|
203
|
+
self,
|
|
204
|
+
start: Optional[int] = None,
|
|
205
|
+
end: Optional[int] = None
|
|
206
|
+
) -> list[str]:
|
|
207
|
+
"""
|
|
208
|
+
Get list of available label names.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
start: Optional start timestamp in nanoseconds.
|
|
212
|
+
end: Optional end timestamp in nanoseconds.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
List of label names.
|
|
216
|
+
"""
|
|
217
|
+
params = {}
|
|
218
|
+
|
|
219
|
+
if start is not None:
|
|
220
|
+
params["start"] = str(start)
|
|
221
|
+
if end is not None:
|
|
222
|
+
params["end"] = str(end)
|
|
223
|
+
|
|
224
|
+
response = self._request("GET", "/loki/api/v1/labels", params or None)
|
|
225
|
+
return response.get("data", [])
|
|
226
|
+
|
|
227
|
+
def get_label_values(
|
|
228
|
+
self,
|
|
229
|
+
label: str,
|
|
230
|
+
start: Optional[int] = None,
|
|
231
|
+
end: Optional[int] = None
|
|
232
|
+
) -> list[str]:
|
|
233
|
+
"""
|
|
234
|
+
Get list of values for a specific label.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
label: Label name to get values for.
|
|
238
|
+
start: Optional start timestamp in nanoseconds.
|
|
239
|
+
end: Optional end timestamp in nanoseconds.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
List of label values.
|
|
243
|
+
"""
|
|
244
|
+
params = {}
|
|
245
|
+
|
|
246
|
+
if start is not None:
|
|
247
|
+
params["start"] = str(start)
|
|
248
|
+
if end is not None:
|
|
249
|
+
params["end"] = str(end)
|
|
250
|
+
|
|
251
|
+
endpoint = f"/loki/api/v1/label/{label}/values"
|
|
252
|
+
response = self._request("GET", endpoint, params or None)
|
|
253
|
+
return response.get("data", [])
|
|
254
|
+
|
|
255
|
+
def get_series(
|
|
256
|
+
self,
|
|
257
|
+
match: list[str],
|
|
258
|
+
start: Optional[int] = None,
|
|
259
|
+
end: Optional[int] = None
|
|
260
|
+
) -> list[dict[str, str]]:
|
|
261
|
+
"""
|
|
262
|
+
Get list of unique label sets matching the given selectors.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
match: List of stream selectors (e.g., ['{job="api"}', '{container="web"}']).
|
|
266
|
+
start: Optional start timestamp in nanoseconds.
|
|
267
|
+
end: Optional end timestamp in nanoseconds.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
List of label dictionaries representing unique streams.
|
|
271
|
+
"""
|
|
272
|
+
params = {"match[]": match}
|
|
273
|
+
|
|
274
|
+
if start is not None:
|
|
275
|
+
params["start"] = str(start)
|
|
276
|
+
if end is not None:
|
|
277
|
+
params["end"] = str(end)
|
|
278
|
+
|
|
279
|
+
response = self._request("GET", "/loki/api/v1/series", params)
|
|
280
|
+
return response.get("data", [])
|
|
281
|
+
|
|
282
|
+
def close(self) -> None:
|
|
283
|
+
"""Close the HTTP session."""
|
|
284
|
+
if self._session is not None:
|
|
285
|
+
self._session.close()
|
|
286
|
+
self._session = None
|
|
287
|
+
|
|
288
|
+
def __enter__(self) -> "LokiClient":
|
|
289
|
+
"""Context manager entry."""
|
|
290
|
+
return self
|
|
291
|
+
|
|
292
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
293
|
+
"""Context manager exit - closes session."""
|
|
294
|
+
self.close()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for loki-reader-core.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LokiError(Exception):
|
|
7
|
+
"""Base exception for all Loki-related errors."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LokiConnectionError(LokiError):
|
|
12
|
+
"""Raised when connection to Loki server fails."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LokiQueryError(LokiError):
|
|
17
|
+
"""Raised when a Loki query fails or returns an error."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LokiAuthError(LokiError):
|
|
22
|
+
"""Raised when authentication to Loki fails."""
|
|
23
|
+
pass
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for loki-reader-core.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .log_entry import LogEntry
|
|
6
|
+
from .log_stream import LogStream
|
|
7
|
+
from .query_stats import QueryStats
|
|
8
|
+
from .query_result import QueryResult
|
|
9
|
+
|
|
10
|
+
__all__ = ["LogEntry", "LogStream", "QueryStats", "QueryResult"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LogEntry model representing a single log line from Loki.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class LogEntry:
|
|
10
|
+
"""
|
|
11
|
+
A single log entry from a Loki stream.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
timestamp: Unix timestamp in nanoseconds when the log was recorded.
|
|
15
|
+
message: The log message content.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
timestamp: int
|
|
19
|
+
message: str
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict:
|
|
22
|
+
"""
|
|
23
|
+
Convert to dictionary for serialization.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Dictionary with timestamp and message.
|
|
27
|
+
"""
|
|
28
|
+
return {
|
|
29
|
+
"timestamp": self.timestamp,
|
|
30
|
+
"message": self.message
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls, data: dict) -> "LogEntry":
|
|
35
|
+
"""
|
|
36
|
+
Create LogEntry from dictionary.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
data: Dictionary with timestamp and message keys.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
LogEntry instance.
|
|
43
|
+
"""
|
|
44
|
+
return cls(
|
|
45
|
+
timestamp=int(data["timestamp"]),
|
|
46
|
+
message=data["message"]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_loki_value(cls, value: list) -> "LogEntry":
|
|
51
|
+
"""
|
|
52
|
+
Create LogEntry from Loki's [timestamp, message] format.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
value: List of [timestamp_string, message_string] from Loki API.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
LogEntry instance.
|
|
59
|
+
"""
|
|
60
|
+
return cls(
|
|
61
|
+
timestamp=int(value[0]),
|
|
62
|
+
message=value[1]
|
|
63
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LogStream model representing a stream of logs with common labels.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .log_entry import LogEntry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class LogStream:
|
|
12
|
+
"""
|
|
13
|
+
A stream of log entries sharing common labels.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
labels: Dictionary of label key-value pairs (e.g., {"job": "api", "container": "web"}).
|
|
17
|
+
entries: List of LogEntry objects in this stream.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
labels: dict[str, str]
|
|
21
|
+
entries: list[LogEntry]
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict:
|
|
24
|
+
"""
|
|
25
|
+
Convert to dictionary for serialization.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Dictionary with labels and entries.
|
|
29
|
+
"""
|
|
30
|
+
return {
|
|
31
|
+
"labels": self.labels,
|
|
32
|
+
"entries": [entry.to_dict() for entry in self.entries]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, data: dict) -> "LogStream":
|
|
37
|
+
"""
|
|
38
|
+
Create LogStream from dictionary.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
data: Dictionary with labels and entries keys.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
LogStream instance.
|
|
45
|
+
"""
|
|
46
|
+
entries = [LogEntry.from_dict(e) for e in data.get("entries", [])]
|
|
47
|
+
return cls(
|
|
48
|
+
labels=data["labels"],
|
|
49
|
+
entries=entries
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_loki_stream(cls, stream_data: dict) -> "LogStream":
|
|
54
|
+
"""
|
|
55
|
+
Create LogStream from Loki API response format.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
stream_data: Dictionary with 'stream' (labels) and 'values' (log entries).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
LogStream instance.
|
|
62
|
+
"""
|
|
63
|
+
labels = stream_data.get("stream", {})
|
|
64
|
+
values = stream_data.get("values", [])
|
|
65
|
+
entries = [LogEntry.from_loki_value(v) for v in values]
|
|
66
|
+
return cls(labels=labels, entries=entries)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QueryResult model representing the result of a Loki query.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .log_stream import LogStream
|
|
8
|
+
from .query_stats import QueryStats
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class QueryResult:
|
|
13
|
+
"""
|
|
14
|
+
Result of a Loki query containing streams and statistics.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
status: Response status from Loki (typically "success").
|
|
18
|
+
streams: List of LogStream objects containing the query results.
|
|
19
|
+
stats: Optional QueryStats with execution statistics.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
status: str
|
|
23
|
+
streams: list[LogStream]
|
|
24
|
+
stats: QueryStats | None
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict:
|
|
27
|
+
"""
|
|
28
|
+
Convert to dictionary for serialization.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Dictionary with status, streams, and stats.
|
|
32
|
+
"""
|
|
33
|
+
return {
|
|
34
|
+
"status": self.status,
|
|
35
|
+
"streams": [stream.to_dict() for stream in self.streams],
|
|
36
|
+
"stats": self.stats.to_dict() if self.stats else None
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, data: dict) -> "QueryResult":
|
|
41
|
+
"""
|
|
42
|
+
Create QueryResult from dictionary.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
data: Dictionary with status, streams, and stats keys.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
QueryResult instance.
|
|
49
|
+
"""
|
|
50
|
+
streams = [LogStream.from_dict(s) for s in data.get("streams", [])]
|
|
51
|
+
stats = QueryStats.from_dict(data["stats"]) if data.get("stats") else None
|
|
52
|
+
return cls(
|
|
53
|
+
status=data["status"],
|
|
54
|
+
streams=streams,
|
|
55
|
+
stats=stats
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_loki_response(cls, response_data: dict) -> "QueryResult":
|
|
60
|
+
"""
|
|
61
|
+
Create QueryResult from Loki API response format.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
response_data: Full response from Loki query API.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
QueryResult instance.
|
|
68
|
+
"""
|
|
69
|
+
status = response_data.get("status", "unknown")
|
|
70
|
+
data = response_data.get("data", {})
|
|
71
|
+
|
|
72
|
+
result_list = data.get("result", [])
|
|
73
|
+
streams = [LogStream.from_loki_stream(s) for s in result_list]
|
|
74
|
+
|
|
75
|
+
stats_data = data.get("stats")
|
|
76
|
+
stats = QueryStats.from_loki_stats(stats_data) if stats_data else None
|
|
77
|
+
|
|
78
|
+
return cls(status=status, streams=streams, stats=stats)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def total_entries(self) -> int:
|
|
82
|
+
"""
|
|
83
|
+
Get total number of log entries across all streams.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Total entry count.
|
|
87
|
+
"""
|
|
88
|
+
return sum(len(stream.entries) for stream in self.streams)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QueryStats model representing statistics from a Loki query.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class QueryStats:
|
|
10
|
+
"""
|
|
11
|
+
Statistics about a Loki query execution.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
bytes_processed: Total bytes processed during the query.
|
|
15
|
+
lines_processed: Total log lines processed during the query.
|
|
16
|
+
exec_time_seconds: Query execution time in seconds.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
bytes_processed: int
|
|
20
|
+
lines_processed: int
|
|
21
|
+
exec_time_seconds: float
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict:
|
|
24
|
+
"""
|
|
25
|
+
Convert to dictionary for serialization.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Dictionary with statistics.
|
|
29
|
+
"""
|
|
30
|
+
return {
|
|
31
|
+
"bytes_processed": self.bytes_processed,
|
|
32
|
+
"lines_processed": self.lines_processed,
|
|
33
|
+
"exec_time_seconds": self.exec_time_seconds
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: dict) -> "QueryStats":
|
|
38
|
+
"""
|
|
39
|
+
Create QueryStats from dictionary.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
data: Dictionary with statistics keys.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
QueryStats instance.
|
|
46
|
+
"""
|
|
47
|
+
return cls(
|
|
48
|
+
bytes_processed=data.get("bytes_processed", 0),
|
|
49
|
+
lines_processed=data.get("lines_processed", 0),
|
|
50
|
+
exec_time_seconds=data.get("exec_time_seconds", 0.0)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_loki_stats(cls, stats_data: dict) -> "QueryStats":
|
|
55
|
+
"""
|
|
56
|
+
Create QueryStats from Loki API response format.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
stats_data: Statistics object from Loki response.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
QueryStats instance.
|
|
63
|
+
"""
|
|
64
|
+
summary = stats_data.get("summary", {})
|
|
65
|
+
return cls(
|
|
66
|
+
bytes_processed=summary.get("totalBytesProcessed", 0),
|
|
67
|
+
lines_processed=summary.get("totalLinesProcessed", 0),
|
|
68
|
+
exec_time_seconds=summary.get("execTime", 0.0)
|
|
69
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Timestamp utility functions for working with Loki's nanosecond timestamps.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
NANOSECONDS_PER_SECOND = 1_000_000_000
|
|
9
|
+
NANOSECONDS_PER_MINUTE = NANOSECONDS_PER_SECOND * 60
|
|
10
|
+
NANOSECONDS_PER_HOUR = NANOSECONDS_PER_MINUTE * 60
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def now_ns() -> int:
|
|
14
|
+
"""
|
|
15
|
+
Get current time as Unix nanoseconds.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Current timestamp in nanoseconds.
|
|
19
|
+
"""
|
|
20
|
+
return int(time.time() * NANOSECONDS_PER_SECOND)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def seconds_to_ns(seconds: int) -> int:
|
|
24
|
+
"""
|
|
25
|
+
Convert Unix seconds to nanoseconds.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
seconds: Unix timestamp in seconds.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Timestamp in nanoseconds.
|
|
32
|
+
"""
|
|
33
|
+
return seconds * NANOSECONDS_PER_SECOND
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ns_to_seconds(nanoseconds: int) -> int:
|
|
37
|
+
"""
|
|
38
|
+
Convert Unix nanoseconds to seconds.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
nanoseconds: Unix timestamp in nanoseconds.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Timestamp in seconds.
|
|
45
|
+
"""
|
|
46
|
+
return nanoseconds // NANOSECONDS_PER_SECOND
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def minutes_ago_ns(minutes: int) -> int:
|
|
50
|
+
"""
|
|
51
|
+
Get timestamp N minutes ago as nanoseconds.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
minutes: Number of minutes in the past.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Timestamp in nanoseconds.
|
|
58
|
+
"""
|
|
59
|
+
return now_ns() - (minutes * NANOSECONDS_PER_MINUTE)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def hours_ago_ns(hours: int) -> int:
|
|
63
|
+
"""
|
|
64
|
+
Get timestamp N hours ago as nanoseconds.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
hours: Number of hours in the past.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Timestamp in nanoseconds.
|
|
71
|
+
"""
|
|
72
|
+
return now_ns() - (hours * NANOSECONDS_PER_HOUR)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def days_ago_ns(days: int) -> int:
|
|
76
|
+
"""
|
|
77
|
+
Get timestamp N days ago as nanoseconds.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
days: Number of days in the past.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Timestamp in nanoseconds.
|
|
84
|
+
"""
|
|
85
|
+
return now_ns() - (days * NANOSECONDS_PER_HOUR * 24)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loki-reader-core
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python library for querying Grafana Loki logs via REST API
|
|
5
|
+
Project-URL: Homepage, https://github.com/jmazzahacks/loki-reader-core
|
|
6
|
+
Project-URL: Issues, https://github.com/jmazzahacks/loki-reader-core/issues
|
|
7
|
+
Author-email: Jason Byteforge <jmazzahacks@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
Keywords: grafana,logging,logs,loki,observability
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: requests
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# loki-reader-core
|
|
18
|
+
|
|
19
|
+
A lightweight Python library for querying Grafana Loki logs via REST API.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- Simple, intuitive client for Loki's HTTP API
|
|
24
|
+
- Supports `query`, `query_range`, label discovery, and series matching
|
|
25
|
+
- SSL/TLS support including custom CA certificates for self-signed certs
|
|
26
|
+
- Multi-tenant support via `X-Scope-OrgID` header
|
|
27
|
+
- Basic authentication
|
|
28
|
+
- Clean dataclass models with `to_dict()`/`from_dict()` serialization
|
|
29
|
+
- All timestamps as Unix nanoseconds (Loki's native format)
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
From PyPI (coming soon):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install loki-reader-core
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
From GitHub:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install loki-reader-core @ git+ssh://git@github.com/jmazzahacks/loki-reader-core.git@main
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### Basic Query
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from loki_reader_core import LokiClient
|
|
51
|
+
from loki_reader_core.utils import hours_ago_ns, now_ns
|
|
52
|
+
|
|
53
|
+
client = LokiClient(base_url="https://loki.example.com")
|
|
54
|
+
|
|
55
|
+
result = client.query_range(
|
|
56
|
+
logql='{job="api-server"} |= "error"',
|
|
57
|
+
start=hours_ago_ns(1),
|
|
58
|
+
end=now_ns(),
|
|
59
|
+
limit=500
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
for stream in result.streams:
|
|
63
|
+
print(f"Labels: {stream.labels}")
|
|
64
|
+
for entry in stream.entries:
|
|
65
|
+
print(f" [{entry.timestamp}] {entry.message}")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### With Authentication and Self-Signed Certificates
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
client = LokiClient(
|
|
72
|
+
base_url="https://loki.internal.company.com:8443",
|
|
73
|
+
auth=("username", "password"),
|
|
74
|
+
ca_cert="/path/to/ca.pem"
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Multi-Tenant Setup
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
client = LokiClient(
|
|
82
|
+
base_url="https://loki.example.com",
|
|
83
|
+
org_id="tenant-1"
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Exploring Labels
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
labels = client.get_labels()
|
|
91
|
+
values = client.get_label_values("application")
|
|
92
|
+
series = client.get_series(match=['{application="my-app"}'])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Context Manager
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
with LokiClient(base_url="https://loki.example.com") as client:
|
|
99
|
+
result = client.query(logql='{job="api"}')
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## API Reference
|
|
103
|
+
|
|
104
|
+
### LokiClient
|
|
105
|
+
|
|
106
|
+
| Parameter | Type | Default | Description |
|
|
107
|
+
|-----------|------|---------|-------------|
|
|
108
|
+
| `base_url` | `str` | required | Loki server URL |
|
|
109
|
+
| `auth` | `tuple[str, str]` | `None` | Basic auth `(username, password)` |
|
|
110
|
+
| `org_id` | `str` | `None` | `X-Scope-OrgID` for multi-tenant |
|
|
111
|
+
| `ca_cert` | `str` | `None` | Path to CA certificate PEM file |
|
|
112
|
+
| `verify_ssl` | `bool` | `True` | Set `False` to disable SSL verification |
|
|
113
|
+
| `timeout` | `int` | `30` | Request timeout in seconds |
|
|
114
|
+
|
|
115
|
+
### Methods
|
|
116
|
+
|
|
117
|
+
| Method | Description |
|
|
118
|
+
|--------|-------------|
|
|
119
|
+
| `query(logql, time, limit)` | Instant query at a single point in time |
|
|
120
|
+
| `query_range(logql, start, end, limit, direction)` | Query across a time range |
|
|
121
|
+
| `get_labels(start, end)` | List available label names |
|
|
122
|
+
| `get_label_values(label, start, end)` | List values for a specific label |
|
|
123
|
+
| `get_series(match, start, end)` | List streams matching selectors |
|
|
124
|
+
|
|
125
|
+
### Timestamp Utilities
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from loki_reader_core.utils import (
|
|
129
|
+
now_ns, # Current time as nanoseconds
|
|
130
|
+
seconds_to_ns, # Convert Unix seconds to nanoseconds
|
|
131
|
+
ns_to_seconds, # Convert nanoseconds to Unix seconds
|
|
132
|
+
minutes_ago_ns, # Timestamp N minutes ago
|
|
133
|
+
hours_ago_ns, # Timestamp N hours ago
|
|
134
|
+
days_ago_ns, # Timestamp N days ago
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Development
|
|
139
|
+
|
|
140
|
+
### Setup
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# Create virtual environment
|
|
144
|
+
python -m venv .
|
|
145
|
+
|
|
146
|
+
# Activate virtual environment
|
|
147
|
+
source bin/activate
|
|
148
|
+
|
|
149
|
+
# Install dependencies
|
|
150
|
+
pip install -r dev-requirements.txt
|
|
151
|
+
pip install -e .
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Running Tests
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
source bin/activate
|
|
158
|
+
pytest tests/ -v
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Building and Publishing
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
./build-publish.sh
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
170
|
+
|
|
171
|
+
## Author
|
|
172
|
+
|
|
173
|
+
Jason Byteforge (@jmazzahacks)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
loki_reader_core/__init__.py,sha256=n9Z3uOxrILiZjojQSX_egG1H2gal1aRcZ8nrsqE4b5w,463
|
|
2
|
+
loki_reader_core/client.py,sha256=Sp7-b6mlR6RzcwqU99DmJT0gSpJTkQTvQrzf7g4PT-Y,8959
|
|
3
|
+
loki_reader_core/exceptions.py,sha256=i10P8n46cwKzH-XMI8QBwOx3Hp6fVQDBMlXKR-U3cUU,446
|
|
4
|
+
loki_reader_core/utils.py,sha256=UpveOw4GiHhjSs_he4lMq65yq13x5iBjljOXF7Kybds,1720
|
|
5
|
+
loki_reader_core/models/__init__.py,sha256=THIMicK2P594P0evr5eWaobF1hmYYX1FBjq3SwF6Lcg,249
|
|
6
|
+
loki_reader_core/models/log_entry.py,sha256=0lj2pAzMlMvV43fJv_lgwGIgSGwNew-3iEejyzwK-Y8,1410
|
|
7
|
+
loki_reader_core/models/log_stream.py,sha256=Ynq0gdlKa5wfrmyLbKuPYY2fCYoOW2B43-s9KdHNdqM,1717
|
|
8
|
+
loki_reader_core/models/query_result.py,sha256=SJVYGLpMw6MwORCIA5gQpzgkZFSFdgmptNW7angbTNU,2455
|
|
9
|
+
loki_reader_core/models/query_stats.py,sha256=CYuc4Jev2NINby9hrTi4AqUtWt2D0Zzt_bdrcnN6t3k,1869
|
|
10
|
+
loki_reader_core-0.0.1.dist-info/METADATA,sha256=jc3ByLUT3JdvXlxQJRwyz5oGIHt0BnkvxMOoFTEgRj8,4224
|
|
11
|
+
loki_reader_core-0.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
loki_reader_core-0.0.1.dist-info/RECORD,,
|