Umbrella issue for an audit of the client telemetry (ClientTelemetryService) and client-side tracing paths. Five problems, one PR.
1. client_request_id silently disables trace sampling (the actual bug)
A request carrying the client_request_id (or legacy client-request-id) header is never sampled, regardless of trace.sampleFraction, and the suppression propagates to every downstream component — the whole call tree is missing from Jaeger/OTLP.
It is silent because the header still does its main job: the trace ID reaches the logs. You keep log correlation and lose tracing with no signal.
Cause. clientRequestIDPropagator.Extract carries the client-supplied ID in a synthesized remote parent span context with no sampled flag (pkg/tracer/client_request_id_propagator.go:52-56):
return trace.ContextWithRemoteSpanContext(ctx, trace.NewSpanContext(trace.SpanContextConfig{
TraceID: traceID,
SpanID: spanID,
Remote: true,
// TraceFlags defaults to 0x00 => not sampled
}))
SetTracerProvider uses ParentBased(TraceIDRatioBased(fraction)) (pkg/tracer/tracer.go:68-76), and OTel's ParentBased defaults remoteParentNotSampled to NeverSample() (sdk@v1.43.0 trace/sampling.go:200). The root sampler — the only one that consults sampleFraction — is reached only when there is no parent at all. Synthesizing a parent takes the decision away from the operator and hardcodes it to drop.
The underlying design error: a client-supplied correlation ID was placed in the parent span context, a slot that in OTel also encodes an upstream sampling decision.
Repro:
1. Start Milvus with trace.exporter=jaeger and trace.sampleFraction=1.0
2. Search with no trace headers -> trace appears
3. Same search with client_request_id set to 32 hex
(pymilvus: CallContext(client_request_id=...)) -> no trace, at any component
The trace ID is still in the logs, which is why this goes unnoticed.
Blast radius: users who explicitly set a request ID — i.e. people actively debugging, exactly when traces matter most. The common support flow ("send us a request id") currently disables tracing for the request being investigated.
1b. Propagator ordering makes the guard dead code
propagation.NewCompositeTextMapPropagator(
clientRequestIDPropagator{}, // runs first
otel.GetTextMapPropagator(), // TraceContext + Baggage, runs second
)
clientRequestIDPropagator.Extract opens with if trace.SpanContextFromContext(ctx).IsValid() { return ctx }, a guard meant to yield to a real traceparent. Running first, nothing has been extracted yet, so it can never fire. The two only compose correctly because TraceContext runs later and overwrites the synthesized context. Reordering the composite would silently flip behavior.
2. Go SDK telemetry correctness gaps
The Go SDK is currently the only SDK implementing the client half. The protocol-critical parts (config_hash algorithm, last_command_timestamp watermark, reply retry-on-failed-heartbeat, reservoir P99) are correct; these are the gaps around them.
- Doomed heartbeat retried forever. Against a server predating
ClientTelemetryService, the heartbeat fires every 30s for the lifetime of the client with no backoff and no self-disable. The error is dropped despite a comment claiming it is logged:
if err != nil {
// Log error but continue - telemetry is best-effort
return // nothing is actually logged
}
connectInternal already handles Unimplemented this way; the heartbeat path missed it. The client-wide retry interceptor also applies, so a heartbeat against an Unavailable server costs six RPCs with backoff instead of one.
max_latency_ms collected but never sent. common.Metrics has the field and the collector computes it, but every proto conversion site omitted it, so the server could never display it.
client_id not stable across restarts. A fresh uuid.New() per process, so server-side history fragments on every restart and client:<id> scoped commands cannot target a long-lived client.
- A test-only fork that had already diverged.
processCommands / calculateConfigHash / collectProtoMetrics / getPendingReplies were reachable only from tests. The test-only calculateConfigHash hashed ID:Type while production hashes ID + Type + payload — and config_hash must match the server byte-for-byte or configs are re-pushed on every heartbeat. The tests were validating an algorithm that never runs.
- No request-ID propagation. pymilvus exposes
CallContext(client_request_id=...); the Go SDK had no equivalent, so there was no way to correlate a Go client call with server-side logs.
3. Async telemetry API cannot be consumed programmatically
GET /_telemetry/clients/{id}/config and .../history push a command and return {"command_id": ..., "status": "pending"}. The reply lands in a StoredCommandReply that no endpoint exposes — and it is smuggled through ClientInfo.Reserved["command_replies"] as a JSON string. TelemetryManager.GetClientCommandReplies() exists but has no production caller.
The only way to read a result was to list every client and scan the array by hand. Every consumer that is not the WebUI — a script, a CLI, an agent — has to reimplement push → poll-all-clients → match → time out.
4. The MEP does not match the implementation
docs/design-docs/design_docs/20260131-client_side_telemetry.md was written before the feature landed and never updated. Anyone implementing the client half for another SDK from it would produce something that does not interoperate.
| Doc says |
Reality |
set_sampling_rate / enable_collections / update_config |
None exist. Real set: push_config, collection_metrics, show_errors, show_latency_history, get_config |
TargetScope = "*" / "client_id:xxx" / "db:name" |
global / client:<id> / database:<db> |
REST at /api/v1/telemetry/* |
/_telemetry/* on the internal HTTP port |
RPCs on service RootCoord |
Own service ClientTelemetryService |
ClientHeartbeatResponse.NewConfigHash |
No such field |
| "Persistent commands re-sent until deleted" |
Suppressed whenever config_hash matches |
| "Disabled by default" |
DefaultTelemetryConfig() has Enabled: true |
CommandHandler returns string |
Returns *CommandReply |
Also missing entirely: the exact config_hash algorithm, the watermark's exactly-once consequence, reply-as-GC semantics, the ttl_seconds: 0 leak, persistent-config JSON merge, the hardcoded server limits, and that there are no milvus.yaml keys for any of it.
5. Ecosystem gap (context, not fixed here)
Client-side telemetry exists only in the Go SDK. pymilvus, Java, Node.js and Rust do not implement it — so the feature currently covers none of the Python user base. Conversely, Node.js has full W3C traceparent and pymilvus has client-request-id, while the Go SDK had neither until this PR.
Additional server-side weaknesses found but not addressed here: no milvus.yaml/paramtable keys, Basic Auth with no RBAC, non-persistent commands live only on the RootCoord that received the push (lost on restart, not shared across replicas), ttl_seconds: 0 commands leak forever if a client never replies, and PushCommand does not validate command_type.
Umbrella issue for an audit of the client telemetry (
ClientTelemetryService) and client-side tracing paths. Five problems, one PR.1.
client_request_idsilently disables trace sampling (the actual bug)A request carrying the
client_request_id(or legacyclient-request-id) header is never sampled, regardless oftrace.sampleFraction, and the suppression propagates to every downstream component — the whole call tree is missing from Jaeger/OTLP.It is silent because the header still does its main job: the trace ID reaches the logs. You keep log correlation and lose tracing with no signal.
Cause.
clientRequestIDPropagator.Extractcarries the client-supplied ID in a synthesized remote parent span context with no sampled flag (pkg/tracer/client_request_id_propagator.go:52-56):SetTracerProviderusesParentBased(TraceIDRatioBased(fraction))(pkg/tracer/tracer.go:68-76), and OTel'sParentBaseddefaultsremoteParentNotSampledtoNeverSample()(sdk@v1.43.0 trace/sampling.go:200). Therootsampler — the only one that consultssampleFraction— is reached only when there is no parent at all. Synthesizing a parent takes the decision away from the operator and hardcodes it to drop.The underlying design error: a client-supplied correlation ID was placed in the parent span context, a slot that in OTel also encodes an upstream sampling decision.
Repro:
Blast radius: users who explicitly set a request ID — i.e. people actively debugging, exactly when traces matter most. The common support flow ("send us a request id") currently disables tracing for the request being investigated.
1b. Propagator ordering makes the guard dead code
clientRequestIDPropagator.Extractopens withif trace.SpanContextFromContext(ctx).IsValid() { return ctx }, a guard meant to yield to a realtraceparent. Running first, nothing has been extracted yet, so it can never fire. The two only compose correctly becauseTraceContextruns later and overwrites the synthesized context. Reordering the composite would silently flip behavior.2. Go SDK telemetry correctness gaps
The Go SDK is currently the only SDK implementing the client half. The protocol-critical parts (config_hash algorithm,
last_command_timestampwatermark, reply retry-on-failed-heartbeat, reservoir P99) are correct; these are the gaps around them.ClientTelemetryService, the heartbeat fires every 30s for the lifetime of the client with no backoff and no self-disable. The error is dropped despite a comment claiming it is logged:connectInternalalready handlesUnimplementedthis way; the heartbeat path missed it. The client-wide retry interceptor also applies, so a heartbeat against anUnavailableserver costs six RPCs with backoff instead of one.max_latency_mscollected but never sent.common.Metricshas the field and the collector computes it, but every proto conversion site omitted it, so the server could never display it.client_idnot stable across restarts. A freshuuid.New()per process, so server-side history fragments on every restart andclient:<id>scoped commands cannot target a long-lived client.processCommands/calculateConfigHash/collectProtoMetrics/getPendingReplieswere reachable only from tests. The test-onlycalculateConfigHashhashedID:Typewhile production hashesID + Type + payload— andconfig_hashmust match the server byte-for-byte or configs are re-pushed on every heartbeat. The tests were validating an algorithm that never runs.CallContext(client_request_id=...); the Go SDK had no equivalent, so there was no way to correlate a Go client call with server-side logs.3. Async telemetry API cannot be consumed programmatically
GET /_telemetry/clients/{id}/configand.../historypush a command and return{"command_id": ..., "status": "pending"}. The reply lands in aStoredCommandReplythat no endpoint exposes — and it is smuggled throughClientInfo.Reserved["command_replies"]as a JSON string.TelemetryManager.GetClientCommandReplies()exists but has no production caller.The only way to read a result was to list every client and scan the array by hand. Every consumer that is not the WebUI — a script, a CLI, an agent — has to reimplement push → poll-all-clients → match → time out.
4. The MEP does not match the implementation
docs/design-docs/design_docs/20260131-client_side_telemetry.mdwas written before the feature landed and never updated. Anyone implementing the client half for another SDK from it would produce something that does not interoperate.set_sampling_rate/enable_collections/update_configpush_config,collection_metrics,show_errors,show_latency_history,get_configTargetScope="*"/"client_id:xxx"/"db:name"global/client:<id>/database:<db>/api/v1/telemetry/*/_telemetry/*on the internal HTTP portservice RootCoordservice ClientTelemetryServiceClientHeartbeatResponse.NewConfigHashconfig_hashmatchesDefaultTelemetryConfig()hasEnabled: trueCommandHandlerreturnsstring*CommandReplyAlso missing entirely: the exact
config_hashalgorithm, the watermark's exactly-once consequence, reply-as-GC semantics, thettl_seconds: 0leak, persistent-config JSON merge, the hardcoded server limits, and that there are nomilvus.yamlkeys for any of it.5. Ecosystem gap (context, not fixed here)
Client-side telemetry exists only in the Go SDK. pymilvus, Java, Node.js and Rust do not implement it — so the feature currently covers none of the Python user base. Conversely, Node.js has full W3C
traceparentand pymilvus hasclient-request-id, while the Go SDK had neither until this PR.Additional server-side weaknesses found but not addressed here: no
milvus.yaml/paramtable keys, Basic Auth with no RBAC, non-persistent commands live only on the RootCoord that received the push (lost on restart, not shared across replicas),ttl_seconds: 0commands leak forever if a client never replies, andPushCommanddoes not validatecommand_type.