loopsentry 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,14 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ uv.lock
12
+ original_ideaa
13
+ idea
14
+ sentry_logs
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: loopsentry
3
+ Version: 0.1.0
4
+ Summary: asyncio Eventloop Blockers Detector/Analyzer.
5
+ Project-URL: Homepage, https://github.com/amzker/loopsentry
6
+ Project-URL: Repository, https://github.com/amzker/loopsentry
7
+ Project-URL: Issues, https://github.com/amzker/loopsentry/issues
8
+ Author-email: amzker <amzker@proton.me>
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: psutil>=6.0.0
11
+ Requires-Dist: rich>=13.0.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: aiohttp; extra == 'dev'
14
+ Requires-Dist: requests; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # LoopSentry
18
+
19
+ ### **Asyncio Event Loop Blockers Detector & Analyzer**
20
+ - utility for detecting blocking calls in asyncio event loops
21
+
22
+ ![Demo](images/loopsentry_report.png)
23
+
24
+ ### **Installation**
25
+
26
+ ```bash
27
+ uv add loopsentry
28
+ ```
29
+ pip way
30
+
31
+ ```bash
32
+ pip install loopsentry
33
+ ```
34
+
35
+ ### **Usage**
36
+ 1. Basic Usage
37
+
38
+ ```python
39
+ import asyncio
40
+ from loopsentry import LoopSentry
41
+
42
+ async def main():
43
+ # start monitoring (default threshold: 0.1s) ie: if blocks is >= 0.1 , it is logged
44
+ sentry = LoopSentry(threshold=0.1)
45
+ sentry.start()
46
+
47
+ print("Running...")
48
+ ... # rest of your application
49
+
50
+ if __name__ == "__main__":
51
+ asyncio.run(main())
52
+ ```
53
+
54
+ 2. Use inside Uvicorn/gunicorn workers in fastapi
55
+ - you need to put it inside a `lifespan` context manager , so if you use multiple workers eachh gets their own LoopSentry instance
56
+
57
+ ```python
58
+ from contextlib import asynccontextmanager
59
+ from fastapi import FastAPI
60
+ from loopsentry import LoopSentry
61
+
62
+ @asynccontextmanager
63
+ async def lifespan(app: FastAPI):
64
+ sentry = LoopSentry()
65
+ sentry.start()
66
+ yield
67
+
68
+ app = FastAPI(lifespan=lifespan)
69
+
70
+ @app.get("/")
71
+ async def root():
72
+ return {"message": "I am being monitored!"}
73
+ ```
74
+
75
+ ### **Log Analysis**
76
+
77
+ ```bash
78
+ uv run loopsentry analyze -d log_directory
79
+ ```
80
+ NOTE: if you used pip to install then
81
+
82
+ ```bash
83
+ loopsentry analyze -d log_directory
84
+ ```
@@ -0,0 +1,68 @@
1
+ # LoopSentry
2
+
3
+ ### **Asyncio Event Loop Blockers Detector & Analyzer**
4
+ - utility for detecting blocking calls in asyncio event loops
5
+
6
+ ![Demo](images/loopsentry_report.png)
7
+
8
+ ### **Installation**
9
+
10
+ ```bash
11
+ uv add loopsentry
12
+ ```
13
+ pip way
14
+
15
+ ```bash
16
+ pip install loopsentry
17
+ ```
18
+
19
+ ### **Usage**
20
+ 1. Basic Usage
21
+
22
+ ```python
23
+ import asyncio
24
+ from loopsentry import LoopSentry
25
+
26
+ async def main():
27
+ # start monitoring (default threshold: 0.1s) ie: if blocks is >= 0.1 , it is logged
28
+ sentry = LoopSentry(threshold=0.1)
29
+ sentry.start()
30
+
31
+ print("Running...")
32
+ ... # rest of your application
33
+
34
+ if __name__ == "__main__":
35
+ asyncio.run(main())
36
+ ```
37
+
38
+ 2. Use inside Uvicorn/gunicorn workers in fastapi
39
+ - you need to put it inside a `lifespan` context manager , so if you use multiple workers eachh gets their own LoopSentry instance
40
+
41
+ ```python
42
+ from contextlib import asynccontextmanager
43
+ from fastapi import FastAPI
44
+ from loopsentry import LoopSentry
45
+
46
+ @asynccontextmanager
47
+ async def lifespan(app: FastAPI):
48
+ sentry = LoopSentry()
49
+ sentry.start()
50
+ yield
51
+
52
+ app = FastAPI(lifespan=lifespan)
53
+
54
+ @app.get("/")
55
+ async def root():
56
+ return {"message": "I am being monitored!"}
57
+ ```
58
+
59
+ ### **Log Analysis**
60
+
61
+ ```bash
62
+ uv run loopsentry analyze -d log_directory
63
+ ```
64
+ NOTE: if you used pip to install then
65
+
66
+ ```bash
67
+ loopsentry analyze -d log_directory
68
+ ```
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "loopsentry"
7
+ version = "0.1.0"
8
+ description = "asyncio Eventloop Blockers Detector/Analyzer."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ authors = [{ name = "amzker", email = "amzker@proton.me" }]
12
+ dependencies = [
13
+ "psutil>=6.0.0",
14
+ "rich>=13.0.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ loopsentry = "loopsentry.cli:main"
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "requests",
23
+ "aiohttp",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/amzker/loopsentry"
28
+ Repository = "https://github.com/amzker/loopsentry"
29
+ Issues = "https://github.com/amzker/loopsentry/issues"
30
+
31
+ [tool.uv.workspace]
32
+ members = [
33
+ ".",
34
+ ]
35
+
36
+ [tool.uv.sources]
37
+ loopsentry = { workspace = true }
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "loopsentry",
42
+ ]
@@ -0,0 +1,3 @@
1
+ from .monitor import LoopSentry
2
+
3
+ __all__ = ["LoopSentry"]
@@ -0,0 +1,204 @@
1
+ import json
2
+ import time
3
+ import re
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+ from rich.panel import Panel
8
+ from rich.syntax import Syntax
9
+ from rich import box
10
+ from rich.prompt import Prompt
11
+
12
+ console = Console()
13
+
14
+ class Analyzer:
15
+ def __init__(self, path):
16
+ self.path = Path(path)
17
+ self.blocks = []
18
+ self.stats = {"total_time": 0.0, "count": 0, "crashes": 0}
19
+
20
+ def _analyze_heuristics(self, stack_list):
21
+ stack_str = "".join(stack_list).lower()
22
+ if "time.sleep" in stack_str: return "Blocking Sleep"
23
+ if "requests." in stack_str: return "Sync HTTP (requests)"
24
+ if "subprocess.run" in stack_str: return "Sync Subprocess"
25
+ if "while" in stack_str and "sleep" not in stack_str: return "⚠ CPU Loop?"
26
+ return "LOGIC_BLOCK: Review Logic"
27
+
28
+ def run(self):
29
+ files = [self.path] if self.path.is_file() else list(self.path.rglob("*.jsonl"))
30
+
31
+ for f in files:
32
+ with open(f, 'r', encoding="utf-8") as handle:
33
+ current_block = None
34
+ for line in handle:
35
+ try:
36
+ entry = json.loads(line)
37
+ if entry['type'] == 'block_started':
38
+ if current_block:
39
+ current_block['total_duration'] = "TRANSITION"
40
+ current_block['resolved'] = True
41
+ self.blocks.append(current_block)
42
+ current_block = entry
43
+ elif entry['type'] == 'block_resolved' and current_block:
44
+ current_block['total_duration'] = entry['duration_current']
45
+ current_block['resolved'] = True
46
+ current_block['hint'] = self._analyze_heuristics(current_block['stack'])
47
+ self.blocks.append(current_block)
48
+
49
+ if isinstance(entry['duration_current'], float):
50
+ self.stats['total_time'] += entry['duration_current']
51
+ self.stats['count'] += 1
52
+ current_block = None
53
+ except Exception:
54
+ continue
55
+
56
+ if current_block:
57
+ current_block['total_duration'] = "CRASH"
58
+ current_block['resolved'] = False
59
+ current_block['hint'] = "Crash/Kill"
60
+ self.blocks.append(current_block)
61
+ self.stats['crashes'] += 1
62
+
63
+ self.blocks.sort(key=lambda x: x['timestamp'], reverse=True)
64
+
65
+ def _parse_location(self, trigger_str):
66
+ match = re.search(r'File "(.*?)", line (\d+)', trigger_str)
67
+ if match:
68
+ # abs path for IDE clickability
69
+ fname = match.group(1)
70
+ lineno = match.group(2)
71
+ return f"{Path(fname).name}:{lineno}", fname
72
+ return "Unknown", ""
73
+
74
+ def interactive_tui(self):
75
+ filter_term = ""
76
+
77
+ while True:
78
+ console.clear()
79
+ title = f"[bold cyan]LoopSentry Analysis[/]"
80
+ if filter_term:
81
+ title += f" [bold yellow](Filter: '{filter_term}')[/]"
82
+
83
+ console.rule(title)
84
+
85
+ display_blocks = []
86
+ for idx, b in enumerate(self.blocks):
87
+ b['_id'] = idx + 1
88
+ searchable_text = (b.get('hint', '') + "".join(b['stack']) + b.get('trigger', '')).lower()
89
+ if not filter_term or filter_term in searchable_text:
90
+ display_blocks.append(b)
91
+
92
+ grid = Table.grid(expand=True)
93
+ grid.add_column(justify="center", ratio=1)
94
+ grid.add_column(justify="center", ratio=1)
95
+ grid.add_column(justify="center", ratio=1)
96
+ grid.add_row(
97
+ Panel(f"[bold red]{len(display_blocks)}/{self.stats['count']}[/]", title="Blocks Shown"),
98
+ Panel(f"[bold yellow]{self.stats['total_time']:.2f}s[/]", title="Total Lost Time"),
99
+ Panel(f"[bold magenta]{self.stats['crashes']}[/]", title="Crashes"),
100
+ )
101
+ console.print(grid)
102
+
103
+ table = Table(title="Event Log", box=box.SIMPLE_HEAD, expand=True)
104
+ table.add_column("ID", style="bold white", width=4, justify="right")
105
+ table.add_column("Time", style="cyan", width=10)
106
+ table.add_column("Dur", style="red", width=10)
107
+ table.add_column("Hint", style="yellow")
108
+ table.add_column("Location", style="blue")
109
+
110
+ for b in display_blocks[:100]:
111
+ dur = b['total_duration']
112
+ dur_fmt = f"{dur:.4f}s" if isinstance(dur, float) else "CRASH"
113
+ short_loc, full_path = self._parse_location(b.get('trigger', ''))
114
+
115
+ table.add_row(str(b['_id']), b['timestamp'][11:19], dur_fmt, b.get('hint', ''), short_loc)
116
+
117
+ if len(display_blocks) > 15:
118
+ table.add_row("...", "...", "...", "...", f"And {len(display_blocks)-15} more...")
119
+
120
+ console.print(table)
121
+ console.print("\n[dim]Commands: [white]<ID>[/] for detail | [white]<text>[/] to search | [white]reset[/] | [white]q[/]uit[/dim]")
122
+
123
+ choice = Prompt.ask("Action")
124
+
125
+ if choice.lower() in ('q', 'quit', 'exit'): break
126
+ if choice.lower() in ('reset', 'clean', 'clear'):
127
+ filter_term = ""
128
+ continue
129
+
130
+ if choice.isdigit():
131
+ selected_id = int(choice)
132
+ if 1 <= selected_id <= len(self.blocks):
133
+ self._show_detail(self.blocks[selected_id - 1])
134
+ else:
135
+ console.print("[red]ID out of range[/red]")
136
+ time.sleep(0.5)
137
+ else:
138
+ filter_term = choice.lower()
139
+
140
+ def _show_detail(self, block):
141
+ console.clear()
142
+ dur = block['total_duration']
143
+ dur_str = f"{dur:.4f}s" if isinstance(dur, float) else "CRASH"
144
+
145
+ console.rule(f"[bold red]Event Detail - {dur_str}")
146
+
147
+ info_table = Table(show_header=False, box=None)
148
+ info_table.add_column(style="bold cyan")
149
+ info_table.add_column()
150
+ info_table.add_row("Timestamp:", block['timestamp'])
151
+ info_table.add_row("PID:", str(block['pid']))
152
+ info_table.add_row("Hint:", f"[yellow]{block.get('hint')}[/yellow]")
153
+
154
+ _, full_path = self._parse_location(block.get('trigger', ''))
155
+ info_table.add_row("Trigger File:", f"file://{full_path}" if full_path else "Unknown")
156
+
157
+ console.print(Panel(info_table, title="Metadata", border_style="blue"))
158
+
159
+ stack_code = "".join(block['stack'])
160
+ syntax = Syntax(stack_code, "python", theme="monokai", line_numbers=True, word_wrap=True)
161
+ console.print(Panel(syntax, title="Stack Trace", border_style="red"))
162
+
163
+ Prompt.ask("\n[dim]Press [bold]Enter[/] to return...[/dim]")
164
+
165
+ def render_html(self):
166
+ json_data = json.dumps(self.blocks)
167
+ html_template = f"""
168
+ <!DOCTYPE html>
169
+ <html lang="en">
170
+ <head>
171
+ <meta charset="UTF-8">
172
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
173
+ <title>LoopSentry Report</title>
174
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
175
+ <script src="https://cdn.tailwindcss.com"></script>
176
+ <style> [v-cloak] {{ display: none; }} body {{ background: #0f172a; color: #e2e8f0; }} pre {{ font-family: 'Fira Code', monospace; }} </style>
177
+ </head>
178
+ <body class="p-6">
179
+ <div id="app" v-cloak class="max-w-7xl mx-auto">
180
+ <h1 class="text-3xl font-bold text-blue-400 mb-8">LoopSentry Report</h1>
181
+ <input v-model="search" placeholder="Filter logs..." class="w-full bg-slate-800 border border-slate-700 rounded p-3 mb-6">
182
+ <div class="space-y-4">
183
+ <div v-for="(b, i) in filteredBlocks" :key="i" class="bg-slate-800 rounded border-l-4 p-4" :class="b.resolved ? 'border-yellow-500' : 'border-red-600'">
184
+ <div class="flex justify-between font-mono text-sm mb-2 cursor-pointer" @click="b.expanded = !b.expanded">
185
+ <span :class="b.resolved ? 'text-yellow-400' : 'text-red-400'">{{{{ typeof b.total_duration === 'number' ? b.total_duration.toFixed(4) + 's' : b.total_duration }}}}</span>
186
+ <span class="text-slate-500">{{{{ b.timestamp.split('T')[1].slice(0,8) }}}}</span>
187
+ </div>
188
+ <div class="text-slate-400 text-xs mb-2">{{{{ b.hint }}}}</div>
189
+ <pre v-if="b.expanded" class="bg-black/30 p-2 rounded text-xs text-slate-300 overflow-x-auto">{{{{ b.stack.join('') }}}}</pre>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ <script>
194
+ const {{ createApp }} = Vue;
195
+ createApp({{
196
+ data() {{ return {{ blocks: {json_data}.map(b => ({{...b, expanded: false}})), search: '' }} }},
197
+ computed: {{ filteredBlocks() {{ return this.blocks.filter(b => (b.trigger+b.hint+b.stack.join('')).toLowerCase().includes(this.search.toLowerCase())) }} }}
198
+ }}).mount('#app');
199
+ </script>
200
+ </body>
201
+ </html>
202
+ """
203
+ with open("loopsentry_report.html", "w", encoding="utf-8") as f: f.write(html_template)
204
+ console.print(f"[green]Report: loopsentry_report.html[/green]")
@@ -0,0 +1,48 @@
1
+ import argparse
2
+ import sys
3
+ import shutil
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+ from .analyzer import Analyzer
7
+
8
+ console = Console()
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(description="LoopSentry: Asyncio Event Loop Monitor")
12
+ subparsers = parser.add_subparsers(dest="command")
13
+
14
+ subparsers.add_parser("clean", help="Clear logs")
15
+
16
+ an_parser = subparsers.add_parser("analyze", help="Analyze logs")
17
+ an_parser.add_argument("-d", "--dir")
18
+ an_parser.add_argument("-f", "--file")
19
+ an_parser.add_argument("--html", action="store_true")
20
+
21
+ args = parser.parse_args()
22
+
23
+ if args.command == "clean":
24
+ if Path("sentry_logs").exists():
25
+ shutil.rmtree("sentry_logs")
26
+ console.print("[green]✔ Logs cleared.[/green]")
27
+ else:
28
+ console.print("[yellow]No logs found.[/yellow]")
29
+
30
+ elif args.command == "analyze":
31
+ target = args.file if args.file else args.dir
32
+ if not target:
33
+ dirs = sorted(Path("sentry_logs").glob("*"))
34
+ if dirs: target = dirs[-1]
35
+ else:
36
+ console.print("[red]No logs found.[/red]")
37
+ return
38
+
39
+ analyzer = Analyzer(target)
40
+ analyzer.run()
41
+ if args.html: analyzer.render_html()
42
+ else: analyzer.interactive_tui()
43
+
44
+ else:
45
+ parser.print_help()
46
+
47
+ if __name__ == "__main__":
48
+ main()
@@ -0,0 +1,146 @@
1
+ import asyncio
2
+ import time
3
+ import threading
4
+ import sys
5
+ import traceback
6
+ import json
7
+ import os
8
+ import signal
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+
15
+ try:
16
+ import psutil
17
+ PSUTIL_AVAILABLE = True
18
+ except ImportError:
19
+ PSUTIL_AVAILABLE = False
20
+
21
+ class LoopSentry:
22
+ def __init__(self, base_dir="sentry_logs", threshold=0.1):
23
+ self.threshold = threshold
24
+ self.running = False
25
+ self._last_tick = 0
26
+ self._is_blocking = False
27
+ self._stop_event = threading.Event()
28
+
29
+ self._segment_start_time = 0
30
+ self._last_stack_signature = None
31
+
32
+ date_str = datetime.now().strftime("%Y-%m-%d")
33
+ self.log_dir = Path(base_dir) / date_str
34
+ self.log_dir.mkdir(parents=True, exist_ok=True)
35
+
36
+ self.pid = os.getpid()
37
+ self.log_file = self.log_dir / f"sentry_{self.pid}.jsonl"
38
+ self._file_handle = open(self.log_file, "a", encoding="utf-8")
39
+
40
+ self.process = psutil.Process(self.pid) if PSUTIL_AVAILABLE else None
41
+
42
+ def start(self):
43
+ if self.running: return
44
+ self.running = True
45
+ self._last_tick = time.time()
46
+
47
+ try:
48
+ signal.signal(signal.SIGINT, self._signal_handler)
49
+ signal.signal(signal.SIGTERM, self._signal_handler)
50
+ except ValueError:
51
+ pass
52
+
53
+ asyncio.get_event_loop().call_soon(self._ticker)
54
+
55
+ self.thread = threading.Thread(target=self._watchdog, daemon=True, name="LoopSentry-Watchdog")
56
+ self.thread.start()
57
+
58
+ console.print(f"[green]✔ LoopSentry Pro Active.[/green] [dim]PID: {self.pid} | Log: {self.log_file}[/dim]")
59
+
60
+ def _signal_handler(self, signum, frame):
61
+ self.running = False
62
+ self._stop_event.set()
63
+ if self._file_handle:
64
+ self._file_handle.flush()
65
+ self._file_handle.close()
66
+ sys.exit(0)
67
+
68
+ def _ticker(self):
69
+ self._last_tick = time.time()
70
+ if self.running:
71
+ asyncio.get_event_loop().call_later(self.threshold / 2, self._ticker)
72
+
73
+ def _watchdog(self):
74
+ while self.running and not self._stop_event.is_set():
75
+ time.sleep(self.threshold)
76
+ now = time.time()
77
+ delta = now - self._last_tick
78
+
79
+ if delta > self.threshold:
80
+ snapshot = self._capture_state()
81
+ current_signature = "".join(snapshot['stack'])
82
+
83
+ if not self._is_blocking:
84
+ self._is_blocking = True
85
+ self._segment_start_time = now - self.threshold
86
+ self._last_stack_signature = current_signature
87
+ self._write_event("block_started", snapshot, duration=delta)
88
+ console.print(f"[bold red] Block Detected![/bold red] ({delta:.2f}s)")
89
+
90
+ elif current_signature != self._last_stack_signature:
91
+ segment_duration = now - self._segment_start_time
92
+ self._write_event("block_resolved", {}, duration=segment_duration)
93
+
94
+ self._segment_start_time = now
95
+ self._last_stack_signature = current_signature
96
+ self._write_event("block_started", snapshot, duration=delta)
97
+ console.print(f"[bold red]>>> Block Shift Detected![/bold red]")
98
+
99
+ else:
100
+ if self._is_blocking:
101
+ self._is_blocking = False
102
+ segment_duration = now - self._segment_start_time
103
+ self._write_event("block_resolved", {}, duration=segment_duration)
104
+ console.print(f"[green]✔ Recovered.[/green]")
105
+ self._last_stack_signature = None
106
+
107
+ def _capture_state(self):
108
+ data = {
109
+ "timestamp": datetime.now().isoformat(),
110
+ "stack": [],
111
+ "trigger": "Unknown",
112
+ "sys": { "cpu_percent": 0.0, "memory_mb": 0.0, "thread_count": threading.active_count() }
113
+ }
114
+ try:
115
+ main_id = threading.main_thread().ident
116
+ frames = sys._current_frames()
117
+ frame = frames.get(main_id)
118
+ if frame:
119
+ stack = traceback.format_stack(frame)
120
+ data["stack"] = stack
121
+ data["trigger"] = stack[-1].strip() if stack else "Unknown"
122
+ except Exception:
123
+ data["stack"] = ["Error capturing stack"]
124
+
125
+ if self.process:
126
+ try:
127
+ with self.process.oneshot():
128
+ data["sys"]["cpu_percent"] = self.process.cpu_percent()
129
+ data["sys"]["memory_mb"] = self.process.memory_info().rss / 1024 / 1024
130
+ except Exception:
131
+ pass
132
+ return data
133
+
134
+ def _write_event(self, event_type, data, duration=0.0):
135
+ entry = {
136
+ "type": event_type,
137
+ "pid": self.pid,
138
+ "timestamp": datetime.now().isoformat(),
139
+ "duration_current": duration,
140
+ **data
141
+ }
142
+ try:
143
+ self._file_handle.write(json.dumps(entry) + "\n")
144
+ self._file_handle.flush()
145
+ except Exception:
146
+ pass