feat(client-v2-otel): add OpenTelemetry span recorder module - #3065
Conversation
Adds the optional module client-v2-otel with OpenTelemetrySpanRecorder, an implementation of the client-v2 observability SPI that reports operation and transport-request spans to OpenTelemetry. The recorder derives every span name and attribute through SpanSupport, so it reports the standard values, and maps them onto OpenTelemetry: CLIENT spans, an operation span under the current context, a request span per attempt under its operation span, typed attributes, and ERROR status plus an exception event on failure. Implements: #2974
Client V2 CoverageCoverage Report
Class Coverage
|
JDBC V2 CoverageCoverage Report
Class Coverage
|
JDBC V1 CoverageCoverage Report
Class Coverage
|
Client V1 CoverageCoverage Report
Class Coverage
|
There was a problem hiding this comment.
Pull request overview
Adds an optional OpenTelemetry implementation of the client-v2 span recorder SPI for issue #2974.
Changes:
- Adds
OpenTelemetrySpanRecorderwith typed attributes, nesting, failures, and idempotent completion. - Adds unit and integration coverage.
- Registers and documents the new Maven module.
Compatibility is additive; existing client-v2 dependencies remain unchanged. The request URL contract and lazy-global test coverage remain unresolved. Author-reported tests were not independently rerun.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
pom.xml |
Registers the module and OpenTelemetry version. |
client-v2-otel/pom.xml |
Defines dependencies and Java 8 compilation. |
OpenTelemetrySpanRecorder.java |
Implements the OpenTelemetry recorder. |
OpenTelemetrySpanRecorderUnitTest.java |
Tests recorder behavior and edge cases. |
OpenTelemetrySpanRecorderTest.java |
Adds live-server tracing tests. |
docs/features.md |
Documents features and compatibility traits. |
CHANGELOG.md |
Announces the new module. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- INSTRUMENTATION_SCOPE_NAME is documented as the default scope name, and the javadoc now states that forTracer(Tracer) reports the scope of the given tracer instead. - Add a test that creates the no-argument recorder before the global SDK is installed and asserts that a span started afterwards reaches that SDK. It fails if the global instance is read in the constructor.
TriageCategory: Summary What this impacts
Concerns
Required reviewer action
|
chernser
left a comment
There was a problem hiding this comment.
- move this code to client-v2 project
- opentelementry depedencies should be compile only - if they provided at runtime we can use it.
- see comments.
The recorder overrides every method of the SPI, so the base class added nothing but its getSpanSupport() accessor. Implement the interface and call SpanSupport.DEFAULT directly, which is where the logic lives.
- SpanSupport is a member of the recorder now, as it implements the interface directly. - Removed the forTracer(Tracer) factory: a public constructor takes the tracer instead. - Removed the duplicated Supplier<Tracer> bodies. The tracer field is the single state: the OpenTelemetry constructor resolves the tracer from the instance, and the no-argument constructor leaves it unset, which keeps the documented lazy read of GlobalOpenTelemetry.
Review feedback from @chernser: - move this code to client-v2 project - opentelemetry dependencies should be compile only The client-v2-otel module is removed and OpenTelemetrySpanRecorder, its unit test and its integration test move into client-v2 unchanged (the package com.clickhouse.client.api.observability.otel is kept). opentelemetry-api is declared with scope provided in client-v2, the same way jackson and gson already are: it is on the compile and test classpath only, it is not transitive to consumers, and the recorder is usable by an application that already provides the OpenTelemetry API at runtime. Core client-v2 has no reference to the recorder, so a user without OpenTelemetry on the classpath never loads the class. Verified: no io/opentelemetry entry in the client-v2 "all" shaded jar or in the clickhouse-jdbc-all uber jar, while the recorder class ships in the plain client-v2 jar. client-v2 unit tests 582 pass (556 before, plus the 26 moved), the 3 integration tests pass, and the full reactor builds. Moving into client-v2 also puts both suites into the CI matrix, which the separate module was not part of.
|
Both items from the review are done in 1. Moved into A side benefit: the separate module was never added to the CI matrix in 2. OpenTelemetry is compile-only. Verified rather than assumed:
Tests: Still open and waiting on you, unchanged by this push: the |
chernser
left a comment
There was a problem hiding this comment.
I've realized that API of span recorder lacks separation by operation type. Add it in this PR (it is fine because we working on one big feature) as two separate methods for query and insert. Each track should result collecting specific metric from com.clickhouse.client.api.metrics.OperationMetrics.
Class com.clickhouse.client.api.metrics.OperationMetrics should have operation type - small enum. Just in case.
The recorder reported the outcome of every completed operation through one recordSuccess method, so a query and an insert were indistinguishable at the point where the metrics of the operation are read, and only the metrics of a read were recorded. Split it into recordQuerySuccess and recordInsertSuccess. Each track records the metrics that describe its own kind of operation: a query reports the returned rows and what the server read, an insert reports what the server wrote. OperationMetrics carries the kind as well, through the new OperationType enum, so a caller that keeps the metrics knows which of them are meaningful.
|
Done in bc5189b — both items. Separation by operation type. Each track collects the metrics of its own kind of operation from
The server sends the whole summary for either kind, so this is a real filter, not just a rename: a query span never claims written rows and an insert span never claims returned rows.
Verification. 586 client-v2 unit tests and 4 integration tests against a live server, none of the existing ones edited or weakened. The new coverage pins the separation itself, not just the new names: one test feeds a summary that carries both read and write metrics to both tracks and asserts each span gets only its own; with the tracks recording the same thing it fails. The integration tests assert the real values through the real entry points — Question — should the failure callback be split too? I left Still open from earlier, both needing your call: the |
chernser
left a comment
There was a problem hiding this comment.
remove operation type UNKNOWN because we always know operation type - it is called from query or insert method
Everything looks good - just review and done!
OperationMetrics keeps a single constructor, OperationMetrics(ClientStatisticsHolder, OperationType), and OperationType loses its UNKNOWN constant. Metrics are created by the client, which always knows whether it runs a query or an insert, and the constructor takes an internal type, so there is no caller that does not know the kind. A null kind is rejected instead of being read as UNKNOWN.
|
Both items of the review are done, pushed as "remove operation type UNKNOWN because we always know operation type" — "There should be only one constructor" — answered in the thread: One consequence worth naming: the removed constructor shipped in 0.10.x, so it is a source-breaking change for anyone who called it. It takes an internal type ( Verification: Still open from earlier rounds, if you want to close them out: |
|
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 6d5dc4c. Configure here.



Description
Implements #2974 — PR 2 of 2, on top of the SPI merged in #2988.
Adds the optional module
com.clickhouse:client-v2-otelwithOpenTelemetrySpanRecorder, a consumer of the merged SPI. It is registered like any other recorder:No change to
client-v2code: the module only implementsSpanRecorderand opts in toSpanSupportfor the standard names and attributes, so it reports the same information as any other recorder.Design
OpenTelemetrySpanRecorder extends DefaultSpanRecorder(packagecom.clickhouse.client.api.observability.otel). Everystart...method takes the name fromSpanSupport(querySpanName/insertSpanName/requestSpanName) and the attributes fromfill*Attributes; everyrecord...method delegates to the matchingSpanSupportmethod. Nothing is recomputed here.new OpenTelemetrySpanRecorder(openTelemetry),OpenTelemetrySpanRecorder.forTracer(tracer)(application-chosen instrumentation scope), ornew OpenTelemetrySpanRecorder()forGlobalOpenTelemetry. The global instance is read when a span is started, not in the constructor, so a client may be built before the application installs its SDK (reading it too early would pin the no-op instance and make a laterGlobalOpenTelemetry.set(...)throw). Default scope name:com.clickhouse.client.Context.current(), so it joins the application's ambient trace; each request span — one per attempt, including retries — is started under its operation span's context. A request span whose operation span was not created by this recorder falls back to the current context instead of failing. Both kinds areSpanKind.CLIENT.String→ string,Boolean→ boolean,Double/Float→ double, any otherNumber→ long, anything else →String.valueOf. Anullkey or value records nothing.setErrorsets statusERRORand recordserror.type;recordFailure/recordRequestFailureadditionally record the throwable as an OpenTelemetry exception event, so the message and stack trace are not lost (the SPI hands the recorder the throwable;error.typealone drops everything but the class name).end()is idempotent (AtomicBoolean), matching the SPI contract; the recorder holds no per-operation state and is thread-safe.Dependency placement.
opentelemetry-apiis a normal dependency of this module only;client-v2is untouched and still needs no OpenTelemetry on the classpath, which is the issue's "no new runtime dependency" constraint. The module is not added topackages/clickhouse-jdbc-all— that would shade OpenTelemetry into the uber-jar for every JDBC user, andjdbc-v2has no way to configure a recorder yet (see Follow-ups). Say the word if you want it in the package anyway; it is a two-line change.Compatibility: purely additive — a new module and one new public class. No existing signature or behaviour changed. Java 8 (
release 8).Changes
client-v2-otel/pom.xml— new module: depends onclient-v2+opentelemetry-api; test scope addsopentelemetry-sdk,opentelemetry-sdk-testing, TestNG and theclickhouse-clienttest-jar for the integration harness.client-v2-otel/.../observability/otel/OpenTelemetrySpanRecorder.java— the recorder and itsSpanimplementation.pom.xml— new<module>client-v2-otel</module>and theopentelemetry.versionproperty (1.51.0).CHANGELOG.md,docs/features.md— newclient-v2-otelsection, including the compatibility-sensitive traits (span kind/nesting, attribute typing, failure mapping, idempotent end, no span made current).Test
New tests only; no existing test edited or weakened.
OpenTelemetrySpanRecorderUnitTest(25 cases, in-memory exporter): query span name/kind/scope and every standard attribute; insert span withdb.collection.name+db.operation.batch.size, and a contrast case that a stream insert (BATCH_SIZE_UNKNOWN) records no batch size and an insert records nodb.query.text; request span is a child of the operation span withhttp.request.method/http.response.status_code/ per-attemptserver.address; two attempts under one operation span (failed attempt isERROR, retry isUNSET, operation staysUNSET); operation span joins an ambient trace; foreign operation span → current context (with and without an ambient span); client failure →ERROR+error.typeand no server error code;ServerException→error.type,db.response.status_code=60,http.response.status_code=404on the request span and the operation span; success records the query id anddb.response.returned_rows, and records nothing when metrics arenull; failure recorded as an exception event with type and message; idempotentend(); attribute typing via@DataProvider(8 rows: String / Boolean / int / long / short / double / float / other object);nullkey or value ignored;forTracerreports under the given scope name and version;nullOpenTelemetry/Tracerrejected.OpenTelemetrySpanRecorderTest(3 integration cases, real server): a successful query exports the operation span withdb.response.returned_rows=3, the server-assigned query id and a childPOSTspan with HTTP 200; a failing query exportsERROR+error.type=…ServerException+db.response.status_code=60on the operation span and HTTP 404 on the request span; a POJO insert exportsinsert <db>.<table>withdb.operation.batch.size=1and its child request span.mvn -pl client-v2-otel -DskipITs=true test→ 25 passed;mvn -pl client-v2-otel -DskipUTs=true -Dit.test=OpenTelemetrySpanRecorderTest verify→ 3 passed;mvn -pl client-v2 -DskipITs=true test→ 556 passed (unchanged);mvn -Dj8 -DskipTests install(full reactor, incl.jdbc-v2andpackages/clickhouse-jdbc-all) → BUILD SUCCESS.Docs / surface
CHANGELOG.md: entry under0.11.0-rc1→ New Features, tagged**[client-v2-otel]**, with the issue link. The feat(client-v2): add span recorder SPI for operation and request tracing #2988 entry's closing sentence now points at this module instead of announcing it as upcoming.docs/features.md: new## client-v2-otelsection with a feature list and compatibility-sensitive traits.0.11.0-rc1). No backport needed.docs/changes_checklist.mdwalk-throughOpenTelemetrySpanRecorderfollows the module's naming and the SPI's documented extension pattern (extendDefaultSpanRecorder, override what you record); nullability is explicit (nullOpenTelemetry/Tracerrejected withIllegalArgumentException,nullattribute key/value ignored);forTracer(...)is a static factory rather than a second constructor sonew OpenTelemetrySpanRecorder(null)cannot be an ambiguous call; behaviour-focused tests added;docs/features.mdupdated.opentelemetry-api), scoped to the new module only, version pinned by a parent property next to the other version properties.client-v2andclickhouse-jdbc-allgain nothing transitively.nullchecks and theinstanceoffallback for a foreign operation span; both are covered by tests.CI note (no workflow file touched, per
AGENTS.md)The whole-reactor
compilejob builds the new module and runs its unit tests, so they gate this PR. Two things need a workflow change, which I did not make:build.yml/test_head.ymlenumerate projects explicitly (project: ["clickhouse-http-client", "client-v2", …]), so the module's integration test does not run in CI untilclient-v2-otelis added to those matrices.release.ymlenumerates the jars attached to a release, so the new artifact must be added there before it is published.Tell me which you want and I will add it in a follow-up (or apply it here if you prefer a CI change in this PR).
Pre-PR validation gate
Client)docs/features.md+CHANGELOG.mdupdatedAGENTS.md,docs/ai-review.mdanddocs/changes_checklist.mdFollow-ups
jdbc-v2surfacing — still the open question from #2988:jdbc-v2builds itsClientfrom string properties, so injecting a recorder needs its own small decision (aspan_recorderdriver property naming a class to instantiate, or a setter onDataSourceImpl). @chernser which would you like? I kept it out of this PR so the recorder itself can land independently.