Goal
Today our tools give almost no feedback beyond errors. A write returns a hardcoded {"status":"OK"} / "Successfully executed X", so the agent can't tell the user how many rows were actually written. We want post-query statistics surfaced in both write and read tool results — written_rows first and foremost, plus read_rows/read_bytes/elapsed.
Scope:
- Writes (INSERT) — primary.
written_rows / written_bytes.
- Reads (SELECT) — covered too.
read_rows, read_bytes, result_rows, elapsed.
- Out of scope: mutations / DDL (
ALTER … UPDATE/DELETE, lightweight DELETE, CREATE/DROP/TRUNCATE) — ClickHouse executes these asynchronously, so there is no synchronous affected-row count to report. We will not fake one.
Research: the driver does not surface stats over HTTP
ClickHouse exposes per-query stats via the X-ClickHouse-Summary HTTP header (and progress/profile packets over native). We use Altinity/clickhouse-go/v2 (fork v2.45.1-0.20260424…). Findings from the fork source:
| Path |
Evidence |
Result |
HTTP exec (writes) |
conn_http_exec.go:18 — defer discardAndClose(res.Body); returns nil |
body discarded, no stats |
HTTP query (reads) |
conn_http_query.go:31-135 — decodes only Native-format data blocks |
no progress/profile |
| HTTP response headers |
conn_http.go:703-722 executeRequest returns resp, never reads X-ClickHouse-Summary |
header present, never read |
| Native (TCP) |
conn_process.go:12-18 — onProcess{ progress, profileInfo } dispatched in packet loop |
works, but N/A — see below |
So clickhouse.WithProgress / WithProfileInfo (which exist; proto.Progress has WroteRows, WroteBytes, Rows, Bytes, ElapsedNs) only fire on the native protocol. Our production auth (OAuth Bearer / Basic via the ch-jwt-verify sidecar) is HTTP-bound, and the HTTP transport reads none of it.
Solution: capture X-ClickHouse-Summary via Options.TransportFunc (no fork patch)
The fork honors a custom round-tripper — conn_http.go:261 → if opt.TransportFunc != nil { return opt.TransportFunc(rt) }. We install a RoundTripper that reads X-ClickHouse-Summary off resp.Header and writes it into a per-request *summaryHolder stashed in the ctx (reachable in RoundTrip via req.Context(), since the driver threads our ctx into http.NewRequestWithContext, conn_http.go:612). Composes cleanly with OAuth/Basic, which is applied separately in applyOptionsToRequest.
Proven end-to-end (spike branch spike-query-stats, cmd/stats-spike/) against CH 26.3 over HTTP:
INSERT -> source="header" written_rows=1234 written_bytes=9872 read_rows=1234 ✅
INS..SEL -> source="header" written_rows=2500 read_rows=5000 ✅
SELECT(stream) -> source="header" read_rows=1234 result_rows=0 ❌ premature header
SELECT(wait_eoq) -> source="header" read_rows=7468 result_rows=7468 ✅ accurate
- The summary arrives as a normal response header (
source="header") — no trailer handling needed.
- INSERT is immediate and accurate (no streaming body).
- Streaming SELECT emits a premature header (
result_rows=0). Fix: wait_end_of_query=1 makes it fully accurate. Confirmed acceptable for our workloads (we already cap result size, so server-side buffering is bounded). We'll set it on both read and write paths for one accurate code path.
Implementation plan
- Install
TransportFunc (statsRoundTripper) when building the HTTP client in pkg/clickhouse/client.go.
- Per-request
*summaryHolder in ctx; parse X-ClickHouse-Summary JSON (read_rows, read_bytes, written_rows, written_bytes, result_rows, elapsed_ns, memory_usage).
- Add
Stats *QueryStats \json:"stats,omitempty"`toQueryResult; populate in executeSelectandexecuteNonSelect. Set wait_end_of_query=1`.
- Replace the write tool's
"Successfully executed X" with a summary line, e.g. "Inserted 1,234 rows (9.9 KB) in 7 ms." — keep the structured stats alongside for programmatic use.
- Graceful degradation: omit
stats if the header is absent (native transport, summary-stripping proxy, older server). Never error on missing stats.
- Keep the payload compact (flat object / one line) — it rides every tool result the model processes.
Open questions
- Verbosity/token budget: include full stats on reads, or only
read_rows + elapsed + a truncation note? Writes clearly want written_rows.
- Config toggle (
clickhouse.query_stats, default on) for token-sensitive deployments?
wait_end_of_query=1 memory implications for the largest uncapped internal queries (e.g. dynamic-tool discovery, schema introspection) — apply only to user-facing read/write, not internal queries?
- Should we still attempt native-protocol stats (via
WithProgress/WithProfileInfo) for completeness, or keep HTTP-summary as the single source given prod is HTTP?
Spike: branch spike-query-stats, cmd/stats-spike/main.go.
Goal
Today our tools give almost no feedback beyond errors. A write returns a hardcoded
{"status":"OK"}/"Successfully executed X", so the agent can't tell the user how many rows were actually written. We want post-query statistics surfaced in both write and read tool results —written_rowsfirst and foremost, plusread_rows/read_bytes/elapsed.Scope:
written_rows/written_bytes.read_rows,read_bytes,result_rows,elapsed.ALTER … UPDATE/DELETE, lightweightDELETE,CREATE/DROP/TRUNCATE) — ClickHouse executes these asynchronously, so there is no synchronous affected-row count to report. We will not fake one.Research: the driver does not surface stats over HTTP
ClickHouse exposes per-query stats via the
X-ClickHouse-SummaryHTTP header (and progress/profile packets over native). We useAltinity/clickhouse-go/v2(forkv2.45.1-0.20260424…). Findings from the fork source:exec(writes)conn_http_exec.go:18—defer discardAndClose(res.Body); returnsnilquery(reads)conn_http_query.go:31-135— decodes only Native-format data blocksconn_http.go:703-722executeRequestreturnsresp, never readsX-ClickHouse-Summaryconn_process.go:12-18—onProcess{ progress, profileInfo }dispatched in packet loopSo
clickhouse.WithProgress/WithProfileInfo(which exist;proto.ProgresshasWroteRows,WroteBytes,Rows,Bytes,ElapsedNs) only fire on the native protocol. Our production auth (OAuth Bearer / Basic via thech-jwt-verifysidecar) is HTTP-bound, and the HTTP transport reads none of it.Solution: capture
X-ClickHouse-SummaryviaOptions.TransportFunc(no fork patch)The fork honors a custom round-tripper —
conn_http.go:261→if opt.TransportFunc != nil { return opt.TransportFunc(rt) }. We install aRoundTripperthat readsX-ClickHouse-Summaryoffresp.Headerand writes it into a per-request*summaryHolderstashed in the ctx (reachable inRoundTripviareq.Context(), since the driver threads our ctx intohttp.NewRequestWithContext,conn_http.go:612). Composes cleanly with OAuth/Basic, which is applied separately inapplyOptionsToRequest.Proven end-to-end (spike branch
spike-query-stats,cmd/stats-spike/) against CH 26.3 over HTTP:source="header") — no trailer handling needed.result_rows=0). Fix:wait_end_of_query=1makes it fully accurate. Confirmed acceptable for our workloads (we already cap result size, so server-side buffering is bounded). We'll set it on both read and write paths for one accurate code path.Implementation plan
TransportFunc(statsRoundTripper) when building the HTTP client inpkg/clickhouse/client.go.*summaryHolderin ctx; parseX-ClickHouse-SummaryJSON (read_rows, read_bytes, written_rows, written_bytes, result_rows, elapsed_ns, memory_usage).Stats *QueryStats \json:"stats,omitempty"`toQueryResult; populate inexecuteSelectandexecuteNonSelect. Setwait_end_of_query=1`."Successfully executed X"with a summary line, e.g. "Inserted 1,234 rows (9.9 KB) in 7 ms." — keep the structuredstatsalongside for programmatic use.statsif the header is absent (native transport, summary-stripping proxy, older server). Never error on missing stats.Open questions
read_rows+elapsed+ a truncation note? Writes clearly wantwritten_rows.clickhouse.query_stats, default on) for token-sensitive deployments?wait_end_of_query=1memory implications for the largest uncapped internal queries (e.g. dynamic-tool discovery, schema introspection) — apply only to user-facing read/write, not internal queries?WithProgress/WithProfileInfo) for completeness, or keep HTTP-summary as the single source given prod is HTTP?Spike: branch
spike-query-stats,cmd/stats-spike/main.go.