loopsentry 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loopsentry/__init__.py +3 -0
- loopsentry/analyzer.py +204 -0
- loopsentry/cli.py +48 -0
- loopsentry/monitor.py +146 -0
- loopsentry-0.1.0.dist-info/METADATA +84 -0
- loopsentry-0.1.0.dist-info/RECORD +8 -0
- loopsentry-0.1.0.dist-info/WHEEL +4 -0
- loopsentry-0.1.0.dist-info/entry_points.txt +2 -0
loopsentry/__init__.py
ADDED
loopsentry/analyzer.py
ADDED
|
@@ -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]")
|
loopsentry/cli.py
ADDED
|
@@ -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()
|
loopsentry/monitor.py
ADDED
|
@@ -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
|
|
@@ -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
|
+

|
|
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,8 @@
|
|
|
1
|
+
loopsentry/__init__.py,sha256=KCL838vfb3s73PNieaEZWKwCxf6imKiY8Vzmzwl6WYE,57
|
|
2
|
+
loopsentry/analyzer.py,sha256=kURxZihJG9u9mgYQIifPbUeEsOxOQfO9YM64KZWaLho,9518
|
|
3
|
+
loopsentry/cli.py,sha256=2mdD_E_cpFLNJhN5XcNPYuRFfcNmmvxg9Wg2PWHDAqw,1409
|
|
4
|
+
loopsentry/monitor.py,sha256=QIEAtSyb4lhzz7bApDALLX79nFweX9NBRG8z4TlaWHc,5278
|
|
5
|
+
loopsentry-0.1.0.dist-info/METADATA,sha256=JJ-7bh7tfWnNoM5KGsfuROfiSRU5TI0xkgm-TKdBnLo,1888
|
|
6
|
+
loopsentry-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
7
|
+
loopsentry-0.1.0.dist-info/entry_points.txt,sha256=sO43JzZNnMg28BpF_LWdQ13U1Tl5nqsCqFfRxIlfF74,51
|
|
8
|
+
loopsentry-0.1.0.dist-info/RECORD,,
|