Skip to content
222 changes: 222 additions & 0 deletions A121-rpc-delay-observability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
A121: RPC Delay Observability
----
* Author(s): Madhav Bissa (@mbissa)
* Approver: @markdroth, @ejona86, @dfawley, @easwars
* Implemented in: Go, Java, C++
* Last updated: 2026-08-17
* Discussion at: https://groups.google.com/g/grpc-io/c/NsxXJ2MxXM4

## Abstract

This proposal introduces client-side metrics and tracing to measure the delays an RPC experiences within the client channel before it is sent over the network — the time an RPC spends queued waiting for name resolution or for a load balancing pick. It adds two duration histograms, a client-side tracing span, new methods on the call tracer API, and two fields on the load balancer's pick result to record these delays.

## Background

Existing gRPC core telemetry, as defined in [gRPC A66 (OpenTelemetry Metrics)][A66] and [gRPC A72 (OpenTelemetry Tracing)][A72], tracks the overall end-to-end duration of RPC calls and attempts. However, these metrics and spans function as aggregate buckets that do not decompose latency, leaving delays inside the client channel invisible to operators.

When a gRPC channel is first created, it is in IDLE state, meaning that it has not done name resolution or attempted to connect to any endpoints. When the application sends an RPC on the channel, the channel will leave IDLE state and attempt to connect. Any RPCs sent on the channel while it is attempting to connect will be queued until the channel becomes connected, which results in increased latency for these RPCs. However, because current telemetry lacks visibility into these connecting delays, developers cannot distinguish between a slow network, a slow backend, or a channel initialization delay.

While there are other sources of client-side delay that involve network I/O (such as credential fetching or retry delays), this proposal focuses on the two most common: **name resolution** and **load balancing pick** delays. The framework is extensible to other client-side delays in the future.

### Related Proposals:
* **[gRPC A66: OpenTelemetry Metrics][A66]**: Establishes base OpenTelemetry metrics.
* **[gRPC A72: OpenTelemetry Tracing][A72]**: Establishes tracing spans and events.
* **[gRPC A56: Priority LB Policy][A56]**: Establishes the priority load balancing policy and child failover mechanics.
* **[gRPC A28: xDS Traffic Splitting and Routing][A28]**: Establishes the `xds_cluster_manager` and `weighted_target` container policies.

## Proposal

### Anatomy of a Delay

gRPC records a **delay** whenever an RPC is blocked inside the client channel. A delay occurs at one of two levels:

* **Call-level**: before any network attempt is initiated, while the RPC waits on name resolution. Recorded against the RPC call.
* **Attempt-level**: within a specific attempt, while the RPC waits on a load balancing pick or a connection. Recorded against the attempt.

Whether a delay is call-level or attempt-level is reflected by which histogram it is recorded to and by whether its trace span is nested under the call span or the attempt span.

Each delay results in a data point being added to the relevant histogram and a new trace span. Every delay has two attributes:

* **`grpc.delay_type`**: A low-cardinality string representing the type of the delay. See below for supported values. This value is constant over the lifetime of an individual delay; if the value changes, that implies that the original delay has ended and a new delay has started. For metrics, this value is used in a metric label. For tracing, this value is an attribute on the trace span.
* **`grpc.delay_reason`**: A high-cardinality string capturing runtime details (such as target names, subchannel IP addresses, or connection error messages). This value can change over the lifetime of a delay. This value is not used in metrics. In tracing, this value is recorded in an event on the trace span.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It might be useful to explicitly say that this field is intended primarily for human understanding, not for automated interpretation.


The base `grpc.delay_type` values are summarized below for reference; each is defined in full — with example reasons — in the per-policy sections that follow ([Resolver Delays](#resolver-delays), [LB Pick Delays](#lb-pick-delays)):

| `grpc.delay_type` | Scope | Meaning | Generated by |
|---|---|---|---|
| `resolving` | call | waiting on name resolution | channel (resolver) |
| `connecting` | attempt | waiting on a subchannel connection | leaf pickers (`pick_first`, `round_robin`, `ring_hash`, `weighted_round_robin`, `xds_override_host`) |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"leaf picker" is the wrong term here, because the xds_override_host policy is not a leaf policy -- and technically, every other policy here except pick_first is a petiole policy, not a leaf policy.

I suggest instead saying something like "LB policies that trigger connection attempts".

| `rls_lookup_pending` | attempt | waiting on an RLS control-plane lookup | `rls` |
| `cds_dynamic_discovery` | attempt | waiting on a CDS resource | `cds` |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggest referencing A74 here.

| `subchannel_state_mismatch` | attempt | picked subchannel left `READY` before it could be used | channel |
| `picker_failing_with_wait_for_ready` | attempt | `wait_for_ready` RPC queued on a failing picker | channel |

The `priority` policy composes the attempt-level values by prepending its numeric priority (e.g. `"0:connecting"`), and these prefixes stack when priority policies are nested (e.g. `"0:1:connecting"`), so the composed set is not statically enumerable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The phrase "attempt-level value" seems confusing here, since this has nothing to do with the question of call vs. attempt.

I suggest instead saying something like "The priority policy prepends the numeric priority to the delay type returned by the child policy".


A delay begins when the channel starts waiting and ends when the wait resolves, when the `grpc.delay_type` changes (which ends the current delay and starts a new one), or when the RPC is cancelled or reaches its deadline.

### Metric Schema

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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


The following client-side per-call metrics are registered, extending the instrumentation framework defined in [gRPC A66][A66].

#### Call Delay Duration Histogram

| Field | Value |
|---|---|
| **Name** | `grpc.client.call.delay.duration` |
| **Type** | Float64 Histogram |
| **Unit** | `s` (seconds) |
| **Description** | EXPERIMENTAL. Time an RPC spent waiting at the call level before an attempt was initiated, such as waiting for name resolution. |
| **Labels** | `grpc.target`, `grpc.method`, `grpc.delay_type` |
| **Bucket Boundaries** | Same as A66 latency buckets: 0, 0.00001, 0.00005, 0.0001, 0.0003, 0.0006, 0.0008, 0.001, 0.002, 0.003, 0.004, 0.005, 0.006, 0.008, 0.01, 0.013, 0.016, 0.02, 0.025, 0.03, 0.04, 0.05, 0.065, 0.08, 0.1, 0.13, 0.16, 0.2, 0.25, 0.3, 0.4, 0.5, 0.65, 0.8, 1, 2, 5, 10, 20, 50, 100 |
| **Default Enabled** | `false` (experimental, opt-in) |

#### Attempt Delay Duration Histogram

| Field | Value |
|---|---|
| **Name** | `grpc.client.attempt.delay.duration` |
| **Type** | Float64 Histogram |
| **Unit** | `s` (seconds) |
| **Description** | EXPERIMENTAL. Time an RPC attempt spent waiting for a load balancing pick or connection establishment. |
| **Labels** | `grpc.target`, `grpc.method`, `grpc.delay_type` |
| **Bucket Boundaries** | Same as A66 latency buckets: 0, 0.00001, 0.00005, 0.0001, 0.0003, 0.0006, 0.0008, 0.001, 0.002, 0.003, 0.004, 0.005, 0.006, 0.008, 0.01, 0.013, 0.016, 0.02, 0.025, 0.03, 0.04, 0.05, 0.065, 0.08, 0.1, 0.13, 0.16, 0.2, 0.25, 0.3, 0.4, 0.5, 0.65, 0.8, 1, 2, 5, 10, 20, 50, 100 |
| **Default Enabled** | `false` (experimental, opt-in) |

### Tracing Schema

Each delay is represented by a child trace span named **`Delay`**, nested under the parent RPC call span (for call-level delays) or the individual attempt span (for attempt-level delays). The span's start and end times bound the delay. It carries a single attribute:

* **`grpc.delay_type`**: the delay type, constant for the lifetime of the span.

Each value of `grpc.delay_reason` (the initial reason when the span opens, and every subsequent change) is recorded as a span event. Tracing backends can programmatically parse these events, which conform to the following schema:

```json
{
"name": "Delay state transition",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This isn't actually a state transition if it's the initial delay reason recorded when the span starts. Maybe this should instead just say something like "Delay triggered".

"attributes": {
"grpc.delay_reason": "string (the current granular state description)"
}
}
```

### Call Tracer API Changes

The channel records delays by calling new methods on the call tracer. The methods come in two scopes, mirroring the two delay levels:

| Method | Scope | Called when |
|---|---|---|
| `recordCallDelayStart(delay_type, reason)` | call | a call-level delay begins (or the `delay_type` changes) |
| `recordCallDelayReasonChanged(reason)` | call | the reason changes within the same `delay_type` |
| `recordCallDelayEnd()` | call | the call-level delay resolves |
| `recordAttemptDelayStart(delay_type, reason)` | attempt | an attempt-level delay begins (or the `delay_type` changes) |
| `recordAttemptDelayReasonChanged(reason)` | attempt | the reason changes within the same `delay_type` |
| `recordAttemptDelayEnd()` | attempt | the attempt-level delay resolves |
Comment on lines +109 to +114

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For LB pick delays, I think this API will require both the channel and the call tracer impl to store the current delay type. The channel will need to store it to know whether it should call recordAttemptDelayStart() or recordAttemptDelayReasonChanged() on each LB pick, and the call tracer will need to store it to know what label value to record for the metric when the delay ends.

I think it might be better to structure this API such that we need to store the delay type in only one place, either the channel or the call tracer impl. It would probably make more sense to do it in the channel, because there could be multiple call tracers, and it would use more memory if each one has to do this itself.

To do it in the channel, we could just add the delay_type parameter to the recordAttemptDelayReasonChanged() and recordAttemptDelayEnd() methods.

Comment on lines +109 to +114

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think we need different method names for call-level and attempt-level delays. We'll already differentiate them based on whether they are called on the call-level tracer or the attempt-level tracer. I think both objects can have the same methods. So they can just be called RecordDelayStart(), RecordDelayReasonChanged(), and RecordDelayEnd().


**Caller (channel) responsibilities.** The channel detects when a delay begins and ends, chooses the `delay_type` and `delay_reason`, and calls `Start` when a `delay_type` first appears or changes, `ReasonChanged` when only the reason changes, and `End` when the wait resolves. The scope — and therefore which histogram the delay is recorded to (see [Metric Schema](#metric-schema)) — is chosen by whether the channel calls the method on the call-scoped or the attempt-scoped tracer.

**Call tracer (telemetry plugin) responsibilities.** On `Start`, the plugin opens the `Delay` span (see [Tracing Schema](#tracing-schema)) and begins timing. On `ReasonChanged`, it adds a `Delay state transition` event. On `End`, it closes the span and records the elapsed duration to the corresponding histogram. The plugin owns timing, so the `End` methods carry no duration argument.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of "begins timing", I suggest saying "records the delay type and start time of the delay".


**Cancellation and deadlines.** The channel already notifies the call tracer when an RPC is cancelled or reaches its deadline. If a delay is open at that point, the call tracer automatically terminates it — closing the span and recording the partial duration — so the channel does not need to explicitly end open delays on these paths.

**Per-language binding.** The six operations bind onto each runtime's existing telemetry types:

* **Go**: new call-scoped and attempt-scoped tracers in gRPC-Go's new stats-handler API. A new API is required because the V1 `stats.Handler` is attempt-scoped only and cannot host a call-level hook.
* **Java**: new methods on the existing `ClientStreamTracer` (attempt scope) and `ClientStreamTracer.Factory` (call scope).
* **C++ (Core)**: new methods on the call and attempt tracer interfaces.

### Resolver Delays

The channel records a call-level delay of type **`resolving`** while an RPC is blocked waiting for name resolution. When an RPC is sent while the channel has not yet received its first resolver result, the channel calls `recordCallDelayStart("resolving", reason)` on the call tracer; when the resolver delivers its first result and the RPC can proceed, the channel calls `recordCallDelayEnd()`. If resolution has already completed by the time the RPC is sent, no delay is recorded.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note that if the RPC has wait_for_ready=true, then the delay reason might be the resolver having reported a failure rather than it not yet having reported any result. That failure should be reported as the delay reason. And if the resolver returns a second failure, the channel will also call recordCallDelayReasonChanged() to indicate the new failure reason.


Example reason: waiting on the initial DNS query (e.g. `"waiting for DNS query to complete for target example.com"`).

### LB Pick Delays

Attempt-level delays occur while an attempt waits for a load balancing pick to select a ready connection. Most delay types and reasons are generated by the LB policies themselves; two are synthesized by the channel.

#### LB Picker API Changes

When a picker cannot return a ready connection, it defers the pick. Two values are added to the pick-result type each runtime already returns, populated on the deferred path:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please use the word "queue" instead of "defer" for delayed LB picks, since that's the term we use for this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please explicitly state that these are both string values.


* `delay_type`: the attempt-level delay type (e.g. `"connecting"`).
* `delay_reason`: the free-form reason.

The channel reads these where it already handles a deferred pick; no new return channel and no pick-loop restructuring are introduced. The fields live on:

* **Go**: `balancer.PickResult` (deferred pick = the `ErrNoSubConnAvailable` return).
* **Java**: `PickResult` (deferred pick = `PickResult.withNoResult()`).
* **C++ (Core)**: the `PickResult::Queue` variant.

The delay types and example reasons generated by each LB policy follow. These examples are non-exhaustive; implementations are encouraged to append additional debug details to the reason.

##### `pick_first` and `round_robin`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we probably don't want to combine PF and RR in the same section, because they're actually two different cases.

Conversely, it seems a bit redundant to have other petiole policies like WRR and ring_hash in their own sections, since basically every petiole policy is the same thing.

So I suggest one section for pick_first, and another for all of the petiole policies (RR, WRR, and ring_hash). It should probably also include least_request (and reference gRFC A48).


Generate `delay_type = "connecting"`. Example reasons:

* Subchannel is `IDLE`, triggering a new connection attempt (e.g. `"subchannel was idle, triggering connection attempt"`).
* TCP/TLS handshake in progress, optionally with the remote address (e.g. `"subchannel connecting: TCP handshake in progress to 192.168.1.50:8080"`). For dualstack targets, the connection attempts across address families follow [gRFC A61][A61].

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think we need to differentiate between IDLE and CONNECTING here. In both cases, we can just say that we're waiting for a connection.

For pick_first, the delay reason will want to be something like "waiting for subchannel to connect to ${address}".

Have we talked about how much detail we want to see in the delay reason for the petiole policies? They will often be connecting to multiple endpoints in parallel, so I'm thinking that they should just delegate to one of the connecting endpoints at random and wrap the delay reason reported by the PF child policy, so they'd say something like "waiting for connections to endpoints (example: waiting for subchannel to connect to ${address})".

* Transport connected, waiting for the initial Out-of-Band health check to return `SERVING`.
* The target subchannel has not yet been created or resolved.
* `round_robin`: waiting for any configured subchannel to become ready (the reason can list the subchannels being attempted).

##### `weighted_round_robin`

Generates `delay_type = "connecting"`; waiting for its subchannels to become ready and report load.

##### `ring_hash`

Generates `delay_type = "connecting"`; the hash ring is waiting for the target ring nodes to connect (the reason can list the specific nodes being attempted).

##### `xds_override_host`

Generates `delay_type = "connecting"`; attempting to connect to a specific overridden host that is not yet ready. This policy implements stateful session affinity, per [gRFC A55][A55], [gRFC A60][A60], and [gRFC A75][A75].

##### `priority`

Prepends its **numeric priority** to the child's `delay_type`, producing composed values such as `"0:connecting"`. Priority policies can be nested (a priority policy's child is itself a priority policy), in which case each level prepends its own numeric priority and the prefixes stack — e.g. `"0:1:connecting"`. On failover — e.g. priority `0` enters `TRANSIENT_FAILURE` and the policy moves to priority `1` — the `delay_type` changes (`"0:connecting"` → `"1:connecting"`), which ends the current delay and starts a new one; the reason records the failover (which can include the child policy name and the failure cause, e.g. `"failing over to priority 1 (child 'tier-1-backup'); priority 0 failed: connection timeout"`). See [gRFC A56][A56].

##### `rls`

Generates `delay_type = "rls_lookup_pending"` while an RPC is blocked on a control-plane lookup (a cache miss). The reason can include the RLS server target or cache keys (e.g. `"Route Lookup Service query pending on rls-server:8080"`). For picks that are not blocked on a lookup, it forwards the child's `delay_type` unmodified. *(The RLS LB policy is not a public API and is not covered by any gRFC.)*

##### `cds`

Generates `delay_type = "cds_dynamic_discovery"` while waiting for a dynamic CDS cluster resource definition. The reason can include the targeted cluster name (e.g. `"waiting for CDS resource definition for cluster cluster_abc"`).

##### `xds_cluster_manager` and `weighted_target`

These pass-through container policies do not modify the metric `delay_type`; they forward the child's value. They may add their own structural details (such as the target cluster name or weight group) to the `delay_reason`.

#### Channel Behavior for LB Pick Delays

For picker-generated delays, the channel reads the deferred pick's `delay_type` and `delay_reason` and drives the call tracer per the [Call Tracer API](#call-tracer-api-changes): `Start` when the `delay_type` first appears or changes, `ReasonChanged` when only the reason changes, and `End` when a pick assigns a ready subchannel.

The channel additionally synthesizes two attempt-level delay types itself, keeping pickers ignorant of `wait_for_ready` semantics and transport-level races:

* **`picker_failing_with_wait_for_ready`**: When the picker returns a failing result (an error other than "no connection available") and the RPC is `wait_for_ready`, the channel queues the RPC and records this delay type, with the picker's error as the reason. (If the RPC is not `wait_for_ready`, it fails immediately and no delay is recorded.) The delay ends when a subsequent picker assigns a ready subchannel; if a queued RPC continues to see errors, the channel updates the reason. If the RPC is cancelled or reaches its deadline while queued, the call tracer terminates the open delay automatically.

* **`subchannel_state_mismatch`**: When the picker returns a ready subchannel but it has transitioned out of `READY` before the RPC can use it (a race before the picker is updated), the channel re-queues the RPC on the same attempt and records this delay type. It waits for the next picker and re-picks; no new attempt is created. The delay ends when a re-pick assigns a subchannel that is actually ready.

## Rationale

**Call vs. attempt metrics.** We split metrics into two histograms to differentiate channel-initialization bottlenecks (name resolution) from per-attempt routing bottlenecks (LB pick and connection). Combining them would obscure whether a delay occurred before or during connection establishment.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure this bullet is useful. It's not really about whethre the delay occurred before or during connection establishment -- that in and of itself wouldn't necessarily have been a reason to split the metrics.

Given that both metrics and tracing already differentiate calls from attempts, and that hedging means that there can be multiple attempts in parallel, I think this split was just the most natural approach. I don't think it's something we need to explicitly justify.


**Child spans vs. events.** We use a child span per delay rather than attaching events to the parent RPC span because it gives better flame-graph isolation in tracing backends, especially when delays are long or when multiple attempts are made (hedging or retries).

**Free-form `delay_reason`.** We allow unconstrained debug strings for `delay_reason` rather than bounded enum tokens. This risks higher cardinality in tracing backends but lets raw IP addresses, subchannel targets, and connection error messages be embedded directly in the trace. The metric label (`grpc.delay_type`) remains strictly bounded to keep metric cardinality low.

**Channel-synthesized delays.** We implement `picker_failing_with_wait_for_ready` and `subchannel_state_mismatch` in the channel rather than as picker-generated types. This keeps leaf pickers ignorant of `wait_for_ready` semantics and transport-level races: the picker reports only its current state, and the channel decides queueing behavior.

## Implementation

We will implement this in Go, Java, and C++ (Core).

[A66]: A66-otel-stats.md
[A72]: A72-open-telemetry-tracing.md
[A56]: A56-priority-lb-policy.md
[A28]: A28-xds-traffic-splitting-and-routing.md
[A61]: A61-IPv4-IPv6-dualstack-backends.md
[A55]: A55-xds-stateful-session-affinity.md
[A60]: A60-xds-stateful-session-affinity-weighted-clusters.md
[A75]: A75-xds-aggregate-cluster-behavior-fixes.md