stackdoctor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ """Light Postgres checks. For deep health checks use Postgres MCP Pro.
2
+
3
+ Every connection is forced read-only with a statement timeout, at the server level.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from contextlib import contextmanager
9
+
10
+ import psycopg
11
+ from psycopg.rows import dict_row
12
+
13
+ from ..config import get_config, skipped
14
+ from ..safety import UnsafeError, preview, validate_select
15
+ from . import event, utcnow
16
+
17
+ SOURCE = "postgres"
18
+ MAX_LIMIT = 1000
19
+ _OPTIONS = "-c default_transaction_read_only=on -c statement_timeout=5000 -c lock_timeout=2000"
20
+
21
+
22
+ @contextmanager
23
+ def connect():
24
+ cfg = get_config()
25
+ # Keyword args override anything in DATABASE_URL, so these always apply.
26
+ conn = psycopg.connect(cfg.database_url, options=_OPTIONS, connect_timeout=5,
27
+ application_name="stackdoctor", row_factory=dict_row)
28
+ try:
29
+ conn.read_only = True
30
+ ro = conn.execute("SHOW default_transaction_read_only").fetchone()
31
+ if ro["default_transaction_read_only"] != "on":
32
+ raise UnsafeError("Could not force a read-only session; refusing to run queries.")
33
+ yield conn
34
+ finally:
35
+ conn.close()
36
+
37
+
38
+ def _not_configured():
39
+ return skipped("DATABASE_URL not set") if not get_config().database_url else None
40
+
41
+
42
+ def active_queries(min_duration_s: float = 0) -> dict:
43
+ if (s := _not_configured()):
44
+ return s
45
+ cfg = get_config()
46
+ with connect() as conn:
47
+ rows = conn.execute(
48
+ """
49
+ SELECT pid, usename, application_name, client_addr::text, state,
50
+ wait_event_type, wait_event, backend_type,
51
+ xact_start, query_start, state_change,
52
+ EXTRACT(EPOCH FROM now() - query_start)::float AS duration_s,
53
+ EXTRACT(EPOCH FROM now() - xact_start)::float AS xact_age_s,
54
+ query
55
+ FROM pg_stat_activity
56
+ WHERE state IS DISTINCT FROM 'idle'
57
+ AND pid <> pg_backend_pid()
58
+ AND backend_type = 'client backend'
59
+ AND COALESCE(EXTRACT(EPOCH FROM now() - query_start), 0) >= %s
60
+ ORDER BY query_start NULLS LAST
61
+ LIMIT 50
62
+ """,
63
+ (min_duration_s,),
64
+ ).fetchall()
65
+
66
+ events, queries = [], []
67
+ for r in rows:
68
+ r["query"] = preview(r["query"], 300)
69
+ r["duration_s"] = round(r["duration_s"] or 0, 1)
70
+ r["xact_age_s"] = round(r["xact_age_s"] or 0, 1)
71
+ queries.append(r)
72
+ if r["state"] == "idle in transaction" and r["xact_age_s"] >= cfg.long_query_s:
73
+ events.append(event(r["xact_start"], SOURCE, "idle_in_transaction",
74
+ f"pid {r['pid']} idle in transaction for {r['xact_age_s']}s: {r['query']}",
75
+ "warning", pid=r["pid"]))
76
+ elif r["state"] == "active" and r["duration_s"] >= cfg.long_query_s and r["wait_event_type"] != "Lock":
77
+ events.append(event(r["query_start"], SOURCE, "long_query",
78
+ f"pid {r['pid']} running {r['duration_s']}s: {r['query']}",
79
+ "warning", pid=r["pid"]))
80
+ return {"count": len(queries), "queries": queries, "events": events}
81
+
82
+
83
+ def blocking_locks() -> dict:
84
+ if (s := _not_configured()):
85
+ return s
86
+ with connect() as conn:
87
+ blocked = conn.execute(
88
+ """
89
+ SELECT a.pid, a.usename, a.application_name, a.query_start,
90
+ EXTRACT(EPOCH FROM now() - a.query_start)::float AS waiting_s,
91
+ pg_blocking_pids(a.pid) AS blocked_by,
92
+ (SELECT string_agg(DISTINCT l.relation::regclass::text, ', ')
93
+ FROM pg_locks l WHERE l.pid = a.pid AND NOT l.granted
94
+ AND l.relation IS NOT NULL) AS relation,
95
+ a.query
96
+ FROM pg_stat_activity a
97
+ WHERE cardinality(pg_blocking_pids(a.pid)) > 0
98
+ ORDER BY a.query_start
99
+ LIMIT 50
100
+ """
101
+ ).fetchall()
102
+ blocker_pids = sorted({p for r in blocked for p in r["blocked_by"]})
103
+ blockers = conn.execute(
104
+ """
105
+ SELECT pid, usename, application_name, state, xact_start, state_change,
106
+ EXTRACT(EPOCH FROM now() - xact_start)::float AS xact_age_s, query
107
+ FROM pg_stat_activity WHERE pid = ANY(%s)
108
+ """,
109
+ (blocker_pids,),
110
+ ).fetchall() if blocker_pids else []
111
+
112
+ events = []
113
+ for b in blockers:
114
+ b["query"] = preview(b["query"], 300)
115
+ b["xact_age_s"] = round(b["xact_age_s"] or 0, 1)
116
+ waiting = [r["pid"] for r in blocked if b["pid"] in r["blocked_by"]]
117
+ events.append(event(b["xact_start"], SOURCE, "lock_held",
118
+ f"pid {b['pid']} ({b['state']}) holds a lock blocking {len(waiting)} "
119
+ f"session(s) since transaction start: {b['query']}",
120
+ "critical", pid=b["pid"]))
121
+ for r in blocked:
122
+ r["query"] = preview(r["query"], 300)
123
+ r["waiting_s"] = round(r["waiting_s"] or 0, 1)
124
+ events.append(event(r["query_start"], SOURCE, "query_blocked",
125
+ f"pid {r['pid']} waiting {r['waiting_s']}s on {r['relation'] or 'a lock'} "
126
+ f"held by {r['blocked_by']}: {r['query']}",
127
+ "warning", pid=r["pid"]))
128
+ return {"blocked_count": len(blocked), "blocked": blocked, "blockers": blockers, "events": events}
129
+
130
+
131
+ _PGSS_HELP = (
132
+ "pg_stat_statements is not installed in this database. To enable it (needs a superuser, "
133
+ "stackdoctor will not do it for you): 1) add `shared_preload_libraries = 'pg_stat_statements'` "
134
+ "to postgresql.conf (or `-c shared_preload_libraries=pg_stat_statements` in docker), "
135
+ "2) restart Postgres, 3) run `CREATE EXTENSION pg_stat_statements;` in this database. "
136
+ "On RDS/Cloud SQL, enable it via the parameter group / database flags."
137
+ )
138
+
139
+
140
+ def slow_queries(limit: int = 10) -> dict:
141
+ if (s := _not_configured()):
142
+ return s
143
+ limit = max(1, min(int(limit), 50))
144
+ with connect() as conn:
145
+ has_ext = conn.execute(
146
+ "SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'").fetchone()
147
+ seq = conn.execute(
148
+ """
149
+ SELECT schemaname || '.' || relname AS table, seq_scan, seq_tup_read,
150
+ idx_scan, n_live_tup
151
+ FROM pg_stat_user_tables
152
+ WHERE seq_scan > 0
153
+ ORDER BY seq_tup_read DESC LIMIT 5
154
+ """
155
+ ).fetchall()
156
+ result: dict = {"seq_scan_heavy_tables": seq}
157
+ if not has_ext:
158
+ result.update(available=False, message=_PGSS_HELP)
159
+ return result
160
+ new_cols = conn.info.server_version >= 130000
161
+ mean, total = ("mean_exec_time", "total_exec_time") if new_cols else ("mean_time", "total_time")
162
+ try:
163
+ rows = conn.execute(
164
+ f"""
165
+ SELECT queryid, calls, round({mean}::numeric, 1) AS mean_ms,
166
+ round({total}::numeric, 1) AS total_ms, rows,
167
+ shared_blks_read, query
168
+ FROM pg_stat_statements
169
+ WHERE query NOT ILIKE '%%pg_stat_statements%%'
170
+ ORDER BY {mean} DESC LIMIT %s
171
+ """,
172
+ (limit,),
173
+ ).fetchall()
174
+ except psycopg.errors.ObjectNotInPrerequisiteState:
175
+ result.update(available=False, message=_PGSS_HELP)
176
+ return result
177
+ events = []
178
+ for r in rows:
179
+ r["query"] = preview(r["query"], 300)
180
+ if r["mean_ms"] >= 1000:
181
+ events.append(event(None, SOURCE, "slow_statement",
182
+ f"{r['calls']} call(s) averaging {r['mean_ms']:.0f}ms: {r['query']}",
183
+ "warning", queryid=r["queryid"]))
184
+ result.update(available=True, queries=rows, events=events,
185
+ note="pg_stat_statements is cumulative since the last reset; it has no timestamps.")
186
+ return result
187
+
188
+
189
+ def run_select(sql: str, limit: int = 100) -> dict:
190
+ if (s := _not_configured()):
191
+ return s
192
+ limit = max(1, min(int(limit), MAX_LIMIT))
193
+ try:
194
+ final_sql = validate_select(sql, limit)
195
+ except UnsafeError as e:
196
+ return {"rejected": True, "reason": str(e)}
197
+ with connect() as conn:
198
+ cur = conn.execute(final_sql)
199
+ rows = cur.fetchmany(limit) if cur.description else []
200
+ columns = [d.name for d in cur.description or []]
201
+ return {"columns": columns, "row_count": len(rows), "limit": limit, "rows": rows,
202
+ "executed_at": utcnow()}
@@ -0,0 +1,154 @@
1
+ """Redis checks. Every command goes through the read-only allowlist."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+
7
+ import redis as redis_lib
8
+
9
+ from ..config import get_config, skipped
10
+ from ..safety import UnsafeError, check_redis_command, preview
11
+ from . import event
12
+
13
+ SOURCE = "redis"
14
+
15
+
16
+ class SafeRedis:
17
+ """A Redis client that can only run allowlisted read-only commands."""
18
+
19
+ def __init__(self, url: str):
20
+ self._r = redis_lib.Redis.from_url(
21
+ url, decode_responses=True, encoding_errors="replace",
22
+ socket_timeout=5, socket_connect_timeout=3)
23
+
24
+ def cmd(self, *args):
25
+ check_redis_command(*args)
26
+ return self._r.execute_command(*args)
27
+
28
+ def scan_iter(self, match: str, limit: int, count: int = 500, max_rounds: int = 200):
29
+ """SCAN (never KEYS) until `limit` keys or `max_rounds` round trips."""
30
+ cursor, found, rounds = 0, [], 0
31
+ while True:
32
+ cursor, keys = self.cmd("SCAN", cursor, "MATCH", match, "COUNT", count)
33
+ found.extend(keys)
34
+ rounds += 1
35
+ if int(cursor) == 0 or len(found) >= limit or rounds >= max_rounds:
36
+ return found[:limit], int(cursor) == 0
37
+
38
+
39
+ @lru_cache(maxsize=4)
40
+ def client(url: str) -> SafeRedis:
41
+ return SafeRedis(url)
42
+
43
+
44
+ def glob_escape(text: str) -> str:
45
+ return "".join("\\" + c if c in "*?[]\\" else c for c in text)
46
+
47
+
48
+ def memory_and_clients() -> dict:
49
+ cfg = get_config()
50
+ if not cfg.redis_url:
51
+ return skipped("REDIS_URL not set")
52
+ r = client(cfg.redis_url)
53
+ info = r.cmd("INFO")
54
+ used, maxmem = info.get("used_memory", 0), info.get("maxmemory", 0)
55
+ try:
56
+ maxclients = int(r.cmd("CONFIG GET", "maxclients").get("maxclients", 0))
57
+ except redis_lib.ResponseError: # CONFIG is often disabled on managed Redis
58
+ maxclients = None
59
+ pct = round(used * 100 / maxmem, 1) if maxmem else None
60
+ result = {
61
+ "redis_version": info.get("redis_version"),
62
+ "uptime_s": info.get("uptime_in_seconds"),
63
+ "memory": {
64
+ "used": info.get("used_memory_human"), "used_bytes": used,
65
+ "peak": info.get("used_memory_peak_human"),
66
+ "maxmemory_bytes": maxmem or None, "used_pct_of_max": pct,
67
+ "maxmemory_policy": info.get("maxmemory_policy"),
68
+ "fragmentation_ratio": info.get("mem_fragmentation_ratio"),
69
+ },
70
+ "clients": {
71
+ "connected": info.get("connected_clients"), "blocked": info.get("blocked_clients"),
72
+ "maxclients": maxclients, "rejected_connections": info.get("rejected_connections"),
73
+ },
74
+ "stats": {k: info.get(k) for k in ("evicted_keys", "expired_keys", "keyspace_hits",
75
+ "keyspace_misses", "instantaneous_ops_per_sec")},
76
+ "keyspace": {k: v for k, v in info.items() if k.startswith("db") and isinstance(v, dict)},
77
+ }
78
+
79
+ events = []
80
+ if pct is not None and pct >= cfg.redis_mem_warn_pct:
81
+ events.append(event(None, SOURCE, "memory_high",
82
+ f"Redis memory at {pct}% of maxmemory ({info.get('used_memory_human')}, "
83
+ f"policy {info.get('maxmemory_policy')})", "critical" if pct >= 95 else "warning"))
84
+ if info.get("evicted_keys"):
85
+ events.append(event(None, SOURCE, "evictions",
86
+ f"{info['evicted_keys']} keys evicted since start (policy {info.get('maxmemory_policy')})",
87
+ "warning"))
88
+ if info.get("rejected_connections"):
89
+ events.append(event(None, SOURCE, "rejected_connections",
90
+ f"{info['rejected_connections']} connections rejected (maxclients {maxclients})",
91
+ "warning"))
92
+ try:
93
+ slow = r.cmd("SLOWLOG GET", 5)
94
+ except redis_lib.ResponseError:
95
+ slow = []
96
+ result["slowlog"] = [{"ts": s.get("start_time"), "duration_us": s.get("duration"),
97
+ "command": preview(s.get("command"), 120)} for s in slow]
98
+ for s in result["slowlog"]:
99
+ if (s["duration_us"] or 0) >= 100_000:
100
+ events.append(event(s["ts"], SOURCE, "slow_command",
101
+ f"slow Redis command {s['duration_us'] / 1000:.0f}ms: {s['command']}", "info"))
102
+ result["events"] = events
103
+ return result
104
+
105
+
106
+ def scan_keys(pattern: str = "*", limit: int = 50) -> dict:
107
+ cfg = get_config()
108
+ if not cfg.redis_url:
109
+ return skipped("REDIS_URL not set")
110
+ limit = max(1, min(int(limit), 500))
111
+ keys, complete = client(cfg.redis_url).scan_iter(pattern or "*", limit)
112
+ return {"pattern": pattern, "count": len(keys), "keys": sorted(keys),
113
+ "scan_complete": complete,
114
+ "note": None if complete else "Stopped early (limit reached); more keys may match."}
115
+
116
+
117
+ def key_info(key: str) -> dict:
118
+ cfg = get_config()
119
+ if not cfg.redis_url:
120
+ return skipped("REDIS_URL not set")
121
+ return describe_key(client(cfg.redis_url), key)
122
+
123
+
124
+ def describe_key(r: SafeRedis, key: str) -> dict:
125
+ ktype = r.cmd("TYPE", key)
126
+ if ktype == "none":
127
+ return {"key": key, "exists": False}
128
+ out = {"key": key, "exists": True, "type": ktype, "ttl_ms": r.cmd("PTTL", key)}
129
+ for name, args in (("memory_bytes", ("MEMORY USAGE", key)), ("encoding", ("OBJECT ENCODING", key))):
130
+ try:
131
+ out[name] = r.cmd(*args)
132
+ except (redis_lib.ResponseError, UnsafeError):
133
+ pass
134
+ if ktype == "string":
135
+ out["length"] = r.cmd("STRLEN", key)
136
+ out["preview"] = preview(r.cmd("GETRANGE", key, 0, 300), 200)
137
+ elif ktype == "list":
138
+ out["length"] = r.cmd("LLEN", key)
139
+ out["preview"] = [preview(v, 200) for v in r.cmd("LRANGE", key, 0, 2)]
140
+ elif ktype == "hash":
141
+ out["length"] = r.cmd("HLEN", key)
142
+ _, items = r.cmd("HSCAN", key, 0, "COUNT", 10)
143
+ out["preview"] = {preview(k, 60): preview(v, 120) for k, v in list(items.items())[:10]}
144
+ elif ktype == "set":
145
+ out["length"] = r.cmd("SCARD", key)
146
+ _, members = r.cmd("SSCAN", key, 0, "COUNT", 10)
147
+ out["preview"] = [preview(m, 120) for m in members[:10]]
148
+ elif ktype == "zset":
149
+ out["length"] = r.cmd("ZCARD", key)
150
+ flat = r.cmd("ZRANGE", key, 0, 4, "WITHSCORES")
151
+ out["preview"] = [[preview(m, 120), s] for m, s in zip(flat[::2], flat[1::2])]
152
+ elif ktype == "stream":
153
+ out["length"] = r.cmd("XLEN", key)
154
+ return out
stackdoctor/config.py ADDED
@@ -0,0 +1,80 @@
1
+ """Configuration from environment variables (and an optional .env file)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from functools import lru_cache
8
+
9
+ from dotenv import find_dotenv, load_dotenv
10
+
11
+
12
+ def _float(name: str, default: float) -> float:
13
+ try:
14
+ return float(os.environ.get(name, default))
15
+ except ValueError:
16
+ return default
17
+
18
+
19
+ def _list(name: str) -> list[str]:
20
+ return [s.strip() for s in os.environ.get(name, "").split(",") if s.strip()]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Config:
25
+ database_url: str | None = None
26
+ redis_url: str | None = None
27
+ celery_broker_url: str | None = None
28
+ celery_result_backend: str | None = None
29
+ celery_app: str | None = None
30
+ celery_queues: list[str] = field(default_factory=list)
31
+ log_sources: list[str] = field(default_factory=list)
32
+
33
+ # Heuristic thresholds
34
+ queue_threshold: int = 100
35
+ expected_workers: int = 0
36
+ long_task_s: float = 60
37
+ long_query_s: float = 30
38
+ redis_mem_warn_pct: float = 85
39
+ log_error_spike: int = 10
40
+ chain_window_min: float = 30
41
+
42
+ # Limits
43
+ check_timeout_s: float = 8
44
+ celery_inspect_timeout_s: float = 1.0
45
+ max_output_chars: int = 20_000
46
+
47
+ @classmethod
48
+ def from_env(cls) -> "Config":
49
+ # Real environment variables win over the .env file.
50
+ load_dotenv(os.environ.get("STACKDOCTOR_ENV_FILE") or find_dotenv(usecwd=True))
51
+ get = lambda k: os.environ.get(k) or None # noqa: E731
52
+ return cls(
53
+ database_url=get("DATABASE_URL"),
54
+ redis_url=get("REDIS_URL"),
55
+ celery_broker_url=get("CELERY_BROKER_URL"),
56
+ celery_result_backend=get("CELERY_RESULT_BACKEND"),
57
+ celery_app=get("CELERY_APP"),
58
+ celery_queues=_list("CELERY_QUEUES"),
59
+ log_sources=_list("LOG_SOURCES"),
60
+ queue_threshold=int(_float("QUEUE_THRESHOLD", 100)),
61
+ expected_workers=int(_float("EXPECTED_WORKERS", 0)),
62
+ long_task_s=_float("LONG_TASK_S", 60),
63
+ long_query_s=_float("LONG_QUERY_S", 30),
64
+ redis_mem_warn_pct=_float("REDIS_MEM_WARN_PCT", 85),
65
+ log_error_spike=int(_float("LOG_ERROR_SPIKE", 10)),
66
+ chain_window_min=_float("CHAIN_WINDOW_MIN", 30),
67
+ check_timeout_s=_float("CHECK_TIMEOUT_S", 8),
68
+ celery_inspect_timeout_s=_float("CELERY_INSPECT_TIMEOUT_S", 1.0),
69
+ max_output_chars=int(_float("MAX_OUTPUT_CHARS", 20_000)),
70
+ )
71
+
72
+
73
+ @lru_cache(maxsize=1)
74
+ def get_config() -> Config:
75
+ return Config.from_env()
76
+
77
+
78
+ def skipped(reason: str) -> dict:
79
+ """Standard result for a check whose source isn't configured."""
80
+ return {"skipped": reason}