Skip to content

feat: add client telemetry support - #2040

Open
xiaofan-luan wants to merge 8 commits into
milvus-io:masterfrom
xiaofan-luan:feat/client-telemetry
Open

feat: add client telemetry support#2040
xiaofan-luan wants to merge 8 commits into
milvus-io:masterfrom
xiaofan-luan:feat/client-telemetry

Conversation

@xiaofan-luan

@xiaofan-luan xiaofan-luan commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a client telemetry manager, configuration object, and gRPC interceptor
  • report one outcome per logical operation across legacy/V1, V2, async, retry, validation, and result processing paths
  • preserve telemetry state across global-cluster reconnects with failure-safe candidate handoff and stale-heartbeat fencing
  • close shared telemetry with fluent wrappers, preserve arbitrary legacy wire request IDs while keeping telemetry trace IDs strict, and omit an unconfigured database
  • keep equal-timestamp command IDs until the timestamp cursor advances so repeated delivery stays idempotent
  • align strict atomic commands, deterministic sampling, collection filtering, RFC3339 validation, acknowledgement, and unsupported backoff with the Go SDK
  • use a 10-second default heartbeat interval and add focused unit plus opt-in end-to-end coverage

Related work

Verification

  • full sdk-core suite: 633 tests, 0 failures, 0 errors, 1 skipped
  • focused telemetry suites: 37/37 passed
  • local diff coverage: 92.63%
  • git diff --check

@sre-ci-robot

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: xiaofan-luan

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
Sampling kept a counter modulo 10000 and recorded an operation while the
remainder was under rate*10000, so the sampled operations formed one
contiguous run per cycle. A cycle is ten thousand operations while a
heartbeat window is tens or hundreds, so every window came out wholly
sampled or wholly dropped: at 3 QPS a rate of 0.25 gave fourteen minutes
of full metrics followed by forty-one reporting nothing from a client
that never stopped working. The long-run ratio was right; the ratio
inside a window, which is the only unit the telemetry API reports, never
was.

Accumulate the rate instead and sample on the operation that carries the
accumulator across a whole unit: 0.25 samples every fourth operation, and
any stretch of operations holds the configured ratio. A rate too small to
represent now still samples rarely rather than never -- the old threshold
truncated anything below 1e-4 to zero and then sampled nothing, so a
configured rate silently meant "off".

Matches the Go client, milvus-io/milvus#52615.

Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
The heartbeat interval is also the metrics window: each heartbeat carries
the operations since the last one, and the coordinator answers a
telemetry query from the window before the newest, so a caller reads
data between one and two intervals old. At the previous default of 30
seconds that is up to a minute behind, which reads as a stalled client
rather than as a lagging view.

Ten seconds puts the worst case at twenty. The cost is the coordinator
heartbeat rate, which scales with the number of connected clients rather
than with traffic; a deployment with enough clients for that to matter
can raise the interval, per client or fleet-wide, with a persistent
push_config.

Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
@xiaofan-luan
xiaofan-luan force-pushed the feat/client-telemetry branch from 816a6a9 to c7fb80e Compare August 24, 2026 06:27
@xiaofan-luan
xiaofan-luan marked this pull request as ready for review August 24, 2026 06:28
@mergify

mergify Bot commented Aug 24, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7fb80ec23

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread sdk-core/src/main/java/io/milvus/client/MilvusServiceClient.java Outdated
Comment thread sdk-core/src/main/java/io/milvus/client/MilvusServiceClient.java Outdated
Comment thread sdk-core/src/main/java/io/milvus/telemetry/ClientTelemetryManager.java Outdated
Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.24620% with 181 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (master@63e7560). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...va/io/milvus/telemetry/ClientTelemetryManager.java 90.62% 25 Missing and 53 partials ⚠️
...ain/java/io/milvus/client/MilvusServiceClient.java 73.80% 19 Missing and 14 partials ⚠️
.../io/milvus/v2/client/globalcluster/GlobalStub.java 62.71% 14 Missing and 8 partials ⚠️
.../main/java/io/milvus/v2/client/MilvusClientV2.java 85.36% 6 Missing and 12 partials ⚠️
...java/io/milvus/telemetry/TelemetryInterceptor.java 88.50% 3 Missing and 7 partials ⚠️
...c/main/java/io/milvus/v2/client/ConnectConfig.java 71.42% 6 Missing and 2 partials ⚠️
...main/java/io/milvus/telemetry/TelemetryConfig.java 85.71% 2 Missing and 4 partials ⚠️
...re/src/main/java/io/milvus/param/ConnectParam.java 66.66% 1 Missing and 1 partial ⚠️
.../src/main/java/io/milvus/v2/utils/ClientUtils.java 33.33% 1 Missing and 1 partial ⚠️
...s/common/interceptor/ClientRequestInterceptor.java 88.88% 0 Missing and 1 partial ⚠️
... and 1 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##             master    #2040   +/-   ##
=========================================
  Coverage          ?   67.26%           
  Complexity        ?     4942           
=========================================
  Files             ?      484           
  Lines             ?    28921           
  Branches          ?     2872           
=========================================
  Hits              ?    19454           
  Misses            ?     7965           
  Partials          ?     1502           
Files with missing lines Coverage Δ
...s/common/interceptor/ClientRequestInterceptor.java 96.55% <88.88%> (ø)
...vus/v2/client/globalcluster/TopologyRefresher.java 53.48% <0.00%> (ø)
...re/src/main/java/io/milvus/param/ConnectParam.java 65.67% <66.66%> (ø)
.../src/main/java/io/milvus/v2/utils/ClientUtils.java 19.10% <33.33%> (ø)
...main/java/io/milvus/telemetry/TelemetryConfig.java 85.71% <85.71%> (ø)
...c/main/java/io/milvus/v2/client/ConnectConfig.java 68.24% <71.42%> (ø)
...java/io/milvus/telemetry/TelemetryInterceptor.java 88.50% <88.50%> (ø)
.../main/java/io/milvus/v2/client/MilvusClientV2.java 61.11% <85.36%> (ø)
.../io/milvus/v2/client/globalcluster/GlobalStub.java 36.69% <62.71%> (ø)
...ain/java/io/milvus/client/MilvusServiceClient.java 63.12% <73.80%> (ø)
... and 1 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mergify mergify Bot added the ci-passed label Aug 24, 2026
Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
@mergify mergify Bot added ci-passed and removed ci-passed labels Aug 24, 2026
@yhmo
yhmo requested a balanced review from Copilot August 24, 2026 11:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

return "";
}
String requestId = connectConfig.getClientRequestId().get();
return ClientRequestInterceptor.isValidClientRequestId(requestId) ? requestId : "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sdk-core/src/main/java/io/milvus/v2/client/MilvusClientV2.java line:334
Medium ---- captureClientRequestId() now applies the strict OTel trace-ID check (exactly 32 lowercase hex, non-zero) before the value is set as CLIENT_REQUEST_ID_OPTION on the future stub, so on queryAsync/searchAsync/hybridSearchAsync/getAsync an arbitrary caller-supplied client_request_id becomes "" and is dropped from the wire metadata, while sync V2 and legacy V1 still transmit it. Before this PR the raw thread-local value reached the wire on the async path too, so callers using ConnectConfig.clientRequestId with a non-OTel correlation ID silently lose access-log correlation on async calls. The strict check is already applied exactly where telemetry needs it (TelemetryInterceptor.requestId), so the raw value can be kept in the option and filtered only when recording error details. This is a follow-up to the resolved thread on ClientRequestInterceptor.java:68 -- that fix restored wire preservation at the interceptor level, but the async path never reaches the interceptor with the raw id, so the drop persists for async V2.

try (TelemetryInterceptor.LogicalOperationScope ignored =
TelemetryInterceptor.beginLogicalOperation()) {
T result = supplier.get();
manager.recordOperation(operation, collection, startNanos, "", requestId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sdk-core/src/main/java/io/milvus/v2/client/MilvusClientV2.java line:348
Medium ---- The happy-path manager.recordOperation(...) here is not wrapped in a best-effort guard, unlike the V1 legacy path: MilvusServiceClient.recordLogicalResult calls telemetry.recordOperation inside try { ... } catch (RuntimeException ignored) { // Telemetry is best-effort and must never replace the operation result. }. Here, if recordOperation throws on the success path, control jumps to the catch (RuntimeException | Error) below, which calls recordOperation again (error path) and then rethrows — so a telemetry-side failure would turn an otherwise successful Milvus operation into an exception for the caller. Consider wrapping the success-path recording in the same try/catch the V1 path uses so the telemetry contract is identical across both client surfaces.

"Insert", "Delete", "Upsert", "Search", "HybridSearch", "Query", "RunAnalyzer"));
public static final CallOptions.Key<Boolean> LOGICAL_OPERATION_OPTION =
CallOptions.Key.create("milvus-telemetry-logical-operation");
private static final ThreadLocal<Integer> LOGICAL_OPERATION_DEPTH =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sdk-core/src/main/java/io/milvus/telemetry/TelemetryInterceptor.java line:45
Low ---- LOGICAL_OPERATION_DEPTH is a static ThreadLocal, and MilvusServiceClient keeps a second, separate static ThreadLocal of the same name (MilvusServiceClient.java line:94). Because both are process-global per thread, any two clients running interleaved or nested logical operations on the same thread share the same depth counter (e.g., a user callback issued inside client A's operation that triggers client B's search). The scopes are balanced by try-with-resources, so normal nesting is fine, but the shared static state means one client's scope can suppress or mis-record telemetry for an unrelated client's operation on the same thread. Consider instance-scoping the depth counters to the owning client.

SearchResp search(SearchReq request, String clusterId) {
return rpcUtils.retry(() -> vectorService.search(this.getRpcStub(), request, clusterId));
String collection = request == null ? "" : request.getCollectionName();
return recordLogicalOperation("Search", collection,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sdk-core/src/main/java/io/milvus/v2/client/MilvusClientV2.java line:1094
Medium ---- Iterators are the one V2 DQL path that is not wrapped in a logical-operation scope: searchIteratorV2/queryIteratorV2 build their stub via createIteratorRpcStub and issue search/query RPCs per page-fetch without any recordLogicalOperation wrapper. Those RPCs still traverse the TelemetryInterceptor, so each page becomes its own per-attempt Search/Query metric. The PR summary states the goal is "report one outcome per logical operation across legacy/V1, V2, async, retry, validation, and result processing paths"; an iterator over N pages will instead report N Search/Query entries. Please confirm whether per-page recording for iterators is intended, or whether iterator construction should be wrapped so one logical iteration produces one metric.

}
}

public static final class MetricSnapshot {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sdk-core/src/main/java/io/milvus/telemetry/ClientTelemetryManager.java line:1317
Low ---- These three snapshot value classes each store the same value twice: public snake_case fields (request_count, end_time, collection_metrics) and private camelCase copies (requestCount, endTime, collections), kept in sync by hand in the constructors. Any future metric field has to be added and assigned in two places, and a one-sided edit would silently diverge the JSON replies (show_errors / show_latency_history build their payloads from the camelCase copies) from what external callers of getMetricsSnapshots()/snapshotRuntimeState() read via the snake_case fields, with no test catching it unless the duplicate is asserted. Consider a single field set with a Gson field-naming strategy (@SerializedName or FieldNamingPolicy) so there is one source of truth to keep in sync.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants