1use collections::HashMap;
2use gpui::{App, Global, InputLatencySnapshot, Window, WindowId, actions};
3use hdrhistogram::Histogram;
4use std::time::Instant;
5
6actions!(
7 dev,
8 [
9 /// Opens a buffer showing the input-to-frame latency histogram for the current window.
10 DumpInputLatencyHistogram,
11 ]
12);
13
14/// Generates a formatted text report of the input-to-frame latency histogram
15/// for the given window. If a previous report was generated (tracked via a
16/// global on the `App`), includes a delta section showing changes since that
17/// report.
18pub fn format_input_latency_report(window: &Window, cx: &mut App) -> String {
19 let snapshot = window.input_latency_snapshot();
20 let state = cx.default_global::<ReporterState>();
21 let report = format_report(&snapshot, state);
22
23 state.previous_snapshot = Some(snapshot);
24 state.previous_timestamp = Some(chrono::Local::now());
25
26 report
27}
28
29#[derive(Default)]
30struct ReporterState {
31 previous_snapshot: Option<InputLatencySnapshot>,
32 previous_timestamp: Option<chrono::DateTime<chrono::Local>>,
33}
34
35impl Global for ReporterState {}
36
37/// Per-window state used for telemetry delta computation. Kept separate from
38/// `ReporterState` so the user-facing dump and the background telemetry flush
39/// maintain independent baselines.
40#[derive(Default)]
41struct TelemetryReporterState {
42 /// Keyed by window id. Each entry holds the cumulative snapshot at the time
43 /// of the last telemetry flush, plus the wall-clock time of that flush.
44 previous: HashMap<WindowId, (Instant, InputLatencySnapshot)>,
45}
46
47impl Global for TelemetryReporterState {}
48
49/// Nanosecond boundaries for the time-range buckets used in telemetry.
50/// These match the display distribution in format_report so the two stay in sync.
51const MS4_NS: u64 = 4_000_000;
52const MS8_NS: u64 = 8_000_000;
53const MS16_NS: u64 = 16_000_000;
54const MS33_NS: u64 = 33_000_000;
55const MS100_NS: u64 = 100_000_000;
56
57/// Minimum number of frames that must be present in the delta window for the
58/// telemetry report to be sent. Avoids sending noise for windows that are
59/// mostly idle.
60const MIN_FRAMES_TO_REPORT: u64 = 5_000;
61
62/// Computes and sends a `input_latency_report` telemetry event for the given
63/// window if enough frames have been recorded since the last report.
64///
65/// Call this periodically (e.g. every five minutes) from a spawned task. A
66/// separate baseline snapshot is kept per window so user-facing histogram dumps
67/// and telemetry never share state.
68pub fn report_input_latency_telemetry(window: &Window, cx: &mut App) {
69 let current = window.input_latency_snapshot();
70 let window_id = window.window_handle().window_id();
71
72 let state = cx.default_global::<TelemetryReporterState>();
73 let now = Instant::now();
74
75 let (delta_latency, delta_coalesce, report_window_seconds) =
76 if let Some((prev_instant, prev_snapshot)) = state.previous.get(&window_id) {
77 let mut delta_latency = current.latency_histogram.clone();
78 delta_latency
79 .subtract(&prev_snapshot.latency_histogram)
80 .ok();
81 let mut delta_coalesce = current.events_per_frame_histogram.clone();
82 delta_coalesce
83 .subtract(&prev_snapshot.events_per_frame_histogram)
84 .ok();
85 let elapsed = now.duration_since(*prev_instant).as_secs();
86 (delta_latency, delta_coalesce, elapsed)
87 } else {
88 // First report for this window: the full cumulative histogram is the
89 // delta from the empty starting state. We don't know how long the
90 // window has been open, so record 0 to signal that this is the
91 // initial accumulation period rather than a fixed-width window.
92 (
93 current.latency_histogram.clone(),
94 current.events_per_frame_histogram.clone(),
95 0u64,
96 )
97 };
98
99 let total_frames = delta_latency.len();
100 if total_frames < MIN_FRAMES_TO_REPORT {
101 return;
102 }
103
104 state.previous.insert(window_id, (now, current));
105
106 let frames_sub4 = count_frames_in_range(&delta_latency, 0, MS4_NS);
107 let frames_4to8 = count_frames_in_range(&delta_latency, MS4_NS, MS8_NS);
108 let frames_8to16 = count_frames_in_range(&delta_latency, MS8_NS, MS16_NS);
109 let frames_16to33 = count_frames_in_range(&delta_latency, MS16_NS, MS33_NS);
110 let frames_33to100 = count_frames_in_range(&delta_latency, MS33_NS, MS100_NS);
111 // frames > 100 ms are implicitly total_frames - (sub4 + 4to8 + 8to16 + 16to33 + 33to100)
112
113 let frames_with_1_event = count_frames_in_range(&delta_coalesce, 1, 2);
114 let frames_with_2_events = count_frames_in_range(&delta_coalesce, 2, 3);
115 let frames_with_3_events = count_frames_in_range(&delta_coalesce, 3, 4);
116 // frames with 4+ events are implicitly total_frames - (1 + 2 + 3)
117
118 telemetry::event!(
119 "Latency Report",
120 frames_sub4 = frames_sub4,
121 frames_4to8 = frames_4to8,
122 frames_8to16 = frames_8to16,
123 frames_16to33 = frames_16to33,
124 frames_33to100 = frames_33to100,
125 total_frames = total_frames,
126 frames_with_1_event = frames_with_1_event,
127 frames_with_2_events = frames_with_2_events,
128 frames_with_3_events = frames_with_3_events,
129 report_window_seconds = report_window_seconds,
130 );
131}
132
133fn count_frames_in_range(histogram: &Histogram<u64>, low_ns: u64, high_ns: u64) -> u64 {
134 histogram
135 .iter_recorded()
136 .filter(|v| v.value_iterated_to() >= low_ns && v.value_iterated_to() < high_ns)
137 .map(|v| v.count_at_value())
138 .sum()
139}
140
141fn format_report(snapshot: &InputLatencySnapshot, previous: &ReporterState) -> String {
142 let histogram = &snapshot.latency_histogram;
143 let total = histogram.len();
144
145 if total == 0 {
146 return "No input latency samples recorded yet.\n\nTry typing or clicking in a buffer first.".to_string();
147 }
148
149 let percentiles: &[(&str, f64)] = &[
150 ("min ", 0.0),
151 ("p50 ", 0.50),
152 ("p75 ", 0.75),
153 ("p90 ", 0.90),
154 ("p95 ", 0.95),
155 ("p99 ", 0.99),
156 ("p99.9", 0.999),
157 ("max ", 1.0),
158 ];
159
160 let now = chrono::Local::now();
161
162 let mut report = String::new();
163 report.push_str("Input Latency Histogram\n");
164 report.push_str("=======================\n");
165
166 let timestamp = now.format("%Y-%m-%d %H:%M:%S %Z");
167 report.push_str(&format!("Timestamp: {timestamp}\n"));
168 report.push_str(&format!("Samples: {total}\n"));
169 if snapshot.mid_draw_events_dropped > 0 {
170 report.push_str(&format!(
171 "Mid-draw events excluded: {}\n",
172 snapshot.mid_draw_events_dropped
173 ));
174 }
175
176 write_latency_percentiles(&mut report, "Percentiles", histogram, percentiles);
177 write_latency_distribution(&mut report, "Distribution", histogram);
178
179 let coalesce = &snapshot.events_per_frame_histogram;
180 let coalesce_total = coalesce.len();
181 if coalesce_total > 0 {
182 report.push('\n');
183 report.push_str("Events coalesced per frame:\n");
184 for (label, quantile) in percentiles {
185 let value = if *quantile == 0.0 {
186 coalesce.min()
187 } else if *quantile == 1.0 {
188 coalesce.max()
189 } else {
190 coalesce.value_at_quantile(*quantile)
191 };
192 report.push_str(&format!(" {label}: {value:>6} events\n"));
193 }
194
195 report.push('\n');
196 report.push_str("Distribution:\n");
197 let bar_width = 30usize;
198 let max_count = coalesce.max();
199 for n in 1..=max_count {
200 let count = coalesce
201 .iter_recorded()
202 .filter(|value| value.value_iterated_to() == n)
203 .map(|value| value.count_at_value())
204 .sum::<u64>();
205 if count == 0 {
206 continue;
207 }
208 let fraction = count as f64 / coalesce_total as f64;
209 let bar_len = (fraction * bar_width as f64) as usize;
210 let bar = "\u{2588}".repeat(bar_len);
211 report.push_str(&format!(
212 " {n:>6} events: {count:>6} ({:>5.1}%) {bar}\n",
213 fraction * 100.0,
214 ));
215 }
216 }
217
218 // Delta section: compare against the previous report's snapshot.
219 if let (Some(prev_snapshot), Some(prev_timestamp)) =
220 (&previous.previous_snapshot, &previous.previous_timestamp)
221 {
222 let prev_latency = &prev_snapshot.latency_histogram;
223 let prev_total = prev_latency.len();
224 let delta_total = total - prev_total;
225
226 report.push('\n');
227 report.push_str("Delta Since Last Report\n");
228 report.push_str("-----------------------\n");
229 let prev_ts = prev_timestamp.format("%Y-%m-%d %H:%M:%S %Z");
230 let elapsed_secs = (now - *prev_timestamp).num_seconds().max(0);
231 report.push_str(&format!(
232 "Previous report: {prev_ts} ({elapsed_secs}s ago)\n"
233 ));
234 report.push_str(&format!("New samples: {delta_total}\n"));
235
236 if delta_total > 0 {
237 let mut delta_histogram = histogram.clone();
238 delta_histogram.subtract(prev_latency).ok();
239
240 write_latency_percentiles(
241 &mut report,
242 "Percentiles (new samples only)",
243 &delta_histogram,
244 percentiles,
245 );
246 write_latency_distribution(
247 &mut report,
248 "Distribution (new samples only)",
249 &delta_histogram,
250 );
251 }
252 }
253
254 report
255}
256
257fn write_latency_percentiles(
258 report: &mut String,
259 heading: &str,
260 histogram: &Histogram<u64>,
261 percentiles: &[(&str, f64)],
262) {
263 let ns_to_ms = |ns: u64| ns as f64 / 1_000_000.0;
264
265 report.push('\n');
266 report.push_str(heading);
267 report.push_str(":\n");
268 for (label, quantile) in percentiles {
269 let value_ns = if *quantile == 0.0 {
270 histogram.min()
271 } else if *quantile == 1.0 {
272 histogram.max()
273 } else {
274 histogram.value_at_quantile(*quantile)
275 };
276 let hz = if value_ns > 0 {
277 1_000_000_000.0 / value_ns as f64
278 } else {
279 f64::INFINITY
280 };
281 report.push_str(&format!(
282 " {label}: {:>8.2}ms ({:>7.1} Hz)\n",
283 ns_to_ms(value_ns),
284 hz
285 ));
286 }
287}
288
289fn write_latency_distribution(report: &mut String, heading: &str, histogram: &Histogram<u64>) {
290 const BUCKETS: &[(u64, u64, &str, &str)] = &[
291 (0, 4_000_000, "0\u{2013}4ms", "(excellent)"),
292 (4_000_000, 8_000_000, "4\u{2013}8ms", "(120fps)"),
293 (8_000_000, 16_000_000, "8\u{2013}16ms", "(60fps)"),
294 (16_000_000, 33_000_000, "16\u{2013}33ms", "(30fps)"),
295 (33_000_000, 100_000_000, "33\u{2013}100ms", ""),
296 (100_000_000, u64::MAX, "100ms+", "(sluggish)"),
297 ];
298 let bar_width = 30usize;
299 let total = histogram.len() as f64;
300
301 report.push('\n');
302 report.push_str(heading);
303 report.push_str(":\n");
304 for (low, high, range, note) in BUCKETS {
305 let count: u64 = histogram
306 .iter_recorded()
307 .filter(|value| value.value_iterated_to() >= *low && value.value_iterated_to() < *high)
308 .map(|value| value.count_at_value())
309 .sum();
310 let fraction = if total > 0.0 {
311 count as f64 / total
312 } else {
313 0.0
314 };
315 let bar_len = (fraction * bar_width as f64) as usize;
316 let bar = "\u{2588}".repeat(bar_len);
317 report.push_str(&format!(
318 " {range:>8} {note:<11}: {count:>6} ({:>5.1}%) {bar}\n",
319 fraction * 100.0,
320 ));
321 }
322}