logsentinel 0.1.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.
- logsentinel-0.1.0/.gitignore +11 -0
- logsentinel-0.1.0/.http +12 -0
- logsentinel-0.1.0/.python-version +1 -0
- logsentinel-0.1.0/PKG-INFO +57 -0
- logsentinel-0.1.0/README.md +38 -0
- logsentinel-0.1.0/examples/fastapi.py +25 -0
- logsentinel-0.1.0/logosentinel/__init__.py +17 -0
- logsentinel-0.1.0/logosentinel/client/__init__.py +0 -0
- logsentinel-0.1.0/logosentinel/client/a_sync.py +53 -0
- logsentinel-0.1.0/logosentinel/client/sync.py +43 -0
- logsentinel-0.1.0/logosentinel/config/__init__.py +0 -0
- logsentinel-0.1.0/logosentinel/config/base.py +9 -0
- logsentinel-0.1.0/logosentinel/config/dev.py +0 -0
- logsentinel-0.1.0/logosentinel/config/prod.py +10 -0
- logsentinel-0.1.0/logosentinel/exceptions.py +5 -0
- logsentinel-0.1.0/logosentinel/handler.py +30 -0
- logsentinel-0.1.0/logosentinel/libs/__init__.py +0 -0
- logsentinel-0.1.0/logosentinel/libs/utils.py +158 -0
- logsentinel-0.1.0/logosentinel/logger.py +31 -0
- logsentinel-0.1.0/logosentinel/middleware.py +145 -0
- logsentinel-0.1.0/logosentinel/sender.py +0 -0
- logsentinel-0.1.0/main.py +39 -0
- logsentinel-0.1.0/pyproject.toml +31 -0
- logsentinel-0.1.0/uv.lock +2434 -0
logsentinel-0.1.0/.http
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: logsentinel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered log monitoring SDK for Python applications
|
|
5
|
+
Project-URL: Homepage, https://github.com/inctifra/logsentinel
|
|
6
|
+
Project-URL: Documentation, https://pypi.org/project/logsentinel
|
|
7
|
+
Project-URL: Source, https://github.com/inctifra/logsentinel
|
|
8
|
+
Author-email: Jeckonia Kwasa <inctifra@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: aiohttp>=3.13.2
|
|
12
|
+
Requires-Dist: backoff>=2.2.1
|
|
13
|
+
Requires-Dist: django-environ>=0.12.0
|
|
14
|
+
Requires-Dist: fastapi[all]>=0.121.1
|
|
15
|
+
Requires-Dist: httpx>=0.28.1
|
|
16
|
+
Requires-Dist: requests>=2.32.3
|
|
17
|
+
Requires-Dist: rich>=14.2.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# LogSentinel Python SDK
|
|
21
|
+
|
|
22
|
+
LogSentinel is a lightweight SDK for monitoring and analyzing logs in Python applications. It automatically captures request and response data, builds detailed metadata, and sends them to the LogSentinel AI platform for analysis.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install logsentinel
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
1. Go to the [LogSentinel Dashboard](https://sentinel.ivps.cloud) and create an API key.
|
|
33
|
+
2. Add it to your environment variables:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export LOGSENTINEL_API_KEY="your_api_key_here"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Example (FastAPI)
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from fastapi import FastAPI
|
|
43
|
+
from logosentinel import LogSentinelASGIMiddleware
|
|
44
|
+
|
|
45
|
+
app = FastAPI()
|
|
46
|
+
app.add_middleware(LogSentinelASGIMiddleware)
|
|
47
|
+
|
|
48
|
+
@app.post("/hello")
|
|
49
|
+
async def hello(data: dict):
|
|
50
|
+
return {"message": "Hello, world!", "received": data}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Logs will automatically be captured and sent to your LogSentinel dashboard for AI analysis.
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT License
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# LogSentinel Python SDK
|
|
2
|
+
|
|
3
|
+
LogSentinel is a lightweight SDK for monitoring and analyzing logs in Python applications. It automatically captures request and response data, builds detailed metadata, and sends them to the LogSentinel AI platform for analysis.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install logsentinel
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
1. Go to the [LogSentinel Dashboard](https://sentinel.ivps.cloud) and create an API key.
|
|
14
|
+
2. Add it to your environment variables:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
export LOGSENTINEL_API_KEY="your_api_key_here"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Example (FastAPI)
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from fastapi import FastAPI
|
|
24
|
+
from logosentinel import LogSentinelASGIMiddleware
|
|
25
|
+
|
|
26
|
+
app = FastAPI()
|
|
27
|
+
app.add_middleware(LogSentinelASGIMiddleware)
|
|
28
|
+
|
|
29
|
+
@app.post("/hello")
|
|
30
|
+
async def hello(data: dict):
|
|
31
|
+
return {"message": "Hello, world!", "received": data}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Logs will automatically be captured and sent to your LogSentinel dashboard for AI analysis.
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
MIT License
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
|
|
2
|
+
from fastapi import FastAPI, HTTPException, responses
|
|
3
|
+
from logosentinel import LogSentinelASGIMiddleware
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
app = FastAPI()
|
|
7
|
+
app.add_middleware(
|
|
8
|
+
LogSentinelASGIMiddleware,
|
|
9
|
+
# api_key=API_KEY,
|
|
10
|
+
send_remote=True
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
@app.post("/hello")
|
|
14
|
+
async def hello_post(payload: dict):
|
|
15
|
+
return responses.JSONResponse(content={"detail": "Invalid user credentials"}, status_code=401)
|
|
16
|
+
|
|
17
|
+
@app.get("/error")
|
|
18
|
+
async def error():
|
|
19
|
+
raise HTTPException(status_code=500, detail="Test error")
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
import uvicorn
|
|
23
|
+
uvicorn.run(app, host="127.0.0.1", port=8002)
|
|
24
|
+
|
|
25
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .config.prod import DEFAULT_BASE_URL, DEFAULT_API_KEY
|
|
2
|
+
from .client.sync import LogSentinelClient
|
|
3
|
+
from .client.a_sync import AsyncLogSentinelClient
|
|
4
|
+
from .middleware import LogSentinelASGIMiddleware, LogSentinelWSGIMiddleware
|
|
5
|
+
from .handler import LogSentinelHandler
|
|
6
|
+
from .logger import SentinelLogger
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"LogSentinelClient",
|
|
10
|
+
"AsyncLogSentinelClient",
|
|
11
|
+
"LogSentinelASGIMiddleware",
|
|
12
|
+
"LogSentinelWSGIMiddleware",
|
|
13
|
+
"LogSentinelHandler",
|
|
14
|
+
"SentinelLogger",
|
|
15
|
+
"DEFAULT_BASE_URL",
|
|
16
|
+
"DEFAULT_API_KEY"
|
|
17
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import httpx
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
from logosentinel.config.prod import DEFAULT_API_KEY, DEFAULT_BASE_URL
|
|
6
|
+
from logosentinel.libs.utils import utc_now_iso, safe_str
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("logsentinel.async_client")
|
|
9
|
+
|
|
10
|
+
class AsyncLogSentinelClient:
|
|
11
|
+
"""
|
|
12
|
+
Async client that immediately sends logs to LogSentinel backend using httpx.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, api_key: str = DEFAULT_API_KEY, base_url: str = DEFAULT_BASE_URL):
|
|
16
|
+
if not api_key:
|
|
17
|
+
raise ValueError("api_key is required")
|
|
18
|
+
self.api_key = api_key
|
|
19
|
+
self.base_url = base_url.rstrip("/")
|
|
20
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
21
|
+
|
|
22
|
+
async def _get_client(self):
|
|
23
|
+
if self._client is None:
|
|
24
|
+
self._client = httpx.AsyncClient(
|
|
25
|
+
headers={
|
|
26
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
"User-Agent": "logsentinel-python-sdk/1.0",
|
|
29
|
+
},
|
|
30
|
+
timeout=5.0
|
|
31
|
+
)
|
|
32
|
+
return self._client
|
|
33
|
+
|
|
34
|
+
async def send(self, message: str, level: str = "INFO", metadata: Optional[Dict] = None):
|
|
35
|
+
payload = {
|
|
36
|
+
"timestamp": utc_now_iso(),
|
|
37
|
+
"message": safe_str(message),
|
|
38
|
+
"level": level.upper(),
|
|
39
|
+
"metadata": metadata or {},
|
|
40
|
+
}
|
|
41
|
+
try:
|
|
42
|
+
client = await self._get_client()
|
|
43
|
+
response = await client.post(f"{self.base_url}/api/sdk/logs", json=payload)
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
return response.json()
|
|
46
|
+
except httpx.HTTPError as e:
|
|
47
|
+
logger.error("Failed to send log to LogSentinel: %s", e)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
async def close(self):
|
|
51
|
+
if self._client:
|
|
52
|
+
await self._client.aclose()
|
|
53
|
+
self._client = None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
from logosentinel.config.prod import DEFAULT_API_KEY, DEFAULT_BASE_URL
|
|
5
|
+
from logosentinel.libs.utils import utc_now_iso, safe_str
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("logsentinel.client.sync")
|
|
8
|
+
|
|
9
|
+
class LogSentinelClient:
|
|
10
|
+
"""
|
|
11
|
+
Synchronous client that immediately sends logs to LogSentinel backend.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, api_key: str = DEFAULT_API_KEY, base_url: str = DEFAULT_BASE_URL):
|
|
15
|
+
if not api_key:
|
|
16
|
+
raise ValueError("api_key is required")
|
|
17
|
+
self.api_key = api_key
|
|
18
|
+
self.base_url = base_url.rstrip("/")
|
|
19
|
+
self.session = requests.Session()
|
|
20
|
+
self.session.headers.update({
|
|
21
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"User-Agent": "logsentinel-python-sdk/1.0",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
def send(self, message: str, level: str = "INFO", metadata: Optional[Dict] = None):
|
|
27
|
+
"""
|
|
28
|
+
Immediately send a log to the backend.
|
|
29
|
+
"""
|
|
30
|
+
payload = {
|
|
31
|
+
"timestamp": utc_now_iso(),
|
|
32
|
+
"message": safe_str(message),
|
|
33
|
+
"level": level.upper(),
|
|
34
|
+
"metadata": metadata or {},
|
|
35
|
+
}
|
|
36
|
+
try:
|
|
37
|
+
url = f"{self.base_url}/logs"
|
|
38
|
+
response = self.session.post(url, json=payload, timeout=5)
|
|
39
|
+
response.raise_for_status()
|
|
40
|
+
return response.json() # optional, if backend returns JSON
|
|
41
|
+
except requests.RequestException as e:
|
|
42
|
+
logger.error("Failed to send log to LogSentinel: %s", e)
|
|
43
|
+
return None
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .base import env
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
DEFAULT_BASE_URL = env.str("LOG_SENTINEL_BASE_URL", default="http://localhost:8000")
|
|
5
|
+
DEFAULT_API_KEY = env.str("LOG_SENTINEL_API_KEY")
|
|
6
|
+
DEFAULT_BATCH_SIZE = env.int("LOG_SENTINEL_BATCH_SIZE", default=25)
|
|
7
|
+
DEFAULT_BATCH_INTERVAL = env.float("LOG_SENTINEL_BATCH_INTERVAL", default=3.0)
|
|
8
|
+
DEFAULT_RETRY_ATTEMPTS = env.int("LOG_SENTINEL_RETRY_ATTEMPTS", default=10)
|
|
9
|
+
DEFAULT_MIN_REMOTE_LEVEL = env.str("LOG_SENTINEL_MIN_LEVEL", default="WARN")
|
|
10
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from logosentinel.client.sync import LogSentinelClient
|
|
3
|
+
|
|
4
|
+
class LogSentinelHandler(logging.Handler):
|
|
5
|
+
def __init__(self, api_key: str, level=logging.NOTSET, base_url: str = None, min_remote_level: str = "INFO"):
|
|
6
|
+
super().__init__(level)
|
|
7
|
+
self.client = LogSentinelClient(api_key, base_url) if api_key else None
|
|
8
|
+
self.min_remote_level = min_remote_level
|
|
9
|
+
|
|
10
|
+
# map level name to numeric threshold
|
|
11
|
+
self.level_map = {
|
|
12
|
+
"DEBUG": 10, "INFO": 20, "SUCCESS": 25,
|
|
13
|
+
"WARNING": 30, "ERROR": 40, "CRITICAL": 50
|
|
14
|
+
}
|
|
15
|
+
self.min_value = self.level_map.get(self.min_remote_level.upper(), 20)
|
|
16
|
+
|
|
17
|
+
def emit(self, record: logging.LogRecord):
|
|
18
|
+
try:
|
|
19
|
+
level_name = record.levelname
|
|
20
|
+
level_val = self.level_map.get(level_name, record.levelno)
|
|
21
|
+
message = self.format(record)
|
|
22
|
+
metadata = {
|
|
23
|
+
"logger": record.name,
|
|
24
|
+
"pathname": record.pathname,
|
|
25
|
+
"lineno": record.lineno,
|
|
26
|
+
}
|
|
27
|
+
if self.client and level_val >= self.min_value:
|
|
28
|
+
self.client.send(message, level=level_name, metadata=metadata)
|
|
29
|
+
except Exception:
|
|
30
|
+
self.handleError(record)
|
|
File without changes
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import threading
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
import platform
|
|
6
|
+
import socket
|
|
7
|
+
import os
|
|
8
|
+
import traceback
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
def utc_now_iso():
|
|
12
|
+
"""
|
|
13
|
+
Return the time in isoformat
|
|
14
|
+
|
|
15
|
+
Useful for system log timestamp
|
|
16
|
+
"""
|
|
17
|
+
return datetime.now(timezone.utc).isoformat()
|
|
18
|
+
|
|
19
|
+
def safe_str(obj) -> str:
|
|
20
|
+
try:
|
|
21
|
+
return str(obj)
|
|
22
|
+
except Exception:
|
|
23
|
+
return "<unserializable>"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_in_thread(fn):
|
|
27
|
+
"""Decorator to run a function in a daemon thread."""
|
|
28
|
+
def wrapper(*args, **kwargs):
|
|
29
|
+
t = threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True)
|
|
30
|
+
t.start()
|
|
31
|
+
return t
|
|
32
|
+
return wrapper
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
SENSITIVE_KEYS = [
|
|
36
|
+
"KEY", "SECRET", "TOKEN", "PASSWORD", "API_KEY", "ACCESS", "PRIVATE"
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
def mask_sensitive_env(env: dict) -> dict:
|
|
40
|
+
"""Return a copy of env with sensitive values masked."""
|
|
41
|
+
safe_env = {}
|
|
42
|
+
for k, v in env.items():
|
|
43
|
+
if any(s in k.upper() for s in SENSITIVE_KEYS):
|
|
44
|
+
safe_env[k] = "***REDACTED***"
|
|
45
|
+
else:
|
|
46
|
+
safe_env[k] = v
|
|
47
|
+
return safe_env
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_metadata(scope=None, environ=None, exc=None, start_time=None, request=None, response=None,
|
|
51
|
+
request_body=None, response_body=None):
|
|
52
|
+
"""
|
|
53
|
+
Build rich metadata for logging.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
scope: ASGI scope object.
|
|
57
|
+
environ: WSGI environ object.
|
|
58
|
+
exc: Exception object if any.
|
|
59
|
+
start_time: Request start time for duration calculation.
|
|
60
|
+
request: Optional DRF/Starlette request object.
|
|
61
|
+
response: Optional response object for status/body info.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
dict: enriched metadata for log submission.
|
|
65
|
+
"""
|
|
66
|
+
duration = int((time.time() - start_time) * 1000) if start_time else None
|
|
67
|
+
client_ip = "unknown"
|
|
68
|
+
path = method = ""
|
|
69
|
+
headers = {}
|
|
70
|
+
query_params = {}
|
|
71
|
+
body_length = None
|
|
72
|
+
|
|
73
|
+
# ASGI
|
|
74
|
+
if scope:
|
|
75
|
+
path = scope.get("path", "")
|
|
76
|
+
method = scope.get("method", "GET")
|
|
77
|
+
client = scope.get("client")
|
|
78
|
+
if client:
|
|
79
|
+
client_ip = client[0]
|
|
80
|
+
headers = {k.decode() if isinstance(k, bytes) else k: v.decode() if isinstance(v, bytes) else v
|
|
81
|
+
for k, v in scope.get("headers", [])}
|
|
82
|
+
query_string = scope.get("query_string", b"")
|
|
83
|
+
query_params = dict([q.split(b"=") for q in query_string.split(b"&") if b"=" in q])
|
|
84
|
+
query_params = {k.decode(): v.decode() for k, v in query_params.items()}
|
|
85
|
+
|
|
86
|
+
# WSGI
|
|
87
|
+
elif environ:
|
|
88
|
+
path = environ.get("PATH_INFO", "")
|
|
89
|
+
method = environ.get("REQUEST_METHOD", "")
|
|
90
|
+
client_ip = environ.get("REMOTE_ADDR", "unknown")
|
|
91
|
+
headers = {k[5:].replace("_", "-").title(): v for k, v in environ.items() if k.startswith("HTTP_")}
|
|
92
|
+
query_params = environ.get("QUERY_STRING", "")
|
|
93
|
+
|
|
94
|
+
# If DRF/Starlette Request object available
|
|
95
|
+
if request:
|
|
96
|
+
try:
|
|
97
|
+
query_params = dict(request.query_params)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
try:
|
|
101
|
+
body_length = len(request.body) if hasattr(request, "body") else None
|
|
102
|
+
except Exception:
|
|
103
|
+
body_length = None
|
|
104
|
+
|
|
105
|
+
# Response info
|
|
106
|
+
status_code = None
|
|
107
|
+
response_length = None
|
|
108
|
+
if response:
|
|
109
|
+
status_code = getattr(response, "status_code", None)
|
|
110
|
+
response_length = len(getattr(response, "content", b"")) if hasattr(response, "content") else None
|
|
111
|
+
|
|
112
|
+
# Build final metadata
|
|
113
|
+
metadata = {
|
|
114
|
+
"id": str(uuid.uuid4()),
|
|
115
|
+
"path": path,
|
|
116
|
+
"method": method,
|
|
117
|
+
"duration_ms": duration,
|
|
118
|
+
"client_ip": client_ip,
|
|
119
|
+
"python_version": platform.python_version(),
|
|
120
|
+
"os": platform.platform(),
|
|
121
|
+
"hostname": socket.gethostname(),
|
|
122
|
+
"pid": os.getpid(),
|
|
123
|
+
"thread_name": threading.current_thread().name,
|
|
124
|
+
"timestamp": utc_now_iso(),
|
|
125
|
+
"headers": headers,
|
|
126
|
+
"query_params": query_params,
|
|
127
|
+
"request_body_length": body_length,
|
|
128
|
+
"response_status": status_code,
|
|
129
|
+
"response_length": response_length,
|
|
130
|
+
# "environment": mask_sensitive_env(dict(os.environ)), # optional: can filter for secrets
|
|
131
|
+
"response_body_length": len(response_body) if response_body else None,
|
|
132
|
+
"request_body": request_body,
|
|
133
|
+
"response_body": response_body,
|
|
134
|
+
}
|
|
135
|
+
metadata["full_url"] = f"{metadata['headers'].get('schema', 'https')}://{metadata['headers'].get('host')}{metadata['path']}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Exception info
|
|
139
|
+
if exc:
|
|
140
|
+
metadata.update({
|
|
141
|
+
"exception_type": type(exc).__name__,
|
|
142
|
+
"exception_message": str(exc),
|
|
143
|
+
"stack_trace": traceback.format_exc(),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# Optional user/project info
|
|
150
|
+
if request and hasattr(request, "user"):
|
|
151
|
+
metadata["user"] = getattr(request.user, "username", None)
|
|
152
|
+
metadata["project"] = getattr(request.user, "name", None)
|
|
153
|
+
|
|
154
|
+
return metadata
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from logosentinel.client.sync import LogSentinelClient
|
|
4
|
+
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
class SentinelLogger:
|
|
8
|
+
LEVELS = {"DEBUG":10,"INFO":20,"SUCCESS":25,"WARNING":30,"ERROR":40,"CRITICAL":50}
|
|
9
|
+
COLOR = {"DEBUG":"magenta","INFO":"cyan","SUCCESS":"green","WARNING":"yellow","ERROR":"red","CRITICAL":"bold red"}
|
|
10
|
+
|
|
11
|
+
def __init__(self, api_key: str = None, base_url: str = None, send_remote: bool = True, min_remote_level: str = "INFO"):
|
|
12
|
+
self.client = LogSentinelClient(api_key, base_url) if send_remote and api_key else None
|
|
13
|
+
self.send_remote = send_remote and api_key is not None
|
|
14
|
+
self.min_remote_level = min_remote_level.upper()
|
|
15
|
+
self.min_remote_value = self.LEVELS.get(self.min_remote_level, 20)
|
|
16
|
+
|
|
17
|
+
def _log(self, level: str, msg: str, **kwargs):
|
|
18
|
+
level = level.upper()
|
|
19
|
+
console.print(f"[{self.COLOR.get(level,'white')}][{level}][/]: {msg}")
|
|
20
|
+
if self.send_remote and self.client and self.LEVELS.get(level,20) >= self.min_remote_value:
|
|
21
|
+
self.client.send(msg, level=level, metadata=kwargs)
|
|
22
|
+
|
|
23
|
+
def debug(self,msg,**k): self._log("DEBUG",msg,**k)
|
|
24
|
+
def info(self,msg,**k): self._log("INFO",msg,**k)
|
|
25
|
+
def success(self,msg,**k): self._log("SUCCESS",msg,**k)
|
|
26
|
+
def warning(self,msg,**k): self._log("WARNING",msg,**k)
|
|
27
|
+
def error(self,msg,**k): self._log("ERROR",msg,**k)
|
|
28
|
+
def critical(self,msg,**k): self._log("CRITICAL",msg,**k)
|
|
29
|
+
def exception(self,msg,**k):
|
|
30
|
+
import traceback
|
|
31
|
+
self._log("ERROR", f"{msg}\n{traceback.format_exc()}", **k)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from typing import Optional, Any
|
|
5
|
+
|
|
6
|
+
from logosentinel.client.a_sync import AsyncLogSentinelClient
|
|
7
|
+
from logosentinel.libs.utils import build_metadata
|
|
8
|
+
from logosentinel.config.prod import DEFAULT_BASE_URL, DEFAULT_API_KEY
|
|
9
|
+
logger = logging.getLogger("logsentinel.middleware")
|
|
10
|
+
|
|
11
|
+
status = None
|
|
12
|
+
response_body = b""
|
|
13
|
+
metadata = None
|
|
14
|
+
|
|
15
|
+
# ASGI middleware
|
|
16
|
+
class LogSentinelASGIMiddleware:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
app: Optional[Any] =None,
|
|
20
|
+
api_key: str= DEFAULT_API_KEY,
|
|
21
|
+
base_url: Optional[str] = DEFAULT_BASE_URL,
|
|
22
|
+
send_remote: bool = True,
|
|
23
|
+
):
|
|
24
|
+
self.app = app
|
|
25
|
+
self.client = AsyncLogSentinelClient(api_key, base_url) if send_remote else None
|
|
26
|
+
|
|
27
|
+
async def __call__(self, scope, receive, send):
|
|
28
|
+
if scope.get("type") != "http":
|
|
29
|
+
await self.app(scope, receive, send)
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
start = time.time()
|
|
33
|
+
request_body = b""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --- Capture the incoming request body ---
|
|
39
|
+
async def receive_wrapper():
|
|
40
|
+
nonlocal request_body
|
|
41
|
+
message = await receive()
|
|
42
|
+
if message["type"] == "http.request":
|
|
43
|
+
body_chunk = message.get("body", b"")
|
|
44
|
+
request_body += body_chunk
|
|
45
|
+
return message
|
|
46
|
+
|
|
47
|
+
# --- Capture response info ---
|
|
48
|
+
async def send_wrapper(message):
|
|
49
|
+
nonlocal request_body
|
|
50
|
+
nonlocal metadata
|
|
51
|
+
nonlocal start
|
|
52
|
+
status = ""
|
|
53
|
+
response_body = b""
|
|
54
|
+
|
|
55
|
+
if message["type"] == "http.response.start":
|
|
56
|
+
status = message.get("status")
|
|
57
|
+
metadata = build_metadata(
|
|
58
|
+
scope=scope,
|
|
59
|
+
start_time=start,
|
|
60
|
+
request_body=request_body.decode("utf-8", errors="ignore"),
|
|
61
|
+
)
|
|
62
|
+
metadata["response_status"] = status
|
|
63
|
+
|
|
64
|
+
elif message["type"] == "http.response.body":
|
|
65
|
+
body_chunk = message.get("body", b"")
|
|
66
|
+
response_body += body_chunk
|
|
67
|
+
|
|
68
|
+
if not message.get("more_body", False):
|
|
69
|
+
metadata["response_length"] = len(response_body)
|
|
70
|
+
metadata["request_body_length"] = len(request_body)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
metadata["response_body"] = response_body.decode("utf-8", errors="ignore")
|
|
74
|
+
except Exception:
|
|
75
|
+
metadata["response_body"] = "<could not decode>"
|
|
76
|
+
|
|
77
|
+
if self.client:
|
|
78
|
+
await self.client.send(
|
|
79
|
+
f"{metadata['method']} {metadata['path']} - {status}",
|
|
80
|
+
level="INFO",
|
|
81
|
+
metadata=metadata,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
await send(message)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
await self.app(scope, receive_wrapper, send_wrapper)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
metadata = build_metadata(
|
|
90
|
+
scope=scope,
|
|
91
|
+
exc=exc,
|
|
92
|
+
start_time=start,
|
|
93
|
+
request_body=request_body.decode("utf-8", errors="ignore"),
|
|
94
|
+
)
|
|
95
|
+
if self.client:
|
|
96
|
+
await self.client.send(
|
|
97
|
+
f"Exception on {metadata['method']} {metadata['path']}",
|
|
98
|
+
level="ERROR",
|
|
99
|
+
metadata=metadata,
|
|
100
|
+
)
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# WSGI middleware
|
|
105
|
+
class LogSentinelWSGIMiddleware:
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
app: Optional[Any] =None,
|
|
109
|
+
api_key: str = DEFAULT_API_KEY,
|
|
110
|
+
base_url: Optional[str] = None,
|
|
111
|
+
send_remote: bool = True,
|
|
112
|
+
):
|
|
113
|
+
self.app = app
|
|
114
|
+
self.client = AsyncLogSentinelClient(api_key, base_url) if send_remote else None
|
|
115
|
+
|
|
116
|
+
def __call__(self, environ, start_response):
|
|
117
|
+
start_time = time.time()
|
|
118
|
+
|
|
119
|
+
def custom_start_response(status, response_headers, exc_info=None):
|
|
120
|
+
try:
|
|
121
|
+
status_code = int(status.split(" ", 1)[0])
|
|
122
|
+
except Exception:
|
|
123
|
+
status_code = 0
|
|
124
|
+
|
|
125
|
+
metadata = build_metadata(environ=environ, start_time=start_time)
|
|
126
|
+
metadata["status"] = status_code
|
|
127
|
+
if self.client:
|
|
128
|
+
self.client.send(
|
|
129
|
+
f"{metadata['method']} {metadata['path']} - {status_code}",
|
|
130
|
+
level="INFO",
|
|
131
|
+
metadata=metadata,
|
|
132
|
+
)
|
|
133
|
+
return start_response(status, response_headers, exc_info)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
return self.app(environ, custom_start_response)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
metadata = build_metadata(environ=environ, exc=exc, start_time=start_time)
|
|
139
|
+
if self.client:
|
|
140
|
+
self.client.send(
|
|
141
|
+
f"Exception on {metadata['method']} {metadata['path']}",
|
|
142
|
+
level="ERROR",
|
|
143
|
+
metadata=metadata,
|
|
144
|
+
)
|
|
145
|
+
raise
|
|
File without changes
|