suitable-loop 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.
- suitable_loop/__init__.py +3 -0
- suitable_loop/__main__.py +5 -0
- suitable_loop/analyzers/__init__.py +1 -0
- suitable_loop/analyzers/code_analyzer.py +652 -0
- suitable_loop/analyzers/git_analyzer.py +510 -0
- suitable_loop/analyzers/log_analyzer.py +663 -0
- suitable_loop/config.py +60 -0
- suitable_loop/db.py +497 -0
- suitable_loop/graph/__init__.py +1 -0
- suitable_loop/graph/engine.py +341 -0
- suitable_loop/models.py +131 -0
- suitable_loop/server.py +46 -0
- suitable_loop/tools/__init__.py +1 -0
- suitable_loop/tools/code_tools.py +104 -0
- suitable_loop/tools/git_tools.py +52 -0
- suitable_loop/tools/log_tools.py +53 -0
- suitable_loop/tools/util_tools.py +49 -0
- suitable_loop-0.1.0.dist-info/METADATA +12 -0
- suitable_loop-0.1.0.dist-info/RECORD +21 -0
- suitable_loop-0.1.0.dist-info/WHEEL +4 -0
- suitable_loop-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"""Log and error analysis engine for CodeZero."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from suitable_loop.config import SuitableLoopConfig
|
|
14
|
+
from suitable_loop.db import Database
|
|
15
|
+
from suitable_loop.models import ErrorCodeLink, ErrorGroup, LogEntry
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Regex patterns for stdlib logging formats
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
# Patterns ordered from most specific to least specific.
|
|
24
|
+
_STDLIB_PATTERNS: list[re.Pattern[str]] = [
|
|
25
|
+
# 2024-01-15 10:30:45,123 - module - ERROR - message
|
|
26
|
+
re.compile(
|
|
27
|
+
r"^(?P<timestamp>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[,\.]\d{1,6})"
|
|
28
|
+
r"\s+-\s+(?P<logger>\S+)"
|
|
29
|
+
r"\s+-\s+(?P<level>[A-Z]+)"
|
|
30
|
+
r"\s+-\s+(?P<message>.+)$"
|
|
31
|
+
),
|
|
32
|
+
# 2024-01-15 10:30:45,123 module ERROR message
|
|
33
|
+
re.compile(
|
|
34
|
+
r"^(?P<timestamp>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[,\.]\d{1,6})"
|
|
35
|
+
r"\s+(?P<logger>\S+)"
|
|
36
|
+
r"\s+(?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL)"
|
|
37
|
+
r"\s+(?P<message>.+)$"
|
|
38
|
+
),
|
|
39
|
+
# [2024-01-15 10:30:45] ERROR module: message
|
|
40
|
+
re.compile(
|
|
41
|
+
r"^\[(?P<timestamp>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[,\.]?\d{0,6})\]"
|
|
42
|
+
r"\s+(?P<level>[A-Z]+)"
|
|
43
|
+
r"\s+(?P<logger>\S+?):\s+(?P<message>.+)$"
|
|
44
|
+
),
|
|
45
|
+
# ERROR 2024-01-15 10:30:45 module - message
|
|
46
|
+
re.compile(
|
|
47
|
+
r"^(?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL)"
|
|
48
|
+
r"\s+(?P<timestamp>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[,\.]?\d{0,6})"
|
|
49
|
+
r"\s+(?P<logger>\S+)"
|
|
50
|
+
r"\s+-\s+(?P<message>.+)$"
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# Traceback frame line: File "path", line N, in func
|
|
55
|
+
_FRAME_RE = re.compile(
|
|
56
|
+
r'^\s+File "(?P<path>[^"]+)",\s+line\s+(?P<lineno>\d+),\s+in\s+(?P<func>\S+)'
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Timestamp parsing formats (tried in order).
|
|
60
|
+
_TIMESTAMP_FORMATS = [
|
|
61
|
+
"%Y-%m-%d %H:%M:%S,%f",
|
|
62
|
+
"%Y-%m-%d %H:%M:%S.%f",
|
|
63
|
+
"%Y-%m-%d %H:%M:%S",
|
|
64
|
+
"%Y-%m-%dT%H:%M:%S,%f",
|
|
65
|
+
"%Y-%m-%dT%H:%M:%S.%f",
|
|
66
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_timestamp(raw: str) -> float | None:
|
|
71
|
+
"""Try to convert a timestamp string to a Unix epoch float."""
|
|
72
|
+
raw = raw.strip()
|
|
73
|
+
for fmt in _TIMESTAMP_FORMATS:
|
|
74
|
+
try:
|
|
75
|
+
from datetime import datetime, timezone
|
|
76
|
+
|
|
77
|
+
dt = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc)
|
|
78
|
+
return dt.timestamp()
|
|
79
|
+
except ValueError:
|
|
80
|
+
continue
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class LogAnalyzer:
|
|
85
|
+
"""Ingests log files, groups errors, and maps stack frames to code."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, db: Database, config: SuitableLoopConfig) -> None:
|
|
88
|
+
self.db = db
|
|
89
|
+
self.config = config
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Public API
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def ingest_logs(self, path: str) -> dict:
|
|
96
|
+
"""Ingest log files from *path* (file or directory).
|
|
97
|
+
|
|
98
|
+
Returns a summary dict with keys: entries_parsed, errors_found,
|
|
99
|
+
error_groups_new, error_groups_updated.
|
|
100
|
+
"""
|
|
101
|
+
target = Path(path)
|
|
102
|
+
if target.is_dir():
|
|
103
|
+
files = sorted(
|
|
104
|
+
p
|
|
105
|
+
for p in target.iterdir()
|
|
106
|
+
if p.is_file() and p.suffix in (".log", ".txt")
|
|
107
|
+
)
|
|
108
|
+
elif target.is_file():
|
|
109
|
+
files = [target]
|
|
110
|
+
else:
|
|
111
|
+
logger.warning("Path does not exist or is not readable: %s", path)
|
|
112
|
+
return {
|
|
113
|
+
"entries_parsed": 0,
|
|
114
|
+
"errors_found": 0,
|
|
115
|
+
"error_groups_new": 0,
|
|
116
|
+
"error_groups_updated": 0,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
max_entries = self.config.logging.max_entries_per_ingest
|
|
120
|
+
entries_parsed = 0
|
|
121
|
+
errors_found = 0
|
|
122
|
+
error_groups_new = 0
|
|
123
|
+
error_groups_updated = 0
|
|
124
|
+
|
|
125
|
+
for file_path in files:
|
|
126
|
+
if entries_parsed >= max_entries:
|
|
127
|
+
logger.info(
|
|
128
|
+
"Reached max_entries_per_ingest limit (%d). Stopping.",
|
|
129
|
+
max_entries,
|
|
130
|
+
)
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
logger.info("Ingesting %s", file_path)
|
|
134
|
+
try:
|
|
135
|
+
lines = file_path.read_text(errors="replace").splitlines()
|
|
136
|
+
except OSError:
|
|
137
|
+
logger.exception("Failed to read %s", file_path)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# 1. Extract tracebacks so we can create error groups.
|
|
141
|
+
tracebacks = self._extract_tracebacks(lines)
|
|
142
|
+
|
|
143
|
+
# Build a lookup: line index -> traceback dict (keyed on the
|
|
144
|
+
# exception line index, i.e., the last line of each traceback).
|
|
145
|
+
tb_by_exc_line: dict[int, dict] = {}
|
|
146
|
+
for tb in tracebacks:
|
|
147
|
+
tb_by_exc_line[tb["end_line"]] = tb
|
|
148
|
+
|
|
149
|
+
# 2. Persist error groups and remember mapping from signature
|
|
150
|
+
# to group_id so we can link log entries later.
|
|
151
|
+
sig_to_group: dict[str, int] = {}
|
|
152
|
+
existing_sigs: set[str] = set()
|
|
153
|
+
|
|
154
|
+
# Pre-check which signatures already exist.
|
|
155
|
+
for tb in tracebacks:
|
|
156
|
+
row = self.db.conn.execute(
|
|
157
|
+
"SELECT id FROM error_groups WHERE signature = ?",
|
|
158
|
+
(tb["signature"],),
|
|
159
|
+
).fetchone()
|
|
160
|
+
if row:
|
|
161
|
+
existing_sigs.add(tb["signature"])
|
|
162
|
+
|
|
163
|
+
for tb in tracebacks:
|
|
164
|
+
now = time.time()
|
|
165
|
+
eg = ErrorGroup(
|
|
166
|
+
signature=tb["signature"],
|
|
167
|
+
exception_type=tb["exception_type"],
|
|
168
|
+
exception_message=tb["exception_message"],
|
|
169
|
+
traceback=tb["raw"],
|
|
170
|
+
first_seen=now,
|
|
171
|
+
last_seen=now,
|
|
172
|
+
occurrence_count=1,
|
|
173
|
+
)
|
|
174
|
+
group_id = self.db.upsert_error_group(eg)
|
|
175
|
+
sig_to_group[tb["signature"]] = group_id
|
|
176
|
+
errors_found += 1
|
|
177
|
+
|
|
178
|
+
if tb["signature"] in existing_sigs:
|
|
179
|
+
error_groups_updated += 1
|
|
180
|
+
else:
|
|
181
|
+
error_groups_new += 1
|
|
182
|
+
existing_sigs.add(tb["signature"])
|
|
183
|
+
|
|
184
|
+
# Map frames to indexed code.
|
|
185
|
+
self._map_frames_to_code(tb["frames"], group_id)
|
|
186
|
+
|
|
187
|
+
# 3. Detect format and parse individual lines.
|
|
188
|
+
auto_detect = self.config.logging.auto_detect_format
|
|
189
|
+
detected_format: str | None = None
|
|
190
|
+
|
|
191
|
+
if auto_detect:
|
|
192
|
+
detected_format = self._detect_format(lines)
|
|
193
|
+
|
|
194
|
+
for line_idx, line in enumerate(lines):
|
|
195
|
+
if entries_parsed >= max_entries:
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
parsed = self._parse_line(line, detected_format)
|
|
199
|
+
if parsed is None:
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
# Check if this line is the exception line of a traceback.
|
|
203
|
+
error_group_id: int | None = None
|
|
204
|
+
if line_idx in tb_by_exc_line:
|
|
205
|
+
tb = tb_by_exc_line[line_idx]
|
|
206
|
+
error_group_id = sig_to_group.get(tb["signature"])
|
|
207
|
+
|
|
208
|
+
entry = LogEntry(
|
|
209
|
+
source_file=str(file_path),
|
|
210
|
+
timestamp=parsed.get("timestamp"),
|
|
211
|
+
level=parsed.get("level", ""),
|
|
212
|
+
logger_name=parsed.get("logger", ""),
|
|
213
|
+
message=parsed.get("message", ""),
|
|
214
|
+
raw_line=line,
|
|
215
|
+
error_group_id=error_group_id,
|
|
216
|
+
)
|
|
217
|
+
self.db.insert_log_entry(entry)
|
|
218
|
+
entries_parsed += 1
|
|
219
|
+
|
|
220
|
+
self.db.commit()
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"entries_parsed": entries_parsed,
|
|
224
|
+
"errors_found": errors_found,
|
|
225
|
+
"error_groups_new": error_groups_new,
|
|
226
|
+
"error_groups_updated": error_groups_updated,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def get_error_groups(self, limit: int = 20) -> list[dict]:
|
|
230
|
+
"""Return error groups sorted by frequency, enriched with code links."""
|
|
231
|
+
groups = self.db.get_error_groups(limit=limit)
|
|
232
|
+
results: list[dict] = []
|
|
233
|
+
for eg in groups:
|
|
234
|
+
links = self.db.get_error_code_links(eg.id) # type: ignore[arg-type]
|
|
235
|
+
results.append(
|
|
236
|
+
{
|
|
237
|
+
"id": eg.id,
|
|
238
|
+
"signature": eg.signature,
|
|
239
|
+
"exception_type": eg.exception_type,
|
|
240
|
+
"exception_message": eg.exception_message,
|
|
241
|
+
"first_seen": eg.first_seen,
|
|
242
|
+
"last_seen": eg.last_seen,
|
|
243
|
+
"occurrence_count": eg.occurrence_count,
|
|
244
|
+
"code_links": links,
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
return results
|
|
248
|
+
|
|
249
|
+
def error_detail(self, error_group_id: int) -> dict | None:
|
|
250
|
+
"""Full detail for a single error group."""
|
|
251
|
+
eg = self.db.get_error_group_by_id(error_group_id)
|
|
252
|
+
if eg is None:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
code_links = self.db.get_error_code_links(error_group_id)
|
|
256
|
+
|
|
257
|
+
# Affected functions (from code links).
|
|
258
|
+
affected_functions: list[dict] = []
|
|
259
|
+
for link in code_links:
|
|
260
|
+
func_id = link.get("function_id")
|
|
261
|
+
if func_id is not None:
|
|
262
|
+
func = self.db.get_function_by_id(func_id)
|
|
263
|
+
if func:
|
|
264
|
+
affected_functions.append(
|
|
265
|
+
{
|
|
266
|
+
"function_id": func.id,
|
|
267
|
+
"qualified_name": func.qualified_name,
|
|
268
|
+
"file_id": func.file_id,
|
|
269
|
+
"line_start": func.line_start,
|
|
270
|
+
"line_end": func.line_end,
|
|
271
|
+
"frame_position": link.get("frame_position"),
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Sample log entries.
|
|
276
|
+
rows = self.db.conn.execute(
|
|
277
|
+
"SELECT * FROM log_entries WHERE error_group_id = ? ORDER BY timestamp DESC LIMIT 10",
|
|
278
|
+
(error_group_id,),
|
|
279
|
+
).fetchall()
|
|
280
|
+
sample_entries = [dict(r) for r in rows]
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
"id": eg.id,
|
|
284
|
+
"signature": eg.signature,
|
|
285
|
+
"exception_type": eg.exception_type,
|
|
286
|
+
"exception_message": eg.exception_message,
|
|
287
|
+
"traceback": eg.traceback,
|
|
288
|
+
"first_seen": eg.first_seen,
|
|
289
|
+
"last_seen": eg.last_seen,
|
|
290
|
+
"occurrence_count": eg.occurrence_count,
|
|
291
|
+
"code_links": code_links,
|
|
292
|
+
"affected_functions": affected_functions,
|
|
293
|
+
"sample_entries": sample_entries,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
def correlate_error(self, error_text: str) -> dict:
|
|
297
|
+
"""Parse raw error text and find matching error groups / code paths."""
|
|
298
|
+
lines = error_text.splitlines()
|
|
299
|
+
tracebacks = self._extract_tracebacks(lines)
|
|
300
|
+
|
|
301
|
+
matched_groups: list[dict] = []
|
|
302
|
+
mapped_frames: list[dict] = []
|
|
303
|
+
|
|
304
|
+
for tb in tracebacks:
|
|
305
|
+
# Look for an existing error group with the same signature.
|
|
306
|
+
row = self.db.conn.execute(
|
|
307
|
+
"SELECT * FROM error_groups WHERE signature = ?",
|
|
308
|
+
(tb["signature"],),
|
|
309
|
+
).fetchone()
|
|
310
|
+
if row:
|
|
311
|
+
eg = ErrorGroup(**dict(row))
|
|
312
|
+
links = self.db.get_error_code_links(eg.id) # type: ignore[arg-type]
|
|
313
|
+
matched_groups.append(
|
|
314
|
+
{
|
|
315
|
+
"id": eg.id,
|
|
316
|
+
"signature": eg.signature,
|
|
317
|
+
"exception_type": eg.exception_type,
|
|
318
|
+
"exception_message": eg.exception_message,
|
|
319
|
+
"occurrence_count": eg.occurrence_count,
|
|
320
|
+
"code_links": links,
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
# No existing group -- try to map frames to code anyway.
|
|
325
|
+
for pos, (fpath, lineno, func_name) in enumerate(tb["frames"]):
|
|
326
|
+
match = self._find_function_for_frame(fpath, lineno)
|
|
327
|
+
if match:
|
|
328
|
+
file_entity, func_entity = match
|
|
329
|
+
mapped_frames.append(
|
|
330
|
+
{
|
|
331
|
+
"frame_position": pos,
|
|
332
|
+
"file_path": fpath,
|
|
333
|
+
"line_number": lineno,
|
|
334
|
+
"frame_function": func_name,
|
|
335
|
+
"matched_function": func_entity.qualified_name,
|
|
336
|
+
"function_id": func_entity.id,
|
|
337
|
+
"file_id": file_entity.id,
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# If no tracebacks found, try a simple text search against existing
|
|
342
|
+
# error messages.
|
|
343
|
+
if not tracebacks:
|
|
344
|
+
search_text = error_text.strip()[:200]
|
|
345
|
+
rows = self.db.conn.execute(
|
|
346
|
+
"SELECT * FROM error_groups WHERE exception_message LIKE ? "
|
|
347
|
+
"ORDER BY occurrence_count DESC LIMIT 5",
|
|
348
|
+
(f"%{search_text}%",),
|
|
349
|
+
).fetchall()
|
|
350
|
+
for row in rows:
|
|
351
|
+
eg = ErrorGroup(**dict(row))
|
|
352
|
+
links = self.db.get_error_code_links(eg.id) # type: ignore[arg-type]
|
|
353
|
+
matched_groups.append(
|
|
354
|
+
{
|
|
355
|
+
"id": eg.id,
|
|
356
|
+
"signature": eg.signature,
|
|
357
|
+
"exception_type": eg.exception_type,
|
|
358
|
+
"exception_message": eg.exception_message,
|
|
359
|
+
"occurrence_count": eg.occurrence_count,
|
|
360
|
+
"code_links": links,
|
|
361
|
+
}
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
"tracebacks_parsed": len(tracebacks),
|
|
366
|
+
"matched_groups": matched_groups,
|
|
367
|
+
"unmapped_frame_matches": mapped_frames,
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
def error_timeline(self, days: int = 7) -> list[dict]:
|
|
371
|
+
"""Return error counts grouped by day and error type for the last *days* days."""
|
|
372
|
+
cutoff = time.time() - days * 86400
|
|
373
|
+
rows = self.db.conn.execute(
|
|
374
|
+
"""
|
|
375
|
+
SELECT
|
|
376
|
+
date(le.timestamp, 'unixepoch') AS day,
|
|
377
|
+
eg.exception_type,
|
|
378
|
+
eg.id AS error_group_id,
|
|
379
|
+
COUNT(*) AS count
|
|
380
|
+
FROM log_entries le
|
|
381
|
+
JOIN error_groups eg ON le.error_group_id = eg.id
|
|
382
|
+
WHERE le.timestamp >= ?
|
|
383
|
+
GROUP BY day, eg.id
|
|
384
|
+
ORDER BY day, count DESC
|
|
385
|
+
""",
|
|
386
|
+
(cutoff,),
|
|
387
|
+
).fetchall()
|
|
388
|
+
return [dict(r) for r in rows]
|
|
389
|
+
|
|
390
|
+
# ------------------------------------------------------------------
|
|
391
|
+
# Private helpers
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
def _parse_stdlib_line(self, line: str) -> dict | None:
|
|
395
|
+
"""Parse a stdlib-style log line and return a dict or *None*."""
|
|
396
|
+
for pattern in _STDLIB_PATTERNS:
|
|
397
|
+
m = pattern.match(line)
|
|
398
|
+
if m:
|
|
399
|
+
groups = m.groupdict()
|
|
400
|
+
ts = _parse_timestamp(groups["timestamp"])
|
|
401
|
+
return {
|
|
402
|
+
"timestamp": ts,
|
|
403
|
+
"level": groups["level"].upper(),
|
|
404
|
+
"logger": groups.get("logger", ""),
|
|
405
|
+
"message": groups["message"],
|
|
406
|
+
}
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
def _parse_json_line(self, line: str) -> dict | None:
|
|
410
|
+
"""Parse a JSON log line and return a normalised dict or *None*."""
|
|
411
|
+
stripped = line.strip()
|
|
412
|
+
if not stripped.startswith("{"):
|
|
413
|
+
return None
|
|
414
|
+
try:
|
|
415
|
+
obj = json.loads(stripped)
|
|
416
|
+
except (json.JSONDecodeError, ValueError):
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
if not isinstance(obj, dict):
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
# Normalise common key names.
|
|
423
|
+
level = (
|
|
424
|
+
obj.get("level")
|
|
425
|
+
or obj.get("levelname")
|
|
426
|
+
or obj.get("severity")
|
|
427
|
+
or ""
|
|
428
|
+
)
|
|
429
|
+
message = (
|
|
430
|
+
obj.get("message")
|
|
431
|
+
or obj.get("msg")
|
|
432
|
+
or obj.get("text")
|
|
433
|
+
or ""
|
|
434
|
+
)
|
|
435
|
+
logger_name = (
|
|
436
|
+
obj.get("logger")
|
|
437
|
+
or obj.get("name")
|
|
438
|
+
or obj.get("logger_name")
|
|
439
|
+
or ""
|
|
440
|
+
)
|
|
441
|
+
raw_ts = obj.get("timestamp") or obj.get("time") or obj.get("ts")
|
|
442
|
+
ts: float | None = None
|
|
443
|
+
if isinstance(raw_ts, (int, float)):
|
|
444
|
+
ts = float(raw_ts)
|
|
445
|
+
elif isinstance(raw_ts, str):
|
|
446
|
+
ts = _parse_timestamp(raw_ts)
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
"timestamp": ts,
|
|
450
|
+
"level": str(level).upper(),
|
|
451
|
+
"logger": str(logger_name),
|
|
452
|
+
"message": str(message),
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
def _extract_tracebacks(self, lines: list[str]) -> list[dict]:
|
|
456
|
+
"""Extract Python traceback blocks from *lines*.
|
|
457
|
+
|
|
458
|
+
Each returned dict contains:
|
|
459
|
+
- start_line / end_line: indices in *lines*
|
|
460
|
+
- raw: the full traceback text
|
|
461
|
+
- exception_type, exception_message: parsed from the last line
|
|
462
|
+
- frames: list of (file_path, line_number, function_name) tuples
|
|
463
|
+
- signature: dedup hash
|
|
464
|
+
"""
|
|
465
|
+
tracebacks: list[dict] = []
|
|
466
|
+
i = 0
|
|
467
|
+
while i < len(lines):
|
|
468
|
+
if lines[i].strip() == "Traceback (most recent call last):":
|
|
469
|
+
start = i
|
|
470
|
+
frames: list[tuple[str, int, str]] = []
|
|
471
|
+
i += 1
|
|
472
|
+
|
|
473
|
+
# Consume frame lines and code lines.
|
|
474
|
+
while i < len(lines):
|
|
475
|
+
frame_match = _FRAME_RE.match(lines[i])
|
|
476
|
+
if frame_match:
|
|
477
|
+
frames.append(
|
|
478
|
+
(
|
|
479
|
+
frame_match.group("path"),
|
|
480
|
+
int(frame_match.group("lineno")),
|
|
481
|
+
frame_match.group("func"),
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
i += 1
|
|
485
|
+
# Skip the source-code line that follows the frame.
|
|
486
|
+
if i < len(lines) and lines[i].startswith(" "):
|
|
487
|
+
i += 1
|
|
488
|
+
continue
|
|
489
|
+
|
|
490
|
+
# A line starting with whitespace that is not a frame is
|
|
491
|
+
# still part of the traceback (e.g., chained exceptions or
|
|
492
|
+
# "During handling..." blocks). But a non-indented line is
|
|
493
|
+
# the exception line (or end of block).
|
|
494
|
+
if lines[i] and not lines[i][0].isspace():
|
|
495
|
+
break
|
|
496
|
+
i += 1
|
|
497
|
+
|
|
498
|
+
# The current line should be the exception line.
|
|
499
|
+
if i < len(lines):
|
|
500
|
+
exc_line = lines[i].strip()
|
|
501
|
+
end = i
|
|
502
|
+
else:
|
|
503
|
+
exc_line = ""
|
|
504
|
+
end = i - 1
|
|
505
|
+
|
|
506
|
+
exc_type, _, exc_msg = exc_line.partition(":")
|
|
507
|
+
exc_type = exc_type.strip()
|
|
508
|
+
exc_msg = exc_msg.strip()
|
|
509
|
+
|
|
510
|
+
raw = "\n".join(lines[start : end + 1])
|
|
511
|
+
signature = self._compute_error_signature(exc_type, frames)
|
|
512
|
+
|
|
513
|
+
tracebacks.append(
|
|
514
|
+
{
|
|
515
|
+
"start_line": start,
|
|
516
|
+
"end_line": end,
|
|
517
|
+
"raw": raw,
|
|
518
|
+
"exception_type": exc_type,
|
|
519
|
+
"exception_message": exc_msg,
|
|
520
|
+
"frames": frames,
|
|
521
|
+
"signature": signature,
|
|
522
|
+
}
|
|
523
|
+
)
|
|
524
|
+
i += 1
|
|
525
|
+
|
|
526
|
+
return tracebacks
|
|
527
|
+
|
|
528
|
+
def _compute_error_signature(
|
|
529
|
+
self, exception_type: str, frames: list[tuple[str, int, str]]
|
|
530
|
+
) -> str:
|
|
531
|
+
"""Return a hex-digest hash that uniquely identifies this error shape.
|
|
532
|
+
|
|
533
|
+
The signature is derived from the exception type and the top 3 stack
|
|
534
|
+
frames (file path, line number, function name) so that identical
|
|
535
|
+
tracebacks always map to the same error group.
|
|
536
|
+
"""
|
|
537
|
+
top_frames = frames[-3:] if len(frames) > 3 else frames
|
|
538
|
+
key_parts = [exception_type]
|
|
539
|
+
for fpath, lineno, func_name in top_frames:
|
|
540
|
+
key_parts.append(f"{fpath}:{lineno}:{func_name}")
|
|
541
|
+
key = "|".join(key_parts)
|
|
542
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
543
|
+
|
|
544
|
+
def _map_frames_to_code(
|
|
545
|
+
self, frames: list[tuple[str, int, str]], error_group_id: int
|
|
546
|
+
) -> None:
|
|
547
|
+
"""Try to match each stack frame to an indexed function and persist links."""
|
|
548
|
+
# Avoid creating duplicate links if we re-ingest.
|
|
549
|
+
existing = self.db.get_error_code_links(error_group_id)
|
|
550
|
+
existing_positions: set[int] = {
|
|
551
|
+
link.get("frame_position", -1) for link in existing
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
for pos, (fpath, lineno, _func_name) in enumerate(frames):
|
|
555
|
+
if pos in existing_positions:
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
match = self._find_function_for_frame(fpath, lineno)
|
|
559
|
+
if match:
|
|
560
|
+
file_entity, func_entity = match
|
|
561
|
+
link = ErrorCodeLink(
|
|
562
|
+
error_group_id=error_group_id,
|
|
563
|
+
function_id=func_entity.id,
|
|
564
|
+
file_id=file_entity.id,
|
|
565
|
+
line_number=lineno,
|
|
566
|
+
frame_position=pos,
|
|
567
|
+
)
|
|
568
|
+
self.db.insert_error_code_link(link)
|
|
569
|
+
else:
|
|
570
|
+
# Even without a function match, try to link by file alone.
|
|
571
|
+
file_entity = self._find_file_for_frame(fpath)
|
|
572
|
+
if file_entity:
|
|
573
|
+
link = ErrorCodeLink(
|
|
574
|
+
error_group_id=error_group_id,
|
|
575
|
+
function_id=None,
|
|
576
|
+
file_id=file_entity.id,
|
|
577
|
+
line_number=lineno,
|
|
578
|
+
frame_position=pos,
|
|
579
|
+
)
|
|
580
|
+
self.db.insert_error_code_link(link)
|
|
581
|
+
|
|
582
|
+
self.db.commit()
|
|
583
|
+
|
|
584
|
+
# ------------------------------------------------------------------
|
|
585
|
+
# Internal utilities
|
|
586
|
+
# ------------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
def _detect_format(self, lines: list[str]) -> str | None:
|
|
589
|
+
"""Sample the first non-empty lines to guess the log format.
|
|
590
|
+
|
|
591
|
+
Returns ``"json"``, ``"stdlib"``, or ``None``.
|
|
592
|
+
"""
|
|
593
|
+
sample_count = 0
|
|
594
|
+
json_hits = 0
|
|
595
|
+
stdlib_hits = 0
|
|
596
|
+
|
|
597
|
+
for line in lines:
|
|
598
|
+
stripped = line.strip()
|
|
599
|
+
if not stripped:
|
|
600
|
+
continue
|
|
601
|
+
if self._parse_json_line(stripped) is not None:
|
|
602
|
+
json_hits += 1
|
|
603
|
+
elif self._parse_stdlib_line(stripped) is not None:
|
|
604
|
+
stdlib_hits += 1
|
|
605
|
+
sample_count += 1
|
|
606
|
+
if sample_count >= 20:
|
|
607
|
+
break
|
|
608
|
+
|
|
609
|
+
if json_hits > stdlib_hits and json_hits > 0:
|
|
610
|
+
return "json"
|
|
611
|
+
if stdlib_hits > 0:
|
|
612
|
+
return "stdlib"
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
def _parse_line(self, line: str, detected_format: str | None) -> dict | None:
|
|
616
|
+
"""Parse a single log line using the detected (or both) format(s)."""
|
|
617
|
+
stripped = line.strip()
|
|
618
|
+
if not stripped:
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
if detected_format == "json":
|
|
622
|
+
return self._parse_json_line(stripped)
|
|
623
|
+
if detected_format == "stdlib":
|
|
624
|
+
return self._parse_stdlib_line(stripped)
|
|
625
|
+
|
|
626
|
+
# Fallback: try both.
|
|
627
|
+
result = self._parse_json_line(stripped)
|
|
628
|
+
if result is not None:
|
|
629
|
+
return result
|
|
630
|
+
return self._parse_stdlib_line(stripped)
|
|
631
|
+
|
|
632
|
+
def _find_function_for_frame(self, fpath: str, lineno: int):
|
|
633
|
+
"""Return ``(FileEntity, FunctionEntity)`` or *None*."""
|
|
634
|
+
file_entity = self._find_file_for_frame(fpath)
|
|
635
|
+
if file_entity is None:
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
# Find the function whose line range contains *lineno*.
|
|
639
|
+
funcs = self.db.get_functions_by_file(file_entity.id) # type: ignore[arg-type]
|
|
640
|
+
for func in funcs:
|
|
641
|
+
if func.line_start <= lineno <= func.line_end:
|
|
642
|
+
return file_entity, func
|
|
643
|
+
return None
|
|
644
|
+
|
|
645
|
+
def _find_file_for_frame(self, fpath: str):
|
|
646
|
+
"""Try to find an indexed ``FileEntity`` matching *fpath*."""
|
|
647
|
+
# Try exact match first.
|
|
648
|
+
fe = self.db.get_file_by_path(fpath)
|
|
649
|
+
if fe:
|
|
650
|
+
return fe
|
|
651
|
+
|
|
652
|
+
# Try matching by filename suffix (the traceback path might be
|
|
653
|
+
# absolute while the indexed path is relative, or vice-versa).
|
|
654
|
+
basename = os.path.basename(fpath)
|
|
655
|
+
rows = self.db.conn.execute(
|
|
656
|
+
"SELECT * FROM files WHERE path LIKE ? LIMIT 1",
|
|
657
|
+
(f"%/{basename}",),
|
|
658
|
+
).fetchall()
|
|
659
|
+
if rows:
|
|
660
|
+
from suitable_loop.models import FileEntity
|
|
661
|
+
|
|
662
|
+
return FileEntity(**dict(rows[0]))
|
|
663
|
+
return None
|