mdbkit 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.
- mdbkit/__init__.py +3 -0
- mdbkit/advisor.py +302 -0
- mdbkit/analysis.py +436 -0
- mdbkit/cli.py +256 -0
- mdbkit/explain.py +244 -0
- mdbkit/filtering.py +55 -0
- mdbkit/parser.py +149 -0
- mdbkit/render.py +141 -0
- mdbkit/scripts.py +69 -0
- mdbkit/triage.py +360 -0
- mdbkit-0.1.0.dist-info/METADATA +207 -0
- mdbkit-0.1.0.dist-info/RECORD +16 -0
- mdbkit-0.1.0.dist-info/WHEEL +5 -0
- mdbkit-0.1.0.dist-info/entry_points.txt +2 -0
- mdbkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- mdbkit-0.1.0.dist-info/top_level.txt +1 -0
mdbkit/explain.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Explain-plan analyzer for `mdbkit explain`.
|
|
2
|
+
|
|
3
|
+
Reads a saved explain output (JSON produced by
|
|
4
|
+
`db.coll.find(...).explain("executionStats")` in mongosh, or exported from
|
|
5
|
+
Compass) and answers the question every DBA asks: *why is this query slow,
|
|
6
|
+
and what would fix it?*
|
|
7
|
+
|
|
8
|
+
Handles classic and SBE (6.0+) plan shapes, aggregate explains ($cursor),
|
|
9
|
+
and sharded winning plans. Reuses the same deterministic advisor as
|
|
10
|
+
`mdbkit advise`, so the recommendation logic is identical everywhere.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from .advisor import Recommendation, advise
|
|
20
|
+
from .analysis import QueryAggregator
|
|
21
|
+
from .parser import LogEntry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ExplainReport:
|
|
26
|
+
ns: str
|
|
27
|
+
stage_chain: List[str]
|
|
28
|
+
collscan: bool
|
|
29
|
+
blocking_sort: bool
|
|
30
|
+
indexes_used: List[str]
|
|
31
|
+
n_returned: int
|
|
32
|
+
keys_examined: int
|
|
33
|
+
docs_examined: int
|
|
34
|
+
exec_ms: int
|
|
35
|
+
rejected_plans: int
|
|
36
|
+
verdicts: List[str] = field(default_factory=list)
|
|
37
|
+
recommendation: Optional[Recommendation] = None
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict:
|
|
40
|
+
return {
|
|
41
|
+
"ns": self.ns,
|
|
42
|
+
"stages": self.stage_chain,
|
|
43
|
+
"collscan": self.collscan,
|
|
44
|
+
"blockingSort": self.blocking_sort,
|
|
45
|
+
"indexesUsed": self.indexes_used,
|
|
46
|
+
"nReturned": self.n_returned,
|
|
47
|
+
"keysExamined": self.keys_examined,
|
|
48
|
+
"docsExamined": self.docs_examined,
|
|
49
|
+
"executionTimeMillis": self.exec_ms,
|
|
50
|
+
"rejectedPlans": self.rejected_plans,
|
|
51
|
+
"verdicts": self.verdicts,
|
|
52
|
+
"recommendation": self.recommendation.to_dict() if self.recommendation else None,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _collect_stages(plan, out: List[dict], depth: int = 0):
|
|
57
|
+
"""Walk a winning plan tree collecting stage docs (classic, SBE, sharded)."""
|
|
58
|
+
if depth > 20 or not isinstance(plan, dict):
|
|
59
|
+
return
|
|
60
|
+
if plan.get("stage"):
|
|
61
|
+
out.append(plan)
|
|
62
|
+
for key in ("queryPlan", "winningPlan", "inputStage", "innerStage",
|
|
63
|
+
"outerStage", "thenStage", "elseStage"):
|
|
64
|
+
if key in plan:
|
|
65
|
+
_collect_stages(plan[key], out, depth + 1)
|
|
66
|
+
for key in ("inputStages", "shards"):
|
|
67
|
+
subs = plan.get(key)
|
|
68
|
+
if isinstance(subs, list):
|
|
69
|
+
for sub in subs:
|
|
70
|
+
_collect_stages(sub, out, depth + 1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _find_query_planner(doc: dict) -> Tuple[dict, dict]:
|
|
74
|
+
"""Locate queryPlanner + executionStats in find or aggregate explains."""
|
|
75
|
+
if "queryPlanner" in doc:
|
|
76
|
+
return doc.get("queryPlanner") or {}, doc.get("executionStats") or {}
|
|
77
|
+
for stage in doc.get("stages", []) or []:
|
|
78
|
+
if not isinstance(stage, dict):
|
|
79
|
+
continue
|
|
80
|
+
cursor = stage.get("$cursor") or stage.get("$geoNearCursor")
|
|
81
|
+
if isinstance(cursor, dict) and "queryPlanner" in cursor:
|
|
82
|
+
return cursor.get("queryPlanner") or {}, cursor.get("executionStats") or {}
|
|
83
|
+
return {}, {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_explain(path: str) -> dict:
|
|
87
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
88
|
+
text = fh.read()
|
|
89
|
+
try:
|
|
90
|
+
doc = json.loads(text)
|
|
91
|
+
except json.JSONDecodeError as exc:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"This file is not valid JSON. Legacy mongo-shell explain output "
|
|
94
|
+
"contains constructors like NumberLong(...) that are not JSON; "
|
|
95
|
+
"re-export with mongosh (e.g. "
|
|
96
|
+
"`EJSON.stringify(db.coll.find(...).explain('executionStats'))`) "
|
|
97
|
+
"or use Compass's JSON export."
|
|
98
|
+
) from exc
|
|
99
|
+
if isinstance(doc, list) and doc:
|
|
100
|
+
doc = doc[0]
|
|
101
|
+
if not isinstance(doc, dict):
|
|
102
|
+
raise ValueError("Expected a single explain JSON document.")
|
|
103
|
+
return doc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def analyze_explain(doc: dict, indexes=None, schema=None) -> ExplainReport:
|
|
107
|
+
qp, es_inner = _find_query_planner(doc)
|
|
108
|
+
es = doc.get("executionStats") or es_inner or {}
|
|
109
|
+
winning = qp.get("winningPlan") or {}
|
|
110
|
+
|
|
111
|
+
stages: List[dict] = []
|
|
112
|
+
_collect_stages(winning, stages)
|
|
113
|
+
chain = [s.get("stage", "?") for s in stages]
|
|
114
|
+
|
|
115
|
+
collscan = any(s.get("stage") == "COLLSCAN" for s in stages)
|
|
116
|
+
blocking_sort = any(s.get("stage") == "SORT" for s in stages)
|
|
117
|
+
fetch = any(s.get("stage") == "FETCH" for s in stages)
|
|
118
|
+
|
|
119
|
+
indexes_used: List[str] = []
|
|
120
|
+
for s in stages:
|
|
121
|
+
if s.get("stage") in ("IXSCAN", "DISTINCT_SCAN", "COUNT_SCAN"):
|
|
122
|
+
name = s.get("indexName") or json.dumps(s.get("keyPattern", {}))
|
|
123
|
+
if name and name not in indexes_used:
|
|
124
|
+
indexes_used.append(name)
|
|
125
|
+
|
|
126
|
+
n_returned = int(es.get("nReturned", 0) or 0)
|
|
127
|
+
keys_examined = int(es.get("totalKeysExamined", 0) or 0)
|
|
128
|
+
docs_examined = int(es.get("totalDocsExamined", 0) or 0)
|
|
129
|
+
exec_ms = int(es.get("executionTimeMillis", 0) or 0)
|
|
130
|
+
rejected = len(qp.get("rejectedPlans") or [])
|
|
131
|
+
|
|
132
|
+
verdicts: List[str] = []
|
|
133
|
+
if collscan:
|
|
134
|
+
verdicts.append(
|
|
135
|
+
f"Full collection scan: examined {docs_examined:,} documents to "
|
|
136
|
+
f"return {n_returned:,}. An index on the filter fields would let "
|
|
137
|
+
"MongoDB skip straight to matching documents."
|
|
138
|
+
)
|
|
139
|
+
if blocking_sort:
|
|
140
|
+
verdicts.append(
|
|
141
|
+
"In-memory (blocking) SORT stage: results are sorted after "
|
|
142
|
+
"retrieval instead of being read from an index in order. Sorts "
|
|
143
|
+
"over 100MB abort the query unless allowDiskUse is set."
|
|
144
|
+
)
|
|
145
|
+
if not collscan and indexes_used:
|
|
146
|
+
if n_returned and docs_examined > 10 * n_returned:
|
|
147
|
+
verdicts.append(
|
|
148
|
+
f"Index used ({', '.join(indexes_used)}) but it is weakly "
|
|
149
|
+
f"selective here: {docs_examined:,} docs fetched for "
|
|
150
|
+
f"{n_returned:,} returned. The index may match the filter "
|
|
151
|
+
"only partially."
|
|
152
|
+
)
|
|
153
|
+
elif keys_examined and n_returned and keys_examined > 10 * n_returned:
|
|
154
|
+
verdicts.append(
|
|
155
|
+
f"Index scanned many more keys ({keys_examined:,}) than "
|
|
156
|
+
f"documents returned ({n_returned:,}); check field order vs "
|
|
157
|
+
"the ESR guideline."
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
verdicts.append(
|
|
161
|
+
f"Index used efficiently ({', '.join(indexes_used)}): "
|
|
162
|
+
f"keysExamined={keys_examined:,}, "
|
|
163
|
+
f"docsExamined={docs_examined:,}, nReturned={n_returned:,}."
|
|
164
|
+
)
|
|
165
|
+
if indexes_used and not fetch and docs_examined == 0 and n_returned:
|
|
166
|
+
verdicts.append(
|
|
167
|
+
"Covered query: answered entirely from the index without touching "
|
|
168
|
+
"documents. This is as good as it gets."
|
|
169
|
+
)
|
|
170
|
+
if rejected:
|
|
171
|
+
verdicts.append(
|
|
172
|
+
f"{rejected} alternative plan(s) were considered and rejected — "
|
|
173
|
+
"if plan choice flaps, look at rejectedPlans in the raw output."
|
|
174
|
+
)
|
|
175
|
+
if not verdicts:
|
|
176
|
+
verdicts.append("No obvious pathology detected in this plan.")
|
|
177
|
+
|
|
178
|
+
# Reuse the advisor for a recommendation when the plan needs help.
|
|
179
|
+
recommendation = None
|
|
180
|
+
command = doc.get("command") if isinstance(doc.get("command"), dict) else {}
|
|
181
|
+
ns = qp.get("namespace") or (command.get("$db", "") + "." +
|
|
182
|
+
str(command.get("find") or
|
|
183
|
+
command.get("aggregate") or "")).strip(".")
|
|
184
|
+
if (collscan or blocking_sort or
|
|
185
|
+
(n_returned and docs_examined > 10 * n_returned)) and command:
|
|
186
|
+
entry = LogEntry(
|
|
187
|
+
ts=None, severity="I", component="COMMAND", msg_id=51803,
|
|
188
|
+
ctx="explain", msg="Slow query",
|
|
189
|
+
attr={
|
|
190
|
+
"ns": ns,
|
|
191
|
+
"command": command,
|
|
192
|
+
"planSummary": "COLLSCAN" if collscan else "IXSCAN",
|
|
193
|
+
"docsExamined": docs_examined,
|
|
194
|
+
"nreturned": n_returned,
|
|
195
|
+
"hasSortStage": blocking_sort,
|
|
196
|
+
"durationMillis": exec_ms,
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
agg = QueryAggregator()
|
|
200
|
+
agg.consume(entry)
|
|
201
|
+
recs = advise(agg.results(), indexes=indexes, schema=schema)
|
|
202
|
+
if recs:
|
|
203
|
+
recommendation = recs[0]
|
|
204
|
+
|
|
205
|
+
return ExplainReport(
|
|
206
|
+
ns=ns or "(unknown)",
|
|
207
|
+
stage_chain=chain,
|
|
208
|
+
collscan=collscan,
|
|
209
|
+
blocking_sort=blocking_sort,
|
|
210
|
+
indexes_used=indexes_used,
|
|
211
|
+
n_returned=n_returned,
|
|
212
|
+
keys_examined=keys_examined,
|
|
213
|
+
docs_examined=docs_examined,
|
|
214
|
+
exec_ms=exec_ms,
|
|
215
|
+
rejected_plans=rejected,
|
|
216
|
+
verdicts=verdicts,
|
|
217
|
+
recommendation=recommendation,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def render_explain(report: ExplainReport) -> str:
|
|
222
|
+
parts = ["== mdbkit explain ==", ""]
|
|
223
|
+
parts.append(f"namespace : {report.ns}")
|
|
224
|
+
parts.append(f"plan : {' -> '.join(report.stage_chain) or '(no stages found)'}")
|
|
225
|
+
if report.indexes_used:
|
|
226
|
+
parts.append(f"index(es) : {', '.join(report.indexes_used)}")
|
|
227
|
+
parts.append(
|
|
228
|
+
f"stats : nReturned={report.n_returned:,} "
|
|
229
|
+
f"keysExamined={report.keys_examined:,} "
|
|
230
|
+
f"docsExamined={report.docs_examined:,} "
|
|
231
|
+
f"time={report.exec_ms:,} ms"
|
|
232
|
+
)
|
|
233
|
+
parts.append("")
|
|
234
|
+
for v in report.verdicts:
|
|
235
|
+
parts.append(f"* {v}")
|
|
236
|
+
rec = report.recommendation
|
|
237
|
+
if rec:
|
|
238
|
+
parts.append("")
|
|
239
|
+
parts.append(f"candidate index ({rec.confidence.upper()} confidence): "
|
|
240
|
+
f"{rec.candidate_str()}")
|
|
241
|
+
for c in rec.caveats:
|
|
242
|
+
parts.append(f" caveat: {c}")
|
|
243
|
+
parts.append(f" validate: {rec.validation}")
|
|
244
|
+
return "\n".join(parts)
|
mdbkit/filtering.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""`mdbkit filter` — the mlogfilter successor.
|
|
2
|
+
|
|
3
|
+
Streams matching raw logv2 lines to stdout so output stays valid JSON logs
|
|
4
|
+
and can be chained: `mdbkit filter f.log --slow 500 | mdbkit queries -`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from .parser import LogEntry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Filter:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
component: Optional[str] = None,
|
|
19
|
+
severity: Optional[str] = None,
|
|
20
|
+
namespace: Optional[str] = None,
|
|
21
|
+
slow_ms: Optional[int] = None,
|
|
22
|
+
ts_from: Optional[datetime] = None,
|
|
23
|
+
ts_to: Optional[datetime] = None,
|
|
24
|
+
msg_contains: Optional[str] = None,
|
|
25
|
+
):
|
|
26
|
+
self.component = component.upper() if component else None
|
|
27
|
+
self.severity = severity.upper() if severity else None
|
|
28
|
+
self.namespace = namespace
|
|
29
|
+
self.slow_ms = slow_ms
|
|
30
|
+
self.ts_from = ts_from
|
|
31
|
+
self.ts_to = ts_to
|
|
32
|
+
self.msg_contains = msg_contains.lower() if msg_contains else None
|
|
33
|
+
|
|
34
|
+
def matches(self, entry: LogEntry) -> bool:
|
|
35
|
+
if self.component and entry.component.upper() != self.component:
|
|
36
|
+
return False
|
|
37
|
+
if self.severity and entry.severity.upper() != self.severity:
|
|
38
|
+
return False
|
|
39
|
+
if self.namespace and entry.attr.get("ns") != self.namespace:
|
|
40
|
+
return False
|
|
41
|
+
if self.slow_ms is not None:
|
|
42
|
+
if int(entry.attr.get("durationMillis", -1) or -1) < self.slow_ms:
|
|
43
|
+
return False
|
|
44
|
+
if self.ts_from and (entry.ts is None or entry.ts < self.ts_from):
|
|
45
|
+
return False
|
|
46
|
+
if self.ts_to and (entry.ts is None or entry.ts > self.ts_to):
|
|
47
|
+
return False
|
|
48
|
+
if self.msg_contains and self.msg_contains not in entry.msg.lower():
|
|
49
|
+
return False
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_when(value: str) -> datetime:
|
|
54
|
+
"""Parse a --from/--to value (ISO 8601, 'Z' allowed)."""
|
|
55
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
mdbkit/parser.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Parser for MongoDB structured JSON logs (logv2, MongoDB 4.4+).
|
|
2
|
+
|
|
3
|
+
Every log line since MongoDB 4.4 is a single JSON document:
|
|
4
|
+
|
|
5
|
+
{"t":{"$date":"..."},"s":"I","c":"COMMAND","id":51803,"ctx":"conn42",
|
|
6
|
+
"msg":"Slow query","attr":{...}}
|
|
7
|
+
|
|
8
|
+
This module parses those lines defensively: real-world logs contain
|
|
9
|
+
truncated lines, interleaved plain-text output, and rotated .gz files.
|
|
10
|
+
Everything here is offline and dependency-free by design.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import gzip
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from typing import Iterator, Optional
|
|
22
|
+
|
|
23
|
+
# Well-known logv2 message ids we care about.
|
|
24
|
+
ID_SLOW_QUERY = 51803
|
|
25
|
+
ID_CONN_ACCEPTED = 22943
|
|
26
|
+
ID_CONN_ENDED = 22944
|
|
27
|
+
ID_CLIENT_METADATA = 51800
|
|
28
|
+
ID_STARTUP = 4615611
|
|
29
|
+
ID_BUILD_INFO = 23403
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class LogEntry:
|
|
34
|
+
"""One parsed logv2 line."""
|
|
35
|
+
|
|
36
|
+
ts: Optional[datetime]
|
|
37
|
+
severity: str
|
|
38
|
+
component: str
|
|
39
|
+
msg_id: int
|
|
40
|
+
ctx: str
|
|
41
|
+
msg: str
|
|
42
|
+
attr: dict = field(default_factory=dict)
|
|
43
|
+
raw: str = ""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_slow_query(self) -> bool:
|
|
47
|
+
return self.msg_id == ID_SLOW_QUERY or self.msg == "Slow query"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ParseStats:
|
|
52
|
+
"""Bookkeeping for how much of the file we understood."""
|
|
53
|
+
|
|
54
|
+
total_lines: int = 0
|
|
55
|
+
parsed: int = 0
|
|
56
|
+
unparsed: int = 0
|
|
57
|
+
first_ts: Optional[datetime] = None
|
|
58
|
+
last_ts: Optional[datetime] = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def unparsed_ratio(self) -> float:
|
|
62
|
+
return self.unparsed / self.total_lines if self.total_lines else 0.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_ts(value) -> Optional[datetime]:
|
|
66
|
+
"""Parse a logv2 timestamp: {"$date": "ISO"} or {"$date": {"$numberLong": ms}}."""
|
|
67
|
+
if isinstance(value, dict):
|
|
68
|
+
value = value.get("$date", value)
|
|
69
|
+
if isinstance(value, dict): # {"$numberLong": "..."} (epoch millis)
|
|
70
|
+
millis = value.get("$numberLong")
|
|
71
|
+
if millis is not None:
|
|
72
|
+
try:
|
|
73
|
+
return datetime.fromtimestamp(int(millis) / 1000.0).astimezone()
|
|
74
|
+
except (ValueError, OSError):
|
|
75
|
+
return None
|
|
76
|
+
if isinstance(value, str):
|
|
77
|
+
try:
|
|
78
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
79
|
+
except ValueError:
|
|
80
|
+
return None
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_line(line: str) -> Optional[LogEntry]:
|
|
85
|
+
"""Parse one line. Returns None for anything that isn't a logv2 JSON doc."""
|
|
86
|
+
line = line.strip()
|
|
87
|
+
if not line or not line.startswith("{"):
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
doc = json.loads(line)
|
|
91
|
+
except (json.JSONDecodeError, ValueError):
|
|
92
|
+
return None
|
|
93
|
+
if not isinstance(doc, dict) or "s" not in doc or "c" not in doc:
|
|
94
|
+
return None
|
|
95
|
+
return LogEntry(
|
|
96
|
+
ts=_parse_ts(doc.get("t")),
|
|
97
|
+
severity=str(doc.get("s", "")),
|
|
98
|
+
component=str(doc.get("c", "")),
|
|
99
|
+
msg_id=int(doc.get("id", 0) or 0),
|
|
100
|
+
ctx=str(doc.get("ctx", "")),
|
|
101
|
+
msg=str(doc.get("msg", "")),
|
|
102
|
+
attr=doc.get("attr") or {},
|
|
103
|
+
raw=line,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def open_log(path: str) -> io.TextIOBase:
|
|
108
|
+
"""Open a log file, stdin ('-'), or a rotated .gz transparently."""
|
|
109
|
+
if path == "-":
|
|
110
|
+
return sys.stdin
|
|
111
|
+
if path.endswith(".gz"):
|
|
112
|
+
return io.TextIOWrapper(gzip.open(path, "rb"), encoding="utf-8", errors="replace")
|
|
113
|
+
# Sniff gzip magic bytes even without the extension.
|
|
114
|
+
with open(path, "rb") as probe:
|
|
115
|
+
magic = probe.read(2)
|
|
116
|
+
if magic == b"\x1f\x8b":
|
|
117
|
+
return io.TextIOWrapper(gzip.open(path, "rb"), encoding="utf-8", errors="replace")
|
|
118
|
+
return open(path, "r", encoding="utf-8", errors="replace")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def iter_entries(path: str, stats: Optional[ParseStats] = None) -> Iterator[LogEntry]:
|
|
122
|
+
"""Stream LogEntry objects from a file, tracking parse stats if given."""
|
|
123
|
+
handle = open_log(path)
|
|
124
|
+
try:
|
|
125
|
+
for line in handle:
|
|
126
|
+
if stats is not None:
|
|
127
|
+
stats.total_lines += 1
|
|
128
|
+
entry = parse_line(line)
|
|
129
|
+
if entry is None:
|
|
130
|
+
if stats is not None and line.strip():
|
|
131
|
+
stats.unparsed += 1
|
|
132
|
+
continue
|
|
133
|
+
if stats is not None:
|
|
134
|
+
stats.parsed += 1
|
|
135
|
+
if entry.ts is not None:
|
|
136
|
+
if stats.first_ts is None:
|
|
137
|
+
stats.first_ts = entry.ts
|
|
138
|
+
stats.last_ts = entry.ts
|
|
139
|
+
yield entry
|
|
140
|
+
finally:
|
|
141
|
+
if handle is not sys.stdin:
|
|
142
|
+
handle.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
PRE_44_HINT = (
|
|
146
|
+
"Most lines in this file are not structured JSON. This looks like a "
|
|
147
|
+
"pre-4.4 MongoDB log (plain text format). mdbkit targets MongoDB 4.4+ "
|
|
148
|
+
"structured logs; for older logs, the original mtools still works."
|
|
149
|
+
)
|
mdbkit/render.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Plain-text rendering. No third-party dependencies, pipe-friendly output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import List, Sequence
|
|
7
|
+
|
|
8
|
+
from .advisor import Recommendation
|
|
9
|
+
from .analysis import ConnectionReport, LogSummary, ShapeStats
|
|
10
|
+
from .parser import ParseStats
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def dump_json(obj) -> str:
|
|
14
|
+
return json.dumps(obj, indent=2, default=str)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _table(headers: Sequence[str], rows: List[Sequence[str]]) -> str:
|
|
18
|
+
widths = [len(h) for h in headers]
|
|
19
|
+
for row in rows:
|
|
20
|
+
for i, cell in enumerate(row):
|
|
21
|
+
widths[i] = max(widths[i], len(str(cell)))
|
|
22
|
+
fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
|
23
|
+
lines = [fmt.format(*headers), fmt.format(*("-" * w for w in widths))]
|
|
24
|
+
lines += [fmt.format(*(str(c) for c in row)) for row in rows]
|
|
25
|
+
return "\n".join(lines)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ms(v: float) -> str:
|
|
29
|
+
if v >= 60_000:
|
|
30
|
+
return f"{v/60_000:.1f}m"
|
|
31
|
+
if v >= 1_000:
|
|
32
|
+
return f"{v/1_000:.1f}s"
|
|
33
|
+
return f"{int(v)}ms"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_parse_stats(stats: ParseStats) -> str:
|
|
37
|
+
span = ""
|
|
38
|
+
if stats.first_ts and stats.last_ts:
|
|
39
|
+
span = f" span: {stats.first_ts.isoformat()} -> {stats.last_ts.isoformat()}"
|
|
40
|
+
return (
|
|
41
|
+
f"lines: {stats.total_lines:,} parsed: {stats.parsed:,} "
|
|
42
|
+
f"unparsed: {stats.unparsed:,}{span}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def render_summary(summary: LogSummary, stats: ParseStats) -> str:
|
|
47
|
+
parts = ["== mdbkit loginfo ==", render_parse_stats(stats), ""]
|
|
48
|
+
parts.append(f"server version(s): {', '.join(summary.versions) or 'not found in log'}")
|
|
49
|
+
if summary.host_info:
|
|
50
|
+
parts.append(f"host: {', '.join(dict.fromkeys(summary.host_info))}")
|
|
51
|
+
parts.append(f"restarts/startups seen: {summary.startups}")
|
|
52
|
+
parts.append(f"connections accepted: {summary.connections_accepted:,}")
|
|
53
|
+
parts.append(
|
|
54
|
+
f"slow queries logged: {summary.slow_queries:,}"
|
|
55
|
+
+ (f" (slowest {_ms(summary.slowest_ms)})" if summary.slow_queries else "")
|
|
56
|
+
)
|
|
57
|
+
parts.append(f"warnings: {summary.warnings:,} errors: {summary.errors:,}")
|
|
58
|
+
parts.append("")
|
|
59
|
+
top = summary.component_counts.most_common(8)
|
|
60
|
+
if top:
|
|
61
|
+
parts.append(_table(["component", "lines"], [(c, f"{n:,}") for c, n in top]))
|
|
62
|
+
return "\n".join(parts)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_queries(results: List[ShapeStats], stats: ParseStats) -> str:
|
|
66
|
+
parts = ["== mdbkit queries (slow query shapes) ==", render_parse_stats(stats), ""]
|
|
67
|
+
if not results:
|
|
68
|
+
parts.append("No slow queries found. (mongod logs operations exceeding "
|
|
69
|
+
"slowms, default 100 ms; lower slowms or enable profiling "
|
|
70
|
+
"to capture more.)")
|
|
71
|
+
return "\n".join(parts)
|
|
72
|
+
rows = []
|
|
73
|
+
for s in results:
|
|
74
|
+
flags = []
|
|
75
|
+
if s.collscan:
|
|
76
|
+
flags.append("COLLSCAN")
|
|
77
|
+
if s.in_memory_sort:
|
|
78
|
+
flags.append("SORT")
|
|
79
|
+
rows.append((
|
|
80
|
+
s.shape.ns,
|
|
81
|
+
s.shape.operation,
|
|
82
|
+
s.count,
|
|
83
|
+
_ms(s.total_ms),
|
|
84
|
+
_ms(s.mean_ms),
|
|
85
|
+
_ms(s.max_ms),
|
|
86
|
+
f"{s.scan_ratio:.0f}:1" if s.n_returned else "-",
|
|
87
|
+
",".join(flags) or "-",
|
|
88
|
+
s.shape.pretty()[:70],
|
|
89
|
+
))
|
|
90
|
+
parts.append(_table(
|
|
91
|
+
["namespace", "op", "count", "total", "mean", "max", "scan", "flags", "shape"],
|
|
92
|
+
rows,
|
|
93
|
+
))
|
|
94
|
+
parts.append("")
|
|
95
|
+
parts.append("scan = docsExamined per returned doc. COLLSCAN/SORT flags mean an "
|
|
96
|
+
"index is likely missing; run `mdbkit advise` for candidates.")
|
|
97
|
+
return "\n".join(parts)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_connections(report: ConnectionReport, stats: ParseStats) -> str:
|
|
101
|
+
parts = ["== mdbkit connections ==", render_parse_stats(stats), ""]
|
|
102
|
+
d = report.to_dict()
|
|
103
|
+
parts.append(
|
|
104
|
+
f"accepted: {d['totalAccepted']:,} ended: {d['totalEnded']:,} "
|
|
105
|
+
f"peak concurrent (as logged): {d['peakConnectionCount']:,}"
|
|
106
|
+
)
|
|
107
|
+
if d["byIp"]:
|
|
108
|
+
parts.append("")
|
|
109
|
+
parts.append(_table(
|
|
110
|
+
["source ip", "accepted", "ended"],
|
|
111
|
+
[(r["ip"], r["accepted"], r["ended"]) for r in d["byIp"][:20]],
|
|
112
|
+
))
|
|
113
|
+
if d["appNames"]:
|
|
114
|
+
parts.append("")
|
|
115
|
+
parts.append(_table(["appName", "handshakes"], list(d["appNames"].items())[:15]))
|
|
116
|
+
return "\n".join(parts)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def render_recommendations(recs: List[Recommendation], stats: ParseStats) -> str:
|
|
120
|
+
parts = ["== mdbkit advise (candidate indexes) ==", render_parse_stats(stats), ""]
|
|
121
|
+
if not recs:
|
|
122
|
+
parts.append("No index candidates: no slow query shapes showed COLLSCAN, "
|
|
123
|
+
"in-memory sorts, or high scan ratios. Good sign — or the "
|
|
124
|
+
"log window is too quiet to judge.")
|
|
125
|
+
return "\n".join(parts)
|
|
126
|
+
for i, rec in enumerate(recs, 1):
|
|
127
|
+
parts.append(f"[{i}] {rec.ns} — confidence: {rec.confidence.upper()}")
|
|
128
|
+
parts.append(f" query shape : {rec.shape}")
|
|
129
|
+
parts.append(f" candidate : {rec.candidate_str()}")
|
|
130
|
+
if rec.covered_by:
|
|
131
|
+
parts.append(f" NOTE : may already be covered by existing "
|
|
132
|
+
f"index '{rec.covered_by}' — investigate before creating")
|
|
133
|
+
for e in rec.evidence:
|
|
134
|
+
parts.append(f" evidence : {e}")
|
|
135
|
+
for c in rec.caveats:
|
|
136
|
+
parts.append(f" caveat : {c}")
|
|
137
|
+
parts.append(f" validate : {rec.validation}")
|
|
138
|
+
parts.append("")
|
|
139
|
+
parts.append("These are CANDIDATES, not commands. Review, test on staging, and "
|
|
140
|
+
"watch write latency and index build impact before production.")
|
|
141
|
+
return "\n".join(parts)
|
mdbkit/scripts.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""mongosh export scripts, printed by `mdbkit export-script`.
|
|
2
|
+
|
|
3
|
+
mdbkit never connects to a database. Instead it prints small mongosh
|
|
4
|
+
scripts the operator runs themselves, producing JSON files that can be
|
|
5
|
+
fed back via --schema / --indexes. The operator sees exactly what runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
SCHEMA_SCRIPT = r"""// mdbkit schema export -- run with:
|
|
9
|
+
// mongosh --quiet "mongodb://HOST/DB" this_file.js > schema.json
|
|
10
|
+
// Samples documents per collection to learn field paths and types.
|
|
11
|
+
// Reads only; writes nothing; literal values are NOT exported (types only).
|
|
12
|
+
const SAMPLE = 100; // docs sampled per collection
|
|
13
|
+
const MAX_DEPTH = 3; // nested path depth
|
|
14
|
+
const out = { db: db.getName(), generatedAt: new Date().toISOString(),
|
|
15
|
+
sampleSize: SAMPLE, collections: {} };
|
|
16
|
+
|
|
17
|
+
function typeName(v) {
|
|
18
|
+
if (v === null) return "null";
|
|
19
|
+
if (Array.isArray(v)) return "array";
|
|
20
|
+
if (v instanceof Date) return "date";
|
|
21
|
+
if (v && v._bsontype === "ObjectId") return "objectId";
|
|
22
|
+
if (typeof v === "object") return "object";
|
|
23
|
+
if (typeof v === "number") return Number.isInteger(v) ? "int" : "double";
|
|
24
|
+
return typeof v; // string, boolean -> bool below
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function record(fields, path, v, depth) {
|
|
28
|
+
let t = typeName(v);
|
|
29
|
+
if (t === "boolean") t = "bool";
|
|
30
|
+
if (!fields[path]) fields[path] = { types: new Set(), count: 0 };
|
|
31
|
+
fields[path].types.add(t);
|
|
32
|
+
fields[path].count += 1;
|
|
33
|
+
if (depth >= MAX_DEPTH) return;
|
|
34
|
+
if (t === "object") {
|
|
35
|
+
for (const k of Object.keys(v)) record(fields, path + "." + k, v[k], depth + 1);
|
|
36
|
+
} else if (t === "array" && v.length > 0 && typeof v[0] === "object"
|
|
37
|
+
&& v[0] !== null && !Array.isArray(v[0])) {
|
|
38
|
+
for (const k of Object.keys(v[0])) record(fields, path + "." + k, v[0][k], depth + 1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
db.getCollectionNames().filter(c => !c.startsWith("system.")).forEach(coll => {
|
|
43
|
+
const fields = {};
|
|
44
|
+
let n = 0;
|
|
45
|
+
db.getCollection(coll).aggregate([{ $sample: { size: SAMPLE } }]).forEach(doc => {
|
|
46
|
+
n += 1;
|
|
47
|
+
for (const k of Object.keys(doc)) record(fields, k, doc[k], 1);
|
|
48
|
+
});
|
|
49
|
+
const serialized = {};
|
|
50
|
+
for (const [path, info] of Object.entries(fields)) {
|
|
51
|
+
serialized[path] = { types: Array.from(info.types).sort(),
|
|
52
|
+
presence: n ? Math.round(100 * info.count / n) / 100 : 0 };
|
|
53
|
+
}
|
|
54
|
+
out.collections[coll] = { sampleSize: n, fields: serialized };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
print(JSON.stringify(out));
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
INDEXES_SCRIPT = r"""// mdbkit index export -- run with:
|
|
61
|
+
// mongosh --quiet "mongodb://HOST/DB" this_file.js > indexes.json
|
|
62
|
+
// Exports index metadata only (getIndexes). Reads nothing else.
|
|
63
|
+
const out = { db: db.getName(), generatedAt: new Date().toISOString(),
|
|
64
|
+
collections: {} };
|
|
65
|
+
db.getCollectionNames().filter(c => !c.startsWith("system.")).forEach(coll => {
|
|
66
|
+
out.collections[coll] = db.getCollection(coll).getIndexes();
|
|
67
|
+
});
|
|
68
|
+
print(JSON.stringify(out));
|
|
69
|
+
"""
|