logler 1.0.7__cp311-cp311-win_amd64.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.
- logler/__init__.py +22 -0
- logler/bootstrap.py +57 -0
- logler/cache.py +75 -0
- logler/cli.py +589 -0
- logler/helpers.py +282 -0
- logler/investigate.py +3962 -0
- logler/llm_cli.py +1426 -0
- logler/log_reader.py +267 -0
- logler/parser.py +207 -0
- logler/safe_regex.py +124 -0
- logler/terminal.py +252 -0
- logler/tracker.py +138 -0
- logler/tree_formatter.py +807 -0
- logler/watcher.py +55 -0
- logler/web/__init__.py +3 -0
- logler/web/app.py +810 -0
- logler/web/static/css/tailwind.css +1 -0
- logler/web/static/css/tailwind.input.css +3 -0
- logler/web/static/logler-logo.png +0 -0
- logler/web/tailwind.config.cjs +9 -0
- logler/web/templates/index.html +1454 -0
- logler-1.0.7.dist-info/METADATA +584 -0
- logler-1.0.7.dist-info/RECORD +28 -0
- logler-1.0.7.dist-info/WHEEL +4 -0
- logler-1.0.7.dist-info/entry_points.txt +2 -0
- logler-1.0.7.dist-info/licenses/LICENSE +21 -0
- logler_rs/__init__.py +5 -0
- logler_rs/logler_rs.cp311-win_amd64.pyd +0 -0
logler/terminal.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Beautiful terminal output using Rich.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from .parser import LogParser, LogEntry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TerminalViewer:
|
|
17
|
+
"""Beautiful terminal log viewer with Rich."""
|
|
18
|
+
|
|
19
|
+
LEVEL_COLORS = {
|
|
20
|
+
"TRACE": "bright_black",
|
|
21
|
+
"DEBUG": "cyan",
|
|
22
|
+
"INFO": "green",
|
|
23
|
+
"WARN": "yellow",
|
|
24
|
+
"WARNING": "yellow",
|
|
25
|
+
"ERROR": "red",
|
|
26
|
+
"CRITICAL": "bold red",
|
|
27
|
+
"FATAL": "bold red on white",
|
|
28
|
+
"UNKNOWN": "white",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def __init__(self, use_colors: bool = True):
|
|
32
|
+
self.console = Console(
|
|
33
|
+
force_terminal=use_colors, color_system="auto" if use_colors else None
|
|
34
|
+
)
|
|
35
|
+
self.parser = LogParser()
|
|
36
|
+
|
|
37
|
+
async def view_file(
|
|
38
|
+
self,
|
|
39
|
+
file_path: str,
|
|
40
|
+
lines: Optional[int] = None,
|
|
41
|
+
follow: bool = False,
|
|
42
|
+
level_filter: Optional[str] = None,
|
|
43
|
+
pattern: Optional[str] = None,
|
|
44
|
+
thread_filter: Optional[str] = None,
|
|
45
|
+
):
|
|
46
|
+
"""View a log file with beautiful formatting."""
|
|
47
|
+
path = Path(file_path)
|
|
48
|
+
|
|
49
|
+
if not path.exists():
|
|
50
|
+
self.console.print(f"[red]❌ File not found: {file_path}[/red]")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
self.console.print(f"\n[bold cyan]📄 {path.name}[/bold cyan]")
|
|
54
|
+
self.console.print(f"[dim]{path.absolute()}[/dim]\n")
|
|
55
|
+
|
|
56
|
+
# Read file
|
|
57
|
+
with open(file_path, "r") as f:
|
|
58
|
+
all_lines = f.readlines()
|
|
59
|
+
|
|
60
|
+
# Parse entries
|
|
61
|
+
entries = [self.parser.parse_line(i + 1, line.rstrip()) for i, line in enumerate(all_lines)]
|
|
62
|
+
|
|
63
|
+
# Apply filters
|
|
64
|
+
entries = self._apply_filters(entries, level_filter, pattern, thread_filter)
|
|
65
|
+
|
|
66
|
+
# Apply line limit
|
|
67
|
+
if lines and not follow:
|
|
68
|
+
entries = entries[-lines:]
|
|
69
|
+
|
|
70
|
+
# Display entries
|
|
71
|
+
if follow:
|
|
72
|
+
await self._follow_mode(
|
|
73
|
+
path, entries[-lines:] if lines else entries, level_filter, pattern, thread_filter
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
self._display_entries(entries)
|
|
77
|
+
|
|
78
|
+
def _apply_filters(
|
|
79
|
+
self,
|
|
80
|
+
entries: list,
|
|
81
|
+
level_filter: Optional[str],
|
|
82
|
+
pattern: Optional[str],
|
|
83
|
+
thread_filter: Optional[str],
|
|
84
|
+
) -> list:
|
|
85
|
+
"""Apply filters to log entries."""
|
|
86
|
+
filtered = entries
|
|
87
|
+
|
|
88
|
+
if level_filter:
|
|
89
|
+
level_upper = level_filter.upper()
|
|
90
|
+
filtered = [e for e in filtered if e.level == level_upper]
|
|
91
|
+
|
|
92
|
+
if pattern:
|
|
93
|
+
filtered = [e for e in filtered if pattern.lower() in e.message.lower()]
|
|
94
|
+
|
|
95
|
+
if thread_filter:
|
|
96
|
+
filtered = [e for e in filtered if e.thread_id == thread_filter]
|
|
97
|
+
|
|
98
|
+
return filtered
|
|
99
|
+
|
|
100
|
+
def _display_entries(self, entries: list):
|
|
101
|
+
"""Display log entries with beautiful formatting."""
|
|
102
|
+
if not entries:
|
|
103
|
+
self.console.print("[yellow]No matching log entries found.[/yellow]")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
for entry in entries:
|
|
107
|
+
self._print_entry(entry)
|
|
108
|
+
|
|
109
|
+
def _print_entry(self, entry: LogEntry):
|
|
110
|
+
"""Print a single log entry with rich formatting."""
|
|
111
|
+
# Build the line components
|
|
112
|
+
parts = []
|
|
113
|
+
|
|
114
|
+
# Line number
|
|
115
|
+
parts.append(Text(f"{entry.line_number:6}", style="dim"))
|
|
116
|
+
|
|
117
|
+
# Timestamp
|
|
118
|
+
if entry.timestamp:
|
|
119
|
+
ts_str = entry.timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
|
120
|
+
parts.append(Text(ts_str, style="cyan"))
|
|
121
|
+
|
|
122
|
+
# Level with color
|
|
123
|
+
level_color = self.LEVEL_COLORS.get(entry.level, "white")
|
|
124
|
+
parts.append(Text(f"{entry.level:8}", style=level_color))
|
|
125
|
+
|
|
126
|
+
# Thread ID if present
|
|
127
|
+
if entry.thread_id:
|
|
128
|
+
parts.append(Text(f"[{entry.thread_id}]", style="magenta"))
|
|
129
|
+
|
|
130
|
+
# Correlation ID if present
|
|
131
|
+
if entry.correlation_id:
|
|
132
|
+
parts.append(Text(f"[{entry.correlation_id}]", style="blue"))
|
|
133
|
+
|
|
134
|
+
# Message
|
|
135
|
+
parts.append(Text(entry.message, style="white"))
|
|
136
|
+
|
|
137
|
+
# Combine and print
|
|
138
|
+
line = Text(" ").join(parts)
|
|
139
|
+
self.console.print(line)
|
|
140
|
+
|
|
141
|
+
# Print trace info if present
|
|
142
|
+
if entry.trace_id or entry.span_id:
|
|
143
|
+
trace_info = []
|
|
144
|
+
if entry.trace_id:
|
|
145
|
+
trace_info.append(f"trace:{entry.trace_id}")
|
|
146
|
+
if entry.span_id:
|
|
147
|
+
trace_info.append(f"span:{entry.span_id}")
|
|
148
|
+
self.console.print(f" [dim]└─ {' '.join(trace_info)}[/dim]")
|
|
149
|
+
|
|
150
|
+
# Print additional fields if present
|
|
151
|
+
if entry.fields:
|
|
152
|
+
for key, value in entry.fields.items():
|
|
153
|
+
self.console.print(f" [dim] {key}: {value}[/dim]")
|
|
154
|
+
|
|
155
|
+
async def _follow_mode(
|
|
156
|
+
self,
|
|
157
|
+
path: Path,
|
|
158
|
+
initial_entries: list,
|
|
159
|
+
level_filter: Optional[str],
|
|
160
|
+
pattern: Optional[str],
|
|
161
|
+
thread_filter: Optional[str],
|
|
162
|
+
):
|
|
163
|
+
"""Follow log file in real-time."""
|
|
164
|
+
# Display initial entries
|
|
165
|
+
self._display_entries(initial_entries)
|
|
166
|
+
|
|
167
|
+
self.console.print("\n[bold green]📡 Following log file... (Ctrl+C to stop)[/bold green]\n")
|
|
168
|
+
|
|
169
|
+
# Track file position
|
|
170
|
+
with open(path, "r") as f:
|
|
171
|
+
f.seek(0, 2) # Go to end
|
|
172
|
+
line_number = len(initial_entries)
|
|
173
|
+
|
|
174
|
+
while True:
|
|
175
|
+
line = f.readline()
|
|
176
|
+
if line:
|
|
177
|
+
line_number += 1
|
|
178
|
+
entry = self.parser.parse_line(line_number, line.rstrip())
|
|
179
|
+
|
|
180
|
+
# Apply filters
|
|
181
|
+
filtered = self._apply_filters([entry], level_filter, pattern, thread_filter)
|
|
182
|
+
if filtered:
|
|
183
|
+
self._print_entry(entry)
|
|
184
|
+
else:
|
|
185
|
+
await asyncio.sleep(0.1)
|
|
186
|
+
|
|
187
|
+
def display_thread_view(self, entries: list):
|
|
188
|
+
"""Display beautiful thread-correlated view."""
|
|
189
|
+
from .tracker import ThreadTracker
|
|
190
|
+
|
|
191
|
+
tracker = ThreadTracker()
|
|
192
|
+
for entry in entries:
|
|
193
|
+
tracker.track(entry)
|
|
194
|
+
|
|
195
|
+
self.console.print("\n[bold]🧵 Thread View[/bold]\n")
|
|
196
|
+
|
|
197
|
+
threads = tracker.get_all_threads()
|
|
198
|
+
|
|
199
|
+
if not threads:
|
|
200
|
+
self.console.print("[yellow]No thread information found.[/yellow]")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
table = Table(title="Threads", show_header=True, header_style="bold magenta")
|
|
204
|
+
table.add_column("Thread ID", style="cyan", no_wrap=True)
|
|
205
|
+
table.add_column("Logs", justify="right", style="green")
|
|
206
|
+
table.add_column("Errors", justify="right", style="red")
|
|
207
|
+
table.add_column("Duration", style="yellow")
|
|
208
|
+
|
|
209
|
+
for thread in threads:
|
|
210
|
+
duration = ""
|
|
211
|
+
if thread["first_seen"] and thread["last_seen"]:
|
|
212
|
+
delta = thread["last_seen"] - thread["first_seen"]
|
|
213
|
+
duration = f"{delta.total_seconds():.2f}s"
|
|
214
|
+
|
|
215
|
+
table.add_row(
|
|
216
|
+
thread["thread_id"],
|
|
217
|
+
str(thread["log_count"]),
|
|
218
|
+
str(thread["error_count"]),
|
|
219
|
+
duration,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
self.console.print(table)
|
|
223
|
+
|
|
224
|
+
# Show thread timeline
|
|
225
|
+
for thread in threads[:5]: # Show top 5
|
|
226
|
+
self._display_thread_timeline(thread, entries)
|
|
227
|
+
|
|
228
|
+
def _display_thread_timeline(self, thread: dict, all_entries: list):
|
|
229
|
+
"""Display timeline for a specific thread."""
|
|
230
|
+
thread_entries = [e for e in all_entries if e.thread_id == thread["thread_id"]]
|
|
231
|
+
|
|
232
|
+
if not thread_entries:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
panel_title = f"🧵 Thread: {thread['thread_id']}"
|
|
236
|
+
panel_content = []
|
|
237
|
+
|
|
238
|
+
for entry in thread_entries[:10]: # Show first 10
|
|
239
|
+
level_color = self.LEVEL_COLORS.get(entry.level, "white")
|
|
240
|
+
panel_content.append(f"[{level_color}]●[/{level_color}] {entry.message[:80]}")
|
|
241
|
+
|
|
242
|
+
if len(thread_entries) > 10:
|
|
243
|
+
panel_content.append(f"[dim]... and {len(thread_entries) - 10} more[/dim]")
|
|
244
|
+
|
|
245
|
+
self.console.print(
|
|
246
|
+
Panel(
|
|
247
|
+
"\n".join(panel_content),
|
|
248
|
+
title=panel_title,
|
|
249
|
+
border_style="cyan",
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
self.console.print()
|
logler/tracker.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thread and trace tracking for log correlation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
from .parser import LogEntry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ThreadTracker:
|
|
12
|
+
"""Track threads and correlate log entries."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.threads: Dict[str, dict] = {}
|
|
16
|
+
self.traces: Dict[str, dict] = {}
|
|
17
|
+
self.correlations: Dict[str, List[LogEntry]] = defaultdict(list)
|
|
18
|
+
|
|
19
|
+
def track(self, entry: LogEntry):
|
|
20
|
+
"""Track a log entry."""
|
|
21
|
+
# Track by thread ID
|
|
22
|
+
if entry.thread_id:
|
|
23
|
+
self._track_thread(entry)
|
|
24
|
+
|
|
25
|
+
# Track by correlation ID
|
|
26
|
+
if entry.correlation_id:
|
|
27
|
+
self.correlations[entry.correlation_id].append(entry)
|
|
28
|
+
|
|
29
|
+
# Track by trace ID
|
|
30
|
+
if entry.trace_id:
|
|
31
|
+
self._track_trace(entry)
|
|
32
|
+
|
|
33
|
+
def _track_thread(self, entry: LogEntry):
|
|
34
|
+
"""Track thread information."""
|
|
35
|
+
thread_id = entry.thread_id
|
|
36
|
+
|
|
37
|
+
if thread_id not in self.threads:
|
|
38
|
+
self.threads[thread_id] = {
|
|
39
|
+
"thread_id": thread_id,
|
|
40
|
+
"first_seen": entry.timestamp,
|
|
41
|
+
"last_seen": entry.timestamp,
|
|
42
|
+
"log_count": 0,
|
|
43
|
+
"error_count": 0,
|
|
44
|
+
"correlation_ids": set(),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
thread = self.threads[thread_id]
|
|
48
|
+
thread["log_count"] += 1
|
|
49
|
+
|
|
50
|
+
if entry.level in ["ERROR", "FATAL", "CRITICAL"]:
|
|
51
|
+
thread["error_count"] += 1
|
|
52
|
+
|
|
53
|
+
if entry.timestamp:
|
|
54
|
+
if not thread["first_seen"] or entry.timestamp < thread["first_seen"]:
|
|
55
|
+
thread["first_seen"] = entry.timestamp
|
|
56
|
+
if not thread["last_seen"] or entry.timestamp > thread["last_seen"]:
|
|
57
|
+
thread["last_seen"] = entry.timestamp
|
|
58
|
+
|
|
59
|
+
if entry.correlation_id:
|
|
60
|
+
thread["correlation_ids"].add(entry.correlation_id)
|
|
61
|
+
|
|
62
|
+
def _track_trace(self, entry: LogEntry):
|
|
63
|
+
"""Track trace information."""
|
|
64
|
+
trace_id = entry.trace_id
|
|
65
|
+
|
|
66
|
+
if trace_id not in self.traces:
|
|
67
|
+
self.traces[trace_id] = {
|
|
68
|
+
"trace_id": trace_id,
|
|
69
|
+
"spans": [],
|
|
70
|
+
"services": set(),
|
|
71
|
+
"start_time": entry.timestamp,
|
|
72
|
+
"end_time": entry.timestamp,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
trace = self.traces[trace_id]
|
|
76
|
+
|
|
77
|
+
if entry.span_id:
|
|
78
|
+
trace["spans"].append(
|
|
79
|
+
{
|
|
80
|
+
"span_id": entry.span_id,
|
|
81
|
+
"timestamp": entry.timestamp,
|
|
82
|
+
"message": entry.message,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if entry.service_name:
|
|
87
|
+
trace["services"].add(entry.service_name)
|
|
88
|
+
|
|
89
|
+
if entry.timestamp:
|
|
90
|
+
if not trace["start_time"] or entry.timestamp < trace["start_time"]:
|
|
91
|
+
trace["start_time"] = entry.timestamp
|
|
92
|
+
if not trace["end_time"] or entry.timestamp > trace["end_time"]:
|
|
93
|
+
trace["end_time"] = entry.timestamp
|
|
94
|
+
|
|
95
|
+
def get_thread(self, thread_id: str) -> Optional[dict]:
|
|
96
|
+
"""Get thread information."""
|
|
97
|
+
thread = self.threads.get(thread_id)
|
|
98
|
+
if thread:
|
|
99
|
+
thread["correlation_ids"] = list(thread["correlation_ids"])
|
|
100
|
+
return thread
|
|
101
|
+
|
|
102
|
+
def get_all_threads(self) -> List[dict]:
|
|
103
|
+
"""Get all tracked threads."""
|
|
104
|
+
result = []
|
|
105
|
+
for thread in self.threads.values():
|
|
106
|
+
thread_copy = thread.copy()
|
|
107
|
+
thread_copy["correlation_ids"] = list(thread["correlation_ids"])
|
|
108
|
+
result.append(thread_copy)
|
|
109
|
+
return sorted(result, key=lambda x: x["log_count"], reverse=True)
|
|
110
|
+
|
|
111
|
+
def get_trace(self, trace_id: str) -> Optional[dict]:
|
|
112
|
+
"""Get trace information."""
|
|
113
|
+
trace = self.traces.get(trace_id)
|
|
114
|
+
if trace:
|
|
115
|
+
trace_copy = trace.copy()
|
|
116
|
+
trace_copy["services"] = list(trace["services"])
|
|
117
|
+
if trace_copy["start_time"] and trace_copy["end_time"]:
|
|
118
|
+
delta = trace_copy["end_time"] - trace_copy["start_time"]
|
|
119
|
+
trace_copy["duration_ms"] = delta.total_seconds() * 1000
|
|
120
|
+
return trace_copy
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def get_all_traces(self) -> List[dict]:
|
|
124
|
+
"""Get all tracked traces."""
|
|
125
|
+
result = []
|
|
126
|
+
for trace_id in self.traces:
|
|
127
|
+
trace = self.get_trace(trace_id)
|
|
128
|
+
if trace:
|
|
129
|
+
result.append(trace)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
def get_by_correlation(self, correlation_id: str) -> List[LogEntry]:
|
|
133
|
+
"""Get logs by correlation ID."""
|
|
134
|
+
return self.correlations.get(correlation_id, [])
|
|
135
|
+
|
|
136
|
+
def get_all_correlations(self) -> List[str]:
|
|
137
|
+
"""Get all correlation IDs."""
|
|
138
|
+
return list(self.correlations.keys())
|