Devtools
gpui-query exposes runtime diagnostics through the QueryClient. You get a serializable snapshot of every active query and mutation, which you can log, render in a debug panel, or serialize to JSON for later analysis.
ClientDiagnostic
Calling client.diagnostics(cx) returns a ClientDiagnostic struct. It is a point-in-time snapshot of the client internals.
pub struct ClientDiagnostic {
pub total_resources: usize,
pub bucket_count: usize,
pub mutation_count: usize,
pub queries: Vec<QueryDiagnostic>,
}
- total_resources: number of active query resources across all type buckets.
- bucket_count: number of type-partitioned buckets. Each unique
(T, E)pair gets its own bucket. - mutation_count: number of active mutation resources.
- queries: per-query diagnostics, one entry per resource.
The struct derives Clone, Debug, and Serialize. You can serialize it to JSON and ship it to a log aggregator or render it in a GPUI debug panel.
QueryDiagnostic
Each entry in queries is a QueryDiagnostic:
pub struct QueryDiagnostic {
pub key: String,
pub status: String,
pub has_data: bool,
pub has_error: bool,
pub cache_policy: String,
pub request_policy: String,
pub cache_hits: u64,
pub cancelled_count: u64,
pub ignored_results: u64,
pub last_updated_at_ms: Option<u128>,
pub started_at_ms: Option<u128>,
}
The fields:
| Field | What it tells you |
|---|---|
key | The stringified QueryKey for this resource. |
status | Human-readable label from QueryStatus: "Idle", "Loading empty", "Loading with data", "Success", "Failure", or "Cancelled". |
has_data | Whether the resource currently holds cached data. |
has_error | Whether the resource currently holds an error. |
cache_policy | Label from the configured CachePolicy: "No cache", "Cache TTL 60s", or "Stale-while-revalidate 60s". |
request_policy | Label from the configured RequestPolicy: "Latest wins" or "Ignore while loading". |
cache_hits | How many times this resource returned cached data instead of fetching. |
cancelled_count | How many in-flight requests were cancelled (for example, by a LatestWins policy replacing them). |
ignored_results | How many stale or out-of-order responses were discarded. |
last_updated_at_ms | UNIX timestamp in milliseconds of the last successful data update, if any. |
started_at_ms | UNIX timestamp in milliseconds of the current or most recent request start, if any. |
Basic usage
let client = cx.global::<QueryClient>();
let diag = client.diagnostics(cx);
println!("Active queries: {}", diag.total_resources);
println!("Buckets: {}", diag.bucket_count);
println!("Mutations: {}", diag.mutation_count);
for q in &diag.queries {
println!(
" {} - {} (data: {}, error: {}, hits: {})",
q.key, q.status, q.has_data, q.has_error, q.cache_hits
);
}
Filtering by type
If you only care about one type, use query_diagnostics::<T, E>(). It returns diagnostics for a single (T, E) bucket, or an empty vec if no bucket exists for that type pair.
let user_diags = client.query_diagnostics::<User, QueryError>(cx);
for q in &user_diags {
println!("user query: {} - {}", q.key, q.status);
}
This avoids iterating over unrelated type buckets when you are debugging a specific domain type.
Practical debugging patterns
Check whether your TTL is right
Use last_updated_at_ms to figure out how old cached data is. Compare it to the current time. If data is consistently stale, your CachePolicy::Ttl { ttl_ms } might be too long. If cache_hits is zero across the board, your TTL might be too short and every read triggers a refetch.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
for q in &diag.queries {
if let Some(updated) = q.last_updated_at_ms {
let age_ms = now - updated;
println!("{} is {}ms old", q.key, age_ms);
}
}
Verify deduplication is working
A high cache_hits count means the cache is doing its job. A count of zero on a resource that should be shared means you might be creating duplicate resources with slightly different keys. Check that your key construction is consistent.
for q in &diag.queries {
if q.cache_hits == 0 && q.status == "Success" {
println!("warning: {} has data but zero cache hits", q.key);
}
}
Spot flaky endpoints
Look at cancelled_count and ignored_results. A high cancelled count on a resource suggests rapid successive fetches are replacing each other (normal under RequestPolicy::LatestWins, but worth investigating if unexpected). High ignored results mean responses are arriving out of order relative to newer requests.
for q in &diag.queries {
if q.cancelled_count > 5 {
println!("{}: {} cancelled requests", q.key, q.cancelled_count);
}
}
Verify invalidation hit the right keys
After calling invalidate_queries on the QueryClient, run diagnostics again to confirm the target resources moved to the expected status. If a key is still "Success" with stale data, your invalidation filter did not match it.
client.invalidate_queries(&filter, cx);
let diag = client.diagnostics(cx);
for q in &diag.queries {
println!("{} - {}", q.key, q.status);
}
Serialization
Because ClientDiagnostic and QueryDiagnostic both derive Serialize, you can dump the full client state to JSON in one call. This is useful for logging, snapshot tests, or building a debug panel that communicates over IPC.
let json = serde_json::to_string_pretty(&client.diagnostics(cx))
.expect("diagnostics are serializable");
println!("{}", json);
If you are building a devtools panel in GPUI, consider polling diagnostics() on a timer. The cost is low: it reads entity state without cloning the actual cached data.
Observers
For real-time state tracking rather than point-in-time snapshots, see Observers. Observers subscribe to individual resource state changes, while diagnostics give you the full client picture in a single call. Use both together: diagnostics for the overview, observers for reactive updates on specific keys.