TCP P: observability — metadata callbacks, OpenTelemetry tracing and ILogger - #589
TCP P: observability — metadata callbacks, OpenTelemetry tracing and ILogger#589alex-clickhouse wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds observability to the native TCP client, including metadata callbacks, OpenTelemetry propagation, and structured logging.
Changes:
- Exposes progress, profile, server-log, totals, and extremes callbacks.
- Adds client, connection, pool tracing and logging.
- Adds extensive unit and integration coverage.
Reviewed changes
Copilot reviewed 35 out of 35 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
ClickHouse.Driver.Tcp/Protocol/Progress.cs |
Removes internal progress model. |
ClickHouse.Driver.Tcp/Protocol/MetadataHandlers.cs |
Uses public metadata models. |
ClickHouse.Driver.Tcp/Protocol/ClientInfo.cs |
Propagates W3C trace context. |
ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs |
Decodes public progress/profile types. |
ClickHouse.Driver.Tcp/Logging/PoolLog.cs |
Defines pool log events. |
ClickHouse.Driver.Tcp/Logging/ConnectionLog.cs |
Defines connection log events. |
ClickHouse.Driver.Tcp/Logging/ClientLog.cs |
Defines operation log events. |
ClickHouse.Driver.Tcp/Diagnostic/TcpActivity.cs |
Implements TCP tracing attributes. |
ClickHouse.Driver.Tcp/Diagnostic/ClientOperation.cs |
Coordinates tracing, logging, and callbacks. |
ClickHouse.Driver.Tcp/Diagnostic/ClickHouseTcpDiagnostics.cs |
Exposes diagnostic source/category names. |
ClickHouse.Driver.Tcp/Client/MetadataCallbackBridge.cs |
Projects metadata packets into callback values. |
ClickHouse.Driver.Tcp/Client/IConnectionFactory.cs |
Instruments connection establishment. |
ClickHouse.Driver.Tcp/Client/ConnectionPool.cs |
Adds pool lifecycle logging. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpServerLogRow.cs |
Adds public server-log model. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpQueryOptions.cs |
Adds per-query callbacks. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpQueryCallbacks.cs |
Defines callback API. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpProgress.cs |
Adds public progress accumulator. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpProfileInfo.cs |
Publishes profile summaries. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpProfileEvent.cs |
Adds public profile-event model. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpClientOptions.cs |
Adds logging and telemetry options. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs |
Instruments query, insert, and ping operations. |
ClickHouse.Driver.Tcp.Tests/Utilities/CapturingLogger.cs |
Adds test logging utilities. |
ClickHouse.Driver.Tcp.Tests/Protocol/ServerPacketDecoderTests.cs |
Updates metadata decoding tests. |
ClickHouse.Driver.Tcp.Tests/Protocol/ClientInfoTraceContextTests.cs |
Tests trace-context encoding. |
ClickHouse.Driver.Tcp.Tests/Protocol/ClickHouseTcpConnectionQueryTests.cs |
Updates query metadata tests. |
ClickHouse.Driver.Tcp.Tests/Protocol/ClickHouseTcpConnectionInsertTests.cs |
Updates insert metadata tests. |
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpTracingIntegrationTests.cs |
Tests tracing and propagation. |
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpLoggingIntegrationTests.cs |
Tests operation and connection logs. |
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpCallbackIntegrationTests.cs |
Tests callbacks end to end. |
ClickHouse.Driver.Tcp.Tests/Diagnostic/TcpActivityTests.cs |
Tests span attributes and status. |
ClickHouse.Driver.Tcp.Tests/Diagnostic/ClientOperationTests.cs |
Tests operation instrumentation. |
ClickHouse.Driver.Tcp.Tests/Client/MetadataCallbackBridgeTests.cs |
Tests metadata projection. |
ClickHouse.Driver.Tcp.Tests/Client/ConnectionPoolLoggingTests.cs |
Tests pool log events. |
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpProgressTests.cs |
Tests progress accumulation. |
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpClientOptionsTests.cs |
Tests option copying. |
Suppressed comments (3)
ClickHouse.Driver.Tcp/Client/ConnectionPool.cs:472
- Log only after closing the reaped connections. A caller-provided logger can throw here; the
finallythen clearsreaped, so the removed idle connections are lost without ever being closed. This is the same close-before-log safeguard already used by pool teardown.
if (reaped.Count != 0 && logger is not null)
{
PoolLog.Retired(logger, reaped.Count);
}
CloseAll(reaped);
ClickHouse.Driver.Tcp/Client/ConnectionPool.cs:282
- This log call can abort teardown before the pool waits for/aborts leased connections and disposes the factory. A throwing logger therefore turns diagnostics into incomplete resource cleanup. Defer this message until teardown is complete or guard it so cleanup always continues.
PoolLog.Draining(logger, closing?.Count ?? 0, inFlight);
ClickHouse.Driver.Tcp/Client/ConnectionPool.cs:782
- Close the discarded connection before logging it. If the caller-provided logger throws here, the connection has already been removed from
leased; the permit is released byfinally, but the socket is lost and never closed.
if (!pooled)
{
// Reusable but not pooled means disposal got there first, and the drain line already reports that.
if (!reusable && logger is not null)
{
PoolLog.Discarded(logger, connection.UsageCount);
}
connection.Close();
}
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
3542e6e to
2bde0e4
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
2bde0e4 to
40deb29
Compare
40deb29 to
03abaf4
Compare
03abaf4 to
523e6d7
Compare
523e6d7 to
770cd1b
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a9a9734. Configure here.
bea85e6 to
f5f896f
Compare
f5f896f to
267660e
Compare
Surfaces the metadata the server already decoded, adds an ActivitySource
with trace-context propagation, and gives the client its own ILogger.
The callbacks: ClickHouseTcpQueryOptions.Callbacks takes a
ClickHouseTcpQueryCallbacks whose six members are the packet hooks.
Progress and ProfileInfo move out of the internal protocol layer as
ClickHouseTcpProgress and ClickHouseTcpProfileInfo. Progress counters are
increments, so the type carries operator + rather than leaving every
caller to find that out; an integration test sums them to an exact row
count. Log and ProfileEvents have a schema the server fixes, so both
project into owned row structs and no borrowed block reaches a caller who
cannot know it is about to be released. Totals and Extremes carry the
query's own result shape and stay borrowed blocks.
Tracing emits the current stable OpenTelemetry database attributes, not
the deprecated set the HTTP transport still uses, and ClientInfo's
OpenTelemetry field is no longer a hardcoded has_trace = 0: with an
ambient W3C Activity it carries the trace context, so the server's own
opentelemetry_span_log rows hang off the caller's trace. The encoding is
asymmetric to verify — the integration test requires the server to have
recorded a span against the trace id the client sent, because a
self-consistent byte-order mistake is invisible to a round trip.
Logging covers the client's own lifecycle under
ClickHouse.Driver.Tcp.{Client,Connection,Pool}, through source-generated
LoggerMessage partials. The two events that matter most are the ones the
pool deliberately swallows: a background top-up dial and a sweep both run
on a timer with nobody awaiting them, so the logger is the only place
either failure is reported.
Server log lines are not bridged into ILogger. Re-emitting them is the
caller's decision, and nothing the client does turns them on: the
server's send_logs_level default is effectively silent, which a test
pins.
StreamAsync enumerates by hand rather than with await foreach, because a
yield cannot sit inside a try that has a catch and a failed query whose
span carries no error is the one thing a trace must not do.
3302 tests pass against 26.6 (26.6.3.62) and 3257 against 25.8
(25.8.32.4) — the ends of the CI matrix — with no failures.
Co-Authored-By: Claude <noreply@anthropic.com>
…operation An insert reported an empty read. The completion line and the span counters were chosen from the Progress write counters, so an insert logged "reading 0 rows / 0 bytes" and its span carried read_rows=0 and elapsed_ns=0. The server sends no Progress packet for rows a client streams to it, at any size, so that branch was never reached. The row count now comes from what the client sent, and a statement the server reported no counters for gets no counter attributes instead of zeroes. Build the loggers through DiagnosticLogger.Create, which wraps the caller's logger so it cannot throw into the driver. The log calls sit between the steps that hand a connection from one owner to the next, where an exception left a socket with nobody to close it. A caller callback still propagates: that is the caller's own code acting on their data, where failing the operation is a defensible answer. Co-Authored-By: Claude <noreply@anthropic.com>
The write branch of the completion line and the span counters were pinned only by unit tests that fed in a Progress packet of my own making, which is how the insert misreport survived: the shape those tests described was one the server never sends. Both branches now have integration coverage — an insert reports the rows the client sent, an INSERT ... SELECT reports the counters the server sent — and the unit tests they restate are gone. Co-Authored-By: Claude <noreply@anthropic.com>
The metadata callbacks handed two of their blocks over as owned rows and the other two as borrowed blocks, on a rule about which schemas the server fixes that is invisible at the call site. Make every one of them lend the block, the same contract StreamAsync already has, so nothing is materialized that the caller did not ask for: the log projection built four strings per row and the profile-event projection two, for every row of every such block, whichever fields the caller went on to read. Blocks now carry the accessors that makes reading one practical: a by-name indexer, TryGetColumn, and Column<T> by name or index, which report the block and its columns when a name is absent and the column's type when it cannot be read as T. The callback bridge had these as a private scan because Block offered no way to address a column by name. The client's own progress and profile-info observers no longer merge into the caller's callbacks. The connection takes both and invokes them in turn, its own first, so a caller callback that throws still cannot cost the client telemetry it already had. That drops the bridge, the wire-shaped handler type it translated into, and the projected row types, and lets ClientOperation stop taking callbacks at all: a caller who wants progress and no tracing starts no operation. The Log and ProfileEvents schemas move to the callback documentation, priority being a Poco severity where a lower number is more severe. The integration tests read those blocks by column name, so a server-side rename fails them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
StatementMaxLength now defaults to 5 rather than 300, so query text reaches a span attribute or a log line only when a caller raises it deliberately. Five rather than zero, because at zero IncludeSqlInActivityTags would produce no db.query.text at all and look broken; at five it produces a visibly truncated stub. Condense the ClickHouseTcpQueryCallbacks and LoggerFactory documentation. The Log and ProfileEvents column tables become one line each, and the LoggerFactory remarks lose the paragraph arguing that logging statement text is deliberate. Drop five unit tests whose every assertion is made again by an integration test against a real server, and collapse the six connection-level metadata tests into one parametrized test. That fixture now asserts only what the client-level one cannot: that each packet reaches its callback and leaves the connection reusable. The two assertions it alone made, the single Totals row and exactly one Extremes block, move to the callback fixture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A timer captures the execution context when it is created and restores it for every callback, and Activity.Current rides in it. The sweep timer is built in the pool constructor, so a client built inside a traced request gave every later background MinPoolSize dial that request's span as the parent of its connect span, with its trace id, for as long as the client lived. The parent has ended by then, so the child sits outside its time bounds, and the captured context also pins that Activity and its parents for the life of the pool. Suppressing the capture at the timer covers the whole chain: the callback runs without a context, so the top-up it starts through Task.Run captures none either. Foreground dials are untouched and still nest under the operation that needed them. The test uses a real timer rather than ControlledTimeProvider, whose timer never fires and so captures nothing to inherit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
267660e to
202b10d
Compare

Stacked on
tcp/epic-k5-qbit(#579). Implements Epic P — observability for the native/TCP client: the metadata the server already decodes becomes a public surface, spans get emitted and propagated, and the client gets its own logger.Three parts, one per commit-worth of concern, plus the decisions behind them.
1. Metadata callbacks
Every metadata packet the server interleaves was already decoded and handed to an
internal MetadataHandlers— which no code underClient/ever referenced, so the dispatch target was unconditionally null at runtime. This connects it.ClickHouseTcpQueryOptions.Callbackstakes aClickHouseTcpQueryCallbacks:Plain callbacks rather than events or
IProgress<T>: an event on a shared client cannot be scoped to one query, andIProgress<T>posts to a captured context, which would move the work off the read path and lose the ordering the wire gives it.Progress/ProfileInfomove out ofProtocolas publicClickHouseTcpProgress/ClickHouseTcpProfileInforecord structs.The Progress counters are increments, not running totals. That is the wire's contract and it is the easiest thing in this API to get wrong, so the type carries
operator +rather than leaving every caller to discover it; an integration test sums the increments overnumbers(2000000)and requires exactly2_000_000, which a running-total reading could not produce.Log and ProfileEvents project into owned row structs (
ClickHouseTcpServerLogRow,ClickHouseTcpProfileEvent). Those two packets have a schema the server fixes, so a row type is safe to publish — and it means no borrowedBlockreaches a caller who cannot know it is about to be released. Totals and Extremes carry the query's own result shape, so they stay borrowed blocks, on the same contractStreamAsyncalready has.Server log lines are not bridged into
ILogger. Re-emitting what the server says through our logger is the caller's decision, not the client's. Worth knowing: nothing the client does turns them on either — the server'ssend_logs_leveldefault is effectively silent, so setting only the callback yields nothing, and a test pins that so the surprise is documented rather than discovered.2. Tracing, and trace context on the wire
ClickHouseTcpDiagnostics.ActivitySourceNameis"ClickHouse.Driver.Tcp", separate from the HTTP transport's source so either can be collected alone. Spans atStreamAsync(soQueryAsync/QueryAsync<T>/ExecuteAsyncall get one), eachInsertAsync,PingAsync, and the dial.Attributes are the current stable OpenTelemetry database conventions —
db.system.name,db.namespace,db.query.text,db.operation.name,server.address,server.port,error.type,db.response.status_code(the server's own error code) — not the deprecateddb.system/db.statement/peer.serviceset the HTTP transport still emits. That is a deliberate inconsistency inside one package: this surface is new and[Experimental], so it seemed the wrong moment to adopt names upstream has already retired.db.clickhouse.*counters are shared with HTTP.db.useris kept for parity even though the conventions dropped it, and the code says so.ClientInfo's OpenTelemetry field is no longer a hardcodedhas_trace = 0. With an ambient W3CActivityit carries the trace id, span id, trace state and flags, so the server's ownsystem.opentelemetry_span_logrows hang off the caller's trace. The HTTP transport does not do this — it relies onHttpClientinjecting W3C headers, and a raw socket has no equivalent free ride.The encoding is the part to review carefully.
trace_idis a ClickHouse UUID — two 8-byte halves, each little-endian — so each half of the big-endian W3C id is reversed;span_idis a plain little-endianUInt64, so it reverses whole. Verified againstclickhouse-go'sswap64/swap64inlib/proto/query.go.A byte-order mistake here is invisible to a round trip: we would send bytes the server reads as a different trace, and nothing on our side would notice. So the proof is asymmetric —
ClickHouseTcpTracingIntegrationTestsruns a query under a recorded span, then requires the server to have written a row tosystem.opentelemetry_span_logagainst the trace id the client sent. Getting the swap wrong yields a different, self-consistent trace id and zero rows.3. Logging
ClickHouseTcpClientOptions.LoggerFactory, categories exposed asClickHouseTcpDiagnostics.{Client,Connection,Pool}LogCategory, and source-generated[LoggerMessage]partials underLogging/so a disabled level formats nothing.The two events that most needed this are the ones the pool deliberately swallows. A background top-up dial and a sweep both run on a timer with nobody awaiting them — the pool's own comments said as much, reserving the work for this epic — so without a logger either failure was invisible.
ConnectionPoolLoggingTestscovers both, and they are reachable no other way.Every statement line carries
QueryId, the key that joins client-side telemetry tosystem.query_log; it is only reported when the caller set one, a server-assigned id never coming back.StatementMaxLengthcaps the statement text in both channels, so one setting governs how much SQL leaves the client whichever way it leaves, and0keeps it out of telemetry entirely.What emits what
Spans
Three shapes, all
ActivityKind.Client. Every one carriesdb.system.name,server.address,server.port,db.namespaceanddb.user.SELECT,INSERT,WITH… (orquerywhen unreadable)StreamAsync, and so alsoQueryAsync,QueryAsync<T>andExecuteAsync, which all funnel through it; plus each of the threeInsertAsyncoverloadspingPingAsyncconnectTcpConnectionFactoryStatement spans add
db.operation.name(when the leading keyword is readable),db.clickhouse.query_id(when the caller set one) anddb.query.text(only withIncludeSqlInActivityTags, truncated toStatementMaxLength).Statement spans are annotated as the response arrives:
db.clickhouse.read_rows/read_bytes/elapsed_nsfrom the summed Progress increments, andresult_rows/result_bytesfrom ProfileInfo.written_rowsis the rows the client sent, because the server sends no Progress at all for rows streamed to it;written_bytesappears only for the statements it does count writes for, anINSERT ... SELECTamong them. A statement the server sent no Progress for gets none of these rather than a set of zeroes.Status is
Okon completion andErroron failure — the latter addingerror.type, anexceptionevent, anddb.response.status_codecarrying the server's own error code when the failure came from the server. An enumeration the caller abandons part-way is leftUnset: neither outcome is an honest claim about a result nobody finished reading.A session's operations produce the same spans, a session being this client over a pinned connection.
Log lines
ClickHouse.Driver.Tcp.Client— one line per operation, each carryingOperationandQueryId:StatementMaxLength)ClickHouse.Driver.Tcp.Connection:ClickHouse.Driver.Tcp.Pool:MaxConnectionLifetimeorIdleTimeoutPoolTimeout— logged just before theTimeoutException3005 and 3006 are the two the pool deliberately swallows — both run on a timer with nobody awaiting them, so a logger is the only place either is reported at all.
Notes for review
StreamAsyncis enumerated by hand rather than withawait foreach. Ayieldcannot sit inside atrythat has acatch, and a failed query whose span carries no error is the one thing a trace must not do — that asymmetry already exists in the HTTP driver'sPostStreamAsync, which records nothing for a transport failure. The disposals in thefinallyare nested, not sequential:Terminate()swallows nothing, so a throw while disposing the enumerator would otherwise skiplease.DisposeAsync()and cost the pool a permit for the client's life.ClientInfo.Writereads ambientActivity.Currentrather than taking it as a parameter. That is the standard instrumentation idiom and needs no plumbing through three layers, but it does make the Query packet's bytes a function of hidden state — worth a second opinion.ClientOperation.StartchecksSource.HasListeners()before scanning the statement for its leading keyword, because that scan allocates a substring and aToUpperInvariant. A unit test pins the null return.valuecolumn is read asInt64only. The protocol notes say the width varies by version, but 25.8 — our floor — was measured sendingInt64, so a second path would have been dead code.docs/change: the TCP client has no public-docs presence at all, so documenting this means introducing the whole client, which is R1/R4's job. Filed as P8.Tests
ClientInfoTraceContextTests), span attribute names and gating (TcpActivityTests), the callback projection's error paths and its degrade-to-Unknownbehaviour over hand-built blocks (MetadataCallbackBridgeTests), the zero-cost contract (ClientOperationTests), pool logging over connections that need no server (ConnectionPoolLoggingTests).ClickHouseTcpClient, the Progress-increment sum, span status on success/failure/abandonment, and the trace-propagation round trip.3302 tests pass against 26.6 (26.6.3.62) and 3257 against 25.8 (25.8.32.4), the ends of the CI matrix, with no failures.
🤖 Generated with Claude Code