Skip to main content

turbopack_trace_server/
lib.rs

1#![feature(deref_patterns)]
2#![feature(bufreader_peek)]
3
4use std::{
5    cmp::Reverse,
6    collections::VecDeque,
7    hash::BuildHasherDefault,
8    path::PathBuf,
9    sync::Arc,
10    thread,
11    time::{Duration, Instant},
12};
13
14use rustc_hash::FxHasher;
15
16use self::{
17    reader::TraceReader,
18    server::serve,
19    span_graph_ref::{SpanGraphEventRef, SpanGraphRef},
20    span_ref::SpanRef,
21    store_container::StoreContainer,
22    timestamp::Timestamp,
23};
24
25mod bottom_up;
26mod chunked_vec;
27mod lazy_sorted_vec;
28#[cfg(test)]
29mod query_tests;
30mod reader;
31mod self_time_tree;
32mod server;
33mod span;
34mod span_bottom_up_ref;
35mod span_graph_ref;
36mod span_ref;
37mod store;
38pub mod store_container;
39mod string_tuple_ref;
40mod timestamp;
41mod u64_empty_string;
42mod u64_string;
43mod viewer;
44
45#[allow(
46    dead_code,
47    reason = "It's actually used, not sure why it is marked as dead code"
48)]
49type FxIndexMap<K, V> = indexmap::IndexMap<K, V, BuildHasherDefault<FxHasher>>;
50
51/// Starts the trace server on a background thread and returns the store
52/// immediately. The WebSocket server runs non-blocking.
53pub fn start_turbopack_trace_server(path: PathBuf, port: Option<u16>) -> Arc<StoreContainer> {
54    let store = Arc::new(StoreContainer::new());
55
56    let store_for_reader = store.clone();
57    let store_for_server = store.clone();
58
59    TraceReader::spawn(store_for_reader, path);
60
61    thread::spawn(move || {
62        serve(store_for_server, port.unwrap_or(5747));
63    });
64
65    store
66}
67
68/// Page size used when the caller doesn't ask for one.
69const DEFAULT_PAGE_SIZE: usize = 20;
70
71/// Upper bound on a caller-supplied page size, so one response stays bounded.
72const MAX_PAGE_SIZE: usize = 500;
73
74/// Default and cap for `QueryOptions::max_depth`. Also bounds the subtree
75/// walks, which is why they need no cycle detection.
76const DEFAULT_SEARCH_MAX_DEPTH: u32 = 32;
77
78/// How spans should be sorted.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum SortMode {
81    /// No sorting — spans appear in execution/natural order.
82    #[default]
83    ExecutionOrder,
84    /// Sort by value (corrected duration descending).
85    Value,
86    /// Sort alphabetically by name, then by category.
87    Name,
88    /// Sort by total allocated bytes, descending.
89    Allocations,
90    /// Sort by `SpanInfo::persistent_allocations`, descending.
91    PersistentAllocations,
92}
93
94/// Options for querying spans from the trace store.
95pub struct QueryOptions {
96    /// Optional parent span ID (as produced by `SpanInfo::id`).
97    /// `None` means root level.
98    pub parent: Option<String>,
99    /// When true, aggregate child spans with the same name.
100    pub aggregated: bool,
101    /// How to sort the results.
102    pub sort: SortMode,
103    /// Optional substring search query.
104    ///
105    /// Matches the parent's entire subtree, not just its direct children. Each
106    /// result's `id` is the full path from `parent` down to the match, so it
107    /// can be passed straight back as `parent`.
108    ///
109    /// Cost scales with the size of that subtree, so a root search on a large
110    /// trace walks everything. Setting `parent`, or lowering `max_depth`,
111    /// bounds it.
112    pub search: Option<String>,
113    /// Maximum depth to descend below `parent` when `search` is set, or when
114    /// `depth` requests a nested subtree. Clamped to `DEFAULT_SEARCH_MAX_DEPTH`.
115    pub max_depth: u32,
116    /// When greater than 1, each returned span carries this many levels of
117    /// descendants inline in `children`.
118    pub depth: u32,
119    /// 1-based page number.
120    pub page: usize,
121    /// Number of spans per page. Clamped to `MAX_PAGE_SIZE`; `None` uses
122    /// `DEFAULT_PAGE_SIZE`.
123    pub page_size: Option<usize>,
124}
125
126/// Information about a single span (or aggregated group of spans).
127pub struct SpanInfo {
128    /// Span ID string.
129    ///
130    /// The format encodes both the type and the navigation path:
131    /// - A **raw span** leaf is its decimal index: `"123"`.
132    /// - An **aggregated span** leaf is `"a"` + the first-span index: `"a123"`.
133    /// - When the span is a child of another span, the parent's ID is prepended with a dash
134    ///   separator, e.g. `"a5-a34"` or `"1-a5-a34-20"`.
135    ///
136    /// Pass the full ID as the `parent` option of the next `query_spans` call
137    /// to enumerate the children of that span.
138    pub id: String,
139    /// Display name: `"category title"` or just `"title"`.
140    pub name: String,
141    /// Raw CPU total time in internal ticks (100 ticks = 1 µs).
142    /// For aggregated spans, this is the **first (example) span's** value, not the group total.
143    /// See `total_cpu_duration` for the group total.
144    pub cpu_duration: u64,
145    /// Concurrency-corrected total time in internal ticks.
146    /// For aggregated spans, this is the **first (example) span's** value, not the group total.
147    /// See `total_corrected_duration` for the group total.
148    pub corrected_duration: u64,
149    /// Start of span relative to parent start, in internal ticks.
150    pub start_relative_to_parent: i64,
151    /// End of span relative to parent start, in internal ticks.
152    pub end_relative_to_parent: i64,
153    /// Key-value attributes from the span.
154    pub args: Vec<(String, String)>,
155    /// True if this entry represents an aggregated group of spans.
156    pub is_aggregated: bool,
157    /// Number of spans in the group (only set for aggregated spans).
158    pub count: Option<u64>,
159    /// Sum of cpu_duration across all spans in the group.
160    pub total_cpu_duration: Option<u64>,
161    /// Average cpu_duration across all spans in the group.
162    pub avg_cpu_duration: Option<u64>,
163    /// Sum of corrected_duration across all spans in the group.
164    pub total_corrected_duration: Option<u64>,
165    /// Average corrected_duration across all spans in the group.
166    pub avg_corrected_duration: Option<u64>,
167    /// Raw span ID of the group's example span, whose `cpu_duration`,
168    /// `corrected_duration` and `memory_samples` are the ones reported above.
169    /// First in execution order — *not* the largest, so it can badly understate
170    /// a group's allocations. Use `heaviest_span_id` for those.
171    pub first_span_id: Option<String>,
172    /// Raw span ID of the group member with the largest
173    /// `total_persistent_allocations`.
174    pub heaviest_span_id: Option<String>,
175    /// Total bytes allocated by this span and all its children.
176    ///
177    /// For aggregated groups this is the group total, unlike `cpu_duration`,
178    /// `corrected_duration` and `memory_samples`, which describe the example
179    /// span only. Every allocation field below follows this field, not those.
180    pub allocations: u64,
181    /// Total bytes deallocated by this span and all its children.
182    /// Group total for aggregated spans.
183    pub deallocations: u64,
184    /// Sum over each span of `max(0, self_allocations - self_deallocations)`,
185    /// for this span and its children. Group total for aggregated spans.
186    ///
187    /// **A ranking signal, not retained memory.** TurboMalloc's per-span
188    /// counters never see memory released outside the allocating span's window
189    /// — turbo-tasks cell and cache drops in particular — so a whole-trace
190    /// total far above real peak RSS is expected, not a leak. Use
191    /// `memory_summary` for absolute memory.
192    ///
193    /// The per-span floor at zero is also why this is not
194    /// `allocations - deallocations`.
195    pub persistent_allocations: u64,
196    /// Number of allocation operations by this span and all its children.
197    /// Group total for aggregated spans.
198    pub allocation_count: u64,
199    /// Bytes allocated by this span itself, excluding children.
200    /// Group total for aggregated spans.
201    pub self_allocations: u64,
202    /// Bytes deallocated by this span itself, excluding children.
203    /// Group total for aggregated spans.
204    ///
205    /// Frees are charged to whichever span was on top of the thread's stack at
206    /// free time, which is often not the span that allocated. So small
207    /// `self_allocations` with large `self_deallocations` means this span is
208    /// where a child's arena gets dropped — that arena is bounded, not leaking.
209    /// The shape to suspect is a large `self_persistent_allocations` with no
210    /// such counterpart above it.
211    pub self_deallocations: u64,
212    /// `max(0, self_allocations - self_deallocations)` for this span alone.
213    /// Group total for aggregated spans.
214    pub self_persistent_allocations: u64,
215    /// Number of allocation operations by this span itself, excluding children.
216    /// Group total for aggregated spans.
217    pub self_allocation_count: u64,
218    /// Process samples recorded while this span (or its example span, for
219    /// aggregated groups) was live.
220    ///
221    /// **Process-wide, not per-span.** There is one global sample series, and a
222    /// span's samples are just the slice covering its time range, so spans that
223    /// overlap in time report identical values no matter what each allocated.
224    /// Rank concurrent work by the allocation fields; use these for absolute
225    /// memory over a span that dominates its window.
226    ///
227    /// Each tuple is `(ts_offset_from_span_start_in_ticks, bytes, pressure,
228    /// footprint, active_worker_threads)`. `bytes` is TurboMalloc memory
229    /// usage; `pressure` is the memory-pressure byte (0 = no pressure, higher
230    /// = more pressure); `footprint` is the process memory footprint (RSS) in
231    /// bytes (0 = not reported by the platform); and `active_worker_threads`
232    /// counts non-parked Tokio scheduler workers. `100 ticks = 1 µs`. The
233    /// offset is within the span.
234    ///
235    /// The store caps the series at `MAX_MEMORY_SAMPLES`; when more samples
236    /// exist in the range, consecutive groups are merged: the timestamp and
237    /// value come from the group's max-memory sample, while pressure,
238    /// footprint and worker count are each the max over the group.
239    pub memory_samples: Vec<(i64, u64, u8, u64, u64)>,
240    /// Summary of `memory_samples`. `None` when the span's range holds none.
241    pub memory_summary: Option<MemorySummary>,
242    /// Descendants of this span, populated only when `QueryOptions::depth` is
243    /// greater than 1. Sorted and aggregated the same way as this level.
244    pub children: Vec<SpanInfo>,
245}
246
247/// Aggregate view of a span's TurboMalloc memory samples.
248///
249/// Unlike the allocation counters these are absolute live-heap readings, so
250/// `peak` is the figure to quote for memory actually in use.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub struct MemorySummary {
253    /// Number of samples in the span's range (after the store's downsampling).
254    pub count: usize,
255    /// Live bytes at the first sample in the range.
256    pub start: u64,
257    /// Live bytes at the last sample in the range.
258    pub end: u64,
259    /// Smallest live-bytes reading in the range.
260    pub min: u64,
261    /// Largest live-bytes reading in the range — the span's peak memory.
262    pub peak: u64,
263    /// Highest memory-pressure byte seen in the range (0 = no pressure).
264    pub max_pressure: u8,
265    /// Largest process memory footprint (RSS) in bytes seen in the range
266    /// (0 = not reported by the platform).
267    pub max_footprint: u64,
268}
269
270impl MemorySummary {
271    /// Summarize a sample series, or `None` if it is empty.
272    fn from_samples(samples: &[(i64, u64, u8, u64, u64)]) -> Option<Self> {
273        let (_, first_bytes, first_pressure, first_footprint, _) = *samples.first()?;
274        let mut summary = MemorySummary {
275            count: samples.len(),
276            start: first_bytes,
277            end: samples.last().expect("non-empty").1,
278            min: first_bytes,
279            peak: first_bytes,
280            max_pressure: first_pressure,
281            max_footprint: first_footprint,
282        };
283        for &(_, bytes, pressure, footprint, _) in &samples[1..] {
284            summary.min = summary.min.min(bytes);
285            summary.peak = summary.peak.max(bytes);
286            summary.max_pressure = summary.max_pressure.max(pressure);
287            summary.max_footprint = summary.max_footprint.max(footprint);
288        }
289        Some(summary)
290    }
291}
292
293/// Result of a `query_spans` call.
294pub struct QueryResult {
295    pub spans: Vec<SpanInfo>,
296    pub page: usize,
297    pub total_pages: usize,
298    pub total_count: usize,
299}
300/// Paginate a vec of items. Returns `(page_items, clamped_page, total_pages, total_count)`.
301fn paginate<T>(items: Vec<T>, page: usize, page_size: usize) -> (Vec<T>, usize, usize, usize) {
302    let page_size = page_size.clamp(1, MAX_PAGE_SIZE);
303    let total_count = items.len();
304    let total_pages = total_count.div_ceil(page_size).max(1);
305    let page = page.clamp(1, total_pages);
306    let start = (page - 1) * page_size;
307    let page_items = items.into_iter().skip(start).take(page_size).collect();
308    (page_items, page, total_pages, total_count)
309}
310
311fn format_span_name(cat: &str, title: &str) -> String {
312    if cat.is_empty() {
313        title.to_string()
314    } else {
315        format!("{cat} {title}")
316    }
317}
318
319/// Build a span ID by appending a leaf segment to the optional parent path.
320fn build_span_id(parent: Option<&str>, leaf: &str) -> String {
321    match parent {
322        Some(p) => format!("{p}-{leaf}"),
323        None => leaf.to_string(),
324    }
325}
326
327/// A span found while walking, paired with the ID path that leads to it.
328struct Located<T> {
329    item: T,
330    /// Full path ID, e.g. `"a5-a34-a91"`, usable as a `parent`.
331    id: String,
332}
333
334/// Does this name match the search query?
335///
336/// Comma-separated terms are ANDed. Each term is a plain substring match
337/// against the span's display name, `"category title"` — the `name` a result
338/// reports — which is why a term copied out of a response finds its span
339/// again.
340fn name_matches(cat: &str, title: &str, query: &str) -> bool {
341    query
342        .split(',')
343        .map(str::trim)
344        .filter(|term| !term.is_empty())
345        .all(|term| contains_in_joined(cat, title, term))
346}
347
348/// Is `term` a substring of `"{cat} {title}"`, without building that string?
349///
350/// This runs for every span in the searched subtree, so the concatenation is
351/// worth avoiding: it was the dominant cost of a search.
352///
353/// A substring of the joined name either lies within one field or straddles
354/// the single space between them, so the straddling case splits the term at a
355/// space and checks that `cat` ends with the head and `title` starts with the
356/// tail. Every space in the term is tried, since both sides may contain their
357/// own.
358fn contains_in_joined(cat: &str, title: &str, term: &str) -> bool {
359    if cat.contains(term) || title.contains(term) {
360        return true;
361    }
362    // `format_span_name` drops the separator only for an empty category, so
363    // that is the one case with no joining space to straddle. An empty title
364    // still leaves a trailing space, which a term may legitimately end on, so
365    // it must not short-circuit here.
366    if cat.is_empty() {
367        return false;
368    }
369    term.match_indices(' ')
370        .any(|(i, _)| cat.ends_with(&term[..i]) && title.starts_with(&term[i + 1..]))
371}
372
373/// Collect the aggregated children of a graph node.
374fn graph_children<'a>(graph: &SpanGraphRef<'a>) -> Vec<SpanGraphRef<'a>> {
375    graph
376        .events()
377        .filter_map(|event| match event {
378            SpanGraphEventRef::Child { graph } => Some(graph),
379            SpanGraphEventRef::SelfTime { .. } => None,
380        })
381        .collect()
382}
383
384/// Collect the aggregated children of a raw span.
385fn span_graph_children<'a>(span: &SpanRef<'a>) -> Vec<SpanGraphRef<'a>> {
386    span.graph()
387        .filter_map(|event| match event {
388            SpanGraphEventRef::Child { graph } => Some(graph),
389            SpanGraphEventRef::SelfTime { .. } => None,
390        })
391        .collect()
392}
393
394/// Walk the aggregated graph below `roots`, returning every node whose name
395/// matches `query`, each tagged with its full path ID.
396///
397/// Raw spans get recursion from the store's search index, but graph nodes are
398/// built on demand and have no index, so this walks them directly.
399///
400/// A match does not stop the descent — nested spans often share a name with an
401/// ancestor.
402fn search_graph_recursive<'a>(
403    roots: Vec<Located<SpanGraphRef<'a>>>,
404    query: &str,
405    max_depth: u32,
406) -> Vec<Located<SpanGraphRef<'a>>> {
407    let mut matches = Vec::new();
408    let mut queue: VecDeque<(Located<SpanGraphRef<'a>>, u32)> =
409        roots.into_iter().map(|located| (located, 1)).collect();
410
411    while let Some((located, depth)) = queue.pop_front() {
412        let (cat, title) = located.item.nice_name();
413        let is_match = name_matches(cat, title, query);
414
415        if depth < max_depth {
416            for child in graph_children(&located.item) {
417                let leaf = format!("a{}", child.first_span().index);
418                let id = build_span_id(Some(&located.id), &leaf);
419                queue.push_back((Located { item: child, id }, depth + 1));
420            }
421        }
422
423        if is_match {
424            matches.push(located);
425        }
426    }
427
428    matches
429}
430
431/// Walk raw spans below `roots`, returning every span whose name matches
432/// `query`.
433///
434/// Used for multi-word terms, which the store's index cannot answer; see the
435/// call site.
436fn search_spans_recursive<'a>(
437    roots: Vec<SpanRef<'a>>,
438    query: &str,
439    max_depth: u32,
440) -> Vec<SpanRef<'a>> {
441    let mut matches = Vec::new();
442    let mut queue: VecDeque<(SpanRef<'a>, u32)> = roots.into_iter().map(|span| (span, 1)).collect();
443
444    while let Some((span, depth)) = queue.pop_front() {
445        let (cat, title) = span.nice_name();
446        if name_matches(cat, title, query) {
447            matches.push(span);
448        }
449        if depth < max_depth {
450            for child in span.children() {
451                queue.push_back((child, depth + 1));
452            }
453        }
454    }
455
456    matches
457}
458
459/// Sort located aggregated nodes in place.
460fn sort_graph(items: &mut [Located<SpanGraphRef<'_>>], sort: SortMode) {
461    match sort {
462        SortMode::Value => items.sort_by(|a, b| {
463            b.item
464                .corrected_total_time()
465                .cmp(&a.item.corrected_total_time())
466                .then_with(|| b.item.total_time().cmp(&a.item.total_time()))
467        }),
468        SortMode::Name => items.sort_by(|a, b| {
469            let (a_cat, a_title) = a.item.nice_name();
470            let (b_cat, b_title) = b.item.nice_name();
471            a_title.cmp(b_title).then_with(|| a_cat.cmp(b_cat))
472        }),
473        SortMode::Allocations => {
474            items.sort_by_key(|s| Reverse(s.item.total_allocations()));
475        }
476        SortMode::PersistentAllocations => {
477            items.sort_by_key(|s| Reverse(s.item.total_persistent_allocations()));
478        }
479        SortMode::ExecutionOrder => {}
480    }
481}
482
483/// Sort located raw spans in place.
484fn sort_spans(items: &mut [Located<SpanRef<'_>>], sort: SortMode) {
485    match sort {
486        SortMode::Value => items.sort_by(|a, b| {
487            b.item
488                .corrected_total_time()
489                .cmp(&a.item.corrected_total_time())
490                .then_with(|| b.item.total_time().cmp(&a.item.total_time()))
491        }),
492        SortMode::Name => items.sort_by(|a, b| {
493            let (a_cat, a_title) = a.item.nice_name();
494            let (b_cat, b_title) = b.item.nice_name();
495            a_title.cmp(b_title).then_with(|| a_cat.cmp(b_cat))
496        }),
497        SortMode::Allocations => {
498            items.sort_by_key(|s| Reverse(s.item.total_allocations()));
499        }
500        SortMode::PersistentAllocations => {
501            items.sort_by_key(|s| Reverse(s.item.total_persistent_allocations()));
502        }
503        SortMode::ExecutionOrder => {}
504    }
505}
506
507/// Memory samples recorded while `span` was live, offset from its start.
508fn memory_samples_for(store: &store::Store, span: &SpanRef<'_>) -> Vec<(i64, u64, u8, u64, u64)> {
509    let span_start = *span.start() as i64;
510    store
511        .memory_samples_for_range_with_ts(span.start(), span.end())
512        .into_iter()
513        .map(|(ts, mem, pressure, footprint, workers)| {
514            ((*ts as i64) - span_start, mem, pressure, footprint, workers)
515        })
516        .collect()
517}
518
519/// Build a `SpanInfo` for an aggregated graph node, recursing into children
520/// while `depth > 1`.
521fn build_graph_span_info(
522    store: &store::Store,
523    located: Located<SpanGraphRef<'_>>,
524    parent_start: Timestamp,
525    sort: SortMode,
526    depth: u32,
527) -> SpanInfo {
528    let Located { item: graph, id } = located;
529    let first = graph.first_span();
530    let (cat, title) = graph.nice_name();
531    let count = graph.count() as u64;
532    let total_cpu = *graph.total_time();
533    let total_corrected = *graph.corrected_total_time();
534
535    let heaviest = graph
536        .root_spans()
537        .max_by_key(|span| span.total_persistent_allocations())
538        .map(|span| span.index.to_string());
539
540    let memory_samples = memory_samples_for(store, &first);
541    let memory_summary = MemorySummary::from_samples(&memory_samples);
542
543    let children = if depth > 1 {
544        let mut located_children: Vec<_> = graph_children(&graph)
545            .into_iter()
546            .map(|child| {
547                let leaf = format!("a{}", child.first_span().index);
548                Located {
549                    id: build_span_id(Some(&id), &leaf),
550                    item: child,
551                }
552            })
553            .collect();
554        sort_graph(&mut located_children, sort);
555        located_children
556            .into_iter()
557            .map(|child| build_graph_span_info(store, child, first.start(), sort, depth - 1))
558            .collect()
559    } else {
560        Vec::new()
561    };
562
563    SpanInfo {
564        id,
565        name: format_span_name(cat, title),
566        cpu_duration: *first.total_time(),
567        corrected_duration: *first.corrected_total_time(),
568        start_relative_to_parent: (*first.start() as i64) - (*parent_start as i64),
569        end_relative_to_parent: (*first.end() as i64) - (*parent_start as i64),
570        args: first
571            .args()
572            .map(|(k, v)| (k.to_string(), v.to_string()))
573            .collect(),
574        is_aggregated: count > 1,
575        count: Some(count),
576        total_cpu_duration: Some(total_cpu),
577        avg_cpu_duration: Some(total_cpu.checked_div(count).unwrap_or(0)),
578        total_corrected_duration: Some(total_corrected),
579        avg_corrected_duration: Some(total_corrected.checked_div(count).unwrap_or(0)),
580        first_span_id: Some(first.index.to_string()),
581        heaviest_span_id: heaviest,
582        allocations: graph.total_allocations(),
583        deallocations: graph.total_deallocations(),
584        persistent_allocations: graph.total_persistent_allocations(),
585        allocation_count: graph.total_allocation_count(),
586        self_allocations: graph.self_allocations(),
587        self_deallocations: graph.self_deallocations(),
588        self_persistent_allocations: graph.self_persistent_allocations(),
589        self_allocation_count: graph.self_allocation_count(),
590        memory_samples,
591        memory_summary,
592        children,
593    }
594}
595
596/// Build a `SpanInfo` for a raw span, recursing into children while `depth > 1`.
597fn build_raw_span_info(
598    store: &store::Store,
599    located: Located<SpanRef<'_>>,
600    parent_start: Timestamp,
601    sort: SortMode,
602    depth: u32,
603) -> SpanInfo {
604    let Located { item: span, id } = located;
605    let (cat, title) = span.nice_name();
606
607    let memory_samples = memory_samples_for(store, &span);
608    let memory_summary = MemorySummary::from_samples(&memory_samples);
609
610    let children = if depth > 1 {
611        let mut located_children: Vec<_> = span
612            .children()
613            .map(|child| Located {
614                id: build_span_id(Some(&id), &child.index.to_string()),
615                item: child,
616            })
617            .collect();
618        sort_spans(&mut located_children, sort);
619        located_children
620            .into_iter()
621            .map(|child| build_raw_span_info(store, child, span.start(), sort, depth - 1))
622            .collect()
623    } else {
624        Vec::new()
625    };
626
627    SpanInfo {
628        id,
629        name: format_span_name(cat, title),
630        cpu_duration: *span.total_time(),
631        corrected_duration: *span.corrected_total_time(),
632        start_relative_to_parent: (*span.start() as i64) - (*parent_start as i64),
633        end_relative_to_parent: (*span.end() as i64) - (*parent_start as i64),
634        args: span
635            .args()
636            .map(|(k, v)| (k.to_string(), v.to_string()))
637            .collect(),
638        is_aggregated: false,
639        count: None,
640        total_cpu_duration: None,
641        avg_cpu_duration: None,
642        total_corrected_duration: None,
643        avg_corrected_duration: None,
644        first_span_id: None,
645        heaviest_span_id: None,
646        allocations: span.total_allocations(),
647        deallocations: span.total_deallocations(),
648        persistent_allocations: span.total_persistent_allocations(),
649        allocation_count: span.total_allocation_count(),
650        self_allocations: span.self_allocations(),
651        self_deallocations: span.self_deallocations(),
652        self_persistent_allocations: span.self_persistent_allocations(),
653        self_allocation_count: span.self_allocation_count(),
654        memory_samples,
655        memory_summary,
656        children,
657    }
658}
659
660/// Query spans from the store.
661///
662/// Waits up to 10 seconds for at least some data to be loaded before
663/// returning, so callers don't need to poll separately.
664pub fn query_spans(store: &Arc<StoreContainer>, options: QueryOptions) -> QueryResult {
665    // Wait briefly for initial data if the store is empty.
666    let deadline = Instant::now() + Duration::from_secs(10);
667    loop {
668        {
669            let guard = store.read();
670            // root span always exists (index 0); real spans start at index 1
671            if guard.spans.len() > 1 {
672                break;
673            }
674        }
675        if Instant::now() >= deadline {
676            break;
677        }
678        thread::sleep(Duration::from_millis(50));
679    }
680
681    let store_guard = store.read();
682    let store_ref = &*store_guard;
683
684    let parent_span: Option<SpanRef<'_>> = if let Some(ref parent_id) = options.parent {
685        resolve_span_by_id(store_ref, parent_id)
686    } else {
687        None
688    };
689
690    let parent_start = parent_span.as_ref().map(|s| s.start()).unwrap_or_default();
691    let max_depth = options.max_depth.clamp(1, DEFAULT_SEARCH_MAX_DEPTH);
692    let depth = options.depth.clamp(1, DEFAULT_SEARCH_MAX_DEPTH);
693    let page_size = options
694        .page_size
695        .unwrap_or(DEFAULT_PAGE_SIZE)
696        .clamp(1, MAX_PAGE_SIZE);
697
698    if options.aggregated {
699        let direct: Vec<Located<SpanGraphRef<'_>>> = match parent_span {
700            Some(ref parent) => span_graph_children(parent),
701            None => span_graph_children(&store_ref.root_span()),
702        }
703        .into_iter()
704        .map(|graph| {
705            let leaf = format!("a{}", graph.first_span().index);
706            Located {
707                id: build_span_id(options.parent.as_deref(), &leaf),
708                item: graph,
709            }
710        })
711        .collect();
712
713        // A search descends the subtree; without one we stay at this level.
714        let mut filtered = match options.search {
715            Some(ref query) => search_graph_recursive(direct, query, max_depth),
716            None => direct,
717        };
718
719        sort_graph(&mut filtered, options.sort);
720
721        let (page_items, page, total_pages, total_count) =
722            paginate(filtered, options.page, page_size);
723
724        let spans = page_items
725            .into_iter()
726            .map(|located| {
727                build_graph_span_info(store_ref, located, parent_start, options.sort, depth)
728            })
729            .collect();
730
731        QueryResult {
732            spans,
733            page,
734            total_pages,
735            total_count,
736        }
737    } else {
738        // The store's index keys category and title separately, so a term
739        // spanning both (a `name` copied from a result) matches no key. It is
740        // shared with the WebSocket viewer, so rather than change its keys,
741        // walk the subtree ourselves whenever any term contains a space.
742        // Single-word queries still take the index, which is much faster.
743        let filtered: Vec<Located<SpanRef<'_>>> = if let Some(ref query) = options.search {
744            let needs_scan = query.split(',').any(|term| term.trim().contains(' '));
745            let matches: Vec<SpanRef<'_>> = if needs_scan {
746                let roots: Vec<SpanRef<'_>> = match parent_span {
747                    Some(ref parent) => parent.children().collect(),
748                    None => store_ref.root_spans().collect(),
749                };
750                search_spans_recursive(roots, query, max_depth)
751            } else {
752                match parent_span {
753                    Some(ref parent) => parent.search(query).collect(),
754                    None => store_ref.root_span().search(query).collect(),
755                }
756            };
757            matches
758                .into_iter()
759                .filter_map(|span| {
760                    raw_span_path(&span, parent_span.as_ref(), max_depth).map(|id| Located {
761                        item: span,
762                        id: build_span_id(options.parent.as_deref(), &id),
763                    })
764                })
765                .collect()
766        } else {
767            match parent_span {
768                Some(ref parent) => parent.children().collect::<Vec<_>>(),
769                None => store_ref.root_spans().collect::<Vec<_>>(),
770            }
771            .into_iter()
772            .map(|span| Located {
773                id: build_span_id(options.parent.as_deref(), &span.index.to_string()),
774                item: span,
775            })
776            .collect()
777        };
778
779        let mut filtered = filtered;
780        sort_spans(&mut filtered, options.sort);
781
782        let (page_items, page, total_pages, total_count) =
783            paginate(filtered, options.page, page_size);
784
785        let spans = page_items
786            .into_iter()
787            .map(|located| {
788                build_raw_span_info(store_ref, located, parent_start, options.sort, depth)
789            })
790            .collect();
791
792        QueryResult {
793            spans,
794            page,
795            total_pages,
796            total_count,
797        }
798    }
799}
800
801/// Build the dash-separated index path from `ancestor` (exclusive) down to
802/// `span` (inclusive), e.g. `"12-48-91"`.
803///
804/// `None` when `span` is not a descendant of `ancestor`, or when the chain is
805/// longer than `max_depth`. Passing `None` for `ancestor` walks up to the
806/// trace root.
807fn raw_span_path(
808    span: &SpanRef<'_>,
809    ancestor: Option<&SpanRef<'_>>,
810    max_depth: u32,
811) -> Option<String> {
812    let stop_at = ancestor.map(|a| a.index);
813    let mut segments = Vec::new();
814    let mut current = *span;
815
816    loop {
817        if Some(current.index) == stop_at {
818            break;
819        }
820        if segments.len() as u32 >= max_depth {
821            return None;
822        }
823        segments.push(current.index.to_string());
824        match current.parent() {
825            Some(parent) => current = parent,
826            // Reached the root without finding `ancestor`.
827            None => {
828                if stop_at.is_some() {
829                    return None;
830                }
831                break;
832            }
833        }
834    }
835
836    segments.reverse();
837    Some(segments.join("-"))
838}
839
840/// Resolve a span by its MCP ID string.
841///
842/// IDs use the format `[a]<index>[-[a]<index>...]`:
843/// - A plain decimal segment (e.g. `"123"`) refers to a raw span at that store index.
844/// - A segment prefixed with `"a"` (e.g. `"a123"`) refers to the first span of an aggregated group
845///   at that store index.
846/// - Segments are separated by `-` to form a navigation path, e.g. `"a5-a34-20"`. Only the **last**
847///   segment is needed to look up the span whose children we want to enumerate; the earlier
848///   segments provide navigation context for the caller.
849fn resolve_span_by_id<'a>(store: &'a store::Store, id: &str) -> Option<SpanRef<'a>> {
850    // Take only the last path segment (everything after the final `-`).
851    let last = id.split('-').next_back().unwrap_or(id);
852    // Strip the optional "a" prefix that marks aggregated spans.
853    let index_str = last.strip_prefix('a').unwrap_or(last);
854    let index: usize = index_str.parse().ok()?;
855    store.spans.get(index).map(|s| SpanRef {
856        span: s,
857        store,
858        index,
859    })
860}
861
862#[cfg(test)]
863mod tests {
864    use rustc_hash::FxHashSet;
865    use turbo_rcstr::RcStr;
866
867    use super::*;
868    use crate::{
869        span::{SpanArgs, SpanIndex},
870        timestamp::Timestamp,
871    };
872
873    /// Build a store shaped like:
874    ///
875    /// ```text
876    /// root
877    ///  └─ outer            (index 1)
878    ///      ├─ middle       (index 2)
879    ///      │   └─ cat needle (index 3)   ← only reachable 3 levels down
880    ///      └─ other        (index 4)
881    /// ```
882    fn nested_store() -> (Arc<StoreContainer>, Vec<SpanIndex>) {
883        let container = Arc::new(StoreContainer::new());
884        let mut indices = Vec::new();
885        {
886            let mut store = container.write();
887            let mut outdated = FxHashSet::default();
888
889            let outer = store.add_span(
890                None,
891                Timestamp::from_micros(0),
892                RcStr::default(),
893                RcStr::from("outer"),
894                SpanArgs::new(),
895                &mut outdated,
896            );
897            let middle = store.add_span(
898                Some(outer),
899                Timestamp::from_micros(1),
900                RcStr::default(),
901                RcStr::from("middle"),
902                SpanArgs::new(),
903                &mut outdated,
904            );
905            // `needle` gets a category so its display name ("cat needle")
906            // spans both fields, which the display-name search test needs.
907            let needle = store.add_span(
908                Some(middle),
909                Timestamp::from_micros(2),
910                RcStr::from("cat"),
911                RcStr::from("needle"),
912                SpanArgs::new(),
913                &mut outdated,
914            );
915            let other = store.add_span(
916                Some(outer),
917                Timestamp::from_micros(3),
918                RcStr::default(),
919                RcStr::from("other"),
920                SpanArgs::new(),
921                &mut outdated,
922            );
923
924            // Most bytes in `other`, so allocation order differs from
925            // execution order.
926            store.add_allocation(needle, 1_000, 10, &mut outdated);
927            store.add_allocation(other, 50_000, 100, &mut outdated);
928
929            for span in [outer, middle, needle, other] {
930                store.complete_span(span);
931            }
932            store.invalidate_outdated_spans(&outdated);
933            indices.extend([outer, middle, needle, other]);
934        }
935        (container, indices)
936    }
937
938    fn query(store: &Arc<StoreContainer>, options: QueryOptions) -> QueryResult {
939        query_spans(store, options)
940    }
941
942    fn options() -> QueryOptions {
943        QueryOptions {
944            parent: None,
945            aggregated: true,
946            sort: SortMode::ExecutionOrder,
947            search: None,
948            max_depth: DEFAULT_SEARCH_MAX_DEPTH,
949            depth: 1,
950            page: 1,
951            page_size: None,
952        }
953    }
954
955    #[test]
956    fn aggregated_search_finds_deep_descendants() {
957        let (store, _) = nested_store();
958
959        // `needle` is three levels below the root.
960        let result = query(
961            &store,
962            QueryOptions {
963                search: Some("needle".to_string()),
964                ..options()
965            },
966        );
967
968        assert_eq!(result.total_count, 1, "expected to find the nested span");
969        assert_eq!(result.spans[0].name, "cat needle");
970    }
971
972    #[test]
973    fn aggregated_search_returns_full_navigable_path() {
974        let (store, _) = nested_store();
975        let result = query(
976            &store,
977            QueryOptions {
978                search: Some("needle".to_string()),
979                ..options()
980            },
981        );
982
983        let id = &result.spans[0].id;
984        assert_eq!(
985            id.split('-').count(),
986            3,
987            "expected a 3-segment path, got {id}"
988        );
989
990        // The path must resolve back to `needle`.
991        let children = query(
992            &store,
993            QueryOptions {
994                parent: Some(id.clone()),
995                ..options()
996            },
997        );
998        assert_eq!(children.total_count, 0, "needle has no children");
999    }
1000
1001    #[test]
1002    fn raw_search_finds_deep_descendants_with_paths() {
1003        let (store, _) = nested_store();
1004        let result = query(
1005            &store,
1006            QueryOptions {
1007                aggregated: false,
1008                search: Some("needle".to_string()),
1009                ..options()
1010            },
1011        );
1012
1013        assert_eq!(result.total_count, 1);
1014        assert_eq!(result.spans[0].name, "cat needle");
1015        // outer-middle-needle
1016        assert_eq!(result.spans[0].id.split('-').count(), 3);
1017    }
1018
1019    #[test]
1020    fn raw_search_matches_the_display_name() {
1021        let (store, _) = nested_store();
1022        // "cat needle" is the `name` a result reports, but the store's index
1023        // keys "cat" and "needle" separately, so only the fallback scan
1024        // resolves it.
1025        let result = query(
1026            &store,
1027            QueryOptions {
1028                aggregated: false,
1029                search: Some("cat needle".to_string()),
1030                ..options()
1031            },
1032        );
1033        assert_eq!(result.total_count, 1);
1034        assert_eq!(result.spans[0].name, "cat needle");
1035    }
1036
1037    #[test]
1038    fn raw_search_handles_multi_word_terms_that_the_index_matches_partially() {
1039        let (store, _) = nested_store();
1040        // "cat" alone matches via the store's index. Adding a second word must
1041        // narrow the result, not be silently dropped: the index answers whole
1042        // terms only, so trusting it here returned every "cat" span.
1043        let both = query(
1044            &store,
1045            QueryOptions {
1046                aggregated: false,
1047                search: Some("cat needle".to_string()),
1048                ..options()
1049            },
1050        );
1051        assert_eq!(both.total_count, 1);
1052        assert_eq!(both.spans[0].name, "cat needle");
1053
1054        // A second word that matches nothing must yield nothing, even though
1055        // the first word matches.
1056        let neither = query(
1057            &store,
1058            QueryOptions {
1059                aggregated: false,
1060                search: Some("cat nomatch".to_string()),
1061                ..options()
1062            },
1063        );
1064        assert_eq!(neither.total_count, 0);
1065    }
1066
1067    #[test]
1068    fn search_respects_max_depth() {
1069        let (store, _) = nested_store();
1070        let shallow = query(
1071            &store,
1072            QueryOptions {
1073                search: Some("needle".to_string()),
1074                max_depth: 2,
1075                ..options()
1076            },
1077        );
1078        assert_eq!(
1079            shallow.total_count, 0,
1080            "needle sits at depth 3 and must be excluded at max_depth 2"
1081        );
1082    }
1083
1084    #[test]
1085    fn depth_returns_nested_children_in_one_call() {
1086        let (store, _) = nested_store();
1087        let result = query(
1088            &store,
1089            QueryOptions {
1090                depth: 3,
1091                ..options()
1092            },
1093        );
1094
1095        let outer = &result.spans[0];
1096        assert_eq!(outer.name, "outer");
1097        assert_eq!(outer.children.len(), 2, "middle and other");
1098
1099        let middle = outer
1100            .children
1101            .iter()
1102            .find(|c| c.name == "middle")
1103            .expect("middle present");
1104        assert_eq!(middle.children.len(), 1);
1105        assert_eq!(middle.children[0].name, "cat needle");
1106
1107        // Nested IDs stay navigable.
1108        assert!(middle.children[0].id.starts_with(&outer.id));
1109    }
1110
1111    #[test]
1112    fn depth_one_leaves_children_empty() {
1113        let (store, _) = nested_store();
1114        let result = query(&store, options());
1115        assert!(result.spans[0].children.is_empty());
1116    }
1117
1118    #[test]
1119    fn raw_allocation_sorts_are_descending() {
1120        let (store, _) = nested_store();
1121
1122        // Regression: these sorted ascending, so the "biggest allocator" query
1123        // returned the smallest ones.
1124        for sort in [SortMode::Allocations, SortMode::PersistentAllocations] {
1125            let result = query(
1126                &store,
1127                QueryOptions {
1128                    aggregated: false,
1129                    // Search the whole tree so more than one span is ranked.
1130                    search: Some("e".to_string()),
1131                    sort,
1132                    ..options()
1133                },
1134            );
1135            let values: Vec<u64> = result
1136                .spans
1137                .iter()
1138                .map(|s| match sort {
1139                    SortMode::PersistentAllocations => s.persistent_allocations,
1140                    _ => s.allocations,
1141                })
1142                .collect();
1143            let mut sorted = values.clone();
1144            sorted.sort_by(|a, b| b.cmp(a));
1145            assert_eq!(values, sorted, "{sort:?} must be descending");
1146        }
1147    }
1148
1149    #[test]
1150    fn page_size_is_honored_and_capped() {
1151        let (store, _) = nested_store();
1152
1153        let paged = query(
1154            &store,
1155            QueryOptions {
1156                parent: Some("1".to_string()),
1157                aggregated: false,
1158                page_size: Some(1),
1159                ..options()
1160            },
1161        );
1162        assert_eq!(paged.spans.len(), 1);
1163        assert_eq!(paged.total_count, 2, "outer has two children");
1164        assert_eq!(paged.total_pages, 2);
1165
1166        // An absurd request is clamped rather than rejected.
1167        let huge = query(
1168            &store,
1169            QueryOptions {
1170                page_size: Some(usize::MAX),
1171                ..options()
1172            },
1173        );
1174        assert_eq!(huge.page, 1);
1175    }
1176
1177    #[test]
1178    fn heaviest_span_id_points_at_the_biggest_member() {
1179        let (store, indices) = nested_store();
1180        let result = query(
1181            &store,
1182            QueryOptions {
1183                parent: Some("1".to_string()),
1184                ..options()
1185            },
1186        );
1187
1188        let other = result
1189            .spans
1190            .iter()
1191            .find(|s| s.name == "other")
1192            .expect("other present");
1193        // Single-member group: heaviest is that member.
1194        assert_eq!(
1195            other.heaviest_span_id.as_deref(),
1196            Some(indices[3].get().to_string().as_str())
1197        );
1198    }
1199
1200    #[test]
1201    fn heaviest_span_id_differs_from_first_in_a_multi_member_group() {
1202        // A single-member group cannot tell the two apart, so build a group of
1203        // three same-named siblings where the heaviest is neither first nor
1204        // last. This is the case that matters: on a real trace `first_span_id`
1205        // reached a 75MB member of a 370MB group whose heaviest held 209MB.
1206        let container = Arc::new(StoreContainer::new());
1207        let mut members = Vec::new();
1208        {
1209            let mut store = container.write();
1210            let mut outdated = FxHashSet::default();
1211            let parent = store.add_span(
1212                None,
1213                Timestamp::from_micros(0),
1214                RcStr::default(),
1215                RcStr::from("parent"),
1216                SpanArgs::new(),
1217                &mut outdated,
1218            );
1219            // Same name so they aggregate; allocations ordered small, large,
1220            // medium so the winner is not simply the first or the last.
1221            for (i, bytes) in [1_000u64, 90_000, 40_000].into_iter().enumerate() {
1222                let child = store.add_span(
1223                    Some(parent),
1224                    Timestamp::from_micros(i as u64 + 1),
1225                    RcStr::default(),
1226                    RcStr::from("member"),
1227                    SpanArgs::new(),
1228                    &mut outdated,
1229                );
1230                store.add_allocation(child, bytes, 10, &mut outdated);
1231                store.complete_span(child);
1232                members.push(child);
1233            }
1234            store.complete_span(parent);
1235            store.invalidate_outdated_spans(&outdated);
1236        }
1237
1238        let result = query(
1239            &container,
1240            QueryOptions {
1241                parent: Some("1".to_string()),
1242                ..options()
1243            },
1244        );
1245        let group = result
1246            .spans
1247            .iter()
1248            .find(|s| s.name == "member")
1249            .expect("group present");
1250        assert_eq!(group.count, Some(3), "the three spans must aggregate");
1251
1252        let first = members[0].get().to_string();
1253        let heaviest = members[1].get().to_string();
1254        assert_eq!(group.first_span_id.as_deref(), Some(first.as_str()));
1255        assert_eq!(group.heaviest_span_id.as_deref(), Some(heaviest.as_str()));
1256        assert_ne!(
1257            group.first_span_id, group.heaviest_span_id,
1258            "otherwise this test cannot tell the two apart"
1259        );
1260    }
1261
1262    #[test]
1263    fn memory_summary_is_absent_without_samples() {
1264        let (store, _) = nested_store();
1265        let result = query(&store, options());
1266        assert!(result.spans[0].memory_summary.is_none());
1267    }
1268
1269    #[test]
1270    fn memory_summary_reports_peak_not_last() {
1271        let samples = [
1272            (0i64, 100u64, 0u8, 5000u64, 2u64),
1273            (1, 900, 3, 4000, 1),
1274            (2, 200, 1, 7000, 3),
1275        ];
1276        let summary = MemorySummary::from_samples(&samples).expect("samples present");
1277        assert_eq!(summary.count, 3);
1278        assert_eq!(summary.start, 100);
1279        assert_eq!(summary.end, 200);
1280        assert_eq!(summary.min, 100);
1281        assert_eq!(summary.peak, 900);
1282        assert_eq!(summary.max_pressure, 3);
1283        // Footprint is maxed independently of the memory peak.
1284        assert_eq!(summary.max_footprint, 7000);
1285        assert!(MemorySummary::from_samples(&[]).is_none());
1286    }
1287
1288    #[test]
1289    fn search_terms_are_anded() {
1290        assert!(name_matches("cat", "needle in haystack", "needle,haystack"));
1291        assert!(!name_matches("cat", "needle only", "needle,haystack"));
1292        // Category counts as a match target too.
1293        assert!(name_matches("turbopack", "build", "turbopack"));
1294    }
1295
1296    #[test]
1297    fn search_matches_the_display_name_callers_see() {
1298        // A result's `name` is "cat title"; searching for that exact string has
1299        // to find the span again, even though it spans both fields.
1300        assert!(name_matches("turbopack", "build", "turbopack build"));
1301        // Every word still has to land somewhere.
1302        assert!(!name_matches("turbopack", "build", "turbopack missing"));
1303        assert!(!name_matches("turbopack", "build", "nope"));
1304    }
1305
1306    #[test]
1307    fn search_term_is_a_substring_of_the_display_name() {
1308        // Contiguous, in order — exactly what a term copied from a `name` is.
1309        assert!(name_matches("turbopack", "build", "turbopack build"));
1310        assert!(!name_matches("turbopack", "build", "build turbopack"));
1311        // Words with something between them are not a substring.
1312        assert!(name_matches(
1313            "a",
1314            "analyze ecmascript module",
1315            "analyze ecmascript"
1316        ));
1317        assert!(!name_matches(
1318            "a",
1319            "analyze ecmascript module",
1320            "analyze module"
1321        ));
1322        // A term may straddle the space joining the two fields.
1323        assert!(name_matches(
1324            "turbopack_ecmascript",
1325            "analyze module",
1326            "ecmascript analyze"
1327        ));
1328        assert!(!name_matches(
1329            "turbopack_ecmascript",
1330            "analyze module",
1331            "analyze ecmascript"
1332        ));
1333        // Straddling requires the exact boundary, not just both halves present.
1334        assert!(!name_matches(
1335            "turbopack_ecmascript",
1336            "analyze module",
1337            "turbopack module"
1338        ));
1339    }
1340
1341    #[test]
1342    fn joined_match_is_equivalent_to_the_formatted_name() {
1343        // `contains_in_joined` must agree with formatting the name and calling
1344        // `contains`, including when a field is empty. `format_span_name` drops
1345        // the separator only for an empty category, so an empty title still
1346        // leaves a trailing space — guarding on `title.is_empty()` would lose
1347        // the terms that end on it.
1348        for (cat, title) in [("foo", ""), ("", "bar"), ("foo", "bar"), ("", "")] {
1349            for term in ["foo", "bar", "foo ", " bar", "foo bar", "o b", "", " "] {
1350                let expected = format_span_name(cat, title).contains(term);
1351                assert_eq!(
1352                    contains_in_joined(cat, title, term),
1353                    expected,
1354                    "cat={cat:?} title={title:?} term={term:?}"
1355                );
1356            }
1357        }
1358    }
1359
1360    #[test]
1361    fn search_handles_non_ascii_without_panicking() {
1362        // The cursor slices `cat`/`title` by byte offset, so every offset has
1363        // to land on a char boundary.
1364        assert!(name_matches("café", "über naïve", "café über"));
1365        assert!(name_matches("café", "über naïve", "é ü"));
1366        assert!(name_matches("café", "über naïve", "über naïve"));
1367        assert!(!name_matches("café", "über naïve", "naïve café"));
1368        assert!(!name_matches("日本語", "テスト", "テスト 日本語"));
1369        assert!(name_matches("日本語", "テスト", "日本語 テスト"));
1370    }
1371
1372    #[test]
1373    fn search_ignores_empty_terms_and_padding() {
1374        // Trimming and the empty-word filter must not turn separators into a
1375        // vacuous match-everything.
1376        assert!(name_matches("turbopack", "build", "  build  "));
1377        assert!(name_matches("turbopack", "build", "build,,"));
1378        assert!(!name_matches("turbopack", "build", "build, nope"));
1379    }
1380}