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.
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ __version__ = "1.0.0"
4
+ __all__ = ["main"]
@@ -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
+ )
@@ -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
+ [![PyPI version](https://badge.fury.io/py/syslog-postmortem.svg)](https://badge.fury.io/py/syslog-postmortem)
24
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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
+ [![GitHub](https://img.shields.io/badge/GitHub-@serber1990-181717?style=flat-square&logo=github)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ postmortem = syslog_postmortem.cli:main
@@ -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