logiq-sdk 1.0.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.
- logiq_sdk-1.0.0/PKG-INFO +78 -0
- logiq_sdk-1.0.0/README.md +65 -0
- logiq_sdk-1.0.0/pyproject.toml +22 -0
- logiq_sdk-1.0.0/setup.cfg +4 -0
- logiq_sdk-1.0.0/src/logiq/__init__.py +12 -0
- logiq_sdk-1.0.0/src/logiq/client.py +334 -0
- logiq_sdk-1.0.0/src/logiq/context.py +19 -0
- logiq_sdk-1.0.0/src/logiq/middleware.py +108 -0
- logiq_sdk-1.0.0/src/logiq_sdk.egg-info/PKG-INFO +78 -0
- logiq_sdk-1.0.0/src/logiq_sdk.egg-info/SOURCES.txt +11 -0
- logiq_sdk-1.0.0/src/logiq_sdk.egg-info/dependency_links.txt +1 -0
- logiq_sdk-1.0.0/src/logiq_sdk.egg-info/requires.txt +6 -0
- logiq_sdk-1.0.0/src/logiq_sdk.egg-info/top_level.txt +1 -0
logiq_sdk-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: logiq-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the LogIQ AI Observability Platform
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: monitoring,observability,logging,apm
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
10
|
+
Provides-Extra: asgi
|
|
11
|
+
Provides-Extra: flask
|
|
12
|
+
Requires-Dist: flask>=2.0; extra == "flask"
|
|
13
|
+
|
|
14
|
+
# logiq
|
|
15
|
+
|
|
16
|
+
Official Python SDK for the [LogIQ](https://logiq.thetechvoyager.in) AI observability platform. Batches structured log/error events on a background thread and ships them to your LogIQ project over HTTP.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install logiq-sdk
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from logiq import Monitor
|
|
28
|
+
|
|
29
|
+
monitor = Monitor(
|
|
30
|
+
api_key="<YOUR_API_KEY>",
|
|
31
|
+
base_url="https://your-logiq-backend.example.com",
|
|
32
|
+
service_name="checkout-service",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
monitor.info("Order placed", operation="create_order", metadata={"order_id": 123})
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
...
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
monitor.capture_exception(exc, operation="create_order")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Only `WARN` and above are sent by default — pass `min_level="INFO"` (or `"DEBUG"`) to `Monitor(...)` to lower the threshold.
|
|
44
|
+
|
|
45
|
+
## ASGI / Flask middleware
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from logiq import MonitorASGIMiddleware, attach_flask_middleware
|
|
49
|
+
|
|
50
|
+
# FastAPI / Starlette
|
|
51
|
+
app.add_middleware(MonitorASGIMiddleware, monitor=monitor)
|
|
52
|
+
|
|
53
|
+
# Flask
|
|
54
|
+
attach_flask_middleware(app, monitor)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Correlation IDs
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from logiq import set_correlation_id, reset_correlation_id, get_correlation_id
|
|
61
|
+
|
|
62
|
+
token = set_correlation_id("req-123")
|
|
63
|
+
try:
|
|
64
|
+
...
|
|
65
|
+
finally:
|
|
66
|
+
reset_correlation_id(token)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Heartbeats
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
monitor.heartbeat() # one-off
|
|
73
|
+
monitor.start_heartbeat_loop(30) # periodic, every 30s, stops on monitor.close()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# logiq
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [LogIQ](https://logiq.thetechvoyager.in) AI observability platform. Batches structured log/error events on a background thread and ships them to your LogIQ project over HTTP.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install logiq-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from logiq import Monitor
|
|
15
|
+
|
|
16
|
+
monitor = Monitor(
|
|
17
|
+
api_key="<YOUR_API_KEY>",
|
|
18
|
+
base_url="https://your-logiq-backend.example.com",
|
|
19
|
+
service_name="checkout-service",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
monitor.info("Order placed", operation="create_order", metadata={"order_id": 123})
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
...
|
|
26
|
+
except Exception as exc:
|
|
27
|
+
monitor.capture_exception(exc, operation="create_order")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Only `WARN` and above are sent by default — pass `min_level="INFO"` (or `"DEBUG"`) to `Monitor(...)` to lower the threshold.
|
|
31
|
+
|
|
32
|
+
## ASGI / Flask middleware
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from logiq import MonitorASGIMiddleware, attach_flask_middleware
|
|
36
|
+
|
|
37
|
+
# FastAPI / Starlette
|
|
38
|
+
app.add_middleware(MonitorASGIMiddleware, monitor=monitor)
|
|
39
|
+
|
|
40
|
+
# Flask
|
|
41
|
+
attach_flask_middleware(app, monitor)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Correlation IDs
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from logiq import set_correlation_id, reset_correlation_id, get_correlation_id
|
|
48
|
+
|
|
49
|
+
token = set_correlation_id("req-123")
|
|
50
|
+
try:
|
|
51
|
+
...
|
|
52
|
+
finally:
|
|
53
|
+
reset_correlation_id(token)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Heartbeats
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
monitor.heartbeat() # one-off
|
|
60
|
+
monitor.start_heartbeat_loop(30) # periodic, every 30s, stops on monitor.close()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "logiq-sdk"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python SDK for the LogIQ AI Observability Platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["monitoring", "observability", "logging", "apm"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"requests>=2.28",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
asgi = [] # MonitorASGIMiddleware ships in the package; no extra deps needed
|
|
19
|
+
flask = ["flask>=2.0"]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from logiq.client import Monitor
|
|
2
|
+
from logiq.context import get_correlation_id, reset_correlation_id, set_correlation_id
|
|
3
|
+
from logiq.middleware import MonitorASGIMiddleware, attach_flask_middleware
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Monitor",
|
|
7
|
+
"MonitorASGIMiddleware",
|
|
8
|
+
"attach_flask_middleware",
|
|
9
|
+
"set_correlation_id",
|
|
10
|
+
"get_correlation_id",
|
|
11
|
+
"reset_correlation_id",
|
|
12
|
+
]
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import traceback
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from logiq.context import get_correlation_id
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_LEVEL_PRIORITY: dict[str, int] = {
|
|
17
|
+
"DEBUG": 0,
|
|
18
|
+
"INFO": 1,
|
|
19
|
+
"WARN": 2,
|
|
20
|
+
"WARNING": 2,
|
|
21
|
+
"ERROR": 3,
|
|
22
|
+
"CRITICAL": 4,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class _MonitorConfig:
|
|
28
|
+
api_key: str
|
|
29
|
+
base_url: str
|
|
30
|
+
service_name: str | None
|
|
31
|
+
source: str
|
|
32
|
+
batch_size: int
|
|
33
|
+
flush_interval: float
|
|
34
|
+
timeout_seconds: float
|
|
35
|
+
max_retries: int
|
|
36
|
+
retry_backoff_seconds: float
|
|
37
|
+
min_level: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Monitor:
|
|
41
|
+
"""Client SDK for LogIQ log ingestion.
|
|
42
|
+
|
|
43
|
+
Features:
|
|
44
|
+
- non-blocking batching on background thread
|
|
45
|
+
- retry with exponential backoff
|
|
46
|
+
- request context propagation through correlation IDs
|
|
47
|
+
- exception capture helpers
|
|
48
|
+
- min_level filter: events below the threshold are silently dropped
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
api_key: str,
|
|
55
|
+
base_url: str,
|
|
56
|
+
service_name: str | None = None,
|
|
57
|
+
source: str = "sdk",
|
|
58
|
+
batch_size: int = 50,
|
|
59
|
+
flush_interval: float = 2.0,
|
|
60
|
+
timeout_seconds: float = 5.0,
|
|
61
|
+
max_retries: int = 3,
|
|
62
|
+
retry_backoff_seconds: float = 0.5,
|
|
63
|
+
min_level: str = "WARN",
|
|
64
|
+
session: Optional[requests.Session] = None,
|
|
65
|
+
start_background: bool = True,
|
|
66
|
+
) -> None:
|
|
67
|
+
if not api_key:
|
|
68
|
+
raise ValueError("api_key is required")
|
|
69
|
+
if not base_url:
|
|
70
|
+
raise ValueError("base_url is required")
|
|
71
|
+
if batch_size < 1:
|
|
72
|
+
raise ValueError("batch_size must be >= 1")
|
|
73
|
+
if flush_interval <= 0:
|
|
74
|
+
raise ValueError("flush_interval must be > 0")
|
|
75
|
+
if max_retries < 0:
|
|
76
|
+
raise ValueError("max_retries must be >= 0")
|
|
77
|
+
if min_level.upper() not in _LEVEL_PRIORITY:
|
|
78
|
+
raise ValueError(f"min_level must be one of {list(_LEVEL_PRIORITY)}")
|
|
79
|
+
|
|
80
|
+
self._cfg = _MonitorConfig(
|
|
81
|
+
api_key=api_key,
|
|
82
|
+
base_url=base_url.rstrip("/"),
|
|
83
|
+
service_name=service_name,
|
|
84
|
+
source=source,
|
|
85
|
+
batch_size=batch_size,
|
|
86
|
+
flush_interval=flush_interval,
|
|
87
|
+
timeout_seconds=timeout_seconds,
|
|
88
|
+
max_retries=max_retries,
|
|
89
|
+
retry_backoff_seconds=retry_backoff_seconds,
|
|
90
|
+
min_level=min_level.upper(),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
self._session = session or requests.Session()
|
|
94
|
+
self._lock = threading.Lock()
|
|
95
|
+
self._buffer: list[dict[str, Any]] = []
|
|
96
|
+
self._dead_letter: list[dict[str, Any]] = []
|
|
97
|
+
self._stop_event = threading.Event()
|
|
98
|
+
self._flush_thread: threading.Thread | None = None
|
|
99
|
+
|
|
100
|
+
if start_background:
|
|
101
|
+
self.start()
|
|
102
|
+
atexit.register(self.close)
|
|
103
|
+
|
|
104
|
+
def start(self) -> None:
|
|
105
|
+
if self._flush_thread and self._flush_thread.is_alive():
|
|
106
|
+
return
|
|
107
|
+
self._stop_event.clear()
|
|
108
|
+
self._flush_thread = threading.Thread(
|
|
109
|
+
target=self._flush_loop, name="logiq-flush", daemon=True
|
|
110
|
+
)
|
|
111
|
+
self._flush_thread.start()
|
|
112
|
+
|
|
113
|
+
def start_heartbeat_loop(self, interval: float = 30.0) -> None:
|
|
114
|
+
"""Send a periodic heartbeat every *interval* seconds so the service
|
|
115
|
+
stays visible in the Servers dashboard while it is running.
|
|
116
|
+
Stops automatically when the Monitor is closed.
|
|
117
|
+
"""
|
|
118
|
+
def _loop() -> None:
|
|
119
|
+
while not self._stop_event.is_set():
|
|
120
|
+
self._stop_event.wait(interval)
|
|
121
|
+
if not self._stop_event.is_set():
|
|
122
|
+
self.heartbeat()
|
|
123
|
+
|
|
124
|
+
t = threading.Thread(target=_loop, name="logiq-heartbeat", daemon=True)
|
|
125
|
+
t.start()
|
|
126
|
+
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
self._stop_event.set()
|
|
129
|
+
if self._flush_thread and self._flush_thread.is_alive():
|
|
130
|
+
self._flush_thread.join(timeout=2.0)
|
|
131
|
+
self.flush()
|
|
132
|
+
|
|
133
|
+
def dead_letter(self) -> list[dict[str, Any]]:
|
|
134
|
+
with self._lock:
|
|
135
|
+
return list(self._dead_letter)
|
|
136
|
+
|
|
137
|
+
def log(
|
|
138
|
+
self,
|
|
139
|
+
message: str,
|
|
140
|
+
*,
|
|
141
|
+
level: str = "INFO",
|
|
142
|
+
operation: str | None = None,
|
|
143
|
+
status: str | None = None,
|
|
144
|
+
error_type: str | None = None,
|
|
145
|
+
metadata: dict[str, Any] | None = None,
|
|
146
|
+
correlation_id: str | None = None,
|
|
147
|
+
service_name: str | None = None,
|
|
148
|
+
source: str | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
if not message:
|
|
151
|
+
return
|
|
152
|
+
if _LEVEL_PRIORITY.get(level.upper(), 1) < _LEVEL_PRIORITY[self._cfg.min_level]:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
resolved_correlation = correlation_id or get_correlation_id()
|
|
156
|
+
payload = {
|
|
157
|
+
"service_name": service_name or self._cfg.service_name,
|
|
158
|
+
"operation": operation,
|
|
159
|
+
"level": level.upper(),
|
|
160
|
+
"status": status,
|
|
161
|
+
"message": message,
|
|
162
|
+
"error_type": error_type,
|
|
163
|
+
"correlation_id": resolved_correlation,
|
|
164
|
+
"metadata": metadata,
|
|
165
|
+
"source": source or self._cfg.source,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
with self._lock:
|
|
169
|
+
self._buffer.append(payload)
|
|
170
|
+
should_flush = len(self._buffer) >= self._cfg.batch_size
|
|
171
|
+
|
|
172
|
+
if should_flush:
|
|
173
|
+
self.flush()
|
|
174
|
+
|
|
175
|
+
def heartbeat(self, service_name: str | None = None) -> None:
|
|
176
|
+
"""Register this service with the monitor immediately, bypassing min_level.
|
|
177
|
+
|
|
178
|
+
Useful at startup so the service appears in the Servers dashboard even
|
|
179
|
+
before any real errors occur.
|
|
180
|
+
"""
|
|
181
|
+
resolved = service_name or self._cfg.service_name
|
|
182
|
+
payload = {
|
|
183
|
+
"service_name": resolved,
|
|
184
|
+
"level": "INFO",
|
|
185
|
+
"message": f"Service '{resolved}' started",
|
|
186
|
+
"source": self._cfg.source,
|
|
187
|
+
"operation": "startup",
|
|
188
|
+
"status": "started",
|
|
189
|
+
"error_type": None,
|
|
190
|
+
"correlation_id": None,
|
|
191
|
+
"metadata": None,
|
|
192
|
+
}
|
|
193
|
+
with self._lock:
|
|
194
|
+
self._buffer.append(payload)
|
|
195
|
+
|
|
196
|
+
def info(self, message: str, **kwargs: Any) -> None:
|
|
197
|
+
self.log(message, level="INFO", **kwargs)
|
|
198
|
+
|
|
199
|
+
def warn(self, message: str, **kwargs: Any) -> None:
|
|
200
|
+
self.log(message, level="WARN", **kwargs)
|
|
201
|
+
|
|
202
|
+
def error(self, message: str, **kwargs: Any) -> None:
|
|
203
|
+
self.log(message, level="ERROR", **kwargs)
|
|
204
|
+
|
|
205
|
+
def debug(self, message: str, **kwargs: Any) -> None:
|
|
206
|
+
self.log(message, level="DEBUG", **kwargs)
|
|
207
|
+
|
|
208
|
+
def capture_exception(
|
|
209
|
+
self,
|
|
210
|
+
exc: BaseException,
|
|
211
|
+
*,
|
|
212
|
+
operation: str | None = None,
|
|
213
|
+
metadata: dict[str, Any] | None = None,
|
|
214
|
+
correlation_id: str | None = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
exc_type = type(exc).__name__
|
|
217
|
+
detail = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
218
|
+
merged_metadata = dict(metadata or {})
|
|
219
|
+
merged_metadata.setdefault("traceback", detail)
|
|
220
|
+
|
|
221
|
+
self.log(
|
|
222
|
+
message=str(exc) or exc_type,
|
|
223
|
+
level="ERROR",
|
|
224
|
+
operation=operation,
|
|
225
|
+
status="error",
|
|
226
|
+
error_type=exc_type,
|
|
227
|
+
metadata=merged_metadata,
|
|
228
|
+
correlation_id=correlation_id,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def trace(self, operation: str, *, metadata: dict[str, Any] | None = None):
|
|
232
|
+
return _TraceContext(self, operation=operation, metadata=metadata)
|
|
233
|
+
|
|
234
|
+
def install_excepthook(self) -> None:
|
|
235
|
+
import sys
|
|
236
|
+
|
|
237
|
+
previous_hook = sys.excepthook
|
|
238
|
+
|
|
239
|
+
def _hook(exc_type: type[BaseException], exc: BaseException, tb: Any) -> None:
|
|
240
|
+
self.capture_exception(exc, operation="unhandled_exception")
|
|
241
|
+
self.flush()
|
|
242
|
+
previous_hook(exc_type, exc, tb)
|
|
243
|
+
|
|
244
|
+
sys.excepthook = _hook
|
|
245
|
+
|
|
246
|
+
def flush(self) -> None:
|
|
247
|
+
while True:
|
|
248
|
+
batch = self._drain_batch(self._cfg.batch_size)
|
|
249
|
+
if not batch:
|
|
250
|
+
return
|
|
251
|
+
success = self._send_batch(batch)
|
|
252
|
+
if not success:
|
|
253
|
+
with self._lock:
|
|
254
|
+
self._dead_letter.extend(batch)
|
|
255
|
+
|
|
256
|
+
def _flush_loop(self) -> None:
|
|
257
|
+
while not self._stop_event.is_set():
|
|
258
|
+
self._stop_event.wait(self._cfg.flush_interval)
|
|
259
|
+
if self._stop_event.is_set():
|
|
260
|
+
break
|
|
261
|
+
self.flush()
|
|
262
|
+
|
|
263
|
+
def _drain_batch(self, n: int) -> list[dict[str, Any]]:
|
|
264
|
+
with self._lock:
|
|
265
|
+
if not self._buffer:
|
|
266
|
+
return []
|
|
267
|
+
batch = self._buffer[:n]
|
|
268
|
+
self._buffer = self._buffer[n:]
|
|
269
|
+
return batch
|
|
270
|
+
|
|
271
|
+
def _send_batch(self, batch: list[dict[str, Any]]) -> bool:
|
|
272
|
+
url = f"{self._cfg.base_url}/api/v1/logs"
|
|
273
|
+
attempt = 0
|
|
274
|
+
while attempt <= self._cfg.max_retries:
|
|
275
|
+
attempt += 1
|
|
276
|
+
try:
|
|
277
|
+
resp = self._session.post(
|
|
278
|
+
url,
|
|
279
|
+
json={"logs": batch},
|
|
280
|
+
headers={
|
|
281
|
+
"Content-Type": "application/json",
|
|
282
|
+
"X-API-Key": self._cfg.api_key,
|
|
283
|
+
"Idempotency-Key": str(uuid.uuid4()),
|
|
284
|
+
},
|
|
285
|
+
timeout=self._cfg.timeout_seconds,
|
|
286
|
+
)
|
|
287
|
+
if 200 <= resp.status_code < 300:
|
|
288
|
+
return True
|
|
289
|
+
except requests.RequestException:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
if attempt <= self._cfg.max_retries:
|
|
293
|
+
time.sleep(self._cfg.retry_backoff_seconds * (2 ** (attempt - 1)))
|
|
294
|
+
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class _TraceContext:
|
|
299
|
+
def __init__(self, monitor: Monitor, *, operation: str, metadata: dict[str, Any] | None):
|
|
300
|
+
self._monitor = monitor
|
|
301
|
+
self._operation = operation
|
|
302
|
+
self._metadata = metadata or {}
|
|
303
|
+
self._start = 0.0
|
|
304
|
+
|
|
305
|
+
def __enter__(self):
|
|
306
|
+
self._start = time.perf_counter()
|
|
307
|
+
self._monitor.debug(
|
|
308
|
+
message=f"{self._operation} started",
|
|
309
|
+
operation=self._operation,
|
|
310
|
+
metadata=self._metadata,
|
|
311
|
+
status="start",
|
|
312
|
+
)
|
|
313
|
+
return self
|
|
314
|
+
|
|
315
|
+
def __exit__(self, exc_type, exc, _tb):
|
|
316
|
+
duration_ms = int((time.perf_counter() - self._start) * 1000)
|
|
317
|
+
metadata = dict(self._metadata)
|
|
318
|
+
metadata["duration_ms"] = duration_ms
|
|
319
|
+
|
|
320
|
+
if exc is None:
|
|
321
|
+
self._monitor.info(
|
|
322
|
+
message=f"{self._operation} completed",
|
|
323
|
+
operation=self._operation,
|
|
324
|
+
status="ok",
|
|
325
|
+
metadata=metadata,
|
|
326
|
+
)
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
self._monitor.capture_exception(
|
|
330
|
+
exc,
|
|
331
|
+
operation=self._operation,
|
|
332
|
+
metadata=metadata,
|
|
333
|
+
)
|
|
334
|
+
return False
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextvars
|
|
4
|
+
|
|
5
|
+
_correlation_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
6
|
+
"logiq_correlation_id", default=None
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def set_correlation_id(correlation_id: str | None) -> contextvars.Token:
|
|
11
|
+
return _correlation_id_var.set(correlation_id)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def reset_correlation_id(token: contextvars.Token) -> None:
|
|
15
|
+
_correlation_id_var.reset(token)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_correlation_id() -> str | None:
|
|
19
|
+
return _correlation_id_var.get()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from logiq.context import reset_correlation_id, set_correlation_id
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MonitorASGIMiddleware:
|
|
11
|
+
"""ASGI middleware that propagates correlation IDs and logs request lifecycle."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, app: Callable[..., Any], monitor: Any):
|
|
14
|
+
self.app = app
|
|
15
|
+
self.monitor = monitor
|
|
16
|
+
|
|
17
|
+
async def __call__(self, scope: dict[str, Any], receive: Callable[..., Any], send: Callable[..., Any]) -> None:
|
|
18
|
+
if scope.get("type") != "http":
|
|
19
|
+
await self.app(scope, receive, send)
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
headers = {
|
|
23
|
+
k.decode("latin-1").lower(): v.decode("latin-1")
|
|
24
|
+
for k, v in scope.get("headers", [])
|
|
25
|
+
}
|
|
26
|
+
correlation_id = (
|
|
27
|
+
headers.get("x-request-id")
|
|
28
|
+
or headers.get("x-correlation-id")
|
|
29
|
+
or str(uuid.uuid4())
|
|
30
|
+
)
|
|
31
|
+
token = set_correlation_id(correlation_id)
|
|
32
|
+
|
|
33
|
+
method = scope.get("method", "UNKNOWN")
|
|
34
|
+
path = scope.get("path", "/")
|
|
35
|
+
started = time.perf_counter()
|
|
36
|
+
status_code = 500
|
|
37
|
+
|
|
38
|
+
async def wrapped_send(message: dict[str, Any]) -> None:
|
|
39
|
+
nonlocal status_code
|
|
40
|
+
if message.get("type") == "http.response.start":
|
|
41
|
+
status_code = int(message.get("status", 500))
|
|
42
|
+
await send(message)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
await self.app(scope, receive, wrapped_send)
|
|
46
|
+
self.monitor.log(
|
|
47
|
+
message=f"{method} {path}",
|
|
48
|
+
level="INFO" if status_code < 500 else "ERROR",
|
|
49
|
+
operation="http_request",
|
|
50
|
+
status=str(status_code),
|
|
51
|
+
metadata={
|
|
52
|
+
"method": method,
|
|
53
|
+
"path": path,
|
|
54
|
+
"duration_ms": int((time.perf_counter() - started) * 1000),
|
|
55
|
+
},
|
|
56
|
+
correlation_id=correlation_id,
|
|
57
|
+
)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
self.monitor.capture_exception(
|
|
60
|
+
exc,
|
|
61
|
+
operation="http_request",
|
|
62
|
+
metadata={"method": method, "path": path},
|
|
63
|
+
correlation_id=correlation_id,
|
|
64
|
+
)
|
|
65
|
+
raise
|
|
66
|
+
finally:
|
|
67
|
+
reset_correlation_id(token)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def attach_flask_middleware(app: Any, monitor: Any) -> None:
|
|
71
|
+
"""Attach request logging middleware to a Flask app."""
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
from flask import g, request
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
raise RuntimeError("Flask is not installed. Install flask to use Flask middleware.") from exc
|
|
77
|
+
|
|
78
|
+
@app.before_request
|
|
79
|
+
def _before_request() -> None:
|
|
80
|
+
correlation_id = (
|
|
81
|
+
request.headers.get("X-Request-ID")
|
|
82
|
+
or request.headers.get("X-Correlation-ID")
|
|
83
|
+
or str(uuid.uuid4())
|
|
84
|
+
)
|
|
85
|
+
g._monitor_started = time.perf_counter()
|
|
86
|
+
g._monitor_token = set_correlation_id(correlation_id)
|
|
87
|
+
g._monitor_correlation_id = correlation_id
|
|
88
|
+
|
|
89
|
+
@app.after_request
|
|
90
|
+
def _after_request(response: Any) -> Any:
|
|
91
|
+
started = getattr(g, "_monitor_started", time.perf_counter())
|
|
92
|
+
correlation_id = getattr(g, "_monitor_correlation_id", None)
|
|
93
|
+
monitor.log(
|
|
94
|
+
message=f"{request.method} {request.path}",
|
|
95
|
+
level="INFO" if response.status_code < 500 else "ERROR",
|
|
96
|
+
operation="http_request",
|
|
97
|
+
status=str(response.status_code),
|
|
98
|
+
metadata={
|
|
99
|
+
"method": request.method,
|
|
100
|
+
"path": request.path,
|
|
101
|
+
"duration_ms": int((time.perf_counter() - started) * 1000),
|
|
102
|
+
},
|
|
103
|
+
correlation_id=correlation_id,
|
|
104
|
+
)
|
|
105
|
+
token = getattr(g, "_monitor_token", None)
|
|
106
|
+
if token is not None:
|
|
107
|
+
reset_correlation_id(token)
|
|
108
|
+
return response
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: logiq-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the LogIQ AI Observability Platform
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: monitoring,observability,logging,apm
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
10
|
+
Provides-Extra: asgi
|
|
11
|
+
Provides-Extra: flask
|
|
12
|
+
Requires-Dist: flask>=2.0; extra == "flask"
|
|
13
|
+
|
|
14
|
+
# logiq
|
|
15
|
+
|
|
16
|
+
Official Python SDK for the [LogIQ](https://logiq.thetechvoyager.in) AI observability platform. Batches structured log/error events on a background thread and ships them to your LogIQ project over HTTP.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install logiq-sdk
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from logiq import Monitor
|
|
28
|
+
|
|
29
|
+
monitor = Monitor(
|
|
30
|
+
api_key="<YOUR_API_KEY>",
|
|
31
|
+
base_url="https://your-logiq-backend.example.com",
|
|
32
|
+
service_name="checkout-service",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
monitor.info("Order placed", operation="create_order", metadata={"order_id": 123})
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
...
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
monitor.capture_exception(exc, operation="create_order")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Only `WARN` and above are sent by default — pass `min_level="INFO"` (or `"DEBUG"`) to `Monitor(...)` to lower the threshold.
|
|
44
|
+
|
|
45
|
+
## ASGI / Flask middleware
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from logiq import MonitorASGIMiddleware, attach_flask_middleware
|
|
49
|
+
|
|
50
|
+
# FastAPI / Starlette
|
|
51
|
+
app.add_middleware(MonitorASGIMiddleware, monitor=monitor)
|
|
52
|
+
|
|
53
|
+
# Flask
|
|
54
|
+
attach_flask_middleware(app, monitor)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Correlation IDs
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from logiq import set_correlation_id, reset_correlation_id, get_correlation_id
|
|
61
|
+
|
|
62
|
+
token = set_correlation_id("req-123")
|
|
63
|
+
try:
|
|
64
|
+
...
|
|
65
|
+
finally:
|
|
66
|
+
reset_correlation_id(token)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Heartbeats
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
monitor.heartbeat() # one-off
|
|
73
|
+
monitor.start_heartbeat_loop(30) # periodic, every 30s, stops on monitor.close()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/logiq/__init__.py
|
|
4
|
+
src/logiq/client.py
|
|
5
|
+
src/logiq/context.py
|
|
6
|
+
src/logiq/middleware.py
|
|
7
|
+
src/logiq_sdk.egg-info/PKG-INFO
|
|
8
|
+
src/logiq_sdk.egg-info/SOURCES.txt
|
|
9
|
+
src/logiq_sdk.egg-info/dependency_links.txt
|
|
10
|
+
src/logiq_sdk.egg-info/requires.txt
|
|
11
|
+
src/logiq_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
logiq
|