feat(client-v2-otel): add OpenTelemetry span recorder module - #3065
feat(client-v2-otel): add OpenTelemetry span recorder module#3065polyglotAI-bot wants to merge 5 commits into
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.
| public Span startRequestSpan(Span operationSpan, String host, int port) { | ||
| SpanSupport support = getSpanSupport(); | ||
| OpenTelemetrySpan span = startSpan(support.requestSpanName(), parentContextOf(operationSpan)); | ||
| support.fillRequestAttributes(span, host, port); |
There was a problem hiding this comment.
Confirmed as a real gap, but it cannot be closed inside this module: SpanRecorder.startRequestSpan(Span, String host, int port) (merged in #2988) is never given the URI, and there is no url.full key in SpanAttribute. Reconstructing it here from host+port is not possible — the path and the query string are unknown to the recorder.
@chernser — this is a decision on the SPI you just approved, so I do not want to change it unilaterally. Options:
- Extend the SPI now, before 0.10.0 ships. Add
SpanAttribute.URL_FULL("url.full"), widenstartRequestSpanto also take the request URI, fill it inSpanSupport.fillRequestAttributes, and passHttpPost#getUri()fromHttpAPIClientHelper. The SPI is unreleased (0.10.0-rc1-SNAPSHOT), so widening the existing method is still free; adding an overload instead is not, becauseSpanRecorderis a plain interface with no default methods, so every implementor would have to implement both. This is a client-v2 change on top of an otel-module PR — I would send it as its own small PR rather than bundle it here. - Record only the sanitized URL (scheme + host + port + path, query string dropped). The ClickHouse HTTP query string carries settings, query parameters and, in some setups, credentials; semconv itself requires
url.fullto be sanitized. Same SPI change as (1), just a narrower value. - Amend the requirement. Keep
http.request.method+server.address+server.portas the request-span attributes and state indocs/features.mdwhyurl.fullis omitted, for the reason above.
My recommendation is (2): it satisfies the issue's request-span contract, and it avoids exporting a query string that can contain sensitive values. Tell me which you prefer and I will follow up — (1)/(2) as a separate client-v2 PR with the integration assertion, or (3) as a docs line here.
- 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
|
| * | ||
| * @param openTelemetry - OpenTelemetry instance to report to; must not be {@code null} | ||
| */ | ||
| public OpenTelemetrySpanRecorder(OpenTelemetry openTelemetry) { |
There was a problem hiding this comment.
this should be a base constructor.
There was a problem hiding this comment.
Done in ddce5b6 — OpenTelemetrySpanRecorder(OpenTelemetry) no longer has its own body: it delegates with this(tracerOf(openTelemetry)). The constructor that only assigns the field is OpenTelemetrySpanRecorder(Tracer), because the no-argument constructor must NOT read GlobalOpenTelemetry eagerly - it is documented (and pinned by testGlobalInstanceIsReadWhenSpanStartsNotWhenRecorderIsCreated) that a client may be built before the application installs its SDK. So the no-argument constructor leaves the tracer unset and getTracer() reads the global instance when a span starts. Tell me if you would rather have this(GlobalOpenTelemetry.get()) and drop the lazy behaviour.
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 |
|



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.