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.
@@ -0,0 +1,11 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ .vscode/
9
+ # Virtual environments
10
+ .venv
11
+ .env
@@ -0,0 +1,12 @@
1
+ ### Simple POST to FastAPI /hello
2
+ POST http://127.0.0.1:8002/hello
3
+ Content-Type: application/json
4
+
5
+ {
6
+ "message": "Hello world",
7
+ "username": "Jeckonia"
8
+ }
9
+
10
+ ###
11
+
12
+ GET http://localhost:8002/error
@@ -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
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+ import environ
3
+
4
+ BASE_DIR = Path(__file__).resolve().parent.parent.parent
5
+
6
+
7
+ env = environ.Env(DEBUG=True, LOG_LEVEL="warn")
8
+
9
+ env.read_env(str(BASE_DIR / ".env"))
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,5 @@
1
+ class LogSentinelError(Exception):
2
+ pass
3
+
4
+ class ConfigurationError(LogSentinelError):
5
+ pass
@@ -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