From 8b2e937c16dabe45d0db486883706fab19b68b90 Mon Sep 17 00:00:00 2001 From: stacknil Date: Wed, 25 Mar 2026 12:53:04 +0800 Subject: [PATCH 1/2] Add machine-readable run summaries --- README.md | 18 +- data/processed/richer_sample/summary.json | 33 +++ data/processed/summary.json | 33 +++ src/telemetry_window_demo/cli.py | 285 +++++++++++++--------- src/telemetry_window_demo/io.py | 20 +- tests/test_pipeline_e2e.py | 81 ++++-- 6 files changed, 329 insertions(+), 141 deletions(-) create mode 100644 data/processed/richer_sample/summary.json create mode 100644 data/processed/summary.json diff --git a/README.md b/README.md index ef47257..77f583e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ python -m telemetry_window_demo.cli run --config configs/richer_sample.yaml That scenario pack reads `data/raw/richer_sample_events.jsonl` and writes outputs to `data/processed/richer_sample/`. It currently produces `28` normalized events, `24` windows, and `8` alerts. +Both sample paths also emit a compact `summary.json` alongside the CSV and PNG outputs. ## Current behavior @@ -64,13 +65,16 @@ The richer scenario pack uses a longer `120` second cooldown so the output stays ## Outputs -Running the default command regenerates: - -- `data/processed/features.csv` -- `data/processed/alerts.csv` -- `data/processed/event_count_timeline.png` -- `data/processed/error_rate_timeline.png` -- `data/processed/alerts_timeline.png` +Running the default command regenerates: + +- `data/processed/features.csv` +- `data/processed/alerts.csv` +- `data/processed/summary.json` +- `data/processed/event_count_timeline.png` +- `data/processed/error_rate_timeline.png` +- `data/processed/alerts_timeline.png` + +The summary artifact includes the input path, output directory, normalized event count, window count, feature row count, alert count, triggered rule names and counts, cooldown setting, and generated artifact paths. ## Scope diff --git a/data/processed/richer_sample/summary.json b/data/processed/richer_sample/summary.json new file mode 100644 index 0000000..be9832b --- /dev/null +++ b/data/processed/richer_sample/summary.json @@ -0,0 +1,33 @@ +{ + "input_path": "data/raw/richer_sample_events.jsonl", + "output_dir": "data/processed/richer_sample", + "normalized_event_count": 28, + "window_count": 24, + "feature_row_count": 24, + "alert_count": 8, + "triggered_rule_names": [ + "high_error_rate", + "high_severity_spike", + "login_fail_burst", + "persistent_high_error", + "rare_event_repeat_malware_alert", + "rare_event_repeat_policy_denied" + ], + "triggered_rule_counts": { + "high_error_rate": 2, + "high_severity_spike": 1, + "login_fail_burst": 1, + "persistent_high_error": 2, + "rare_event_repeat_malware_alert": 1, + "rare_event_repeat_policy_denied": 1 + }, + "cooldown_seconds": 120, + "generated_artifacts": [ + "data/processed/richer_sample/features.csv", + "data/processed/richer_sample/alerts.csv", + "data/processed/richer_sample/summary.json", + "data/processed/richer_sample/event_count_timeline.png", + "data/processed/richer_sample/error_rate_timeline.png", + "data/processed/richer_sample/alerts_timeline.png" + ] +} diff --git a/data/processed/summary.json b/data/processed/summary.json new file mode 100644 index 0000000..52308c3 --- /dev/null +++ b/data/processed/summary.json @@ -0,0 +1,33 @@ +{ + "input_path": "data/raw/sample_events.jsonl", + "output_dir": "data/processed", + "normalized_event_count": 41, + "window_count": 24, + "feature_row_count": 24, + "alert_count": 12, + "triggered_rule_names": [ + "high_error_rate", + "high_severity_spike", + "login_fail_burst", + "persistent_high_error", + "rare_event_repeat_malware_alert", + "source_spread_spike" + ], + "triggered_rule_counts": { + "high_error_rate": 3, + "high_severity_spike": 2, + "login_fail_burst": 2, + "persistent_high_error": 3, + "rare_event_repeat_malware_alert": 1, + "source_spread_spike": 1 + }, + "cooldown_seconds": 60, + "generated_artifacts": [ + "data/processed/features.csv", + "data/processed/alerts.csv", + "data/processed/summary.json", + "data/processed/event_count_timeline.png", + "data/processed/error_rate_timeline.png", + "data/processed/alerts_timeline.png" + ] +} diff --git a/src/telemetry_window_demo/cli.py b/src/telemetry_window_demo/cli.py index ee78511..7b9ca01 100644 --- a/src/telemetry_window_demo/cli.py +++ b/src/telemetry_window_demo/cli.py @@ -1,10 +1,10 @@ -from __future__ import annotations - -import argparse -from pathlib import Path -from typing import Any - -from .features import compute_window_features +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +from .features import compute_window_features from .io import ( format_timestamp, load_alert_table, @@ -12,117 +12,134 @@ load_events, load_feature_table, resolve_config_path, + write_json, write_table, ) -from .preprocess import normalize_events -from .rules import apply_rules -from .visualize import plot_outputs -from .windowing import build_windows - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="telemetry-window-demo", - description="Windowed telemetry analytics on timestamped event streams.", - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - run_parser = subparsers.add_parser("run", help="Run the full telemetry pipeline.") - run_parser.add_argument("--config", required=True, help="Path to a YAML config file.") - run_parser.set_defaults(func=run_command) - - summarize_parser = subparsers.add_parser( - "summarize", - help="Summarize an input event file.", - ) - summarize_parser.add_argument("--input", required=True, help="Path to .jsonl or .csv.") - summarize_parser.set_defaults(func=summarize_command) - - plot_parser = subparsers.add_parser("plot", help="Render plots from CSV outputs.") - plot_parser.add_argument("--features", required=True, help="Path to features.csv.") - plot_parser.add_argument("--alerts", help="Path to alerts.csv.") - plot_parser.add_argument( - "--output-dir", - default="data/processed", - help="Directory where plot images will be written.", - ) - plot_parser.set_defaults(func=plot_command) - - return parser - - -def run_command(args: argparse.Namespace) -> None: - config_path = Path(args.config).resolve() - config = load_config(config_path) - time_config = config.get("time", {}) - feature_config = config.get("features", {}) - input_path = resolve_config_path(config_path, config["input_path"]) - output_dir = resolve_config_path(config_path, config.get("output_dir", "data/processed")) - - events = load_events(input_path) - normalized = normalize_events( - events, - timestamp_col=time_config.get("timestamp_col", "timestamp"), - error_statuses=feature_config.get("error_statuses"), - high_severity_levels=feature_config.get("severity_levels"), - ) - windows = build_windows( - normalized, - timestamp_col=time_config.get("timestamp_col", "timestamp"), - window_size_seconds=int(time_config.get("window_size_seconds", 60)), - step_size_seconds=int(time_config.get("step_size_seconds", 10)), - ) - features = compute_window_features( - normalized, - windows, - count_event_types=feature_config.get("count_event_types"), - ) +from .preprocess import normalize_events +from .rules import apply_rules +from .visualize import plot_outputs +from .windowing import build_windows + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + args.func(args) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="telemetry-window-demo", + description="Windowed telemetry analytics on timestamped event streams.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + run_parser = subparsers.add_parser("run", help="Run the full telemetry pipeline.") + run_parser.add_argument("--config", required=True, help="Path to a YAML config file.") + run_parser.set_defaults(func=run_command) + + summarize_parser = subparsers.add_parser( + "summarize", + help="Summarize an input event file.", + ) + summarize_parser.add_argument("--input", required=True, help="Path to .jsonl or .csv.") + summarize_parser.set_defaults(func=summarize_command) + + plot_parser = subparsers.add_parser("plot", help="Render plots from CSV outputs.") + plot_parser.add_argument("--features", required=True, help="Path to features.csv.") + plot_parser.add_argument("--alerts", help="Path to alerts.csv.") + plot_parser.add_argument( + "--output-dir", + default="data/processed", + help="Directory where plot images will be written.", + ) + plot_parser.set_defaults(func=plot_command) + + return parser + + +def run_command(args: argparse.Namespace) -> None: + config_path = Path(args.config).resolve() + config = load_config(config_path) + time_config = config.get("time", {}) + feature_config = config.get("features", {}) + input_path = resolve_config_path(config_path, config["input_path"]) + output_dir = resolve_config_path(config_path, config.get("output_dir", "data/processed")) + + events = load_events(input_path) + normalized = normalize_events( + events, + timestamp_col=time_config.get("timestamp_col", "timestamp"), + error_statuses=feature_config.get("error_statuses"), + high_severity_levels=feature_config.get("severity_levels"), + ) + windows = build_windows( + normalized, + timestamp_col=time_config.get("timestamp_col", "timestamp"), + window_size_seconds=int(time_config.get("window_size_seconds", 60)), + step_size_seconds=int(time_config.get("step_size_seconds", 10)), + ) + features = compute_window_features( + normalized, + windows, + count_event_types=feature_config.get("count_event_types"), + ) alerts = apply_rules(features, config.get("rules")) + cooldown_seconds = int(config.get("rules", {}).get("cooldown_seconds", 0)) feature_path = write_table(features, output_dir / "features.csv") alert_path = write_table(alerts, output_dir / "alerts.csv") plot_paths = plot_outputs(features, alerts, output_dir) - - print(f"[OK] Loaded {len(normalized)} events") - print(f"[OK] Generated {len(features)} windows") - print(f"[OK] Computed {max(len(features.columns) - 2, 0)} features per window") - print(f"[OK] Triggered {len(alerts)} alerts") - print(f"[OK] Saved {feature_path.name}, {alert_path.name}") - print(f"[OK] Saved plots to {_display_path(output_dir)}") - for plot_path in plot_paths: - print(f" - {plot_path.name}") - - -def summarize_command(args: argparse.Namespace) -> None: - events = normalize_events(load_events(args.input)) - min_time = format_timestamp(events["timestamp"].min()) - max_time = format_timestamp(events["timestamp"].max()) - top_event_types = events["event_type"].value_counts().head(5).to_dict() - overall_error_rate = float(events["is_error"].mean()) if not events.empty else 0.0 - - print(f"events: {len(events)}") - print(f"time_range: {min_time} -> {max_time}") - print(f"unique_sources: {events['source'].nunique()}") - print(f"unique_targets: {events['target'].nunique()}") - print(f"overall_error_rate: {overall_error_rate:.2f}") - print(f"top_event_types: {top_event_types}") - - -def plot_command(args: argparse.Namespace) -> None: - features = load_feature_table(args.features) - alerts = load_alert_table(args.alerts) if args.alerts else load_alert_table(Path(args.features).with_name("alerts.csv")) - plot_paths = plot_outputs(features, alerts, args.output_dir) - print(f"[OK] Saved plots to {_display_path(Path(args.output_dir).resolve())}") - for plot_path in plot_paths: - print(f" - {plot_path.name}") - - + summary_path = output_dir / "summary.json" + summary = _build_run_summary( + input_path=input_path, + output_dir=output_dir, + normalized=normalized, + windows=windows, + features=features, + alerts=alerts, + cooldown_seconds=cooldown_seconds, + feature_path=feature_path, + alert_path=alert_path, + summary_path=summary_path, + plot_paths=plot_paths, + ) + write_json(summary, summary_path) + + print(f"[OK] Loaded {len(normalized)} events") + print(f"[OK] Generated {len(features)} windows") + print(f"[OK] Computed {max(len(features.columns) - 2, 0)} features per window") + print(f"[OK] Triggered {len(alerts)} alerts") + print(f"[OK] Saved {feature_path.name}, {alert_path.name}") + print(f"[OK] Saved plots to {_display_path(output_dir)}") + for plot_path in plot_paths: + print(f" - {plot_path.name}") + + +def summarize_command(args: argparse.Namespace) -> None: + events = normalize_events(load_events(args.input)) + min_time = format_timestamp(events["timestamp"].min()) + max_time = format_timestamp(events["timestamp"].max()) + top_event_types = events["event_type"].value_counts().head(5).to_dict() + overall_error_rate = float(events["is_error"].mean()) if not events.empty else 0.0 + + print(f"events: {len(events)}") + print(f"time_range: {min_time} -> {max_time}") + print(f"unique_sources: {events['source'].nunique()}") + print(f"unique_targets: {events['target'].nunique()}") + print(f"overall_error_rate: {overall_error_rate:.2f}") + print(f"top_event_types: {top_event_types}") + + +def plot_command(args: argparse.Namespace) -> None: + features = load_feature_table(args.features) + alerts = load_alert_table(args.alerts) if args.alerts else load_alert_table(Path(args.features).with_name("alerts.csv")) + plot_paths = plot_outputs(features, alerts, args.output_dir) + print(f"[OK] Saved plots to {_display_path(Path(args.output_dir).resolve())}") + for plot_path in plot_paths: + print(f" - {plot_path.name}") + + def _display_path(path: Path) -> str: cwd = Path.cwd().resolve() resolved = path.resolve() @@ -132,5 +149,47 @@ def _display_path(path: Path) -> str: return resolved.as_posix() -if __name__ == "__main__": - main() +def _build_run_summary( + input_path: Path, + output_dir: Path, + normalized: Any, + windows: list[Any], + features: Any, + alerts: Any, + cooldown_seconds: int, + feature_path: Path, + alert_path: Path, + summary_path: Path, + plot_paths: list[Path], +) -> dict[str, object]: + if alerts.empty: + rule_counts: dict[str, int] = {} + else: + rule_counts = { + str(rule_name): int(count) + for rule_name, count in alerts["rule_name"].value_counts().sort_index().items() + } + + artifact_paths = [ + feature_path, + alert_path, + summary_path, + *plot_paths, + ] + + return { + "input_path": _display_path(input_path), + "output_dir": _display_path(output_dir), + "normalized_event_count": int(len(normalized)), + "window_count": int(len(windows)), + "feature_row_count": int(len(features)), + "alert_count": int(len(alerts)), + "triggered_rule_names": sorted(rule_counts), + "triggered_rule_counts": rule_counts, + "cooldown_seconds": int(cooldown_seconds), + "generated_artifacts": [_display_path(path) for path in artifact_paths], + } + + +if __name__ == "__main__": + main() diff --git a/src/telemetry_window_demo/io.py b/src/telemetry_window_demo/io.py index e33388d..a04647d 100644 --- a/src/telemetry_window_demo/io.py +++ b/src/telemetry_window_demo/io.py @@ -82,9 +82,9 @@ def load_alert_table(path: str | Path) -> pd.DataFrame: ) -def write_table(frame: pd.DataFrame, path: str | Path) -> Path: - output_path = Path(path) - output_path.parent.mkdir(parents=True, exist_ok=True) +def write_table(frame: pd.DataFrame, path: str | Path) -> Path: + output_path = Path(path) + output_path.parent.mkdir(parents=True, exist_ok=True) export = frame.copy() for column in export.columns: @@ -95,8 +95,18 @@ def write_table(frame: pd.DataFrame, path: str | Path) -> Path: ): export[column] = export[column].map(format_timestamp) - export.to_csv(output_path, index=False) - return output_path + export.to_csv(output_path, index=False) + return output_path + + +def write_json(payload: dict[str, Any], path: str | Path) -> Path: + output_path = Path(path) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(payload, indent=2) + "\n", + encoding="utf-8", + ) + return output_path def format_timestamp(value: Any) -> str: diff --git a/tests/test_pipeline_e2e.py b/tests/test_pipeline_e2e.py index d36792e..0bd724f 100644 --- a/tests/test_pipeline_e2e.py +++ b/tests/test_pipeline_e2e.py @@ -1,13 +1,22 @@ -from __future__ import annotations - -from argparse import Namespace -from pathlib import Path +from __future__ import annotations + +import json +from argparse import Namespace +from pathlib import Path import pandas as pd import yaml -from telemetry_window_demo.cli import run_command -from telemetry_window_demo.io import load_alert_table, load_config, load_feature_table +from telemetry_window_demo.cli import run_command +from telemetry_window_demo.io import load_alert_table, load_config, load_feature_table + + +def _load_summary(path: Path) -> dict[str, object]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _artifact_names(summary: dict[str, object]) -> set[str]: + return {Path(path).name for path in summary["generated_artifacts"]} def test_default_pipeline_reproduces_sample_outputs(tmp_path, capsys) -> None: @@ -28,22 +37,42 @@ def test_default_pipeline_reproduces_sample_outputs(tmp_path, capsys) -> None: run_command(Namespace(config=str(temp_config_path))) - generated_features = load_feature_table(generated_output_dir / "features.csv") - generated_alerts = load_alert_table(generated_output_dir / "alerts.csv") - expected_features = load_feature_table(expected_output_dir / "features.csv") + generated_features = load_feature_table(generated_output_dir / "features.csv") + generated_alerts = load_alert_table(generated_output_dir / "alerts.csv") + generated_summary = _load_summary(generated_output_dir / "summary.json") + expected_features = load_feature_table(expected_output_dir / "features.csv") expected_alerts = load_alert_table(expected_output_dir / "alerts.csv") + expected_summary = _load_summary(expected_output_dir / "summary.json") assert len(generated_features) == 24 assert len(generated_alerts) == 12 pd.testing.assert_frame_equal(generated_features, expected_features) pd.testing.assert_frame_equal(generated_alerts, expected_alerts) - - for file_name in ( - "event_count_timeline.png", - "error_rate_timeline.png", - "alerts_timeline.png", - ): - assert (generated_output_dir / file_name).exists() + assert generated_summary["normalized_event_count"] == 41 + assert generated_summary["window_count"] == 24 + assert generated_summary["feature_row_count"] == 24 + assert generated_summary["alert_count"] == 12 + assert generated_summary["cooldown_seconds"] == 60 + assert generated_summary["triggered_rule_names"] == expected_summary["triggered_rule_names"] + assert generated_summary["triggered_rule_counts"] == expected_summary["triggered_rule_counts"] + assert Path(generated_summary["input_path"]).name == "sample_events.jsonl" + assert Path(generated_summary["output_dir"]).name == "processed" + assert _artifact_names(generated_summary) == { + "features.csv", + "alerts.csv", + "summary.json", + "event_count_timeline.png", + "error_rate_timeline.png", + "alerts_timeline.png", + } + + for file_name in ( + "event_count_timeline.png", + "error_rate_timeline.png", + "alerts_timeline.png", + "summary.json", + ): + assert (generated_output_dir / file_name).exists() stdout = capsys.readouterr().out assert "[OK] Loaded 41 events" in stdout @@ -72,18 +101,38 @@ def test_richer_sample_pipeline_reproduces_sample_outputs(tmp_path, capsys) -> N generated_features = load_feature_table(generated_output_dir / "features.csv") generated_alerts = load_alert_table(generated_output_dir / "alerts.csv") + generated_summary = _load_summary(generated_output_dir / "summary.json") expected_features = load_feature_table(expected_output_dir / "features.csv") expected_alerts = load_alert_table(expected_output_dir / "alerts.csv") + expected_summary = _load_summary(expected_output_dir / "summary.json") assert len(generated_features) == 24 assert len(generated_alerts) == 8 pd.testing.assert_frame_equal(generated_features, expected_features) pd.testing.assert_frame_equal(generated_alerts, expected_alerts) + assert generated_summary["normalized_event_count"] == 28 + assert generated_summary["window_count"] == 24 + assert generated_summary["feature_row_count"] == 24 + assert generated_summary["alert_count"] == 8 + assert generated_summary["cooldown_seconds"] == 120 + assert generated_summary["triggered_rule_names"] == expected_summary["triggered_rule_names"] + assert generated_summary["triggered_rule_counts"] == expected_summary["triggered_rule_counts"] + assert Path(generated_summary["input_path"]).name == "richer_sample_events.jsonl" + assert Path(generated_summary["output_dir"]).name == "richer_sample" + assert _artifact_names(generated_summary) == { + "features.csv", + "alerts.csv", + "summary.json", + "event_count_timeline.png", + "error_rate_timeline.png", + "alerts_timeline.png", + } for file_name in ( "event_count_timeline.png", "error_rate_timeline.png", "alerts_timeline.png", + "summary.json", ): assert (generated_output_dir / file_name).exists() From 3deaa7a80120e5938caa49c46eec4a9a0f6236e0 Mon Sep 17 00:00:00 2001 From: stacknil Date: Wed, 25 Mar 2026 13:08:28 +0800 Subject: [PATCH 2/2] fix: tolerate null rules when writing summary --- src/telemetry_window_demo/cli.py | 29 ++++++++++++------------ tests/test_pipeline_e2e.py | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/telemetry_window_demo/cli.py b/src/telemetry_window_demo/cli.py index 7b9ca01..02093d7 100644 --- a/src/telemetry_window_demo/cli.py +++ b/src/telemetry_window_demo/cli.py @@ -58,13 +58,14 @@ def build_parser() -> argparse.ArgumentParser: return parser -def run_command(args: argparse.Namespace) -> None: - config_path = Path(args.config).resolve() - config = load_config(config_path) - time_config = config.get("time", {}) - feature_config = config.get("features", {}) - input_path = resolve_config_path(config_path, config["input_path"]) - output_dir = resolve_config_path(config_path, config.get("output_dir", "data/processed")) +def run_command(args: argparse.Namespace) -> None: + config_path = Path(args.config).resolve() + config = load_config(config_path) + time_config = config.get("time", {}) + feature_config = config.get("features", {}) + rules_config = config.get("rules") or {} + input_path = resolve_config_path(config_path, config["input_path"]) + output_dir = resolve_config_path(config_path, config.get("output_dir", "data/processed")) events = load_events(input_path) normalized = normalize_events( @@ -79,13 +80,13 @@ def run_command(args: argparse.Namespace) -> None: window_size_seconds=int(time_config.get("window_size_seconds", 60)), step_size_seconds=int(time_config.get("step_size_seconds", 10)), ) - features = compute_window_features( - normalized, - windows, - count_event_types=feature_config.get("count_event_types"), - ) - alerts = apply_rules(features, config.get("rules")) - cooldown_seconds = int(config.get("rules", {}).get("cooldown_seconds", 0)) + features = compute_window_features( + normalized, + windows, + count_event_types=feature_config.get("count_event_types"), + ) + alerts = apply_rules(features, rules_config) + cooldown_seconds = int(rules_config.get("cooldown_seconds", 0)) feature_path = write_table(features, output_dir / "features.csv") alert_path = write_table(alerts, output_dir / "alerts.csv") diff --git a/tests/test_pipeline_e2e.py b/tests/test_pipeline_e2e.py index 0bd724f..69ec994 100644 --- a/tests/test_pipeline_e2e.py +++ b/tests/test_pipeline_e2e.py @@ -139,3 +139,42 @@ def test_richer_sample_pipeline_reproduces_sample_outputs(tmp_path, capsys) -> N stdout = capsys.readouterr().out assert "[OK] Loaded 28 events" in stdout assert "[OK] Triggered 8 alerts" in stdout + + +def test_pipeline_writes_summary_when_rules_are_null(tmp_path, capsys) -> None: + repo_root = Path(__file__).resolve().parents[1] + config_path = repo_root / "configs" / "default.yaml" + generated_output_dir = tmp_path / "null_rules" + + config = load_config(config_path) + config["input_path"] = str((repo_root / "data" / "raw" / "sample_events.jsonl").resolve()) + config["output_dir"] = str(generated_output_dir.resolve()) + config["rules"] = None + + temp_config_path = tmp_path / "null_rules.yaml" + temp_config_path.write_text( + yaml.safe_dump(config, sort_keys=False), + encoding="utf-8", + ) + + run_command(Namespace(config=str(temp_config_path))) + + generated_alerts = load_alert_table(generated_output_dir / "alerts.csv") + generated_summary = _load_summary(generated_output_dir / "summary.json") + + assert generated_summary["cooldown_seconds"] == 0 + assert generated_summary["alert_count"] == len(generated_alerts) + assert Path(generated_summary["input_path"]).name == "sample_events.jsonl" + assert Path(generated_summary["output_dir"]).name == "null_rules" + assert _artifact_names(generated_summary) == { + "features.csv", + "alerts.csv", + "summary.json", + "event_count_timeline.png", + "error_rate_timeline.png", + "alerts_timeline.png", + } + assert (generated_output_dir / "summary.json").exists() + + stdout = capsys.readouterr().out + assert "[OK] Loaded 41 events" in stdout