syslog-postmortem 1.0.0__tar.gz

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,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,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,129 @@
1
+ # syslog-postmortem
2
+
3
+ [![PyPI version](https://badge.fury.io/py/syslog-postmortem.svg)](https://badge.fury.io/py/syslog-postmortem)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Generate a **structured postmortem draft** from system logs in seconds — no more reconstructing incident timelines by hand.
7
+
8
+ 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.
9
+
10
+ ---
11
+
12
+ ## ✨ What it does
13
+
14
+ Given a time window and optional service list, `syslog-postmortem`:
15
+
16
+ 1. **Collects** entries from `journalctl` (primary) and `/var/log` files (supplement)
17
+ 2. **Deduplicates** repeated messages and counts occurrences
18
+ 3. **Detects patterns** — OOM kills, service crashes, auth bursts, disk exhaustion, cascading failures, connection errors
19
+ 4. **Builds a timeline** sorted chronologically with severity icons
20
+ 5. **Identifies contributing factors** automatically (restart loops, cascade chains, error bursts)
21
+ 6. **Generates action items** from pattern hints
22
+ 7. **Outputs** a complete Markdown postmortem ready to edit and share
23
+
24
+ ---
25
+
26
+ ## 📥 Installation
27
+
28
+ ```bash
29
+ pip install syslog-postmortem
30
+ ```
31
+
32
+ ---
33
+
34
+ ## 🛠 Usage
35
+
36
+ ```bash
37
+ postmortem --from "2026-05-10 14:00" --to "2026-05-10 16:00"
38
+ ```
39
+
40
+ ```bash
41
+ # Focus on specific services
42
+ postmortem --from "2026-05-10 14:00" --to "2026-05-10 16:00" \
43
+ --services nginx,postgresql,redis \
44
+ --title "Database outage" \
45
+ --output incident-2026-05-10.md
46
+
47
+ # HTML output
48
+ postmortem --from "2026-05-10 14:00" --to "2026-05-10 16:00" \
49
+ --format html --output report.html
50
+
51
+ # journalctl only (skip /var/log files)
52
+ postmortem --from "2026-05-10 14:00" --to "2026-05-10 16:00" --no-files
53
+ ```
54
+
55
+ ---
56
+
57
+ ## 📋 Options
58
+
59
+ | Option | Description |
60
+ |--------|-------------|
61
+ | `--from DATETIME` | Start of incident window **required** |
62
+ | `--to DATETIME` | End of incident window **required** |
63
+ | `--title TEXT` | Postmortem title (default: `Incident YYYY-MM-DD`) |
64
+ | `--services LIST` | Comma-separated services to focus on |
65
+ | `--output FILE` | Output path (default: `postmortem_YYYYMMDD_HHMM.md`) |
66
+ | `--format` | `markdown` (default) or `html` |
67
+ | `--no-files` | Skip `/var/log` parsing, use journalctl only |
68
+ | `--priorities` | journalctl priority filter (default: `0..4`) |
69
+
70
+ ---
71
+
72
+ ## 📄 Output structure
73
+
74
+ ```markdown
75
+ # Postmortem: Database outage
76
+
77
+ | | |
78
+ |---|---|
79
+ | **Date** | 2026-05-10 |
80
+ | **Window** | 2026-05-10 14:00 → 2026-05-10 16:00 |
81
+ | **Duration** | 2h 0m |
82
+ | **Severity** | Critical |
83
+
84
+ ## Summary
85
+ Analysis of 1,243 raw log entries (89 unique events after deduplication)...
86
+ First anomaly detected at **14:03:22** in **postgresql** (CRITICAL).
87
+
88
+ ## Timeline
89
+ | Time | Service | Severity | Event |
90
+ |------|---------|----------|-------|
91
+ | 14:03:22 | `postgresql` | ⛔ CRITICAL | could not connect to server |
92
+ | 14:03:45 | `nginx` | 🔴 ERROR | upstream connect error |
93
+
94
+ ## Contributing Factors
95
+ - **Service instability**: `postgresql` triggered restart-loop detection 4 time(s)
96
+ - **Cascading failure**: `nginx` errors began 23s after first `postgresql` critical event
97
+
98
+ ## Action Items
99
+ - [ ] Investigate postgresql restart cause; check dependencies and configuration
100
+ - [ ] Verify the downstream service is running and listening on the expected port
101
+ ```
102
+
103
+ ---
104
+
105
+ ## 🔍 Detected patterns
106
+
107
+ | Pattern | Triggers |
108
+ |---------|----------|
109
+ | OOM Killer | `out of memory`, `oom_kill`, `killed process` |
110
+ | Disk Full | `no space left on device`, `disk full` |
111
+ | Kernel Error | `kernel: BUG`, `segfault`, `general protection` |
112
+ | Service Failed | `failed to start`, `unit entered failed state` |
113
+ | Process Crash | `segmentation fault`, `core dump`, `aborted` |
114
+ | Connection Refused | `connection refused`, `upstream connect error` |
115
+ | Timeout | `timed out`, `ETIMEDOUT`, `request timeout` |
116
+ | Auth Failure | `Failed password`, `authentication failure`, `invalid user` |
117
+ | SSL/TLS Error | `certificate expired`, `TLS handshake failed` |
118
+ | Database Error | `could not connect to database`, `max connections reached` |
119
+ | High Load | `load average` spike, `cpu throttled` |
120
+
121
+ ---
122
+
123
+ ## 📝 License
124
+
125
+ MIT — see [LICENSE](LICENSE).
126
+
127
+ ## 🌐 Connect
128
+
129
+ [![GitHub](https://img.shields.io/badge/GitHub-@serber1990-181717?style=flat-square&logo=github)](https://github.com/serber1990)
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "syslog-postmortem"
7
+ version = "1.0.0"
8
+ description = "Generate structured postmortem drafts from journalctl, syslog, auth.log and dmesg"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{ name = "Serber1990", email = "serber1990@pm.me" }]
12
+ requires-python = ">=3.8"
13
+ dependencies = ["shellcolorize"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Environment :: Console",
18
+ "Topic :: System :: Logging",
19
+ "Topic :: System :: Systems Administration",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/serber1990/syslog-postmortem"
24
+ "Bug Tracker" = "https://github.com/serber1990/syslog-postmortem/issues"
25
+ "Source Code" = "https://github.com/serber1990/syslog-postmortem"
26
+
27
+ [project.scripts]
28
+ postmortem = "syslog_postmortem.cli:main"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["."]
32
+ include = ["syslog_postmortem*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )