onetool-mcp 1.0.0b1__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.
- bench/__init__.py +5 -0
- bench/cli.py +69 -0
- bench/harness/__init__.py +66 -0
- bench/harness/client.py +692 -0
- bench/harness/config.py +397 -0
- bench/harness/csv_writer.py +109 -0
- bench/harness/evaluate.py +512 -0
- bench/harness/metrics.py +283 -0
- bench/harness/runner.py +899 -0
- bench/py.typed +0 -0
- bench/reporter.py +629 -0
- bench/run.py +487 -0
- bench/secrets.py +101 -0
- bench/utils.py +16 -0
- onetool/__init__.py +4 -0
- onetool/cli.py +391 -0
- onetool/py.typed +0 -0
- onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
- onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
- onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
- onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
- ot/__init__.py +37 -0
- ot/__main__.py +6 -0
- ot/_cli.py +107 -0
- ot/_tui.py +53 -0
- ot/config/__init__.py +46 -0
- ot/config/defaults/bench.yaml +4 -0
- ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
- ot/config/defaults/diagram-templates/c4-context.puml +30 -0
- ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
- ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
- ot/config/defaults/diagram-templates/microservices.d2 +81 -0
- ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
- ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
- ot/config/defaults/onetool.yaml +25 -0
- ot/config/defaults/prompts.yaml +97 -0
- ot/config/defaults/servers.yaml +7 -0
- ot/config/defaults/snippets.yaml +4 -0
- ot/config/defaults/tool_templates/__init__.py +7 -0
- ot/config/defaults/tool_templates/extension.py +52 -0
- ot/config/defaults/tool_templates/isolated.py +61 -0
- ot/config/dynamic.py +121 -0
- ot/config/global_templates/__init__.py +2 -0
- ot/config/global_templates/bench-secrets-template.yaml +6 -0
- ot/config/global_templates/bench.yaml +9 -0
- ot/config/global_templates/onetool.yaml +27 -0
- ot/config/global_templates/secrets-template.yaml +44 -0
- ot/config/global_templates/servers.yaml +18 -0
- ot/config/global_templates/snippets.yaml +235 -0
- ot/config/loader.py +1087 -0
- ot/config/mcp.py +145 -0
- ot/config/secrets.py +190 -0
- ot/config/tool_config.py +125 -0
- ot/decorators.py +116 -0
- ot/executor/__init__.py +35 -0
- ot/executor/base.py +16 -0
- ot/executor/fence_processor.py +83 -0
- ot/executor/linter.py +142 -0
- ot/executor/pack_proxy.py +260 -0
- ot/executor/param_resolver.py +140 -0
- ot/executor/pep723.py +288 -0
- ot/executor/result_store.py +369 -0
- ot/executor/runner.py +496 -0
- ot/executor/simple.py +163 -0
- ot/executor/tool_loader.py +396 -0
- ot/executor/validator.py +398 -0
- ot/executor/worker_pool.py +388 -0
- ot/executor/worker_proxy.py +189 -0
- ot/http_client.py +145 -0
- ot/logging/__init__.py +37 -0
- ot/logging/config.py +315 -0
- ot/logging/entry.py +213 -0
- ot/logging/format.py +188 -0
- ot/logging/span.py +349 -0
- ot/meta.py +1555 -0
- ot/paths.py +453 -0
- ot/prompts.py +218 -0
- ot/proxy/__init__.py +21 -0
- ot/proxy/manager.py +396 -0
- ot/py.typed +0 -0
- ot/registry/__init__.py +189 -0
- ot/registry/models.py +57 -0
- ot/registry/parser.py +269 -0
- ot/registry/registry.py +413 -0
- ot/server.py +315 -0
- ot/shortcuts/__init__.py +15 -0
- ot/shortcuts/aliases.py +87 -0
- ot/shortcuts/snippets.py +258 -0
- ot/stats/__init__.py +35 -0
- ot/stats/html.py +250 -0
- ot/stats/jsonl_writer.py +283 -0
- ot/stats/reader.py +354 -0
- ot/stats/timing.py +57 -0
- ot/support.py +63 -0
- ot/tools.py +114 -0
- ot/utils/__init__.py +81 -0
- ot/utils/batch.py +161 -0
- ot/utils/cache.py +120 -0
- ot/utils/deps.py +403 -0
- ot/utils/exceptions.py +23 -0
- ot/utils/factory.py +179 -0
- ot/utils/format.py +65 -0
- ot/utils/http.py +202 -0
- ot/utils/platform.py +45 -0
- ot/utils/sanitize.py +130 -0
- ot/utils/truncate.py +69 -0
- ot_tools/__init__.py +4 -0
- ot_tools/_convert/__init__.py +12 -0
- ot_tools/_convert/excel.py +279 -0
- ot_tools/_convert/pdf.py +254 -0
- ot_tools/_convert/powerpoint.py +268 -0
- ot_tools/_convert/utils.py +358 -0
- ot_tools/_convert/word.py +283 -0
- ot_tools/brave_search.py +604 -0
- ot_tools/code_search.py +736 -0
- ot_tools/context7.py +495 -0
- ot_tools/convert.py +614 -0
- ot_tools/db.py +415 -0
- ot_tools/diagram.py +1604 -0
- ot_tools/diagram.yaml +167 -0
- ot_tools/excel.py +1372 -0
- ot_tools/file.py +1348 -0
- ot_tools/firecrawl.py +732 -0
- ot_tools/grounding_search.py +646 -0
- ot_tools/package.py +604 -0
- ot_tools/py.typed +0 -0
- ot_tools/ripgrep.py +544 -0
- ot_tools/scaffold.py +471 -0
- ot_tools/transform.py +213 -0
- ot_tools/web_fetch.py +384 -0
ot/stats/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Runtime statistics collection for OneTool.
|
|
2
|
+
|
|
3
|
+
Two-level statistics:
|
|
4
|
+
- Run-level: Tracks run() calls, durations, and calculates context savings estimates.
|
|
5
|
+
- Tool-level: Tracks actual tool invocations at the executor dispatch level.
|
|
6
|
+
|
|
7
|
+
Records are stored in a single JSONL file with a 'type' field discriminator.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ot.stats.html import generate_html_report
|
|
11
|
+
from ot.stats.jsonl_writer import (
|
|
12
|
+
JsonlStatsWriter,
|
|
13
|
+
get_client_name,
|
|
14
|
+
get_stats_writer,
|
|
15
|
+
record_tool_stats,
|
|
16
|
+
set_client_name,
|
|
17
|
+
set_stats_writer,
|
|
18
|
+
)
|
|
19
|
+
from ot.stats.reader import AggregatedStats, Period, StatsReader, ToolStats
|
|
20
|
+
from ot.stats.timing import timed_tool_call
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AggregatedStats",
|
|
24
|
+
"JsonlStatsWriter",
|
|
25
|
+
"Period",
|
|
26
|
+
"StatsReader",
|
|
27
|
+
"ToolStats",
|
|
28
|
+
"generate_html_report",
|
|
29
|
+
"get_client_name",
|
|
30
|
+
"get_stats_writer",
|
|
31
|
+
"record_tool_stats",
|
|
32
|
+
"set_client_name",
|
|
33
|
+
"set_stats_writer",
|
|
34
|
+
"timed_tool_call",
|
|
35
|
+
]
|
ot/stats/html.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""HTML report generation for OneTool statistics.
|
|
2
|
+
|
|
3
|
+
Generates self-contained HTML reports with inline CSS, no JS dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from ot.support import (
|
|
11
|
+
KOFI_URL,
|
|
12
|
+
SUPPORT_HTML_BUTTON_TEXT,
|
|
13
|
+
SUPPORT_HTML_MESSAGE,
|
|
14
|
+
SUPPORT_HTML_TITLE,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ot.stats.reader import AggregatedStats
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_html_report(stats: AggregatedStats) -> str:
|
|
22
|
+
"""Generate a self-contained HTML report from aggregated stats.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
stats: Aggregated statistics from StatsReader
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Complete HTML document as string
|
|
29
|
+
"""
|
|
30
|
+
# Format time saved in human-readable format
|
|
31
|
+
time_saved_seconds = stats.time_saved_ms / 1000
|
|
32
|
+
if time_saved_seconds >= 3600:
|
|
33
|
+
time_saved_str = f"{time_saved_seconds / 3600:.1f} hours"
|
|
34
|
+
elif time_saved_seconds >= 60:
|
|
35
|
+
time_saved_str = f"{time_saved_seconds / 60:.1f} minutes"
|
|
36
|
+
else:
|
|
37
|
+
time_saved_str = f"{time_saved_seconds:.1f} seconds"
|
|
38
|
+
|
|
39
|
+
# Format context saved
|
|
40
|
+
if stats.context_saved >= 1_000_000:
|
|
41
|
+
context_saved_str = f"{stats.context_saved / 1_000_000:.1f}M tokens"
|
|
42
|
+
elif stats.context_saved >= 1_000:
|
|
43
|
+
context_saved_str = f"{stats.context_saved / 1_000:.1f}K tokens"
|
|
44
|
+
else:
|
|
45
|
+
context_saved_str = f"{stats.context_saved} tokens"
|
|
46
|
+
|
|
47
|
+
# Format savings estimate with coffee equivalent
|
|
48
|
+
if stats.savings_usd >= 1.0:
|
|
49
|
+
savings_str = f"${stats.savings_usd:.2f}"
|
|
50
|
+
elif stats.savings_usd >= 0.01:
|
|
51
|
+
savings_str = f"${stats.savings_usd:.3f}"
|
|
52
|
+
else:
|
|
53
|
+
savings_str = f"${stats.savings_usd:.4f}"
|
|
54
|
+
|
|
55
|
+
# Coffee equivalent (whole number)
|
|
56
|
+
coffees = int(stats.coffees)
|
|
57
|
+
coffees_str = f"{coffees} coffee{'s' if coffees != 1 else ''}"
|
|
58
|
+
|
|
59
|
+
coffees_display = f"☕ {coffees_str}"
|
|
60
|
+
|
|
61
|
+
# Group tools by pack (prefix before the dot)
|
|
62
|
+
from collections import defaultdict
|
|
63
|
+
|
|
64
|
+
packs: dict[str, list] = defaultdict(list)
|
|
65
|
+
for tool in stats.tools:
|
|
66
|
+
pack_name = tool.tool.split(".")[0] if "." in tool.tool else "other"
|
|
67
|
+
packs[pack_name].append(tool)
|
|
68
|
+
|
|
69
|
+
# Build tool rows grouped by pack
|
|
70
|
+
tool_rows = ""
|
|
71
|
+
for pack_name in sorted(packs.keys()):
|
|
72
|
+
pack_tools = packs[pack_name]
|
|
73
|
+
pack_calls = sum(t.total_calls for t in pack_tools)
|
|
74
|
+
tool_rows += f"""
|
|
75
|
+
<tr class="pack-header">
|
|
76
|
+
<td><strong>{pack_name}</strong></td>
|
|
77
|
+
<td class="num"><strong>{pack_calls:,}</strong></td>
|
|
78
|
+
<td class="num"></td>
|
|
79
|
+
<td class="num"></td>
|
|
80
|
+
</tr>"""
|
|
81
|
+
for tool in pack_tools:
|
|
82
|
+
# Show just the function name (after the dot)
|
|
83
|
+
func_name = tool.tool.split(".", 1)[1] if "." in tool.tool else tool.tool
|
|
84
|
+
avg_duration = f"{tool.avg_duration_ms:.0f}ms"
|
|
85
|
+
tool_rows += f"""
|
|
86
|
+
<tr>
|
|
87
|
+
<td class="tool-name">{func_name}</td>
|
|
88
|
+
<td class="num">{tool.total_calls}</td>
|
|
89
|
+
<td class="num">{tool.success_rate:.1f}%</td>
|
|
90
|
+
<td class="num">{avg_duration}</td>
|
|
91
|
+
</tr>"""
|
|
92
|
+
|
|
93
|
+
# Period display
|
|
94
|
+
period_display = stats.period.capitalize()
|
|
95
|
+
if stats.start_time and stats.end_time:
|
|
96
|
+
# Extract just the date portion
|
|
97
|
+
start_date = stats.start_time[:10]
|
|
98
|
+
end_date = stats.end_time[:10]
|
|
99
|
+
if start_date == end_date:
|
|
100
|
+
period_display = f"{period_display} ({start_date})"
|
|
101
|
+
else:
|
|
102
|
+
period_display = f"{period_display} ({start_date} to {end_date})"
|
|
103
|
+
|
|
104
|
+
html = f"""<!DOCTYPE html>
|
|
105
|
+
<html lang="en">
|
|
106
|
+
<head>
|
|
107
|
+
<meta charset="UTF-8">
|
|
108
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
109
|
+
<title>OneTool Statistics Report</title>
|
|
110
|
+
<style>
|
|
111
|
+
:root {{
|
|
112
|
+
--bg: #f8f9fa;
|
|
113
|
+
--card-bg: #ffffff;
|
|
114
|
+
--text: #212529;
|
|
115
|
+
--text-muted: #6c757d;
|
|
116
|
+
--border: #dee2e6;
|
|
117
|
+
--primary: #0d6efd;
|
|
118
|
+
--success: #198754;
|
|
119
|
+
--warning: #ffc107;
|
|
120
|
+
--kofi: #ff5e5b;
|
|
121
|
+
}}
|
|
122
|
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
123
|
+
body {{
|
|
124
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
125
|
+
background: var(--bg);
|
|
126
|
+
color: var(--text);
|
|
127
|
+
line-height: 1.5;
|
|
128
|
+
padding: 2rem;
|
|
129
|
+
}}
|
|
130
|
+
.container {{ max-width: 1200px; margin: 0 auto; }}
|
|
131
|
+
h1 {{ margin-bottom: 0.5rem; }}
|
|
132
|
+
.subtitle {{ color: var(--text-muted); margin-bottom: 2rem; }}
|
|
133
|
+
.cards {{
|
|
134
|
+
display: grid;
|
|
135
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
136
|
+
gap: 1rem;
|
|
137
|
+
margin-bottom: 2rem;
|
|
138
|
+
}}
|
|
139
|
+
.card {{
|
|
140
|
+
background: var(--card-bg);
|
|
141
|
+
border: 1px solid var(--border);
|
|
142
|
+
border-radius: 8px;
|
|
143
|
+
padding: 1.5rem;
|
|
144
|
+
}}
|
|
145
|
+
.card-label {{ color: var(--text-muted); font-size: 0.875rem; margin-bottom: 0.25rem; }}
|
|
146
|
+
.card-value {{ font-size: 1.75rem; font-weight: 600; }}
|
|
147
|
+
.card-value.success {{ color: var(--success); }}
|
|
148
|
+
.card-value.primary {{ color: var(--primary); }}
|
|
149
|
+
.card-value.warning {{ color: var(--warning); }}
|
|
150
|
+
.card-sub {{ color: var(--text-muted); font-size: 0.875rem; margin-top: 0.25rem; }}
|
|
151
|
+
.card-sub.success {{ color: var(--success); }}
|
|
152
|
+
table {{
|
|
153
|
+
width: 100%;
|
|
154
|
+
background: var(--card-bg);
|
|
155
|
+
border: 1px solid var(--border);
|
|
156
|
+
border-radius: 8px;
|
|
157
|
+
border-collapse: collapse;
|
|
158
|
+
overflow: hidden;
|
|
159
|
+
}}
|
|
160
|
+
th, td {{ padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--border); }}
|
|
161
|
+
th {{ background: var(--bg); font-weight: 600; }}
|
|
162
|
+
tr:last-child td {{ border-bottom: none; }}
|
|
163
|
+
.num {{ text-align: right; font-variant-numeric: tabular-nums; }}
|
|
164
|
+
.pack-header {{ background: var(--bg); }}
|
|
165
|
+
.tool-name {{ padding-left: 2rem; }}
|
|
166
|
+
.empty {{ color: var(--text-muted); text-align: center; padding: 2rem; }}
|
|
167
|
+
.support {{
|
|
168
|
+
margin-top: 2rem;
|
|
169
|
+
padding: 1.5rem;
|
|
170
|
+
background: var(--card-bg);
|
|
171
|
+
border: 1px solid var(--border);
|
|
172
|
+
border-radius: 8px;
|
|
173
|
+
text-align: center;
|
|
174
|
+
}}
|
|
175
|
+
.support h3 {{ margin-bottom: 0.5rem; font-size: 1rem; }}
|
|
176
|
+
.support p {{ color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem; }}
|
|
177
|
+
.kofi-btn {{
|
|
178
|
+
display: inline-block;
|
|
179
|
+
background: var(--kofi);
|
|
180
|
+
color: white;
|
|
181
|
+
text-decoration: none;
|
|
182
|
+
padding: 0.5rem 1rem;
|
|
183
|
+
border-radius: 6px;
|
|
184
|
+
font-weight: 500;
|
|
185
|
+
font-size: 0.875rem;
|
|
186
|
+
}}
|
|
187
|
+
.kofi-btn:hover {{ opacity: 0.9; }}
|
|
188
|
+
footer {{ margin-top: 2rem; color: var(--text-muted); font-size: 0.875rem; text-align: center; }}
|
|
189
|
+
</style>
|
|
190
|
+
</head>
|
|
191
|
+
<body>
|
|
192
|
+
<div class="container">
|
|
193
|
+
<h1>OneTool Statistics</h1>
|
|
194
|
+
<p class="subtitle">Period: {period_display}</p>
|
|
195
|
+
|
|
196
|
+
<div class="cards">
|
|
197
|
+
<div class="card">
|
|
198
|
+
<div class="card-label">Total Calls</div>
|
|
199
|
+
<div class="card-value">{stats.total_calls:,}</div>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="card">
|
|
202
|
+
<div class="card-label">Call Success Rate</div>
|
|
203
|
+
<div class="card-value success">{stats.success_rate:.1f}%</div>
|
|
204
|
+
</div>
|
|
205
|
+
<div class="card">
|
|
206
|
+
<div class="card-label">Tokens Saved</div>
|
|
207
|
+
<div class="card-value primary">{context_saved_str}</div>
|
|
208
|
+
</div>
|
|
209
|
+
<div class="card">
|
|
210
|
+
<div class="card-label">Time Saved</div>
|
|
211
|
+
<div class="card-value primary">{time_saved_str}</div>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="card">
|
|
214
|
+
<div class="card-label">Money Saved</div>
|
|
215
|
+
<div class="card-value success">{savings_str}</div>
|
|
216
|
+
<div class="card-sub success">{coffees_display}</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<h2 style="margin-bottom: 1rem;">Per-Tool Breakdown</h2>
|
|
221
|
+
<table>
|
|
222
|
+
<thead>
|
|
223
|
+
<tr>
|
|
224
|
+
<th>Tool</th>
|
|
225
|
+
<th class="num">Calls</th>
|
|
226
|
+
<th class="num">Success Rate</th>
|
|
227
|
+
<th class="num">Avg Duration</th>
|
|
228
|
+
</tr>
|
|
229
|
+
</thead>
|
|
230
|
+
<tbody>
|
|
231
|
+
{tool_rows if tool_rows else '<tr><td colspan="4" class="empty">No data available</td></tr>'}
|
|
232
|
+
</tbody>
|
|
233
|
+
</table>
|
|
234
|
+
|
|
235
|
+
<div class="support">
|
|
236
|
+
<h3>{SUPPORT_HTML_TITLE}</h3>
|
|
237
|
+
<p>{SUPPORT_HTML_MESSAGE}</p>
|
|
238
|
+
<a href="{KOFI_URL}" class="kofi-btn" target="_blank" rel="noopener">
|
|
239
|
+
{SUPPORT_HTML_BUTTON_TEXT}
|
|
240
|
+
</a>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<footer>
|
|
244
|
+
Generated by OneTool · {stats.end_time[:19] if stats.end_time else 'N/A'}
|
|
245
|
+
</footer>
|
|
246
|
+
</div>
|
|
247
|
+
</body>
|
|
248
|
+
</html>"""
|
|
249
|
+
|
|
250
|
+
return html
|
ot/stats/jsonl_writer.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Unified JSONL stats writer for run-level and tool-level records.
|
|
2
|
+
|
|
3
|
+
Records are buffered in memory and flushed to JSONL at configurable intervals.
|
|
4
|
+
Data loss is tolerable - stats are nice-to-have, not critical.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
14
|
+
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
RecordType = Literal["run", "tool"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _create_run_record(
|
|
25
|
+
client: str,
|
|
26
|
+
chars_in: int,
|
|
27
|
+
chars_out: int,
|
|
28
|
+
duration_ms: int,
|
|
29
|
+
success: bool,
|
|
30
|
+
error_type: str | None = None,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""Create a run-level stats record.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
client: MCP client name (e.g., "Claude Desktop")
|
|
36
|
+
chars_in: Input character count
|
|
37
|
+
chars_out: Output character count
|
|
38
|
+
duration_ms: Execution time in milliseconds
|
|
39
|
+
success: Whether the run succeeded
|
|
40
|
+
error_type: Exception class name if failed
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Record dict ready for JSONL serialization
|
|
44
|
+
"""
|
|
45
|
+
record: dict[str, Any] = {
|
|
46
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
47
|
+
"type": "run",
|
|
48
|
+
"client": client,
|
|
49
|
+
"chars_in": chars_in,
|
|
50
|
+
"chars_out": chars_out,
|
|
51
|
+
"duration_ms": duration_ms,
|
|
52
|
+
"success": success,
|
|
53
|
+
}
|
|
54
|
+
if error_type:
|
|
55
|
+
record["error_type"] = error_type
|
|
56
|
+
return record
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _create_tool_record(
|
|
60
|
+
client: str,
|
|
61
|
+
tool: str,
|
|
62
|
+
duration_ms: int,
|
|
63
|
+
success: bool,
|
|
64
|
+
error_type: str | None = None,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
"""Create a tool-level stats record.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
client: MCP client name (e.g., "Claude Desktop")
|
|
70
|
+
tool: Fully qualified tool name (e.g., "brave.search")
|
|
71
|
+
duration_ms: Execution time in milliseconds
|
|
72
|
+
success: Whether the tool call succeeded
|
|
73
|
+
error_type: Exception class name if failed
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Record dict ready for JSONL serialization
|
|
77
|
+
"""
|
|
78
|
+
record: dict[str, Any] = {
|
|
79
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
80
|
+
"type": "tool",
|
|
81
|
+
"client": client,
|
|
82
|
+
"tool": tool,
|
|
83
|
+
"duration_ms": duration_ms,
|
|
84
|
+
"success": success,
|
|
85
|
+
}
|
|
86
|
+
if error_type:
|
|
87
|
+
record["error_type"] = error_type
|
|
88
|
+
return record
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class JsonlStatsWriter:
|
|
92
|
+
"""Unified async batched JSONL writer for statistics.
|
|
93
|
+
|
|
94
|
+
Handles both run-level and tool-level records in a single file,
|
|
95
|
+
discriminated by the 'type' field.
|
|
96
|
+
|
|
97
|
+
Usage:
|
|
98
|
+
writer = JsonlStatsWriter(path, flush_interval=30)
|
|
99
|
+
await writer.start()
|
|
100
|
+
|
|
101
|
+
# Record run stats
|
|
102
|
+
writer.record_run(client="Claude", chars_in=500, ...)
|
|
103
|
+
|
|
104
|
+
# Record tool stats
|
|
105
|
+
writer.record_tool(client="Claude", tool="brave.search", ...)
|
|
106
|
+
|
|
107
|
+
# On shutdown
|
|
108
|
+
await writer.stop()
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, path: Path, flush_interval: int = 30) -> None:
|
|
112
|
+
"""Initialize writer.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
path: Path to JSONL file
|
|
116
|
+
flush_interval: Seconds between flushes
|
|
117
|
+
"""
|
|
118
|
+
self._path = path
|
|
119
|
+
self._flush_interval = flush_interval
|
|
120
|
+
self._buffer: list[dict[str, Any]] = []
|
|
121
|
+
self._lock = asyncio.Lock()
|
|
122
|
+
self._task: asyncio.Task[None] | None = None
|
|
123
|
+
self._running = False
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def path(self) -> Path:
|
|
127
|
+
"""Get the JSONL file path."""
|
|
128
|
+
return self._path
|
|
129
|
+
|
|
130
|
+
def record_run(
|
|
131
|
+
self,
|
|
132
|
+
client: str,
|
|
133
|
+
chars_in: int,
|
|
134
|
+
chars_out: int,
|
|
135
|
+
duration_ms: int,
|
|
136
|
+
success: bool,
|
|
137
|
+
error_type: str | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Record a run-level stats event (non-blocking)."""
|
|
140
|
+
record = _create_run_record(
|
|
141
|
+
client=client,
|
|
142
|
+
chars_in=chars_in,
|
|
143
|
+
chars_out=chars_out,
|
|
144
|
+
duration_ms=duration_ms,
|
|
145
|
+
success=success,
|
|
146
|
+
error_type=error_type,
|
|
147
|
+
)
|
|
148
|
+
self._buffer.append(record)
|
|
149
|
+
|
|
150
|
+
def record_tool(
|
|
151
|
+
self,
|
|
152
|
+
client: str,
|
|
153
|
+
tool: str,
|
|
154
|
+
duration_ms: int,
|
|
155
|
+
success: bool,
|
|
156
|
+
error_type: str | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Record a tool-level stats event (non-blocking)."""
|
|
159
|
+
record = _create_tool_record(
|
|
160
|
+
client=client,
|
|
161
|
+
tool=tool,
|
|
162
|
+
duration_ms=duration_ms,
|
|
163
|
+
success=success,
|
|
164
|
+
error_type=error_type,
|
|
165
|
+
)
|
|
166
|
+
self._buffer.append(record)
|
|
167
|
+
|
|
168
|
+
async def start(self) -> None:
|
|
169
|
+
"""Start the background flush task."""
|
|
170
|
+
if self._running:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
self._running = True
|
|
174
|
+
self._task = asyncio.create_task(self._flush_loop())
|
|
175
|
+
logger.debug(f"JSONL stats writer started: {self._path}")
|
|
176
|
+
|
|
177
|
+
async def stop(self) -> None:
|
|
178
|
+
"""Stop the writer and flush remaining records."""
|
|
179
|
+
self._running = False
|
|
180
|
+
|
|
181
|
+
if self._task is not None:
|
|
182
|
+
self._task.cancel()
|
|
183
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
184
|
+
await self._task
|
|
185
|
+
self._task = None
|
|
186
|
+
|
|
187
|
+
# Final flush
|
|
188
|
+
await self._flush()
|
|
189
|
+
logger.debug("JSONL stats writer stopped")
|
|
190
|
+
|
|
191
|
+
async def _flush_loop(self) -> None:
|
|
192
|
+
"""Background task that flushes buffer periodically."""
|
|
193
|
+
while self._running:
|
|
194
|
+
try:
|
|
195
|
+
await asyncio.sleep(self._flush_interval)
|
|
196
|
+
await self._flush()
|
|
197
|
+
except asyncio.CancelledError:
|
|
198
|
+
break
|
|
199
|
+
except Exception as e:
|
|
200
|
+
# Log but don't crash - stats are not critical
|
|
201
|
+
logger.warning(f"JSONL stats flush error: {e}")
|
|
202
|
+
|
|
203
|
+
async def _flush(self) -> None:
|
|
204
|
+
"""Flush buffer to JSONL file."""
|
|
205
|
+
async with self._lock:
|
|
206
|
+
if not self._buffer:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
records = self._buffer.copy()
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
await self._write_records(records)
|
|
213
|
+
# Clear buffer only after successful write to prevent data loss
|
|
214
|
+
async with self._lock:
|
|
215
|
+
del self._buffer[: len(records)]
|
|
216
|
+
logger.debug(f"Flushed {len(records)} JSONL stats records")
|
|
217
|
+
except Exception as e:
|
|
218
|
+
# Log but don't crash - stats are not critical
|
|
219
|
+
# Buffer NOT cleared; records will retry on next flush
|
|
220
|
+
logger.warning(f"Failed to write JSONL stats: {e}")
|
|
221
|
+
|
|
222
|
+
async def _write_records(self, records: Sequence[dict[str, Any]]) -> None:
|
|
223
|
+
"""Write records to JSONL file."""
|
|
224
|
+
# Ensure parent directory exists
|
|
225
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
|
|
227
|
+
# Run blocking I/O in thread pool
|
|
228
|
+
await asyncio.to_thread(self._write_jsonl, records)
|
|
229
|
+
|
|
230
|
+
def _write_jsonl(self, records: Sequence[dict[str, Any]]) -> None:
|
|
231
|
+
"""Sync JSONL write (called from thread pool)."""
|
|
232
|
+
with self._path.open("a") as f:
|
|
233
|
+
for record in records:
|
|
234
|
+
f.write(json.dumps(record, separators=(",", ":")) + "\n")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Global stats writer instance (set by server on startup)
|
|
238
|
+
_stats_writer: JsonlStatsWriter | None = None
|
|
239
|
+
_client_name: str = "unknown"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def get_stats_writer() -> JsonlStatsWriter | None:
|
|
243
|
+
"""Get the global stats writer instance."""
|
|
244
|
+
return _stats_writer
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def set_stats_writer(writer: JsonlStatsWriter | None) -> None:
|
|
248
|
+
"""Set the global stats writer instance."""
|
|
249
|
+
global _stats_writer
|
|
250
|
+
_stats_writer = writer
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_client_name() -> str:
|
|
254
|
+
"""Get the current MCP client name."""
|
|
255
|
+
return _client_name
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def set_client_name(name: str) -> None:
|
|
259
|
+
"""Set the current MCP client name."""
|
|
260
|
+
global _client_name
|
|
261
|
+
_client_name = name
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def record_tool_stats(
|
|
265
|
+
tool: str,
|
|
266
|
+
duration_ms: int,
|
|
267
|
+
success: bool,
|
|
268
|
+
error_type: str | None = None,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Record tool-level stats if writer is available.
|
|
271
|
+
|
|
272
|
+
Convenience function for use from executor dispatch points.
|
|
273
|
+
Uses the global client name set during MCP initialization.
|
|
274
|
+
"""
|
|
275
|
+
writer = get_stats_writer()
|
|
276
|
+
if writer is not None:
|
|
277
|
+
writer.record_tool(
|
|
278
|
+
client=get_client_name(),
|
|
279
|
+
tool=tool,
|
|
280
|
+
duration_ms=duration_ms,
|
|
281
|
+
success=success,
|
|
282
|
+
error_type=error_type,
|
|
283
|
+
)
|