syslog-postmortem 1.0.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.
- syslog_postmortem/__init__.py +4 -0
- syslog_postmortem/analyzer.py +231 -0
- syslog_postmortem/cli.py +157 -0
- syslog_postmortem/collector.py +228 -0
- syslog_postmortem/patterns.py +108 -0
- syslog_postmortem/renderer.py +284 -0
- syslog_postmortem-1.0.0.dist-info/METADATA +149 -0
- syslog_postmortem-1.0.0.dist-info/RECORD +12 -0
- syslog_postmortem-1.0.0.dist-info/WHEEL +5 -0
- syslog_postmortem-1.0.0.dist-info/entry_points.txt +2 -0
- syslog_postmortem-1.0.0.dist-info/licenses/LICENSE +21 -0
- syslog_postmortem-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pattern analysis, deduplication, cascade detection and contributing factor generation.
|
|
3
|
+
"""
|
|
4
|
+
from collections import Counter, defaultdict
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import List, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from .collector import RawEntry, priority_to_severity
|
|
10
|
+
from .patterns import match_patterns, Pattern
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── Normalised event ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Event:
|
|
17
|
+
timestamp: datetime
|
|
18
|
+
service: str
|
|
19
|
+
severity: str # CRITICAL | ERROR | WARNING | INFO
|
|
20
|
+
message: str
|
|
21
|
+
source: str
|
|
22
|
+
patterns: List[Pattern] = field(default_factory=list)
|
|
23
|
+
count: int = 1 # after deduplication
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── Analysis result ───────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AnalysisResult:
|
|
30
|
+
events: List[Event]
|
|
31
|
+
timeline: List[Event] # deduplicated, sorted
|
|
32
|
+
by_severity: Dict[str, List[Event]]
|
|
33
|
+
by_service: Dict[str, List[Event]]
|
|
34
|
+
pattern_counts: Dict[str, int] # pattern_name → count
|
|
35
|
+
contributing_factors: List[str]
|
|
36
|
+
action_items: List[str]
|
|
37
|
+
first_anomaly: Optional[Event]
|
|
38
|
+
peak_window: Optional[datetime] # minute with most errors
|
|
39
|
+
services_affected: List[str]
|
|
40
|
+
total_raw: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def _deduplicate(events: List[Event], window_seconds: int = 60) -> List[Event]:
|
|
46
|
+
"""
|
|
47
|
+
Merge identical messages that repeat within `window_seconds`.
|
|
48
|
+
Keeps the first occurrence with a count of how many times it appeared.
|
|
49
|
+
"""
|
|
50
|
+
result: List[Event] = []
|
|
51
|
+
seen: Dict[str, Event] = {}
|
|
52
|
+
|
|
53
|
+
for ev in events:
|
|
54
|
+
key = (ev.service, ev.message[:120])
|
|
55
|
+
if key in seen:
|
|
56
|
+
last = seen[key]
|
|
57
|
+
if (ev.timestamp - last.timestamp).total_seconds() <= window_seconds:
|
|
58
|
+
last.count += 1
|
|
59
|
+
continue
|
|
60
|
+
seen[key] = ev
|
|
61
|
+
result.append(ev)
|
|
62
|
+
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _peak_minute(events: List[Event]) -> Optional[datetime]:
|
|
67
|
+
buckets: Counter = Counter()
|
|
68
|
+
for ev in events:
|
|
69
|
+
if ev.severity in ('CRITICAL', 'ERROR', 'WARNING'):
|
|
70
|
+
buckets[ev.timestamp.replace(second=0, microsecond=0)] += 1
|
|
71
|
+
return buckets.most_common(1)[0][0] if buckets else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _detect_cascades(events: List[Event], window_seconds: int = 120) -> List[str]:
|
|
75
|
+
"""
|
|
76
|
+
Detect cascading failures: service A fails, service B fails shortly after.
|
|
77
|
+
Returns human-readable descriptions.
|
|
78
|
+
"""
|
|
79
|
+
cascades = []
|
|
80
|
+
criticals = [e for e in events if e.severity == 'CRITICAL']
|
|
81
|
+
errors = [e for e in events if e.severity == 'ERROR']
|
|
82
|
+
|
|
83
|
+
for trigger in criticals:
|
|
84
|
+
for follow in errors:
|
|
85
|
+
if follow.service == trigger.service:
|
|
86
|
+
continue
|
|
87
|
+
delta = (follow.timestamp - trigger.timestamp).total_seconds()
|
|
88
|
+
if 0 < delta <= window_seconds:
|
|
89
|
+
cascades.append(
|
|
90
|
+
f"**{follow.service}** errors began "
|
|
91
|
+
f"**{int(delta)}s** after first **{trigger.service}** critical event "
|
|
92
|
+
f"({trigger.timestamp.strftime('%H:%M:%S')})"
|
|
93
|
+
)
|
|
94
|
+
break # one cascade per trigger service pair
|
|
95
|
+
|
|
96
|
+
return cascades
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Main analyser ─────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def analyze(raw: List[RawEntry], services_filter: List[str] = None) -> AnalysisResult:
|
|
102
|
+
total_raw = len(raw)
|
|
103
|
+
|
|
104
|
+
# 1. Normalise RawEntry → Event and run pattern matching
|
|
105
|
+
events: List[Event] = []
|
|
106
|
+
for r in raw:
|
|
107
|
+
if services_filter and r.service.lower() not in [s.lower() for s in services_filter]:
|
|
108
|
+
continue
|
|
109
|
+
severity = priority_to_severity(r.priority)
|
|
110
|
+
matched = match_patterns(r.message)
|
|
111
|
+
# Upgrade severity based on pattern if stricter
|
|
112
|
+
sev_rank = {'CRITICAL': 0, 'ERROR': 1, 'WARNING': 2, 'INFO': 3, 'DEBUG': 4}
|
|
113
|
+
for p in matched:
|
|
114
|
+
if sev_rank.get(p.severity, 4) < sev_rank.get(severity, 4):
|
|
115
|
+
severity = p.severity
|
|
116
|
+
events.append(Event(
|
|
117
|
+
timestamp=r.timestamp,
|
|
118
|
+
service=r.service,
|
|
119
|
+
severity=severity,
|
|
120
|
+
message=r.message,
|
|
121
|
+
source=r.source,
|
|
122
|
+
patterns=matched,
|
|
123
|
+
))
|
|
124
|
+
|
|
125
|
+
# 2. Deduplicate
|
|
126
|
+
timeline = _deduplicate(events)
|
|
127
|
+
|
|
128
|
+
# 3. Group by severity and service
|
|
129
|
+
by_severity: Dict[str, List[Event]] = defaultdict(list)
|
|
130
|
+
by_service: Dict[str, List[Event]] = defaultdict(list)
|
|
131
|
+
for ev in timeline:
|
|
132
|
+
by_severity[ev.severity].append(ev)
|
|
133
|
+
by_service[ev.service].append(ev)
|
|
134
|
+
|
|
135
|
+
# 4. Pattern counts
|
|
136
|
+
pattern_counts: Counter = Counter()
|
|
137
|
+
for ev in timeline:
|
|
138
|
+
for p in ev.patterns:
|
|
139
|
+
pattern_counts[p.name] += ev.count
|
|
140
|
+
|
|
141
|
+
# 5. First anomaly (first CRITICAL or ERROR)
|
|
142
|
+
first_anomaly = next(
|
|
143
|
+
(e for e in timeline if e.severity in ('CRITICAL', 'ERROR')), None
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# 6. Peak minute
|
|
147
|
+
peak_window = _peak_minute(timeline)
|
|
148
|
+
|
|
149
|
+
# 7. Services affected (those with at least one ERROR+)
|
|
150
|
+
services_affected = sorted({
|
|
151
|
+
e.service for e in timeline
|
|
152
|
+
if e.severity in ('CRITICAL', 'ERROR')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
# 8. Contributing factors
|
|
156
|
+
factors: List[str] = []
|
|
157
|
+
|
|
158
|
+
# Restart loops
|
|
159
|
+
restart_counts = Counter(
|
|
160
|
+
e.service for e in timeline
|
|
161
|
+
if any(p.name == 'service_restart' for p in e.patterns)
|
|
162
|
+
)
|
|
163
|
+
for svc, cnt in restart_counts.most_common():
|
|
164
|
+
factors.append(
|
|
165
|
+
f"**Service instability**: `{svc}` triggered restart-loop detection **{cnt} time(s)**"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# OOM events
|
|
169
|
+
oom_count = pattern_counts.get('oom', 0)
|
|
170
|
+
if oom_count:
|
|
171
|
+
factors.append(
|
|
172
|
+
f"**Memory pressure**: OOM killer fired **{oom_count} time(s)** during the window"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Disk full
|
|
176
|
+
if pattern_counts.get('disk_full', 0):
|
|
177
|
+
factors.append("**Disk exhaustion**: 'No space left on device' events detected")
|
|
178
|
+
|
|
179
|
+
# Auth failure burst (>5 in window)
|
|
180
|
+
auth_count = sum(
|
|
181
|
+
e.count for e in timeline
|
|
182
|
+
if any(p.name == 'auth_failure' for p in e.patterns)
|
|
183
|
+
)
|
|
184
|
+
if auth_count >= 5:
|
|
185
|
+
factors.append(
|
|
186
|
+
f"**Auth anomaly**: Burst of **{auth_count} authentication failure(s)** detected"
|
|
187
|
+
" — possible brute-force attempt"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Cascading failures
|
|
191
|
+
factors.extend(_detect_cascades(timeline))
|
|
192
|
+
|
|
193
|
+
# High error density (>30 error events in any 5-minute window)
|
|
194
|
+
buckets: Counter = Counter()
|
|
195
|
+
for ev in timeline:
|
|
196
|
+
if ev.severity in ('CRITICAL', 'ERROR'):
|
|
197
|
+
slot = ev.timestamp.replace(second=0, microsecond=0)
|
|
198
|
+
slot = slot.replace(minute=(slot.minute // 5) * 5)
|
|
199
|
+
buckets[slot] += ev.count
|
|
200
|
+
dense = [(t, c) for t, c in buckets.items() if c >= 30]
|
|
201
|
+
for t, c in sorted(dense):
|
|
202
|
+
factors.append(
|
|
203
|
+
f"**Error burst**: **{c} errors** in the 5-minute window starting "
|
|
204
|
+
f"{t.strftime('%H:%M')}"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if not factors:
|
|
208
|
+
factors.append("No specific contributing factors auto-detected — manual analysis required")
|
|
209
|
+
|
|
210
|
+
# 9. Action items (deduplicated from pattern hints)
|
|
211
|
+
seen_hints: set = set()
|
|
212
|
+
action_items: List[str] = []
|
|
213
|
+
for ev in timeline:
|
|
214
|
+
for p in ev.patterns:
|
|
215
|
+
if p.action_hint not in seen_hints:
|
|
216
|
+
seen_hints.add(p.action_hint)
|
|
217
|
+
action_items.append(p.action_hint)
|
|
218
|
+
|
|
219
|
+
return AnalysisResult(
|
|
220
|
+
events=events,
|
|
221
|
+
timeline=timeline,
|
|
222
|
+
by_severity=dict(by_severity),
|
|
223
|
+
by_service=dict(by_service),
|
|
224
|
+
pattern_counts=dict(pattern_counts),
|
|
225
|
+
contributing_factors=factors,
|
|
226
|
+
action_items=action_items,
|
|
227
|
+
first_anomaly=first_anomaly,
|
|
228
|
+
peak_window=peak_window,
|
|
229
|
+
services_affected=services_affected,
|
|
230
|
+
total_raw=total_raw,
|
|
231
|
+
)
|
syslog_postmortem/cli.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from shellcolorize import Color
|
|
7
|
+
|
|
8
|
+
from .collector import collect_all
|
|
9
|
+
from .analyzer import analyze
|
|
10
|
+
from .renderer import render_markdown, render_html
|
|
11
|
+
|
|
12
|
+
VERSION = "1.0.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _header() -> None:
|
|
16
|
+
title = 'syslog-postmortem'
|
|
17
|
+
w = len(title) + 6
|
|
18
|
+
print()
|
|
19
|
+
print(f" {Color.CYAN}╔{'═' * w}╗{Color.RESET}")
|
|
20
|
+
print(f" {Color.CYAN}║{Color.RESET} {Color.BOLD}{Color.CYAN}{title}{Color.RESET} {Color.CYAN}║{Color.RESET}")
|
|
21
|
+
print(f" {Color.CYAN}╚{'═' * w}╝{Color.RESET}")
|
|
22
|
+
print()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _step(msg: str) -> None:
|
|
26
|
+
print(f" {Color.CYAN}▶{Color.RESET} {msg}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ok(msg: str) -> None:
|
|
30
|
+
print(f" {Color.GREEN}✔{Color.RESET} {msg}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _warn(msg: str) -> None:
|
|
34
|
+
print(f" {Color.YELLOW}⚠{Color.RESET} {msg}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _err(msg: str) -> None:
|
|
38
|
+
print(f" {Color.RED}✖{Color.RESET} {msg}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main() -> None:
|
|
42
|
+
parser = argparse.ArgumentParser(
|
|
43
|
+
prog='postmortem',
|
|
44
|
+
description='Generate a structured postmortem draft from system logs.',
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument('--from', dest='since', required=True,
|
|
47
|
+
metavar='DATETIME',
|
|
48
|
+
help='Start of incident window, e.g. "2026-05-10 14:00"')
|
|
49
|
+
parser.add_argument('--to', dest='until', required=True,
|
|
50
|
+
metavar='DATETIME',
|
|
51
|
+
help='End of incident window, e.g. "2026-05-10 16:00"')
|
|
52
|
+
parser.add_argument('--title', default=None,
|
|
53
|
+
help='Postmortem title (default: auto-generated)')
|
|
54
|
+
parser.add_argument('--services', default=None,
|
|
55
|
+
help='Comma-separated list of services to focus on, e.g. nginx,postgresql')
|
|
56
|
+
parser.add_argument('--output', '-o', default=None,
|
|
57
|
+
help='Output file path (default: postmortem_YYYYMMDD_HHMM.md)')
|
|
58
|
+
parser.add_argument('--format', choices=['markdown', 'html'], default='markdown',
|
|
59
|
+
help='Output format (default: markdown)')
|
|
60
|
+
parser.add_argument('--no-files', action='store_true',
|
|
61
|
+
help='Skip /var/log file parsing, use journalctl only')
|
|
62
|
+
parser.add_argument('--priorities', default='0..4',
|
|
63
|
+
help='journalctl priority filter (default: 0..4 = emerg..warning)')
|
|
64
|
+
parser.add_argument('-v', '--version', action='version', version=f'postmortem {VERSION}')
|
|
65
|
+
args = parser.parse_args()
|
|
66
|
+
|
|
67
|
+
# ── Validate timestamps ────────────────────────────────────────────────────
|
|
68
|
+
fmt = '%Y-%m-%d %H:%M'
|
|
69
|
+
try:
|
|
70
|
+
since_dt = datetime.strptime(args.since, fmt)
|
|
71
|
+
until_dt = datetime.strptime(args.until, fmt)
|
|
72
|
+
except ValueError:
|
|
73
|
+
_err('Dates must be in format "YYYY-MM-DD HH:MM"')
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
|
|
76
|
+
if until_dt <= since_dt:
|
|
77
|
+
_err('--to must be after --from')
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
if (until_dt - since_dt) > timedelta(hours=72):
|
|
81
|
+
_warn('Window is > 72 hours — this may produce a very large report')
|
|
82
|
+
|
|
83
|
+
# ── Setup ──────────────────────────────────────────────────────────────────
|
|
84
|
+
_header()
|
|
85
|
+
|
|
86
|
+
services = [s.strip() for s in args.services.split(',')] if args.services else None
|
|
87
|
+
title = args.title or f"Incident {since_dt.strftime('%Y-%m-%d')}"
|
|
88
|
+
|
|
89
|
+
ext = 'html' if args.format == 'html' else 'md'
|
|
90
|
+
output_path = args.output or f"postmortem_{since_dt.strftime('%Y%m%d_%H%M')}.{ext}"
|
|
91
|
+
|
|
92
|
+
print(f" {Color.DIM}Window : {args.since} → {args.until}{Color.RESET}")
|
|
93
|
+
print(f" {Color.DIM}Services: {', '.join(services) if services else 'all'}{Color.RESET}")
|
|
94
|
+
print(f" {Color.DIM}Output : {output_path}{Color.RESET}")
|
|
95
|
+
print()
|
|
96
|
+
|
|
97
|
+
# ── Collect ────────────────────────────────────────────────────────────────
|
|
98
|
+
_step('Collecting logs...')
|
|
99
|
+
raw = collect_all(
|
|
100
|
+
since=args.since,
|
|
101
|
+
until=args.until,
|
|
102
|
+
units=services,
|
|
103
|
+
include_files=not args.no_files,
|
|
104
|
+
priorities=args.priorities,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if not raw:
|
|
108
|
+
_warn('No log entries found in the specified window.')
|
|
109
|
+
_warn('Check that journalctl is available and the time window is correct.')
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
_ok(f"Collected {len(raw)} raw entries")
|
|
113
|
+
|
|
114
|
+
# ── Analyse ────────────────────────────────────────────────────────────────
|
|
115
|
+
_step('Analysing patterns...')
|
|
116
|
+
result = analyze(raw, services_filter=services)
|
|
117
|
+
_ok(f"Found {len(result.timeline)} unique events across "
|
|
118
|
+
f"{len(result.by_service)} services")
|
|
119
|
+
|
|
120
|
+
sev_summary = ', '.join(
|
|
121
|
+
f"{sev}: {len(result.by_severity[sev])}"
|
|
122
|
+
for sev in ['CRITICAL', 'ERROR', 'WARNING']
|
|
123
|
+
if result.by_severity.get(sev)
|
|
124
|
+
)
|
|
125
|
+
if sev_summary:
|
|
126
|
+
print(f" {Color.DIM} → {sev_summary}{Color.RESET}")
|
|
127
|
+
|
|
128
|
+
if result.contributing_factors:
|
|
129
|
+
_ok(f"Detected {len(result.contributing_factors)} contributing factor(s)")
|
|
130
|
+
|
|
131
|
+
# ── Render ─────────────────────────────────────────────────────────────────
|
|
132
|
+
_step(f'Generating {args.format} postmortem...')
|
|
133
|
+
md = render_markdown(result, title, args.since, args.until, services)
|
|
134
|
+
|
|
135
|
+
if args.format == 'html':
|
|
136
|
+
output = render_html(md, title)
|
|
137
|
+
else:
|
|
138
|
+
output = md
|
|
139
|
+
|
|
140
|
+
Path(output_path).write_text(output, encoding='utf-8')
|
|
141
|
+
_ok(f"Postmortem saved to {Color.BOLD}{output_path}{Color.RESET}")
|
|
142
|
+
|
|
143
|
+
# ── Quick summary ──────────────────────────────────────────────────────────
|
|
144
|
+
print()
|
|
145
|
+
if result.first_anomaly:
|
|
146
|
+
print(f" {Color.DIM}First anomaly : "
|
|
147
|
+
f"{result.first_anomaly.timestamp.strftime('%H:%M:%S')} — "
|
|
148
|
+
f"{result.first_anomaly.service}{Color.RESET}")
|
|
149
|
+
if result.services_affected:
|
|
150
|
+
print(f" {Color.DIM}Affected : {', '.join(result.services_affected)}{Color.RESET}")
|
|
151
|
+
if result.action_items:
|
|
152
|
+
print(f" {Color.DIM}Action items : {len(result.action_items)} generated{Color.RESET}")
|
|
153
|
+
print()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == '__main__':
|
|
157
|
+
main()
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Log collection from journalctl (primary) and /var/log files (fallback/supplement).
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── Data model ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RawEntry:
|
|
17
|
+
timestamp: datetime
|
|
18
|
+
service: str
|
|
19
|
+
priority: int # syslog priority 0-7 (0=emerg, 7=debug)
|
|
20
|
+
message: str
|
|
21
|
+
source: str # 'journald' | 'syslog' | 'auth' | 'dmesg'
|
|
22
|
+
hostname: str = ''
|
|
23
|
+
pid: Optional[int] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── Priority helpers ──────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
PRIORITY_MAP = {
|
|
29
|
+
0: 'CRITICAL', 1: 'CRITICAL', 2: 'CRITICAL',
|
|
30
|
+
3: 'ERROR',
|
|
31
|
+
4: 'WARNING',
|
|
32
|
+
5: 'INFO', 6: 'INFO', 7: 'DEBUG',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def priority_to_severity(p: int) -> str:
|
|
37
|
+
return PRIORITY_MAP.get(p, 'INFO')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── journalctl ────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def collect_journalctl(since: str, until: str,
|
|
43
|
+
units: List[str] = None,
|
|
44
|
+
priorities: str = '0..4') -> List[RawEntry]:
|
|
45
|
+
"""
|
|
46
|
+
Collect entries from systemd journal.
|
|
47
|
+
`priorities` controls syslog priority filter (default: emerg..warning).
|
|
48
|
+
Returns an empty list if journalctl is unavailable.
|
|
49
|
+
"""
|
|
50
|
+
cmd = [
|
|
51
|
+
'journalctl',
|
|
52
|
+
'--since', since,
|
|
53
|
+
'--until', until,
|
|
54
|
+
'--output', 'json',
|
|
55
|
+
'--no-pager',
|
|
56
|
+
'--priority', priorities,
|
|
57
|
+
]
|
|
58
|
+
if units:
|
|
59
|
+
for u in units:
|
|
60
|
+
cmd += ['-u', u]
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
64
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
entries = []
|
|
68
|
+
for line in result.stdout.splitlines():
|
|
69
|
+
line = line.strip()
|
|
70
|
+
if not line:
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
obj = json.loads(line)
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
ts_us = int(obj.get('__REALTIME_TIMESTAMP', 0))
|
|
78
|
+
if not ts_us:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
ts = datetime.fromtimestamp(ts_us / 1_000_000)
|
|
82
|
+
service = (obj.get('_SYSTEMD_UNIT') or
|
|
83
|
+
obj.get('SYSLOG_IDENTIFIER') or
|
|
84
|
+
obj.get('_COMM') or 'unknown')
|
|
85
|
+
service = service.removesuffix('.service')
|
|
86
|
+
|
|
87
|
+
priority = int(obj.get('PRIORITY', 6))
|
|
88
|
+
message = obj.get('MESSAGE', '')
|
|
89
|
+
if isinstance(message, list):
|
|
90
|
+
message = ' '.join(str(b) for b in message)
|
|
91
|
+
|
|
92
|
+
pid_raw = obj.get('_PID')
|
|
93
|
+
pid = int(pid_raw) if pid_raw and str(pid_raw).isdigit() else None
|
|
94
|
+
|
|
95
|
+
entries.append(RawEntry(
|
|
96
|
+
timestamp=ts,
|
|
97
|
+
service=service,
|
|
98
|
+
priority=priority,
|
|
99
|
+
message=str(message).strip(),
|
|
100
|
+
source='journald',
|
|
101
|
+
hostname=obj.get('_HOSTNAME', ''),
|
|
102
|
+
pid=pid,
|
|
103
|
+
))
|
|
104
|
+
|
|
105
|
+
return entries
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── /var/log file parsers ─────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
# Standard syslog line: "May 10 14:03:22 host service[pid]: message"
|
|
111
|
+
_SYSLOG_RE = re.compile(
|
|
112
|
+
r'^(\w{3}\s+\d+\s[\d:]+)\s+(\S+)\s+(\S+?)(?:\[(\d+)\])?:\s+(.*)'
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# ISO timestamp variant: "2026-05-10T14:03:22.123456+00:00 host service: msg"
|
|
116
|
+
_ISO_SYSLOG_RE = re.compile(
|
|
117
|
+
r'^(\d{4}-\d{2}-\d{2}T[\d:.+]+)\s+(\S+)\s+(\S+?)(?:\[(\d+)\])?:\s+(.*)'
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_syslog_ts(ts_str: str, year: int) -> Optional[datetime]:
|
|
122
|
+
ts_str = ts_str.strip()
|
|
123
|
+
for fmt in ('%b %d %H:%M:%S', '%b %d %H:%M:%S'):
|
|
124
|
+
try:
|
|
125
|
+
dt = datetime.strptime(ts_str, fmt).replace(year=year)
|
|
126
|
+
return dt
|
|
127
|
+
except ValueError:
|
|
128
|
+
continue
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_iso_ts(ts_str: str) -> Optional[datetime]:
|
|
133
|
+
ts_str = re.sub(r'([+-]\d{2}:\d{2})$', '', ts_str)
|
|
134
|
+
for fmt in ('%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%dT%H:%M:%S'):
|
|
135
|
+
try:
|
|
136
|
+
return datetime.strptime(ts_str, fmt)
|
|
137
|
+
except ValueError:
|
|
138
|
+
continue
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def collect_logfile(path: str, since: datetime, until: datetime,
|
|
143
|
+
source_label: str = 'syslog',
|
|
144
|
+
default_priority: int = 3) -> List[RawEntry]:
|
|
145
|
+
"""Parse a /var/log-style file and return entries in [since, until]."""
|
|
146
|
+
p = Path(path)
|
|
147
|
+
if not p.exists() or not p.is_file():
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
entries = []
|
|
151
|
+
year = since.year
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
with open(p, 'r', errors='replace') as fh:
|
|
155
|
+
for line in fh:
|
|
156
|
+
line = line.rstrip('\n')
|
|
157
|
+
ts = None
|
|
158
|
+
service = source_label
|
|
159
|
+
message = line
|
|
160
|
+
pid = None
|
|
161
|
+
hostname = ''
|
|
162
|
+
|
|
163
|
+
m = _SYSLOG_RE.match(line)
|
|
164
|
+
if m:
|
|
165
|
+
ts = _parse_syslog_ts(m.group(1), year)
|
|
166
|
+
hostname = m.group(2)
|
|
167
|
+
service = m.group(3)
|
|
168
|
+
pid = int(m.group(4)) if m.group(4) else None
|
|
169
|
+
message = m.group(5)
|
|
170
|
+
else:
|
|
171
|
+
m = _ISO_SYSLOG_RE.match(line)
|
|
172
|
+
if m:
|
|
173
|
+
ts = _parse_iso_ts(m.group(1))
|
|
174
|
+
hostname = m.group(2)
|
|
175
|
+
service = m.group(3)
|
|
176
|
+
pid = int(m.group(4)) if m.group(4) else None
|
|
177
|
+
message = m.group(5)
|
|
178
|
+
|
|
179
|
+
if ts is None or not (since <= ts <= until):
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
entries.append(RawEntry(
|
|
183
|
+
timestamp=ts,
|
|
184
|
+
service=service,
|
|
185
|
+
priority=default_priority,
|
|
186
|
+
message=message.strip(),
|
|
187
|
+
source=source_label,
|
|
188
|
+
hostname=hostname,
|
|
189
|
+
pid=pid,
|
|
190
|
+
))
|
|
191
|
+
except PermissionError:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
return entries
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── Main collection entry point ───────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def collect_all(since: str, until: str,
|
|
200
|
+
units: List[str] = None,
|
|
201
|
+
include_files: bool = True,
|
|
202
|
+
priorities: str = '0..4') -> List[RawEntry]:
|
|
203
|
+
"""
|
|
204
|
+
Collect from journalctl and optionally from /var/log files.
|
|
205
|
+
Returns a merged, time-sorted list of RawEntry.
|
|
206
|
+
"""
|
|
207
|
+
since_dt = datetime.strptime(since, '%Y-%m-%d %H:%M')
|
|
208
|
+
until_dt = datetime.strptime(until, '%Y-%m-%d %H:%M')
|
|
209
|
+
|
|
210
|
+
entries = collect_journalctl(since, until, units, priorities)
|
|
211
|
+
|
|
212
|
+
if include_files:
|
|
213
|
+
log_files = [
|
|
214
|
+
('/var/log/syslog', 'syslog', 4),
|
|
215
|
+
('/var/log/messages', 'syslog', 4),
|
|
216
|
+
('/var/log/auth.log', 'auth', 4),
|
|
217
|
+
('/var/log/secure', 'auth', 4),
|
|
218
|
+
('/var/log/kern.log', 'kernel', 3),
|
|
219
|
+
('/var/log/dmesg', 'kernel', 3),
|
|
220
|
+
]
|
|
221
|
+
seen_msgs: set = {e.message for e in entries}
|
|
222
|
+
for path, label, prio in log_files:
|
|
223
|
+
for entry in collect_logfile(path, since_dt, until_dt, label, prio):
|
|
224
|
+
if entry.message not in seen_msgs:
|
|
225
|
+
entries.append(entry)
|
|
226
|
+
seen_msgs.add(entry.message)
|
|
227
|
+
|
|
228
|
+
return sorted(entries, key=lambda e: e.timestamp)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Known error patterns with severity labels and action hints.
|
|
3
|
+
Each pattern is matched against log message text.
|
|
4
|
+
"""
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Pattern:
|
|
11
|
+
name: str
|
|
12
|
+
regex: re.Pattern
|
|
13
|
+
severity: str # CRITICAL | ERROR | WARNING
|
|
14
|
+
label: str # human-readable category
|
|
15
|
+
action_hint: str # suggested action item
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PATTERNS = [
|
|
19
|
+
Pattern(
|
|
20
|
+
name='oom',
|
|
21
|
+
regex=re.compile(r'out of memory|oom.kill|killed process|oom_reaper', re.I),
|
|
22
|
+
severity='CRITICAL',
|
|
23
|
+
label='OOM Killer',
|
|
24
|
+
action_hint='Investigate memory usage; consider adding swap or increasing RAM',
|
|
25
|
+
),
|
|
26
|
+
Pattern(
|
|
27
|
+
name='disk_full',
|
|
28
|
+
regex=re.compile(r'no space left on device|disk full|filesystem.*full|write.*failed.*enospc', re.I),
|
|
29
|
+
severity='CRITICAL',
|
|
30
|
+
label='Disk Full',
|
|
31
|
+
action_hint='Free disk space immediately; review log rotation and data retention',
|
|
32
|
+
),
|
|
33
|
+
Pattern(
|
|
34
|
+
name='kernel_oops',
|
|
35
|
+
regex=re.compile(r'kernel: bug|kernel: oops|kernel: warning.*call trace|segfault|general protection', re.I),
|
|
36
|
+
severity='CRITICAL',
|
|
37
|
+
label='Kernel Error',
|
|
38
|
+
action_hint='Review kernel logs; consider rebooting if system is unstable',
|
|
39
|
+
),
|
|
40
|
+
Pattern(
|
|
41
|
+
name='service_failed',
|
|
42
|
+
regex=re.compile(r'\bfailed\b.*start|start.*\bfailed\b|unit.*entered failed|activating.*failed', re.I),
|
|
43
|
+
severity='ERROR',
|
|
44
|
+
label='Service Failed',
|
|
45
|
+
action_hint='Check service logs with journalctl -u <service>; review service configuration',
|
|
46
|
+
),
|
|
47
|
+
Pattern(
|
|
48
|
+
name='service_crash',
|
|
49
|
+
regex=re.compile(r'segmentation fault|core dump|crashed|dumped core|aborted', re.I),
|
|
50
|
+
severity='ERROR',
|
|
51
|
+
label='Process Crash',
|
|
52
|
+
action_hint='Collect core dump; review application error logs',
|
|
53
|
+
),
|
|
54
|
+
Pattern(
|
|
55
|
+
name='connection_refused',
|
|
56
|
+
regex=re.compile(r'connection refused|econnrefused|connect.*failed|upstream.*connect.*error', re.I),
|
|
57
|
+
severity='ERROR',
|
|
58
|
+
label='Connection Refused',
|
|
59
|
+
action_hint='Verify the downstream service is running and listening on the expected port',
|
|
60
|
+
),
|
|
61
|
+
Pattern(
|
|
62
|
+
name='timeout',
|
|
63
|
+
regex=re.compile(r'\btimed? out\b|etimedout|operation timed out|request timeout|read timeout', re.I),
|
|
64
|
+
severity='WARNING',
|
|
65
|
+
label='Timeout',
|
|
66
|
+
action_hint='Check network latency and service response times; review timeout thresholds',
|
|
67
|
+
),
|
|
68
|
+
Pattern(
|
|
69
|
+
name='auth_failure',
|
|
70
|
+
regex=re.compile(r'failed password|authentication failure|invalid user|permission denied.*ssh|pam.*auth.*fail', re.I),
|
|
71
|
+
severity='WARNING',
|
|
72
|
+
label='Auth Failure',
|
|
73
|
+
action_hint='Review SSH access logs; consider IP blocking if burst detected',
|
|
74
|
+
),
|
|
75
|
+
Pattern(
|
|
76
|
+
name='service_restart',
|
|
77
|
+
regex=re.compile(r'start request repeated too quickly|restarting.*unit|automatic.*restart', re.I),
|
|
78
|
+
severity='WARNING',
|
|
79
|
+
label='Service Restart Loop',
|
|
80
|
+
action_hint='Service is crash-looping; check dependencies and configuration',
|
|
81
|
+
),
|
|
82
|
+
Pattern(
|
|
83
|
+
name='ssl_cert',
|
|
84
|
+
regex=re.compile(r'certificate.*expir|ssl.*error|tls.*handshake.*fail|certificate verify failed', re.I),
|
|
85
|
+
severity='ERROR',
|
|
86
|
+
label='SSL/TLS Error',
|
|
87
|
+
action_hint='Renew the certificate; check certificate chain and validity',
|
|
88
|
+
),
|
|
89
|
+
Pattern(
|
|
90
|
+
name='db_error',
|
|
91
|
+
regex=re.compile(r'could not connect.*database|database.*unavailable|max.*connection.*reached|deadlock', re.I),
|
|
92
|
+
severity='ERROR',
|
|
93
|
+
label='Database Error',
|
|
94
|
+
action_hint='Check database service status, connection pool settings, and max connections',
|
|
95
|
+
),
|
|
96
|
+
Pattern(
|
|
97
|
+
name='high_load',
|
|
98
|
+
regex=re.compile(r'load average.*\b([5-9]\d|\d{2,})\b|cpu.*throttl|system.*overload', re.I),
|
|
99
|
+
severity='WARNING',
|
|
100
|
+
label='High System Load',
|
|
101
|
+
action_hint='Identify CPU-intensive processes; consider horizontal scaling',
|
|
102
|
+
),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def match_patterns(message: str) -> list:
|
|
107
|
+
"""Return list of matching Patterns for a log message."""
|
|
108
|
+
return [p for p in PATTERNS if p.regex.search(message)]
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Render an AnalysisResult to Markdown or HTML.
|
|
3
|
+
"""
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .analyzer import AnalysisResult, Event
|
|
8
|
+
|
|
9
|
+
VERSION = "1.0.0"
|
|
10
|
+
|
|
11
|
+
_SEV_ICON = {
|
|
12
|
+
'CRITICAL': '⛔',
|
|
13
|
+
'ERROR': '🔴',
|
|
14
|
+
'WARNING': '⚠️',
|
|
15
|
+
'INFO': 'ℹ️',
|
|
16
|
+
'DEBUG': '🔵',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_SEV_ORDER = ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
def _ts(dt: datetime) -> str:
|
|
25
|
+
return dt.strftime('%H:%M:%S')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _md_escape(s: str) -> str:
|
|
29
|
+
return s.replace('|', '\\|').replace('`', "'")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _duration(since: str, until: str) -> str:
|
|
33
|
+
fmt = '%Y-%m-%d %H:%M'
|
|
34
|
+
try:
|
|
35
|
+
delta = datetime.strptime(until, fmt) - datetime.strptime(since, fmt)
|
|
36
|
+
h, rem = divmod(int(delta.total_seconds()), 3600)
|
|
37
|
+
m = rem // 60
|
|
38
|
+
parts = []
|
|
39
|
+
if h:
|
|
40
|
+
parts.append(f"{h}h")
|
|
41
|
+
if m:
|
|
42
|
+
parts.append(f"{m}m")
|
|
43
|
+
return ' '.join(parts) or '< 1m'
|
|
44
|
+
except ValueError:
|
|
45
|
+
return 'unknown'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _overall_severity(result: AnalysisResult) -> str:
|
|
49
|
+
if result.by_severity.get('CRITICAL'):
|
|
50
|
+
return 'Critical'
|
|
51
|
+
if result.by_severity.get('ERROR'):
|
|
52
|
+
return 'High'
|
|
53
|
+
if result.by_severity.get('WARNING'):
|
|
54
|
+
return 'Medium'
|
|
55
|
+
return 'Low'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Markdown renderer ─────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def render_markdown(result: AnalysisResult, title: str,
|
|
61
|
+
since: str, until: str,
|
|
62
|
+
services: Optional[list] = None) -> str:
|
|
63
|
+
lines = []
|
|
64
|
+
date_str = since.split()[0]
|
|
65
|
+
duration = _duration(since, until)
|
|
66
|
+
severity_label = _overall_severity(result)
|
|
67
|
+
affected_str = ', '.join(f'`{s}`' for s in result.services_affected) or '_none detected_'
|
|
68
|
+
|
|
69
|
+
# ── Header ─────────────────────────────────────────────────────────────────
|
|
70
|
+
lines += [
|
|
71
|
+
f"# Postmortem: {title}",
|
|
72
|
+
"",
|
|
73
|
+
f"| | |",
|
|
74
|
+
f"|---|---|",
|
|
75
|
+
f"| **Date** | {date_str} |",
|
|
76
|
+
f"| **Window** | {since} → {until} |",
|
|
77
|
+
f"| **Duration** | {duration} |",
|
|
78
|
+
f"| **Severity** | {severity_label} |",
|
|
79
|
+
f"| **Status** | Draft |",
|
|
80
|
+
f"| **Services affected** | {affected_str} |",
|
|
81
|
+
"",
|
|
82
|
+
"---",
|
|
83
|
+
"",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
# ── Summary ────────────────────────────────────────────────────────────────
|
|
87
|
+
lines += ["## Summary", ""]
|
|
88
|
+
total_errors = len(result.by_severity.get('CRITICAL', [])) + \
|
|
89
|
+
len(result.by_severity.get('ERROR', []))
|
|
90
|
+
total_events = len(result.timeline)
|
|
91
|
+
|
|
92
|
+
summary_parts = [
|
|
93
|
+
f"Analysis of **{result.total_raw} raw log entries** "
|
|
94
|
+
f"({total_events} unique events after deduplication) "
|
|
95
|
+
f"across **{len(result.by_service)} services**."
|
|
96
|
+
]
|
|
97
|
+
if result.first_anomaly:
|
|
98
|
+
summary_parts.append(
|
|
99
|
+
f"First anomaly detected at **{_ts(result.first_anomaly.timestamp)}** "
|
|
100
|
+
f"in **`{result.first_anomaly.service}`** "
|
|
101
|
+
f"({result.first_anomaly.severity})."
|
|
102
|
+
)
|
|
103
|
+
if result.peak_window:
|
|
104
|
+
summary_parts.append(
|
|
105
|
+
f"Highest error density at **{result.peak_window.strftime('%H:%M')}**."
|
|
106
|
+
)
|
|
107
|
+
lines += [' '.join(summary_parts), "", "> *Auto-generated draft — review all sections before sharing.*", "", "---", ""]
|
|
108
|
+
|
|
109
|
+
# ── Timeline ───────────────────────────────────────────────────────────────
|
|
110
|
+
lines += ["## Timeline", ""]
|
|
111
|
+
show = [e for e in result.timeline if e.severity in ('CRITICAL', 'ERROR', 'WARNING')][:50]
|
|
112
|
+
if show:
|
|
113
|
+
lines += [
|
|
114
|
+
"| Time | Service | Severity | Event |",
|
|
115
|
+
"|------|---------|----------|-------|",
|
|
116
|
+
]
|
|
117
|
+
for ev in show:
|
|
118
|
+
icon = _SEV_ICON.get(ev.severity, '')
|
|
119
|
+
svc = f"`{ev.service}`"
|
|
120
|
+
sev = f"{icon} {ev.severity}"
|
|
121
|
+
msg = _md_escape(ev.message[:100])
|
|
122
|
+
cnt = f" _(×{ev.count})_" if ev.count > 1 else ""
|
|
123
|
+
lines.append(f"| {_ts(ev.timestamp)} | {svc} | {sev} | {msg}{cnt} |")
|
|
124
|
+
if len([e for e in result.timeline if e.severity in ('CRITICAL', 'ERROR', 'WARNING')]) > 50:
|
|
125
|
+
lines.append(f"\n_Table truncated to 50 entries. Full list in error sections below._")
|
|
126
|
+
else:
|
|
127
|
+
lines.append("_No warnings or errors found in the specified window._")
|
|
128
|
+
lines += ["", "---", ""]
|
|
129
|
+
|
|
130
|
+
# ── Errors by severity ─────────────────────────────────────────────────────
|
|
131
|
+
lines += ["## Events by Severity", ""]
|
|
132
|
+
for sev in _SEV_ORDER:
|
|
133
|
+
evs = result.by_severity.get(sev, [])
|
|
134
|
+
if not evs:
|
|
135
|
+
continue
|
|
136
|
+
icon = _SEV_ICON.get(sev, '')
|
|
137
|
+
lines += [f"### {icon} {sev} ({len(evs)} event{'s' if len(evs) != 1 else ''})", ""]
|
|
138
|
+
for ev in evs[:30]:
|
|
139
|
+
cnt = f" _(×{ev.count})_" if ev.count > 1 else ""
|
|
140
|
+
patterns = ""
|
|
141
|
+
if ev.patterns:
|
|
142
|
+
patterns = " `[" + ", ".join(p.label for p in ev.patterns) + "]`"
|
|
143
|
+
lines.append(
|
|
144
|
+
f"- `[{_ts(ev.timestamp)}]` **{ev.service}** — "
|
|
145
|
+
f"{_md_escape(ev.message[:120])}{cnt}{patterns}"
|
|
146
|
+
)
|
|
147
|
+
if len(evs) > 30:
|
|
148
|
+
lines.append(f"\n_...and {len(evs) - 30} more {sev} events._")
|
|
149
|
+
lines.append("")
|
|
150
|
+
lines += ["---", ""]
|
|
151
|
+
|
|
152
|
+
# ── Pattern summary ────────────────────────────────────────────────────────
|
|
153
|
+
if result.pattern_counts:
|
|
154
|
+
lines += ["## Pattern Analysis", "",
|
|
155
|
+
"| Pattern | Occurrences | Affected services |",
|
|
156
|
+
"|---------|-------------|-------------------|"]
|
|
157
|
+
from .patterns import PATTERNS
|
|
158
|
+
pmap = {p.name: p for p in PATTERNS}
|
|
159
|
+
for name, count in sorted(result.pattern_counts.items(), key=lambda x: -x[1]):
|
|
160
|
+
pat = pmap.get(name)
|
|
161
|
+
label = pat.label if pat else name
|
|
162
|
+
svcs = sorted({
|
|
163
|
+
e.service for e in result.timeline
|
|
164
|
+
if any(p.name == name for p in e.patterns)
|
|
165
|
+
})
|
|
166
|
+
svc_str = ', '.join(f'`{s}`' for s in svcs[:5])
|
|
167
|
+
if len(svcs) > 5:
|
|
168
|
+
svc_str += f' +{len(svcs)-5} more'
|
|
169
|
+
lines.append(f"| {label} | {count} | {svc_str} |")
|
|
170
|
+
lines += ["", "---", ""]
|
|
171
|
+
|
|
172
|
+
# ── Contributing factors ───────────────────────────────────────────────────
|
|
173
|
+
lines += [
|
|
174
|
+
"## Contributing Factors",
|
|
175
|
+
"",
|
|
176
|
+
"> *Auto-detected from log patterns — verify each before including in final report.*",
|
|
177
|
+
"",
|
|
178
|
+
]
|
|
179
|
+
for f in result.contributing_factors:
|
|
180
|
+
lines.append(f"- {f}")
|
|
181
|
+
lines += ["", "---", ""]
|
|
182
|
+
|
|
183
|
+
# ── Impact ─────────────────────────────────────────────────────────────────
|
|
184
|
+
lines += [
|
|
185
|
+
"## Impact Assessment",
|
|
186
|
+
"",
|
|
187
|
+
"<!-- Fill in manually -->",
|
|
188
|
+
"",
|
|
189
|
+
f"- **Affected services:** {affected_str}",
|
|
190
|
+
"- **Affected users:** ",
|
|
191
|
+
"- **Data loss:** ",
|
|
192
|
+
"- **Downtime:** ",
|
|
193
|
+
"- **Revenue impact:** ",
|
|
194
|
+
"",
|
|
195
|
+
"---",
|
|
196
|
+
"",
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
# ── Root cause ─────────────────────────────────────────────────────────────
|
|
200
|
+
lines += [
|
|
201
|
+
"## Root Cause Analysis",
|
|
202
|
+
"",
|
|
203
|
+
"<!-- Fill in manually after investigation -->",
|
|
204
|
+
"",
|
|
205
|
+
"### What happened?",
|
|
206
|
+
"",
|
|
207
|
+
"### Why did it happen?",
|
|
208
|
+
"",
|
|
209
|
+
"### Why wasn't it caught earlier?",
|
|
210
|
+
"",
|
|
211
|
+
"---",
|
|
212
|
+
"",
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
# ── Action items ───────────────────────────────────────────────────────────
|
|
216
|
+
lines += ["## Action Items", ""]
|
|
217
|
+
if result.action_items:
|
|
218
|
+
lines.append("_Generated from detected patterns — assign owner and priority._")
|
|
219
|
+
lines.append("")
|
|
220
|
+
for item in result.action_items:
|
|
221
|
+
lines.append(f"- [ ] {item}")
|
|
222
|
+
else:
|
|
223
|
+
lines.append("- [ ] ")
|
|
224
|
+
lines += ["", "---", ""]
|
|
225
|
+
|
|
226
|
+
# ── Lessons learned ────────────────────────────────────────────────────────
|
|
227
|
+
lines += [
|
|
228
|
+
"## Lessons Learned",
|
|
229
|
+
"",
|
|
230
|
+
"<!-- Fill in manually -->",
|
|
231
|
+
"",
|
|
232
|
+
"---",
|
|
233
|
+
"",
|
|
234
|
+
f"*Generated by [syslog-postmortem](https://github.com/serber1990/syslog-postmortem) "
|
|
235
|
+
f"v{VERSION} at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*",
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
return '\n'.join(lines)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ── HTML renderer ─────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def render_html(md: str, title: str) -> str:
|
|
244
|
+
"""Wrap Markdown in a minimal HTML shell with inline CSS."""
|
|
245
|
+
css = """
|
|
246
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
247
|
+
max-width: 960px; margin: 40px auto; padding: 0 20px; color: #24292e; }
|
|
248
|
+
h1 { border-bottom: 2px solid #e1e4e8; padding-bottom: 12px; }
|
|
249
|
+
h2 { border-bottom: 1px solid #e1e4e8; padding-bottom: 8px; margin-top: 32px; }
|
|
250
|
+
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
|
|
251
|
+
th { background: #f6f8fa; text-align: left; }
|
|
252
|
+
th, td { border: 1px solid #e1e4e8; padding: 8px 12px; font-size: 14px; }
|
|
253
|
+
tr:nth-child(even) { background: #f6f8fa; }
|
|
254
|
+
code { background: #f6f8fa; border-radius: 3px; padding: 2px 5px; font-size: 90%; }
|
|
255
|
+
blockquote { border-left: 4px solid #e1e4e8; margin: 0; padding: 8px 16px; color: #6a737d; }
|
|
256
|
+
pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; }
|
|
257
|
+
li { margin: 4px 0; }
|
|
258
|
+
"""
|
|
259
|
+
# Minimal Markdown → HTML (just enough for our output)
|
|
260
|
+
import re as _re
|
|
261
|
+
html = md
|
|
262
|
+
html = _re.sub(r'^# (.+)$', r'<h1>\1</h1>', html, flags=_re.M)
|
|
263
|
+
html = _re.sub(r'^## (.+)$', r'<h2>\1</h2>', html, flags=_re.M)
|
|
264
|
+
html = _re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=_re.M)
|
|
265
|
+
html = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
|
|
266
|
+
html = _re.sub(r'`(.+?)`', r'<code>\1</code>', html)
|
|
267
|
+
html = _re.sub(r'^- \[ \] (.+)$', r'<li>☐ \1</li>', html, flags=_re.M)
|
|
268
|
+
html = _re.sub(r'^- (.+)$', r'<li>\1</li>', html, flags=_re.M)
|
|
269
|
+
html = _re.sub(r'^---$', r'<hr>', html, flags=_re.M)
|
|
270
|
+
html = html.replace('\n', '<br>\n')
|
|
271
|
+
|
|
272
|
+
return f"""<!DOCTYPE html>
|
|
273
|
+
<html lang="en">
|
|
274
|
+
<head>
|
|
275
|
+
<meta charset="UTF-8">
|
|
276
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
277
|
+
<title>{title}</title>
|
|
278
|
+
<style>{css}</style>
|
|
279
|
+
</head>
|
|
280
|
+
<body>
|
|
281
|
+
{html}
|
|
282
|
+
</body>
|
|
283
|
+
</html>
|
|
284
|
+
"""
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: syslog-postmortem
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Generate structured postmortem drafts from journalctl, syslog, auth.log and dmesg
|
|
5
|
+
Author-email: Serber1990 <serber1990@pm.me>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/serber1990/syslog-postmortem
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/serber1990/syslog-postmortem/issues
|
|
9
|
+
Project-URL: Source Code, https://github.com/serber1990/syslog-postmortem
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Topic :: System :: Logging
|
|
14
|
+
Classifier: Topic :: System :: Systems Administration
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: shellcolorize
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# syslog-postmortem
|
|
22
|
+
|
|
23
|
+
[](https://badge.fury.io/py/syslog-postmortem)
|
|
24
|
+
[](https://opensource.org/licenses/MIT)
|
|
25
|
+
|
|
26
|
+
Generate a **structured postmortem draft** from system logs in seconds — no more reconstructing incident timelines by hand.
|
|
27
|
+
|
|
28
|
+
Pulls from `journalctl`, `/var/log/syslog`, `/var/log/auth.log` and `/var/log/dmesg`, correlates events, detects patterns, and produces a ready-to-edit Markdown or HTML postmortem.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## ✨ What it does
|
|
33
|
+
|
|
34
|
+
Given a time window and optional service list, `syslog-postmortem`:
|
|
35
|
+
|
|
36
|
+
1. **Collects** entries from `journalctl` (primary) and `/var/log` files (supplement)
|
|
37
|
+
2. **Deduplicates** repeated messages and counts occurrences
|
|
38
|
+
3. **Detects patterns** — OOM kills, service crashes, auth bursts, disk exhaustion, cascading failures, connection errors
|
|
39
|
+
4. **Builds a timeline** sorted chronologically with severity icons
|
|
40
|
+
5. **Identifies contributing factors** automatically (restart loops, cascade chains, error bursts)
|
|
41
|
+
6. **Generates action items** from pattern hints
|
|
42
|
+
7. **Outputs** a complete Markdown postmortem ready to edit and share
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 📥 Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install syslog-postmortem
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 🛠 Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
postmortem --from "2026-05-10 14:00" --to "2026-05-10 16:00"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Focus on specific services
|
|
62
|
+
postmortem --from "2026-05-10 14:00" --to "2026-05-10 16:00" \
|
|
63
|
+
--services nginx,postgresql,redis \
|
|
64
|
+
--title "Database outage" \
|
|
65
|
+
--output incident-2026-05-10.md
|
|
66
|
+
|
|
67
|
+
# HTML output
|
|
68
|
+
postmortem --from "2026-05-10 14:00" --to "2026-05-10 16:00" \
|
|
69
|
+
--format html --output report.html
|
|
70
|
+
|
|
71
|
+
# journalctl only (skip /var/log files)
|
|
72
|
+
postmortem --from "2026-05-10 14:00" --to "2026-05-10 16:00" --no-files
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 📋 Options
|
|
78
|
+
|
|
79
|
+
| Option | Description |
|
|
80
|
+
|--------|-------------|
|
|
81
|
+
| `--from DATETIME` | Start of incident window **required** |
|
|
82
|
+
| `--to DATETIME` | End of incident window **required** |
|
|
83
|
+
| `--title TEXT` | Postmortem title (default: `Incident YYYY-MM-DD`) |
|
|
84
|
+
| `--services LIST` | Comma-separated services to focus on |
|
|
85
|
+
| `--output FILE` | Output path (default: `postmortem_YYYYMMDD_HHMM.md`) |
|
|
86
|
+
| `--format` | `markdown` (default) or `html` |
|
|
87
|
+
| `--no-files` | Skip `/var/log` parsing, use journalctl only |
|
|
88
|
+
| `--priorities` | journalctl priority filter (default: `0..4`) |
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 📄 Output structure
|
|
93
|
+
|
|
94
|
+
```markdown
|
|
95
|
+
# Postmortem: Database outage
|
|
96
|
+
|
|
97
|
+
| | |
|
|
98
|
+
|---|---|
|
|
99
|
+
| **Date** | 2026-05-10 |
|
|
100
|
+
| **Window** | 2026-05-10 14:00 → 2026-05-10 16:00 |
|
|
101
|
+
| **Duration** | 2h 0m |
|
|
102
|
+
| **Severity** | Critical |
|
|
103
|
+
|
|
104
|
+
## Summary
|
|
105
|
+
Analysis of 1,243 raw log entries (89 unique events after deduplication)...
|
|
106
|
+
First anomaly detected at **14:03:22** in **postgresql** (CRITICAL).
|
|
107
|
+
|
|
108
|
+
## Timeline
|
|
109
|
+
| Time | Service | Severity | Event |
|
|
110
|
+
|------|---------|----------|-------|
|
|
111
|
+
| 14:03:22 | `postgresql` | ⛔ CRITICAL | could not connect to server |
|
|
112
|
+
| 14:03:45 | `nginx` | 🔴 ERROR | upstream connect error |
|
|
113
|
+
|
|
114
|
+
## Contributing Factors
|
|
115
|
+
- **Service instability**: `postgresql` triggered restart-loop detection 4 time(s)
|
|
116
|
+
- **Cascading failure**: `nginx` errors began 23s after first `postgresql` critical event
|
|
117
|
+
|
|
118
|
+
## Action Items
|
|
119
|
+
- [ ] Investigate postgresql restart cause; check dependencies and configuration
|
|
120
|
+
- [ ] Verify the downstream service is running and listening on the expected port
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## 🔍 Detected patterns
|
|
126
|
+
|
|
127
|
+
| Pattern | Triggers |
|
|
128
|
+
|---------|----------|
|
|
129
|
+
| OOM Killer | `out of memory`, `oom_kill`, `killed process` |
|
|
130
|
+
| Disk Full | `no space left on device`, `disk full` |
|
|
131
|
+
| Kernel Error | `kernel: BUG`, `segfault`, `general protection` |
|
|
132
|
+
| Service Failed | `failed to start`, `unit entered failed state` |
|
|
133
|
+
| Process Crash | `segmentation fault`, `core dump`, `aborted` |
|
|
134
|
+
| Connection Refused | `connection refused`, `upstream connect error` |
|
|
135
|
+
| Timeout | `timed out`, `ETIMEDOUT`, `request timeout` |
|
|
136
|
+
| Auth Failure | `Failed password`, `authentication failure`, `invalid user` |
|
|
137
|
+
| SSL/TLS Error | `certificate expired`, `TLS handshake failed` |
|
|
138
|
+
| Database Error | `could not connect to database`, `max connections reached` |
|
|
139
|
+
| High Load | `load average` spike, `cpu throttled` |
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## 📝 License
|
|
144
|
+
|
|
145
|
+
MIT — see [LICENSE](LICENSE).
|
|
146
|
+
|
|
147
|
+
## 🌐 Connect
|
|
148
|
+
|
|
149
|
+
[](https://github.com/serber1990)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
syslog_postmortem/__init__.py,sha256=J4ZAkuqeqNK6dDc8PUWN8zxZ4lZX1Kuaqo3ErNNwtPg,64
|
|
2
|
+
syslog_postmortem/analyzer.py,sha256=D1A2iYVyt5OSu2VUM_Wyy4p-CkOnG2eRCmhlfyUCWu4,8368
|
|
3
|
+
syslog_postmortem/cli.py,sha256=m_J17cLIOZZJWB7ocC_Q9sbs38aDAdgVkCOXeegPuOw,6697
|
|
4
|
+
syslog_postmortem/collector.py,sha256=D0zx5jtHjz4iuY1WBa49pv-gePwnlzEpFD6MFMYVWMc,7736
|
|
5
|
+
syslog_postmortem/patterns.py,sha256=oYoZ0qoh9f1AG3Tl7g15tGDXRhYjDsHOKmrKoP8_vIg,4224
|
|
6
|
+
syslog_postmortem/renderer.py,sha256=-JkXSeAV4VMyOQr1annSLWLL3_AV6yyJf-XVS8levSU,11796
|
|
7
|
+
syslog_postmortem-1.0.0.dist-info/licenses/LICENSE,sha256=rOAaRlMktc-uI4u5mxNvBLTLqUvsyujBQMivrLv2olo,1067
|
|
8
|
+
syslog_postmortem-1.0.0.dist-info/METADATA,sha256=hwNe1dfJBxNWKKVSnY9DJj3bfx4vIZ3BXQIIYfXbbzg,5214
|
|
9
|
+
syslog_postmortem-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
syslog_postmortem-1.0.0.dist-info/entry_points.txt,sha256=pbDocgiS1s8q2SNCytQ7_r17-IwxEn_ckFYySDv7FFs,58
|
|
11
|
+
syslog_postmortem-1.0.0.dist-info/top_level.txt,sha256=L1LckoKPgMN0O1vrWN546Pj8KEs3lVuiItsP1GyGL2c,18
|
|
12
|
+
syslog_postmortem-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Serber1990
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
syslog_postmortem
|