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.
- stackdoctor/__init__.py +0 -0
- stackdoctor/__main__.py +3 -0
- stackdoctor/checks/__init__.py +29 -0
- stackdoctor/checks/celery.py +319 -0
- stackdoctor/checks/logs.py +235 -0
- stackdoctor/checks/postgres.py +202 -0
- stackdoctor/checks/redis.py +154 -0
- stackdoctor/config.py +80 -0
- stackdoctor/diagnose.py +248 -0
- stackdoctor/safety.py +331 -0
- stackdoctor/server.py +164 -0
- stackdoctor-0.1.0.dist-info/METADATA +341 -0
- stackdoctor-0.1.0.dist-info/RECORD +16 -0
- stackdoctor-0.1.0.dist-info/WHEEL +4 -0
- stackdoctor-0.1.0.dist-info/entry_points.txt +2 -0
- stackdoctor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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}
|