• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

llnl / dftracer-utils / 29863677266

21 Jul 2026 07:57PM UTC coverage: 52.767% (+0.1%) from 52.66%
29863677266

Pull #99

github

web-flow
Merge 7169a8c39 into 06bc84ec9
Pull Request #99: Support CM time_metric (NS/MS/SEC/US) across reader and viz

41662 of 102203 branches covered (40.76%)

Branch coverage included in aggregate %.

1724 of 2125 new or added lines in 32 files covered. (81.13%)

92 existing lines in 6 files now uncovered.

36790 of 46473 relevant lines covered (79.16%)

60195.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

54.3
/src/dftracer/utils/server/viz_api.cpp
1
#include <dftracer/utils/core/common/logging.h>
2
#include <dftracer/utils/core/common/to_chars.h>
3
#include <dftracer/utils/core/common/transparent_string_hash.h>
4
#include <dftracer/utils/core/coro/channel.h>
5
#include <dftracer/utils/core/pipeline/executor.h>
6
#include <dftracer/utils/core/tasks/coro_scope.h>
7
#include <dftracer/utils/server/http_request.h>
8
#include <dftracer/utils/server/http_response.h>
9
#include <dftracer/utils/server/json_builder.h>
10
#include <dftracer/utils/server/router.h>
11
#include <dftracer/utils/server/signal_handler.h>
12
#include <dftracer/utils/server/trace_index.h>
13
#include <dftracer/utils/server/viz_api.h>
14
#include <dftracer/utils/utilities/common/json/json_doc_guard.h>
15
#include <dftracer/utils/utilities/common/json/json_value.h>
16
#include <dftracer/utils/utilities/common/query/query.h>
17
#include <dftracer/utils/utilities/composites/dft/views/view_builder_utility.h>
18
#include <dftracer/utils/utilities/composites/dft/views/view_definition.h>
19
#include <dftracer/utils/utilities/composites/dft/views/view_reader_utility.h>
20
#include <dftracer/utils/utilities/fileio/lines/sources/async_streaming_gz_line_generator.h>
21
#include <dftracer/utils/utilities/indexer/index_database.h>
22
#include <simdjson.h>
23

24
#include <algorithm>
25
#include <atomic>
26
#include <cstddef>
27
#include <cstdint>
28
#include <cstdlib>
29
#include <functional>
30
#include <limits>
31
#include <memory>
32
#include <mutex>
33
#include <numeric>
34
#include <string>
35
#include <string_view>
36
#include <tuple>
37
#include <unordered_set>
38
#include <vector>
39

40
namespace dftracer::utils::server {
41

42
using namespace dftracer::utils::utilities::composites::dft;
43
using namespace dftracer::utils::utilities::composites::dft::views;
44

45
static const std::unordered_set<std::string> HASH_METADATA_NAMES = {"FH", "HH",
283!
46
                                                                    "SH"};
283!
47

48
static constexpr int DEFAULT_VIEWPORT_WIDTH = 1920;
49
static constexpr int MIN_VIEWPORT_WIDTH = 320;
50
static constexpr int MAX_VIEWPORT_WIDTH = 8192;
51

52
// Rewrite the unsigned integer value of `key` (e.g. "\"dur\":") in `json` to
53
// `value`. No-op when the key is absent. Returns whether it rewrote.
54
static bool rewrite_uint_field(std::string& json, std::string_view key,
800✔
55
                               std::uint64_t value) {
56
    auto pos = json.find(key);
800✔
57
    if (pos == std::string::npos) return false;
800✔
58
    pos += key.size();
800✔
59
    while (pos < json.size() && std::isspace(json[pos])) ++pos;
800!
60
    auto end_pos = pos;
800✔
61
    while (end_pos < json.size() &&
11,080✔
62
           (std::isdigit(json[end_pos]) || json[end_pos] == '-')) {
7,120!
63
        ++end_pos;
6,320✔
64
    }
65
    if (end_pos == pos) return false;
800!
66
    json.replace(pos, end_pos - pos, std::to_string(value));
800!
67
    return true;
800✔
68
}
400✔
69

70
/// Normalize a Chrome Trace Event JSON string: subtract `offset` (native units)
71
/// from "ts" and scale ts/dur from the trace's native unit `metric` into
72
/// microseconds. Falls back to the original string on parse failure. For a US
73
/// trace this only subtracts the offset (scaling is identity).
74
static std::string normalize_event_ts(const std::string& event_json,
400✔
75
                                      std::uint64_t offset,
76
                                      TraceIndex::TimeMetric metric) {
77
    thread_local simdjson::dom::parser tl_parser;
400✔
78
    auto result = tl_parser.parse(event_json);
400✔
79
    if (result.error()) return event_json;
400!
80

81
    auto root = result.value_unsafe();
400✔
82
    if (!root.is_object()) return event_json;
400!
83

84
    auto ts_result = root["ts"];
400✔
85
    if (ts_result.error()) return event_json;
400!
86

87
    std::uint64_t old_ts = 0;
400✔
88
    if (ts_result.is_uint64()) {
400!
89
        old_ts = ts_result.get_uint64().value_unsafe();
400✔
90
    } else if (ts_result.is_int64()) {
200!
91
        auto val = ts_result.get_int64().value_unsafe();
×
92
        old_ts = val >= 0 ? static_cast<std::uint64_t>(val) : 0;
×
93
    } else {
94
        return event_json;
×
95
    }
96

97
    using dftracer::utils::utilities::composites::dft::scale_between;
98
    using TM = TraceIndex::TimeMetric;
99
    std::uint64_t new_ts =
200✔
100
        scale_between(metric, TM::US, old_ts >= offset ? old_ts - offset : 0);
400!
101

102
    std::string modified = event_json;
400!
103
    if (!rewrite_uint_field(modified, "\"ts\":", new_ts)) return event_json;
400!
104

105
    if (metric != TM::US) {
400✔
106
        auto dur_result = root["dur"];
400✔
107
        if (!dur_result.error() && dur_result.is_uint64()) {
400!
108
            rewrite_uint_field(
400!
109
                modified, "\"dur\":",
200!
110
                scale_between(metric, TM::US, dur_result.get_uint64().value()));
600!
111
        }
200✔
112
    }
200✔
113
    return modified;
400✔
114
}
400✔
115

116
/// Compute the minimum event duration threshold for a given summary level.
117
/// Level 1 = full detail, higher levels filter shorter events.
118
static double duration_threshold(double begin, double end, unsigned level,
22✔
119
                                 unsigned viewport_width = 1920) {
120
    if (level <= 1) return 0.0;
22✔
121
    double range = end - begin;
6✔
122
    return range /
6✔
123
           (static_cast<double>(viewport_width) * static_cast<double>(level));
6✔
124
}
11✔
125

126
static std::string extract_json_value(simdjson::dom::element val) {
6✔
127
    if (val.is_string()) {
6✔
128
        return std::string(val.get_string().value_unsafe());
3!
129
    }
130
    if (val.is_int64()) {
4!
131
        return std::to_string(val.get_int64().value_unsafe());
6!
132
    }
133
    if (val.is_uint64()) {
×
134
        return std::to_string(val.get_uint64().value_unsafe());
×
135
    }
136
    return {};
×
137
}
3✔
138

139
static void append_lane_clause(std::string& dsl, const char* field,
2✔
140
                               const std::string& val) {
141
    if (!dsl.empty()) dsl += " and ";
2✔
142
    bool numeric =
1✔
143
        !val.empty() && std::all_of(val.begin(), val.end(),
2!
144
                                    [](char c) { return std::isdigit(c); });
2✔
145
    if (numeric) {
2!
146
        dsl += std::string(field) + " == " + val;
2!
147
    } else {
1✔
148
        dsl += std::string(field) + " == \"" + val + "\"";
×
149
    }
150
}
2✔
151

152
static void apply_lanes(std::string& dsl, std::string_view lanes_str) {
28✔
153
    if (lanes_str.empty()) return;
28✔
154

155
    thread_local simdjson::dom::parser tl_parser;
2✔
156
    auto result = tl_parser.parse(lanes_str.data(), lanes_str.size());
2✔
157
    if (result.error()) return;
2!
158

159
    auto root = result.value_unsafe();
2✔
160

161
    if (root.is_array()) {
2!
162
        auto arr = root.get_array().value_unsafe();
2✔
163
        for (auto item : arr) {
4✔
164
            if (!item.is_object()) continue;
2✔
165
            auto obj = item.get_object().value_unsafe();
2✔
166

167
            auto field_result = obj["field"];
2✔
168
            if (field_result.error()) field_result = obj["fields"];
2✔
169
            auto value_result = obj["value"];
2✔
170
            if (field_result.error() || value_result.error()) continue;
2!
171

172
            if (!field_result.value_unsafe().is_string()) continue;
2✔
173
            const char* field =
1✔
174
                field_result.value_unsafe().get_c_str().value_unsafe();
2✔
175
            auto val = extract_json_value(value_result.value_unsafe());
2!
176
            if (!val.empty()) append_lane_clause(dsl, field, val);
2✔
177
        }
2✔
178
    } else if (root.is_object()) {
1!
179
        auto obj = root.get_object().value_unsafe();
×
180

181
        auto field_result = obj["field"];
×
182
        if (field_result.error()) field_result = obj["fields"];
×
183
        auto value_result = obj["value"];
×
184

185
        if (!field_result.error() && !value_result.error()) {
×
186
            if (field_result.value_unsafe().is_string()) {
×
187
                const char* field =
188
                    field_result.value_unsafe().get_c_str().value_unsafe();
×
189
                auto val = extract_json_value(value_result.value_unsafe());
×
190
                if (!val.empty()) append_lane_clause(dsl, field, val);
×
191
            }
×
192
        }
193
    }
194
}
14✔
195

196
static void apply_filters(std::string& dsl, std::string_view filters_str) {
28✔
197
    if (filters_str.empty()) return;
28✔
198

199
    thread_local simdjson::dom::parser tl_parser;
4✔
200
    auto result = tl_parser.parse(filters_str.data(), filters_str.size());
4✔
201
    if (result.error()) return;
4!
202

203
    auto root = result.value_unsafe();
4✔
204
    if (!root.is_array()) return;
4✔
205

206
    auto arr = root.get_array().value_unsafe();
4✔
207
    for (auto item : arr) {
8✔
208
        if (!item.is_object()) continue;
4✔
209
        auto obj = item.get_object().value_unsafe();
4✔
210

211
        auto field_result = obj["field"];
4✔
212
        auto op_result = obj["op"];
4✔
213
        auto value_result = obj["value"];
4✔
214
        if (field_result.error() || op_result.error() || value_result.error())
4!
215
            continue;
×
216

217
        if (!field_result.value_unsafe().is_string() ||
6!
218
            !op_result.value_unsafe().is_string())
4!
219
            continue;
×
220

221
        const char* field =
2✔
222
            field_result.value_unsafe().get_c_str().value_unsafe();
4✔
223
        const char* op = op_result.value_unsafe().get_c_str().value_unsafe();
4✔
224

225
        std::string val = extract_json_value(value_result.value_unsafe());
4!
226
        if (val.empty()) continue;
4!
227

228
        std::string op_str(op);
4✔
229
        std::string field_str(field);
4✔
230
        if (field_str == "begin") field_str = "ts";
4!
231
        if (field_str == "end") field_str = "ts";
4!
232
        if (field_str == "duration") field_str = "dur";
4!
233

234
        std::string query_op;
4✔
235
        if (op_str == "=")
4✔
236
            query_op = "==";
2!
237
        else if (op_str == ">=")
2!
238
            query_op = ">=";
2!
239
        else if (op_str == "<=")
×
240
            query_op = "<=";
×
241
        else if (op_str == ">")
×
242
            query_op = ">";
×
243
        else if (op_str == "<")
×
244
            query_op = "<";
×
245
        else
246
            continue;
×
247

248
        if (!dsl.empty()) dsl += " and ";
4!
249
        bool numeric = !val.empty() && (std::isdigit(val[0]) || val[0] == '-');
6!
250
        if (numeric || query_op != "==") {
4!
251
            dsl += field_str + " " + query_op + " " + val;
4!
252
        } else {
2✔
253
            dsl += field_str + " " + query_op + " \"" + val + "\"";
×
254
        }
255
    }
4!
256
}
14✔
257

258
// --- GET /api/v1/viz/events ---
259
// Build the query view (time range + lane/filter/pid/tid/cat predicates) for a
260
// viz request from the parsed parameters.
261
static ViewDefinition build_viz_view(const QueryParams& params, double begin,
28✔
262
                                     double end, double min_dur) {
263
    ViewDefinition view;
28✔
264
    view.name = "viz_query";
28!
265
    view.description = "Visualization query";
28!
266

267
    // Reserve once and append in place: numbers go through to_chars into a
268
    // stack buffer (no per-number heap allocation, unlike std::to_string), and
269
    // literals/string_views are appended directly (no temporary
270
    // concatenations).
271
    std::string dsl;
28✔
272
    dsl.reserve(128);
28!
273
    char numbuf[20];  // max digits of a uint64_t
274
    auto append_u64 = [&](std::uint64_t v) {
84✔
275
        dsl.append(numbuf, to_chars_u64(numbuf, numbuf + sizeof(numbuf), v));
70✔
276
    };
84✔
277

278
    dsl += "ts >= ";
28!
279
    append_u64(static_cast<std::uint64_t>(begin));
28!
280
    dsl += " and ts <= ";
28!
281
    append_u64(static_cast<std::uint64_t>(end));
28!
282
    if (min_dur > 0) {
28✔
283
        dsl += " and dur >= ";
14!
284
        append_u64(static_cast<std::uint64_t>(min_dur));
14!
285
    }
7✔
286

287
    apply_lanes(dsl, params.get("lanes"));
28!
288
    apply_filters(dsl, params.get("filters"));
28!
289

290
    auto pid = params.get("pid");
28!
291
    if (!pid.empty()) {
28!
292
        dsl += " and pid == ";
×
293
        dsl += pid;
×
294
    }
295

296
    auto tid = params.get("tid");
28!
297
    if (!tid.empty()) {
28!
298
        dsl += " and tid == ";
×
299
        dsl += tid;
×
300
    }
301

302
    auto cat = params.get("cat");
28!
303
    if (!cat.empty()) {
28!
304
        dsl += " and cat == \"";
×
305
        dsl += cat;
×
306
        dsl += '"';
×
307
    }
308

309
    // Raw DSL from the front-end query box, already validated by the caller.
310
    auto query = params.get("query");
28!
311
    if (!query.empty()) {
28!
312
        dsl += " and (";
×
313
        dsl += query;
×
314
        dsl += ')';
×
315
    }
316

317
    view.with_query(dsl);
28!
318
    return view;
42✔
319
}
28!
320

321
// Select the files to scan: the explicit ?file= or all indexed files, then drop
322
// files whose cached time bounds don't overlap [begin, end]. Pure/synchronous.
323
static std::vector<const TraceIndex::FileInfo*> select_viz_target_files(
20✔
324
    TraceIndex& index, const QueryParams& params, double begin, double end) {
325
    auto target_files = collect_candidate_files(index, params);
20✔
326

327
    // An explicit ?file= is a direct request for that file; never drop it on
328
    // cached time bounds (which can be wrong, e.g. multi-node clock skew).
329
    if (!params.get("file").empty()) return target_files;
20!
330

331
    if (begin > 0 || end > 0) {
20!
332
        std::vector<const TraceIndex::FileInfo*> filtered;
20✔
333
        filtered.reserve(target_files.size());
20!
334
        for (auto* fi : target_files) {
48✔
335
            if (fi->min_timestamp_us == 0 && fi->max_timestamp_us == 0) {
28!
336
                filtered.push_back(fi);
×
337
                continue;
×
338
            }
339
            double fi_min = static_cast<double>(fi->min_timestamp_us);
28✔
340
            double fi_max = static_cast<double>(fi->max_timestamp_us);
28✔
341
            if (fi_max < begin || fi_min > end) continue;
28!
342
            filtered.push_back(fi);
28!
343
        }
344
        target_files = std::move(filtered);
20✔
345
    }
20✔
346
    return target_files;
20✔
347
}
10!
348

349
// Normalize event timestamps (when global_min > 0) and serialize the collected
350
// events plus metadata into the Chrome Trace Event Format body. `global_min` is
351
// the de-normalization base (already 0 unless normalization is active);
352
// `display_global_min` is the value reported in the metadata. Pure/synchronous.
353
static std::string build_viz_events_body(std::vector<std::string>& events,
14✔
354
                                         std::uint64_t global_min,
355
                                         double meta_begin, double meta_end,
356
                                         int limit, bool truncated,
357
                                         std::uint64_t display_global_min,
358
                                         TraceIndex::TimeMetric metric) {
359
    // Even without an offset, a non-US trace still needs ts/dur scaled to us.
360
    if (global_min > 0 || metric != TraceIndex::TimeMetric::US) {
14!
361
        for (auto& event : events) {
412✔
362
            event = normalize_event_ts(event, global_min, metric);
400!
363
        }
364
    }
6✔
365

366
    auto& b = scratch_json_builder();
14✔
367
    b.start_object();
14✔
368
    b.escape_and_append_with_quotes("events");
14✔
369
    b.append_colon();
14✔
370
    b.start_array();
14✔
371
    for (std::size_t i = 0; i < events.size(); ++i) {
414✔
372
        if (i > 0) b.append_comma();
400✔
373
        b.append_raw(events[i]);  // Already JSON
400✔
374
    }
200✔
375
    b.end_array();
14✔
376
    b.append_comma();
14✔
377
    b.escape_and_append_with_quotes("metadata");
14✔
378
    b.append_colon();
14✔
379
    b.start_object();
14✔
380
    b.append_key_value("begin", meta_begin);
14✔
381
    b.append_comma();
14✔
382
    b.append_key_value("end", meta_end);
14✔
383
    b.append_comma();
14✔
384
    b.append_key_value("count", events.size());
14✔
385
    b.append_comma();
14✔
386
    b.append_key_value("limit", limit);
14✔
387
    b.append_comma();
14✔
388
    b.append_key_value("truncated", truncated);
14✔
389
    b.append_comma();
14✔
390
    b.append_key_value("ts_normalized", global_min > 0);
14✔
391
    b.append_comma();
14✔
392
    b.append_key_value("global_min_timestamp_us", display_global_min);
14✔
393
    b.end_object();
14✔
394
    b.end_object();
14✔
395
    return std::string(b);
14!
396
}
397

398
// One checkpoint byte-range to read from a specific file. Work is distributed
399
// at this granularity (not per file) so a single large file still fans out
400
// across every worker.
401
struct ScanWorkItem {
402
    const TraceIndex::FileInfo* file;
403
    std::uint64_t start_byte;
404
    std::uint64_t end_byte;
405
    std::size_t checkpoint_idx;
406
};
407

408
// Scan events matching `view` within [begin, end] across `target_files`,
409
// invoking `on_batch(slot, events)` for each batch. `on_batch` must be
410
// thread-safe across slots: it is called concurrently from worker coroutines,
411
// but each worker owns its own slot so per-slot state needs no lock. Returns
412
// true if scanning stopped early because `limit` (0 = unlimited) was reached.
413
static coro::CoroTask<bool> scan_view_events(
84!
414
    TraceIndex& index, std::vector<const TraceIndex::FileInfo*>& target_files,
415
    ViewDefinition& view, double begin, double end, int limit,
416
    std::size_t num_slots,
417
    const std::function<void(std::size_t,
418
                             const std::vector<std::string_view>&)>& on_batch,
419
    CancelToken cancel, bool scan_all_chunks = false) {
14!
420
    const std::int64_t cap =
28✔
421
        limit > 0 ? limit : std::numeric_limits<std::int64_t>::max();
14✔
422
    std::atomic<std::int64_t> produced{0};
14✔
423

424
    std::size_t num_workers =
28✔
425
        std::max<std::size_t>(1, std::min(num_slots, index.max_concurrent()));
14!
426
    auto* executor = Executor::current();
14✔
427
    auto chan = coro::make_channel<ScanWorkItem>(num_workers * 4);
14!
428
    auto* target_files_ptr = &target_files;
14✔
429
    auto* view_ptr = &view;
14✔
430
    auto* index_ptr = &index;
14✔
431
    auto* produced_ptr = &produced;
14✔
432
    auto* on_batch_ptr = &on_batch;
14✔
433

434
    CoroScope scope(executor);
14!
435

436
    // Producer: prune each file to its matching checkpoints (bloom + time) and
437
    // feed those byte-ranges as work items. Building is cheap next to the
438
    // reads.
439
    scope.spawn([ch = chan->producer(), target_files_ptr, view_ptr, index_ptr,
150!
440
                 produced_ptr, cap, begin, end, scan_all_chunks,
70✔
441
                 cancel](CoroScope&) mutable -> coro::CoroTask<void> {
28!
442
        auto guard = ch.guard();
14!
443
        for (auto* file_info : *target_files_ptr) {
56✔
444
            if (cancel.cancelled()) co_return;
30!
445
            if (produced_ptr->load(std::memory_order_relaxed) >= cap) co_return;
16!
446
            if (file_info->uncompressed_size == 0 &&
16!
447
                file_info->num_checkpoints == 0)
448
                continue;
449

450
            ViewBuilderInput builder_input;
16✔
451
            builder_input.with_view(*view_ptr)
32!
452
                .with_file_path(file_info->path)
16!
453
                .with_index_path(
16!
454
                    file_info->has_bloom_data ? file_info->index_path : "")
16!
455
                .with_uncompressed_size(file_info->uncompressed_size)
16!
456
                .with_num_checkpoints(file_info->num_checkpoints)
16!
457
                .with_bloom_cache(&index_ptr->bloom_cache())
16!
458
                .with_time_range(begin, end)
16!
459
                .with_scan_all_chunks(scan_all_chunks);
16!
460

461
            ViewBuilderUtility builder;
16!
462
            auto build_output = co_await builder.process(builder_input);
32!
463
            if (!build_output || !build_output->file_may_match) continue;
16!
464

465
            for (const auto& c : build_output->candidates) {
78!
466
                if (cancel.cancelled()) co_return;
39!
467
                if (produced_ptr->load(std::memory_order_relaxed) >= cap)
39!
468
                    co_return;
469
                if (!co_await ch.send(ScanWorkItem{
208!
470
                        file_info, c.start_byte, c.end_byte, c.checkpoint_idx}))
156✔
471
                    co_return;
472
            }
13✔
473
        }
42✔
474
        co_return;
14✔
475
    });
152!
476

477
    for (std::size_t w = 0; w < num_workers; ++w) {
42✔
478
        // Worker `w` writes only to slot `w`; a coroutine never runs
479
        // concurrently with itself, so per-slot output needs no lock.
480
        scope.spawn([w, chan, view_ptr, produced_ptr, on_batch_ptr, cap,
284!
481
                     cancel](CoroScope&) -> coro::CoroTask<void> {
56!
482
            // Keep draining once done: leaving the channel while the producer
483
            // is suspended in send() with no other reader deadlocks the scope,
484
            // and the join never completes (a cancelled request would then
485
            // hang shutdown). Draining is free - no work is done per item.
486
            while (auto item = co_await chan->receive()) {
192!
487
                if (cancel.cancelled()) continue;
13!
488
                if (produced_ptr->load(std::memory_order_relaxed) >= cap)
13!
489
                    continue;
490

491
                ViewReaderInput reader_input;
13✔
492
                reader_input.with_file_path(item->file->path)
13!
493
                    .with_index_path(item->file->index_path)
13!
494
                    .with_byte_range(item->start_byte, item->end_byte)
13!
495
                    .with_checkpoint_idx(item->checkpoint_idx)
13!
496
                    .with_view(*view_ptr);
13!
497

498
                ViewReaderUtility reader;
13!
499
                auto gen = reader.process(reader_input);
13!
500
                while (auto batch = co_await gen.next()) {
104!
501
                    if (batch->events.empty()) continue;
13!
502
                    if (cancel.cancelled()) break;
13!
503
                    if (produced_ptr->load(std::memory_order_relaxed) >= cap)
13!
504
                        break;
505
                    (*on_batch_ptr)(w, batch->events);
13!
506
                    produced_ptr->fetch_add(
26✔
507
                        static_cast<std::int64_t>(batch->events.size()),
13✔
508
                        std::memory_order_relaxed);
509
                }
26✔
510
            }
93✔
511
            co_return;
28✔
512
        });
324!
513
    }
28✔
514

515
    co_await scope.join();
42!
516
    co_return produced.load(std::memory_order_relaxed) >= cap;
14!
517
}
70!
518

519
static coro::CoroTask<void> append_app_spans(std::vector<std::string>& out,
520
                                             TraceIndex& index, double begin,
521
                                             double end,
522
                                             const QueryParams& params);
523
static coro::CoroTask<const VizSummary*> ensure_viz_summary(TraceIndex& index);
524
static bool viz_summary_eligible(const QueryParams& params);
525

526
static coro::CoroTask<HttpResponse> handle_viz_events(const HttpRequest& req,
62!
527
                                                      const QueryParams& params,
528
                                                      TraceIndex& index) {
8!
529
    // Required: begin, end, summary
530
    if (!params.has("begin") || !params.has("end") || !params.has("summary")) {
10!
531
        co_return HttpResponse::bad_request(
15!
532
            "Missing required parameters: begin, end, summary");
7!
533
    }
534

535
    double begin = params.get_double("begin", 0);
11✔
536
    double end = params.get_double("end", 0);
11✔
537
    int summary = params.get_int("summary", 1);
11✔
538
    if (summary < 1) summary = 1;
11!
539

540
    // Validate the optional raw DSL query before it is spliced into the view.
541
    auto query = params.get("query");
11✔
542
    if (!query.empty() &&
11!
543
        !utilities::common::query::try_parse(query).has_value()) {
×
544
        co_return HttpResponse::bad_request("Invalid query: " +
×
545
                                            std::string(query));
×
546
    }
547

548
    // Timestamp normalization: default ON, opt-out with ?ts_normalize=0
549
    auto ts_norm_param = params.get("ts_normalize");
11✔
550
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
11!
551

552
    std::uint64_t global_min = 0;
11✔
553
    if (normalize) {
11✔
554
        global_min = index.global_min_timestamp_us();
6✔
555
        if (global_min == std::numeric_limits<std::uint64_t>::max()) {
6!
556
            global_min = 0;  // No valid bounds, skip normalization
557
        }
558
    }
6✔
559

560
    // When normalization is active the user sends normalized
561
    // begin/end values (relative to global_min).  De-normalize them
562
    // so the predicate filters against absolute timestamps.
563
    double original_begin = begin;
11✔
564
    double original_end = end;
11✔
565
    // Client sends us; the scan matches the native index. Convert first, then
566
    // de-normalize against the native base. Identity for US traces.
567
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
11✔
568
        begin = static_cast<double>(
1✔
569
            index.us_to_native(static_cast<std::uint64_t>(begin)));
1✔
570
        end = static_cast<double>(
1✔
571
            index.us_to_native(static_cast<std::uint64_t>(end)));
1✔
572
    }
1✔
573
    if (normalize && global_min > 0) {
11!
574
        begin += static_cast<double>(global_min);
6✔
575
        end += static_cast<double>(global_min);
6✔
576
    }
6✔
577

578
    double min_dur =
11✔
579
        duration_threshold(begin, end, static_cast<unsigned>(summary));
11!
580
    // Full detail (summary=1) has no duration floor, so a wide window would
581
    // return every sub-pixel event (millions on a large trace, hanging the
582
    // client). Bound it to ~1px at the client width; sub-pixel events cannot
583
    // be drawn anyway, and zooming in shrinks the window so detail returns.
584
    if (min_dur <= 0 && end > begin) {
11!
585
        int px_width = params.get_int("width", DEFAULT_VIEWPORT_WIDTH);
7!
586
        px_width = std::clamp(px_width, MIN_VIEWPORT_WIDTH, MAX_VIEWPORT_WIDTH);
7!
587
        min_dur = (end - begin) / static_cast<double>(px_width);
7✔
588
    }
7✔
589

590
    // Overlap, bounded: look back at most `lookback` (the longest event's
591
    // duration, supplied by the client) so events that started before the
592
    // window but extend into it are included, without scanning to time 0.
593
    double lookback = params.get_double("lookback", 0);
11✔
594
    if (lookback < 0) lookback = 0;
11!
595
    double scan_begin = begin - lookback;
11✔
596
    if (scan_begin < 0) scan_begin = 0;
11!
597

598
    ViewDefinition view = build_viz_view(params, scan_begin, end, min_dur);
11!
599

600
    // Optional limit: 0 (default) means no limit.
601
    int limit = params.get_int("limit", 0);
11✔
602
    if (limit < 0) limit = 0;
11!
603

604
    // Zoom-out fast path: when nothing is filtered and the duration threshold
605
    // is at least the summary's long-event threshold, the events that survive
606
    // are exactly a subset of the summary's cached long_events. Serve them from
607
    // memory instead of scanning every file (minutes for a large trace).
608
    if (min_dur > 0 && viz_summary_eligible(params)) {
11!
609
        const VizSummary* s = co_await ensure_viz_summary(index);
16!
610
        if (s != nullptr && s->long_threshold_us > 0 &&
4!
611
            min_dur >= s->long_threshold_us) {
4✔
612
            auto pid_s = params.get("pid");
4!
613
            auto tid_s = params.get("tid");
4!
614
            bool has_pid = !pid_s.empty();
4✔
615
            bool has_tid = !tid_s.empty();
4✔
616
            std::int64_t want_pid =
8✔
617
                has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
4!
618
            std::int64_t want_tid =
8✔
619
                has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
4!
620
            std::vector<const VizSummary::AppSpan*> hits;
4✔
621
            for (const auto& ev : s->long_events) {
354✔
622
                if (static_cast<double>(ev.end - ev.begin) < min_dur) continue;
350✔
623
                if (static_cast<double>(ev.end) <= begin ||
200!
624
                    static_cast<double>(ev.begin) >= end)
200✔
625
                    continue;
626
                if (has_pid && ev.pid != want_pid) continue;
200!
627
                if (has_tid && ev.tid != want_tid) continue;
200!
628
                hits.push_back(&ev);
200!
629
            }
350✔
630
            // Keep the widest: a shallow zoom can match more of the list than
631
            // any response should carry.
632
            if (hits.size() > VizSummary::MAX_RESPONSE_SPANS) {
4!
633
                std::nth_element(
×
634
                    hits.begin(), hits.begin() + VizSummary::MAX_RESPONSE_SPANS,
635
                    hits.end(),
NEW
636
                    [](const VizSummary::AppSpan* a,
×
637
                       const VizSummary::AppSpan* x) {
NEW
638
                        return a->end - a->begin > x->end - x->begin;
×
639
                    });
640
                hits.resize(VizSummary::MAX_RESPONSE_SPANS);
×
641
            }
642
            std::vector<std::string> collected;
4✔
643
            collected.reserve(hits.size());
4!
644
            for (const auto* ev : hits) collected.push_back(ev->json);
204!
645
            co_await append_app_spans(collected, index, begin, end, params);
8!
646
            std::string body = build_viz_events_body(
4!
647
                collected, global_min, original_begin, original_end, limit,
4✔
648
                false, index.native_to_us(index.global_min_timestamp_us()),
4!
649
                index.time_metric());
4✔
650
            co_return HttpResponse::ok(body);
4!
651
        }
4✔
652
    }
4!
653

654
    std::vector<const TraceIndex::FileInfo*> target_files =
9✔
655
        select_viz_target_files(index, params, scan_begin, end);
9!
656

657
    // Each worker slot collects into its own vector (no shared state, no lock);
658
    // the slots are concatenated single-threaded after the scan.
659
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
9!
660
    std::vector<std::vector<std::string>> partials(slots);
9!
661
    auto collect = [&partials](std::size_t w,
9✔
662
                               const std::vector<std::string_view>& events) {
UNCOV
663
        auto& out = partials[w];
×
UNCOV
664
        out.reserve(out.size() + events.size());
×
UNCOV
665
        for (auto ev : events) out.emplace_back(ev);
×
UNCOV
666
    };
×
667

668
    bool truncated = co_await scan_view_events(
15!
669
        index, target_files, view, scan_begin, end, limit, slots, collect,
9!
670
        req.cancel_token, !params.get("file").empty());
9!
671

672
    std::vector<std::string> collected_events;
9✔
673
    for (auto& p : partials) {
15✔
674
        for (auto& s : p) collected_events.emplace_back(std::move(s));
6!
675
    }
6✔
676
    if (limit > 0 && static_cast<int>(collected_events.size()) > limit) {
9!
677
        collected_events.resize(static_cast<std::size_t>(limit));
×
678
        truncated = true;
679
    }
680

681
    co_await append_app_spans(collected_events, index, begin, end, params);
12!
682

683
    std::string body = build_viz_events_body(
3!
684
        collected_events, global_min, original_begin, original_end, limit,
3✔
685
        truncated, index.native_to_us(index.global_min_timestamp_us()),
3!
686
        index.time_metric());
3✔
687
    co_return HttpResponse::ok(body);
3!
688
}
129!
689

690
namespace {
691

692
struct NameStat {
693
    std::uint64_t count = 0;
694
    double total = 0;
695
    double min = 0;
696
    double max = 0;
697
    void merge_from(const NameStat& o) {
×
698
        if (count == 0) {
×
699
            min = o.min;
×
700
            max = o.max;
×
701
        } else if (o.count > 0) {
×
702
            min = std::min(min, o.min);
×
703
            max = std::max(max, o.max);
×
704
        }
705
        count += o.count;
×
706
        total += o.total;
×
707
    }
×
708
};
709

710
using NameMap = dftracer::utils::StringViewMap<NameStat>;
711

712
static double json_number(simdjson::dom::element el);
713

714
enum class GroupBy { Name, Cat, Pid, Fhash };
715

716
static GroupBy parse_group_by(std::string_view g) {
4✔
717
    if (g == "cat") return GroupBy::Cat;
4!
718
    if (g == "pid") return GroupBy::Pid;
4!
719
    if (g == "fhash" || g == "file") return GroupBy::Fhash;
4!
720
    return GroupBy::Name;
4✔
721
}
2✔
722

723
// Parse one event's dur and grouping key, folding it into a worker-local map.
724
static void fold_event(std::string_view event, GroupBy group, NameMap& local) {
×
725
    thread_local simdjson::dom::parser parser;
×
726
    thread_local std::string buf;
×
727
    thread_local std::string keybuf;
×
728
    buf.assign(event);
×
729
    auto res = parser.parse(buf);
×
730
    if (res.error()) return;
×
731
    auto root = res.value_unsafe();
×
732
    if (!root.is_object()) return;
×
733

734
    auto dur_r = root["dur"];
×
735
    if (dur_r.error()) return;  // skip instant/metadata events (no duration)
×
736
    double dur = 0;
×
737
    if (dur_r.is_uint64())
×
738
        dur = static_cast<double>(dur_r.get_uint64().value_unsafe());
×
739
    else if (dur_r.is_int64())
×
740
        dur = static_cast<double>(dur_r.get_int64().value_unsafe());
×
741
    else if (dur_r.is_double())
×
742
        dur = dur_r.get_double().value_unsafe();
×
743
    else
744
        return;
×
745

746
    std::string_view name;
×
747
    if (group == GroupBy::Name) {
×
748
        auto r = root["name"];
×
749
        if (!r.error() && r.is_string()) name = r.get_string().value_unsafe();
×
750
    } else if (group == GroupBy::Cat) {
×
751
        auto r = root["cat"];
×
752
        if (!r.error() && r.is_string()) name = r.get_string().value_unsafe();
×
753
    } else if (group == GroupBy::Pid) {
×
754
        auto r = root["pid"];
×
755
        if (!r.error()) {
×
756
            keybuf = std::to_string(
×
757
                static_cast<std::int64_t>(json_number(r.value_unsafe())));
×
758
            name = keybuf;
×
759
        }
760
    } else {  // Fhash
761
        auto args = root["args"];
×
762
        if (!args.error() && args.is_object()) {
×
763
            auto r = args["fhash"];
×
764
            if (!r.error() && r.is_string())
×
765
                name = r.get_string().value_unsafe();
×
766
        }
767
        if (name.empty()) return;  // only I/O events have a file hash
×
768
    }
769

770
    auto it = local.find(name);
×
771
    if (it == local.end()) {
×
772
        it = local.emplace(std::string(name), NameStat{}).first;
×
773
        it->second.min = dur;
×
774
        it->second.max = dur;
×
775
    } else {
776
        it->second.min = std::min(it->second.min, dur);
×
777
        it->second.max = std::max(it->second.max, dur);
×
778
    }
779
    it->second.count += 1;
×
780
    it->second.total += dur;
×
781
}
782

783
// Sub-pixel events are bucketed by (pid, tid, pixel-column) instead of dropped,
784
// so zoomed-out views still show where activity is. The live path also assigns
785
// each bucket a containment depth (see assign_view_depths) so blocks stack
786
// under their enclosing events instead of collapsing to row 0.
787
struct DensityKey {
788
    std::int64_t pid;
789
    std::int64_t tid;
790
    std::int64_t col;
791
    std::string group;  // group_by value; "" when grouping is off or missing
792
    bool operator==(const DensityKey& o) const {
127✔
793
        return pid == o.pid && tid == o.tid && col == o.col && group == o.group;
127!
794
    }
795
};
796

797
struct DensityKeyHash {
798
    std::uint64_t operator()(const DensityKey& k) const noexcept {
2,384✔
799
        std::uint64_t h = 1469598103934665603ULL;
2,384✔
800
        auto mix = [&](std::uint64_t v) {
10,728✔
801
            h ^= v;
9,536✔
802
            h *= 1099511628211ULL;
9,536✔
803
        };
10,728✔
804
        mix(static_cast<std::uint64_t>(k.pid));
2,384!
805
        mix(static_cast<std::uint64_t>(k.tid));
2,384!
806
        mix(static_cast<std::uint64_t>(k.col));
2,384!
807
        mix(std::hash<std::string>{}(k.group));
2,384!
808
        return h;
2,384✔
809
    }
810
};
811

812
struct DensityAgg {
50✔
813
    std::uint32_t count = 0;
50✔
814
    double total = 0;
50✔
815
    double max_dur = -1;
50✔
816
    std::uint32_t depth = 0;  // containment depth, set by assign_view_depths
50✔
817
    std::string
818
        name;  // representative: name of the longest event in the bucket
819
    void merge_from(const DensityAgg& o) {
120✔
820
        count += o.count;
120✔
821
        total += o.total;
120✔
822
        if (o.max_dur > max_dur) {
120✔
823
            max_dur = o.max_dur;
×
824
            name = o.name;
×
825
        }
826
    }
120✔
827
};
828

829
using DensityMap =
830
    ankerl::unordered_dense::map<DensityKey, DensityAgg, DensityKeyHash>;
831

832
static double json_number(simdjson::dom::element el) {
7,484✔
833
    if (el.is_uint64())
7,484!
834
        return static_cast<double>(el.get_uint64().value_unsafe());
7,484✔
835
    if (el.is_int64())
×
836
        return static_cast<double>(el.get_int64().value_unsafe());
×
837
    if (el.is_double()) return el.get_double().value_unsafe();
×
838
    return 0;
×
839
}
3,742✔
840

841
// Value of `col` in the event for group_by, as a display string. Dotted paths
842
// walk nested objects; bare names fall back into "args" (the canonical home of
843
// domain fields). Anything missing or non-scalar yields "" so events are never
844
// dropped - the client renders those under "(none)".
845
static std::string extract_group_value(simdjson::dom::element root,
280✔
846
                                       std::string_view col) {
847
    auto scalar = [](simdjson::dom::element el) -> std::string {
280✔
848
        if (el.is_string()) return std::string(el.get_string().value_unsafe());
210!
NEW
849
        if (el.is_int64()) return std::to_string(el.get_int64().value_unsafe());
×
NEW
850
        if (el.is_uint64())
×
NEW
851
            return std::to_string(el.get_uint64().value_unsafe());
×
NEW
852
        if (el.is_double())
×
NEW
853
            return std::to_string(el.get_double().value_unsafe());
×
NEW
854
        if (el.is_bool())
×
NEW
855
            return el.get_bool().value_unsafe() ? "true" : "false";
×
NEW
856
        return "";
×
857
    };
70✔
858
    auto walk = [&](std::string_view path,
420✔
859
                    simdjson::dom::element& out) -> bool {
860
        simdjson::dom::element cur = root;
280✔
861
        std::size_t start = 0;
280✔
862
        while (start <= path.size()) {
280✔
863
            auto dot = path.find('.', start);
280✔
864
            auto key = path.substr(start, dot == std::string_view::npos
420!
865
                                              ? path.size() - start
280✔
866
                                              : dot - start);
867
            if (!key.empty()) {
280✔
868
                auto next = cur[key];
280✔
869
                if (next.error()) return false;
280✔
870
                cur = next.value_unsafe();
140✔
871
            }
70✔
872
            if (dot == std::string_view::npos) break;
140✔
NEW
873
            start = dot + 1;
×
874
        }
875
        out = cur;
140✔
876
        return true;
140✔
877
    };
280✔
878
    simdjson::dom::element v;
280✔
879
    if (walk(col, v)) return scalar(v);
280!
880
    if (col.find('.') == std::string_view::npos) {
140✔
881
        auto nested = root["args"][col];
140✔
882
        if (!nested.error()) return scalar(nested.value_unsafe());
140!
883
    }
70✔
884
    return "";
140!
885
}
140✔
886

NEW
887
static std::string extract_group_from_line(std::string_view event,
×
888
                                           std::string_view col) {
NEW
889
    thread_local simdjson::dom::parser parser;
×
NEW
890
    thread_local std::string buf;
×
NEW
891
    buf.assign(event);
×
NEW
892
    auto res = parser.parse(buf);
×
NEW
893
    if (res.error()) return "";
×
NEW
894
    auto root = res.value_unsafe();
×
NEW
895
    if (!root.is_object()) return "";
×
NEW
896
    return extract_group_value(root, col);
×
897
}
898

899
// Fold a small event into the density map. Returns false (keep as an individual
900
// event) when the event has no duration or is at/above the `threshold`.
901
static bool fold_density(std::string_view event, double threshold, double begin,
380✔
902
                         DensityMap& dens, double* out_dur = nullptr,
903
                         std::string_view group_col = {}) {
904
    thread_local simdjson::dom::parser parser;
380✔
905
    thread_local std::string buf;
380✔
906
    buf.assign(event);
380!
907
    auto res = parser.parse(buf);
380✔
908
    if (res.error()) return false;
380!
909
    auto root = res.value_unsafe();
380✔
910
    if (!root.is_object()) return false;
380✔
911

912
    auto dr = root["dur"];
380✔
913
    if (dr.error()) return false;
380!
914
    double dur = json_number(dr.value_unsafe());
380✔
915
    if (out_dur) *out_dur = dur;
380✔
916
    if (threshold <= 0 || dur >= threshold) return false;
380!
917

918
    double ts = 0;
380✔
919
    auto tr = root["ts"];
380✔
920
    if (!tr.error()) ts = json_number(tr.value_unsafe());
380✔
921
    std::int64_t pid = 0;
380✔
922
    auto pr = root["pid"];
380✔
923
    if (!pr.error())
380✔
924
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
380✔
925
    std::int64_t tid = 0;
380✔
926
    auto tir = root["tid"];
380✔
927
    if (!tir.error())
380✔
928
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
380✔
929
    std::string_view name;
380✔
930
    auto nr = root["name"];
380✔
931
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
380!
932

933
    std::int64_t col = static_cast<std::int64_t>((ts - begin) / threshold);
380✔
934
    std::string group;
380✔
935
    if (!group_col.empty()) group = extract_group_value(root, group_col);
380!
936
    DensityKey k{pid, tid, col, std::move(group)};
380✔
937
    auto it = dens.find(k);
380!
938
    if (it == dens.end()) it = dens.emplace(std::move(k), DensityAgg{}).first;
380!
939
    auto& a = it->second;
380✔
940
    a.count += 1;
380✔
941
    a.total += dur;
380✔
942
    if (dur > a.max_dur) {
380!
943
        a.max_dur = dur;
380✔
944
        a.name.assign(name);
380!
945
    }
190✔
946
    return true;
380✔
947
}
380✔
948

949
// Thin out FH/HH hash-declaration records: keep every one an event in this
950
// response refers to, plus a small carry of the rest so the client keeps
951
// building its hash map. A trace with millions of files emits these constantly
952
// and in a dense window they outweigh the drawable events several times over.
953
static constexpr std::size_t MAX_UNREFERENCED_HASH_RECORDS = 500;
954

955
static void drop_unreferenced_hash_records(std::vector<std::string>& big) {
6✔
956
    thread_local simdjson::dom::parser parser;
6✔
957
    thread_local std::string buf;
6✔
958
    ankerl::unordered_dense::set<std::string> referenced;
6!
959
    std::vector<bool> is_decl(big.size(), false);
6!
960

961
    for (std::size_t i = 0; i < big.size(); ++i) {
6!
NEW
962
        buf.assign(big[i]);
×
NEW
963
        auto res = parser.parse(buf);
×
NEW
964
        if (res.error()) continue;
×
NEW
965
        auto root = res.value_unsafe();
×
NEW
966
        if (!root.is_object()) continue;
×
NEW
967
        auto nr = root["name"];
×
NEW
968
        std::string_view name;
×
NEW
969
        if (!nr.error() && nr.is_string())
×
NEW
970
            name = nr.get_string().value_unsafe();
×
NEW
971
        auto args = root["args"];
×
NEW
972
        if (args.error() || !args.is_object()) continue;
×
NEW
973
        if (name == "FH" || name == "HH") {
×
NEW
974
            is_decl[i] = true;
×
NEW
975
            continue;
×
976
        }
NEW
977
        for (const char* key : {"fhash", "hhash"}) {
×
NEW
978
            auto v = args[key];
×
NEW
979
            if (!v.error() && v.is_string())
×
NEW
980
                referenced.emplace(v.get_string().value_unsafe());
×
981
        }
982
    }
983

984
    std::size_t out = 0;
6✔
985
    std::size_t carried = 0;
6✔
986
    for (std::size_t i = 0; i < big.size(); ++i) {
6✔
NEW
987
        if (is_decl[i]) {
×
NEW
988
            buf.assign(big[i]);
×
NEW
989
            auto res = parser.parse(buf);
×
NEW
990
            bool keep = false;
×
NEW
991
            if (!res.error()) {
×
NEW
992
                auto v = res.value_unsafe()["args"]["value"];
×
NEW
993
                if (!v.error() && v.is_string())
×
NEW
994
                    keep = referenced.count(
×
NEW
995
                               std::string(v.get_string().value_unsafe())) != 0;
×
996
            }
NEW
997
            if (!keep) {
×
NEW
998
                if (carried >= MAX_UNREFERENCED_HASH_RECORDS) continue;
×
NEW
999
                ++carried;
×
1000
            }
1001
        }
NEW
1002
        if (out != i) big[out] = std::move(big[i]);
×
NEW
1003
        ++out;
×
1004
    }
1005
    big.resize(out);
6!
1006
}
6✔
1007

1008
// Parse just the ts and dur of an event. Returns false for metadata/instant
1009
// events that lack either field.
1010
static bool parse_ts_dur(std::string_view event, double& ts, double& dur) {
×
1011
    thread_local simdjson::dom::parser parser;
×
1012
    thread_local std::string buf;
×
1013
    buf.assign(event);
×
1014
    auto res = parser.parse(buf);
×
1015
    if (res.error()) return false;
×
1016
    auto root = res.value_unsafe();
×
1017
    if (!root.is_object()) return false;
×
1018
    auto dr = root["dur"];
×
1019
    auto tr = root["ts"];
×
1020
    if (dr.error() || tr.error()) return false;
×
1021
    dur = json_number(dr.value_unsafe());
×
1022
    ts = json_number(tr.value_unsafe());
×
1023
    return true;
×
1024
}
1025

1026
// Parse pid, tid, ts, dur of an event. Returns false for metadata/instant
1027
// events that lack ts or dur.
1028
static bool parse_lane_ts_dur(std::string_view event, std::int64_t& pid,
×
1029
                              std::int64_t& tid, double& ts, double& dur) {
1030
    thread_local simdjson::dom::parser parser;
×
1031
    thread_local std::string buf;
×
1032
    buf.assign(event);
×
1033
    auto res = parser.parse(buf);
×
1034
    if (res.error()) return false;
×
1035
    auto root = res.value_unsafe();
×
1036
    if (!root.is_object()) return false;
×
1037
    auto dr = root["dur"];
×
1038
    auto tr = root["ts"];
×
1039
    if (dr.error() || tr.error()) return false;
×
1040
    dur = json_number(dr.value_unsafe());
×
1041
    ts = json_number(tr.value_unsafe());
×
1042
    auto pr = root["pid"];
×
1043
    pid = pr.error()
×
1044
              ? 0
×
1045
              : static_cast<std::int64_t>(json_number(pr.value_unsafe()));
×
1046
    auto tir = root["tid"];
×
1047
    tid = tir.error()
×
1048
              ? 0
×
1049
              : static_cast<std::int64_t>(json_number(tir.value_unsafe()));
×
1050
    return true;
×
1051
}
1052

1053
// Containment depth per event/block, stable across zoom because an event's
1054
// ancestors are always longer and so survive any threshold that kept it. A
1055
// block is nested only under big events that fully cover its bucket interval
1056
// [ts, ts+threshold]; a bucket-sized sibling that merely overlaps it is not an
1057
// ancestor, so folded events stay on their sibling's row.
1058
static std::vector<std::uint32_t> assign_view_depths(
6✔
1059
    const std::vector<std::string>& big, DensityMap& dens, double begin,
1060
    double threshold) {
1061
    std::vector<std::uint32_t> depth(big.size(), 0);
6!
1062

1063
    struct Item {
1064
        double ts;          // interval start (big) or bucket left edge (block)
1065
        double end;         // big end, or bucket right edge for a block query
1066
        int big_idx;        // index into `big`, or -1 for a density query
1067
        DensityAgg* block;  // set for a density query, null for a big event
1068
    };
1069
    struct LaneKey {
1070
        std::int64_t pid, tid;
1071
        bool operator==(const LaneKey& o) const {
×
1072
            return pid == o.pid && tid == o.tid;
×
1073
        }
1074
    };
1075
    struct LaneHash {
1076
        std::uint64_t operator()(const LaneKey& k) const noexcept {
560✔
1077
            std::uint64_t h = 1469598103934665603ULL;
560✔
1078
            h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
560✔
1079
            h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
560✔
1080
            return h;
560✔
1081
        }
1082
    };
1083
    ankerl::unordered_dense::map<LaneKey, std::vector<Item>, LaneHash> lanes;
6!
1084

1085
    for (std::size_t i = 0; i < big.size(); ++i) {
6!
1086
        std::int64_t pid = 0, tid = 0;
×
1087
        double ts = 0, dur = 0;
×
1088
        if (!parse_lane_ts_dur(big[i], pid, tid, ts, dur)) continue;
×
1089
        double end = ts + (dur > 0 ? dur : 0);
×
1090
        lanes[LaneKey{pid, tid}].push_back(
×
1091
            Item{ts, end, static_cast<int>(i), nullptr});
×
1092
    }
1093
    if (threshold > 0) {
6✔
1094
        for (auto& kv : dens) {
266✔
1095
            double lo = begin + static_cast<double>(kv.first.col) * threshold;
260✔
1096
            lanes[LaneKey{kv.first.pid, kv.first.tid}].push_back(
260!
1097
                Item{lo, lo + threshold, -1, &kv.second});
260!
1098
        }
1099
    }
3✔
1100

1101
    for (auto& kv : lanes) {
266✔
1102
        auto& items = kv.second;
260✔
1103
        // Opens sort before queries at equal ts so a block sitting exactly at a
1104
        // parent's start counts that parent as an ancestor.
1105
        std::sort(items.begin(), items.end(), [](const Item& a, const Item& b) {
260!
1106
            if (a.ts != b.ts) return a.ts < b.ts;
×
1107
            return (a.block == nullptr) && (b.block != nullptr);
×
1108
        });
1109
        // Greedy lowest-free-row packing: staggered overlapping spans reuse
1110
        // rows (real concurrency) instead of one row each, while nested events
1111
        // still cascade.
1112
        std::vector<double> row_end;  // end time occupying each row
260✔
1113
        for (auto& it : items) {
520✔
1114
            if (it.block) {
260!
1115
                std::size_t r = 0;
260✔
1116
                while (r < row_end.size() && row_end[r] > it.end) ++r;
260!
1117
                it.block->depth = static_cast<std::uint32_t>(r);
260✔
1118
            } else {
130✔
NEW
1119
                std::size_t r = 0;
×
NEW
1120
                while (r < row_end.size() && row_end[r] > it.ts) ++r;
×
NEW
1121
                if (r == row_end.size())
×
NEW
1122
                    row_end.push_back(it.end);
×
1123
                else
NEW
1124
                    row_end[r] = it.end;
×
1125
                depth[static_cast<std::size_t>(it.big_idx)] =
×
1126
                    static_cast<std::uint32_t>(r);
1127
            }
1128
        }
1129
    }
260✔
1130
    return depth;
9✔
1131
}
6!
1132

1133
// --- Counters (bandwidth / IOPS over time) ---
1134
// Per-bucket read/write bytes and I/O op counts, aggregated from POSIX/STDIO/IO
1135
// events (bytes come from args.ret).
1136
struct CounterAcc {
1137
    std::vector<double> read_bytes;
1138
    std::vector<double> write_bytes;
1139
    std::vector<double> ops;
1140
    void init(std::size_t n) {
×
1141
        read_bytes.assign(n, 0.0);
×
1142
        write_bytes.assign(n, 0.0);
×
1143
        ops.assign(n, 0.0);
×
1144
    }
×
1145
    void merge_from(const CounterAcc& o) {
×
1146
        for (std::size_t i = 0; i < ops.size(); ++i) {
×
1147
            read_bytes[i] += o.read_bytes[i];
×
1148
            write_bytes[i] += o.write_bytes[i];
×
1149
            ops[i] += o.ops[i];
×
1150
        }
1151
    }
×
1152
};
1153

1154
static void fold_counter(std::string_view event, double begin, double bucket_us,
×
1155
                         std::size_t buckets, CounterAcc& acc) {
1156
    thread_local simdjson::dom::parser parser;
×
1157
    thread_local std::string buf;
×
1158
    buf.assign(event);
×
1159
    auto res = parser.parse(buf);
×
1160
    if (res.error()) return;
×
1161
    auto root = res.value_unsafe();
×
1162
    if (!root.is_object()) return;
×
1163

1164
    auto cat_r = root["cat"];
×
1165
    if (cat_r.error() || !cat_r.is_string()) return;
×
1166
    std::string_view cat = cat_r.get_string().value_unsafe();
×
1167
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
×
1168

1169
    auto ts_r = root["ts"];
×
1170
    if (ts_r.error()) return;
×
1171
    double ts = json_number(ts_r.value_unsafe());
×
1172
    long col = static_cast<long>((ts - begin) / bucket_us);
×
1173
    if (col < 0 || col >= static_cast<long>(buckets)) return;
×
1174

1175
    acc.ops[col] += 1.0;
×
1176

1177
    std::string_view name;
×
1178
    auto name_r = root["name"];
×
1179
    if (!name_r.error() && name_r.is_string())
×
1180
        name = name_r.get_string().value_unsafe();
×
1181

1182
    double bytes = 0;
×
1183
    auto args = root["args"];
×
1184
    if (!args.error() && args.is_object()) {
×
1185
        auto ret = args["ret"];
×
1186
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
×
1187
    }
1188
    if (bytes <= 0) return;
×
1189
    if (name.find("write") != std::string_view::npos)
×
1190
        acc.write_bytes[col] += bytes;
×
1191
    else if (name.find("read") != std::string_view::npos)
×
1192
        acc.read_bytes[col] += bytes;
×
1193
}
1194

1195
}  // namespace
1196

1197
// --- Activity summary ("mipmap") build -------------------------------------
1198
// Cells are shared and updated with relaxed atomics with no per-event lock:
1199
// workers scan disjoint checkpoint (time) ranges, so they touch mostly-disjoint
1200
// buckets. Only first-time lane and name creation take a brief lock.
1201

1202
namespace {
1203

1204
struct PidTid {
1205
    std::int64_t pid;
1206
    std::int64_t tid;
UNCOV
1207
    bool operator==(const PidTid& o) const {
×
UNCOV
1208
        return pid == o.pid && tid == o.tid;
×
1209
    }
1210
};
1211

1212
struct PidTidHash {
UNCOV
1213
    std::uint64_t operator()(const PidTid& k) const noexcept {
×
UNCOV
1214
        std::uint64_t h = 1469598103934665603ULL;
×
UNCOV
1215
        h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
×
UNCOV
1216
        h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
×
UNCOV
1217
        return h;
×
1218
    }
1219
};
1220

1221
// One worker's sparse cells at the finest level, keyed by lane and bucket.
1222
// `demote` coarsens the whole accumulator by 4x when it outgrows its budget;
1223
// since every level is an exact 4:1 fold of the one below, workers can sit at
1224
// different depths and still merge.
1225
struct FineAcc {
14✔
1226
    ankerl::unordered_dense::map<std::uint64_t, std::uint32_t> slot;
1227
    std::vector<std::uint64_t> keys;
1228
    std::vector<VizSummary::Cell> cells;
1229
    unsigned demote = 0;
14✔
1230

NEW
1231
    static std::uint64_t key_of(std::uint32_t lane, std::uint64_t bucket) {
×
NEW
1232
        return (static_cast<std::uint64_t>(lane) << 32) | bucket;
×
1233
    }
1234

NEW
1235
    void add(std::uint32_t lane, std::uint64_t bucket, double dur,
×
1236
             std::uint32_t name_id) {
NEW
1237
        std::uint64_t k = key_of(lane, bucket >> (2 * demote));
×
NEW
1238
        auto it = slot.find(k);
×
1239
        std::uint32_t i;
NEW
1240
        if (it != slot.end()) {
×
NEW
1241
            i = it->second;
×
1242
        } else {
NEW
1243
            i = static_cast<std::uint32_t>(cells.size());
×
NEW
1244
            slot.emplace(k, i);
×
NEW
1245
            keys.push_back(k);
×
NEW
1246
            cells.emplace_back();
×
1247
        }
NEW
1248
        auto& c = cells[i];
×
NEW
1249
        c.count += 1;
×
NEW
1250
        c.total += static_cast<std::uint64_t>(dur < 0 ? 0 : dur);
×
NEW
1251
        std::uint32_t d = dur >= 4294967295.0
×
1252
                              ? 4294967295u
×
NEW
1253
                              : static_cast<std::uint32_t>(dur < 0 ? 0 : dur);
×
NEW
1254
        if (d > c.max_dur || c.count == 1) {
×
NEW
1255
            c.max_dur = d;
×
NEW
1256
            c.name_id = name_id;
×
1257
        }
NEW
1258
    }
×
1259

NEW
1260
    void coarsen() {
×
NEW
1261
        ++demote;
×
NEW
1262
        ankerl::unordered_dense::map<std::uint64_t, std::uint32_t> ns;
×
NEW
1263
        std::vector<std::uint64_t> nk;
×
NEW
1264
        std::vector<VizSummary::Cell> nc;
×
NEW
1265
        ns.reserve(keys.size() / 2);
×
NEW
1266
        for (std::size_t i = 0; i < keys.size(); ++i) {
×
NEW
1267
            std::uint64_t k = key_of(static_cast<std::uint32_t>(keys[i] >> 32),
×
NEW
1268
                                     (keys[i] & 0xFFFFFFFFULL) >> 2);
×
NEW
1269
            auto it = ns.find(k);
×
NEW
1270
            if (it == ns.end()) {
×
NEW
1271
                ns.emplace(k, static_cast<std::uint32_t>(nc.size()));
×
NEW
1272
                nk.push_back(k);
×
NEW
1273
                nc.push_back(cells[i]);
×
1274
            } else {
NEW
1275
                auto& dst = nc[it->second];
×
NEW
1276
                const auto& src = cells[i];
×
NEW
1277
                dst.count += src.count;
×
NEW
1278
                dst.total += src.total;
×
NEW
1279
                if (src.max_dur > dst.max_dur) {
×
NEW
1280
                    dst.max_dur = src.max_dur;
×
NEW
1281
                    dst.name_id = src.name_id;
×
1282
                }
1283
            }
1284
        }
NEW
1285
        slot = std::move(ns);
×
NEW
1286
        keys = std::move(nk);
×
NEW
1287
        cells = std::move(nc);
×
UNCOV
1288
    }
×
1289
};
1290

1291
struct SumBuild {
7!
1292
    std::size_t nb = 0;             // level 0 (counter grid)
7✔
1293
    double bucket_us = 1;
7✔
1294
    std::size_t nb_fine = 0;        // finest level, before any coarsening
7✔
1295
    double fine_bucket_us = 1;
7✔
1296
    std::size_t max_lanes = 0;
7✔
1297
    std::size_t fine_cell_cap = 0;  // per worker
7✔
1298
    std::size_t long_cap = 0;       // per worker
7✔
1299
    std::uint64_t t0 = 0;
7✔
1300

1301
    std::mutex lane_mtx;
1302
    ankerl::unordered_dense::map<PidTid, std::uint32_t, PidTidHash> lane_of;
1303
    std::vector<PidTid> lane_keys;
1304

1305
    std::vector<std::atomic<double>> cread;
1306
    std::vector<std::atomic<double>> cwrite;
1307
    std::vector<std::atomic<double>> cops;
1308
    std::atomic<std::uint64_t> gmax_dur{0};
7✔
1309

1310
    std::mutex name_mtx;
1311
    dftracer::utils::StringViewMap<std::uint32_t> name_of;
1312
    std::vector<std::string> names;
1313

1314
    // Per-worker caches so the hot path never locks.
1315
    std::vector<ankerl::unordered_dense::map<PidTid, std::uint32_t, PidTidHash>>
1316
        lane_cache;
1317
    std::vector<dftracer::utils::StringViewMap<std::uint32_t>> name_cache;
1318

1319
    std::vector<FineAcc> fine;
1320
    std::vector<std::size_t> long_quota;  // per level-0 bucket, per worker
1321

1322
    // Per-worker Analyze aggregates (no lock); merged single-threaded after the
1323
    // scan. One map per GroupBy dimension.
1324
    std::vector<NameMap> g_name, g_cat, g_pid, g_fhash;
1325

1326
    // Per-worker FH resolution (file-hash -> path) from metadata records, so
1327
    // the "By file" grouping can show real paths without depending on the
1328
    // client.
1329
    std::vector<dftracer::utils::StringViewMap<std::string>> fh_parts;
1330

1331
    std::vector<dftracer::utils::StringViewMap<std::string>> sh_parts;
1332
    std::vector<ankerl::unordered_dense::map<std::int64_t, std::string>>
1333
        app_start, app_end;
1334

1335
    // Per-worker events at least one finest-level bucket wide, kept whole (see
1336
    // VizSummary::long_events).
1337
    std::vector<std::vector<VizSummary::AppSpan>> long_evs;
1338

1339
    // Per-worker column discovery: distinct column names, and the event names
1340
    // already harvested (schema is per event name).
1341
    std::vector<dftracer::utils::StringViewSet> cols, col_seen;
1342

1343
    // Per-worker operation-name -> category (first seen); merged after the scan
1344
    // into VizSummary::name_cats for real layer labels.
1345
    std::vector<dftracer::utils::StringViewMap<std::string>> name_cat;
1346

1347
    // Per-worker file-hashes seen on a read/write op (files with real data
1348
    // I/O).
1349
    std::vector<dftracer::utils::StringViewSet> io_fh;
1350

1351
    // Per-worker process-hierarchy state (see VizSummary::procs), keyed by pid
1352
    // so it stays small however many events a process emits.
1353
    std::vector<ankerl::unordered_dense::map<std::int64_t, VizSummary::ProcRow>>
1354
        procs;
1355
    std::vector<std::vector<VizSummary::ForkEdge>> forks;
1356
    std::vector<dftracer::utils::StringViewMap<std::string>> hh_parts;
1357

1358
    VizSummary::ProcRow& proc_of(std::size_t w, std::int64_t pid) {
630✔
1359
        auto& m = procs[w];
630✔
1360
        auto it = m.find(pid);
630!
1361
        if (it == m.end()) {
630✔
1362
            it = m.emplace(pid, VizSummary::ProcRow{}).first;
128!
1363
            it->second.pid = pid;
128✔
1364
        }
64✔
1365
        return it->second;
630✔
1366
    }
1367

1368
    static constexpr std::uint32_t NO_LANE =
1369
        std::numeric_limits<std::uint32_t>::max();
1370

NEW
1371
    std::uint32_t get_lane(std::size_t w, std::int64_t pid, std::int64_t tid) {
×
UNCOV
1372
        PidTid key{pid, tid};
×
UNCOV
1373
        auto& cache = lane_cache[w];
×
UNCOV
1374
        auto ci = cache.find(key);
×
UNCOV
1375
        if (ci != cache.end()) return ci->second;
×
UNCOV
1376
        std::lock_guard<std::mutex> lk(lane_mtx);
×
UNCOV
1377
        auto it = lane_of.find(key);
×
1378
        std::uint32_t lane;
UNCOV
1379
        if (it != lane_of.end()) {
×
NEW
1380
            lane = it->second;
×
1381
        } else {
NEW
1382
            if (lane_keys.size() >= max_lanes)
×
NEW
1383
                return NO_LANE;  // budget reached; drop further lanes
×
NEW
1384
            lane = static_cast<std::uint32_t>(lane_keys.size());
×
NEW
1385
            lane_of.emplace(key, lane);
×
NEW
1386
            lane_keys.push_back(key);
×
1387
        }
UNCOV
1388
        cache.emplace(key, lane);
×
UNCOV
1389
        return lane;
×
UNCOV
1390
    }
×
1391

1392
    // Fold an event into the worker's cells, coarsening when over budget.
NEW
1393
    void fold_cell(std::size_t w, std::uint32_t lane, double ts, double dur,
×
1394
                   std::uint32_t name_id) {
NEW
1395
        auto& acc = fine[w];
×
NEW
1396
        double rel = (ts - static_cast<double>(t0)) / fine_bucket_us;
×
NEW
1397
        auto bucket = rel <= 0 ? 0 : static_cast<std::uint64_t>(rel);
×
NEW
1398
        if (bucket >= nb_fine) bucket = nb_fine - 1;
×
NEW
1399
        acc.add(lane, bucket, dur, name_id);
×
NEW
1400
        while (acc.cells.size() > fine_cell_cap &&
×
NEW
1401
               acc.demote < VizSummary::EXTRA_LEVELS)
×
NEW
1402
            acc.coarsen();
×
NEW
1403
    }
×
1404

NEW
1405
    std::uint64_t coarse_bucket(std::uint64_t ts) const {
×
NEW
1406
        double rel =
×
NEW
1407
            (static_cast<double>(ts) - static_cast<double>(t0)) / bucket_us;
×
NEW
1408
        if (rel <= 0) return 0;
×
NEW
1409
        auto bk = static_cast<std::uint64_t>(rel);
×
NEW
1410
        return bk >= nb ? nb - 1 : bk;
×
1411
    }
1412

1413
    // Trim a worker's long events to `long_quota` per level-0 bucket, keeping
1414
    // the widest and folding the rest into cells. A global "widest wins" cap
1415
    // would spend the whole budget on a few enormous events and leave zoomed-in
1416
    // windows empty, so the budget is spread over time instead.
NEW
1417
    void compact_long(std::size_t w) {
×
NEW
1418
        auto& lv = long_evs[w];
×
NEW
1419
        std::sort(
×
1420
            lv.begin(), lv.end(),
NEW
1421
            [this](const VizSummary::AppSpan& a, const VizSummary::AppSpan& x) {
×
NEW
1422
                auto ba = coarse_bucket(a.begin);
×
NEW
1423
                auto bx = coarse_bucket(x.begin);
×
NEW
1424
                if (ba != bx) return ba < bx;
×
NEW
1425
                return a.end - a.begin > x.end - x.begin;
×
1426
            });
NEW
1427
        std::size_t out = 0, run = 0;
×
NEW
1428
        std::uint64_t cur = std::numeric_limits<std::uint64_t>::max();
×
NEW
1429
        for (std::size_t i = 0; i < lv.size(); ++i) {
×
NEW
1430
            auto bk = coarse_bucket(lv[i].begin);
×
NEW
1431
            if (bk != cur) {
×
NEW
1432
                cur = bk;
×
NEW
1433
                run = 0;
×
1434
            }
NEW
1435
            if (run < long_quota[w]) {
×
NEW
1436
                ++run;
×
NEW
1437
                if (out != i) lv[out] = std::move(lv[i]);
×
NEW
1438
                ++out;
×
NEW
1439
                continue;
×
1440
            }
NEW
1441
            auto lane = get_lane(w, lv[i].pid, lv[i].tid);
×
NEW
1442
            if (lane != NO_LANE)
×
NEW
1443
                fold_cell(w, lane, static_cast<double>(lv[i].begin),
×
NEW
1444
                          static_cast<double>(lv[i].end - lv[i].begin),
×
NEW
1445
                          lv[i].name_id);
×
1446
        }
NEW
1447
        lv.resize(out);
×
NEW
1448
    }
×
1449

1450
    void push_long(std::size_t w, VizSummary::AppSpan&& span) {
630✔
1451
        auto& lv = long_evs[w];
630✔
1452
        lv.push_back(std::move(span));
630✔
1453
        if (lv.size() < long_cap) return;
630✔
NEW
1454
        compact_long(w);
×
NEW
1455
        if (lv.size() > long_cap / 2 && long_quota[w] > 1) {
×
NEW
1456
            long_quota[w] /= 2;
×
NEW
1457
            compact_long(w);
×
1458
        }
1459
    }
315✔
1460

1461
    std::uint32_t intern(std::size_t w, std::string_view name) {
630✔
1462
        auto& cache = name_cache[w];
630✔
1463
        auto ci = cache.find(name);
630!
1464
        if (ci != cache.end()) return ci->second;
630✔
1465
        std::lock_guard<std::mutex> lk(name_mtx);
48!
1466
        auto it = name_of.find(name);
48!
1467
        std::uint32_t id;
1468
        if (it != name_of.end()) {
48✔
1469
            id = it->second;
×
1470
        } else {
1471
            id = static_cast<std::uint32_t>(names.size());
48✔
1472
            names.emplace_back(name);
48!
1473
            name_of.emplace(std::string(name), id);
48!
1474
        }
1475
        cache.emplace(std::string(name), id);
48!
1476
        return id;
48✔
1477
    }
339✔
1478
};
1479

1480
template <class T>
1481
static void atomic_max(std::atomic<T>& a, T v) {
630✔
1482
    T cur = a.load(std::memory_order_relaxed);
630✔
1483
    while (v > cur &&
758!
1484
           !a.compare_exchange_weak(cur, v, std::memory_order_relaxed)) {
192!
1485
    }
1486
}
630✔
1487

1488
static void atomic_add_double(std::atomic<double>& a, double v) {
332✔
1489
    double cur = a.load(std::memory_order_relaxed);
332✔
1490
    while (!a.compare_exchange_weak(cur, cur + v, std::memory_order_relaxed)) {
332!
1491
    }
1492
}
332✔
1493

1494
// Fold one (key, dur) sample into a group aggregate, mirroring fold_event.
1495
static void fold_group(NameMap& m, std::string_view key, double dur) {
1,890✔
1496
    auto it = m.find(key);
1,890!
1497
    if (it == m.end()) {
1,890✔
1498
        it = m.emplace(std::string(key), NameStat{}).first;
202!
1499
        it->second.min = dur;
202✔
1500
        it->second.max = dur;
202✔
1501
    } else {
101✔
1502
        it->second.min = std::min(it->second.min, dur);
1,688✔
1503
        it->second.max = std::max(it->second.max, dur);
1,688✔
1504
    }
1505
    it->second.count += 1;
1,890✔
1506
    it->second.total += dur;
1,890✔
1507
}
1,890✔
1508

1509
static bool is_fork_syscall(std::string_view name) {
630✔
1510
    return name == "fork" || name == "vfork" || name == "clone" ||
1,575!
1511
           name == "clone3" || name == "posix_spawn" ||
1,260!
1512
           name == "posix_spawnp" ||
945!
1513
           name.find("sys_clone") != std::string_view::npos ||
945!
1514
           name.find("sys_fork") != std::string_view::npos ||
1,260!
1515
           name.find("sys_vfork") != std::string_view::npos;
945✔
1516
}
1517

1518
static void fold_summary(std::size_t w, std::string_view event, SumBuild& b) {
632✔
1519
    thread_local simdjson::dom::parser parser;
632✔
1520
    thread_local std::string buf;
632✔
1521
    buf.assign(event);
632!
1522
    auto res = parser.parse(buf);
632✔
1523
    if (res.error()) return;
859!
1524
    auto root = res.value_unsafe();
632✔
1525
    if (!root.is_object()) return;
632✔
1526

1527
    std::string_view name0;
632✔
1528
    {
1529
        auto nr = root["name"];
632✔
1530
        if (!nr.error() && nr.is_string())
632!
1531
            name0 = nr.get_string().value_unsafe();
632✔
1532
    }
1533
    // Hash and rank metadata carry no ts/dur, so they are harvested before the
1534
    // gates below drop such records.
1535
    if (name0 == "FH" || name0 == "SH" || name0 == "HH") {
632!
1536
        auto args = root["args"];
×
1537
        if (!args.error() && args.is_object()) {
×
1538
            auto v = args["value"];
×
1539
            auto n = args["name"];
×
1540
            if (!v.error() && v.is_string() && !n.error() && n.is_string()) {
×
NEW
1541
                auto& tbl = name0 == "FH"   ? b.fh_parts[w]
×
NEW
1542
                            : name0 == "SH" ? b.sh_parts[w]
×
NEW
1543
                                            : b.hh_parts[w];
×
1544
                tbl.emplace(std::string(v.get_string().value_unsafe()),
×
1545
                            std::string(n.get_string().value_unsafe()));
×
1546
            }
1547
        }
1548
        return;
×
1549
    }
1550
    if (name0 == "PR") {
632!
NEW
1551
        auto args = root["args"];
×
NEW
1552
        auto pp = root["pid"];
×
NEW
1553
        if (!args.error() && args.is_object() && !pp.error()) {
×
NEW
1554
            auto an = args["name"];
×
NEW
1555
            auto av = args["value"];
×
NEW
1556
            if (!an.error() && an.is_string() &&
×
NEW
1557
                an.get_string().value_unsafe() == "rank" && !av.error() &&
×
NEW
1558
                av.is_string()) {
×
1559
                auto pid =
NEW
1560
                    static_cast<std::int64_t>(json_number(pp.value_unsafe()));
×
NEW
1561
                auto& row = b.proc_of(w, pid);
×
NEW
1562
                if (row.rank.empty())
×
NEW
1563
                    row.rank = std::string(av.get_string().value_unsafe());
×
1564
            }
1565
        }
NEW
1566
        return;
×
1567
    }
1568

1569
    auto dr = root["dur"];
632✔
1570
    bool has_dur = !dr.error();
632✔
1571
    double dur = has_dur ? json_number(dr.value_unsafe()) : 0;
632✔
1572

1573
    // Process hierarchy (see VizSummary::procs). Runs before the dur gate so it
1574
    // sees exactly what a live proctree scan sees.
1575
    {
1576
        auto pr = root["pid"];
632✔
1577
        auto tr = root["ts"];
632✔
1578
        if (!pr.error() && !tr.error()) {
632!
1579
            auto pid =
315✔
1580
                static_cast<std::int64_t>(json_number(pr.value_unsafe()));
630✔
1581
            auto ts =
315✔
1582
                static_cast<std::uint64_t>(json_number(tr.value_unsafe()));
630✔
1583
            auto& row = b.proc_of(w, pid);
630!
1584
            if (row.first_ts == 0 || ts < row.first_ts) row.first_ts = ts;
630!
1585

1586
            double ret = 0;
630✔
1587
            auto args = root["args"];
630✔
1588
            if (!args.error() && args.is_object()) {
630✔
1589
                auto ppr = args["ppid"];
230✔
1590
                if (!ppr.error()) {
230✔
1591
                    auto pp = static_cast<std::int64_t>(
13✔
1592
                        json_number(ppr.value_unsafe()));
26✔
1593
                    if (pp > 0 && pp != pid) row.ppid = pp;
26!
1594
                }
13✔
1595
                auto rr = args["ret"];
230✔
1596
                if (!rr.error()) ret = json_number(rr.value_unsafe());
230✔
1597
                auto hr = args["hhash"];
230✔
1598
                if (!hr.error() && hr.is_string() && row.hhash.empty())
230!
1599
                    row.hhash = std::string(hr.get_string().value_unsafe());
126!
1600
            }
115✔
1601
            if (ret > 0 && (name0.find("read") != std::string_view::npos ||
661✔
1602
                            name0.find("write") != std::string_view::npos))
62✔
1603
                row.bytes += static_cast<std::uint64_t>(ret);
154✔
1604

1605
            auto cr = root["cat"];
630✔
1606
            if (!cr.error() && cr.is_string()) {
630!
1607
                auto cat = cr.get_string().value_unsafe();
630✔
1608
                if (cat == "POSIX" || cat == "STDIO" || cat == "IO") {
630!
1609
                    row.io_ops += 1;
178✔
1610
                    if (has_dur) row.io_busy += dur;
178✔
1611
                }
89✔
1612
            }
315✔
1613
            if (!name0.empty() && is_fork_syscall(name0))
630!
NEW
1614
                b.forks[w].push_back(VizSummary::ForkEdge{
×
NEW
1615
                    ts, pid, ret > 0 ? static_cast<std::int64_t>(ret) : -1});
×
1616
        }
315✔
1617
    }
1618

1619
    if (!has_dur) return;
632✔
1620

1621
    // Column discovery: harvest the schema once per distinct event name (same
1622
    // name => same keys), so this is O(distinct names), not O(events).
1623
    if (b.col_seen[w].find(name0) == b.col_seen[w].end()) {
630✔
1624
        b.col_seen[w].emplace(name0);
48!
1625
        static const ankerl::unordered_dense::set<std::string_view> SKIP = {
24!
1626
            "pid", "tid", "ts", "dur", "ph", "id", "args"};
24!
1627
        auto obj = root.get_object();
48✔
1628
        if (!obj.error())
48✔
1629
            for (auto kv : obj.value_unsafe())
478✔
1630
                if (SKIP.find(kv.key) == SKIP.end()) b.cols[w].emplace(kv.key);
454!
1631
        auto ar = root["args"];
48✔
1632
        if (!ar.error() && ar.is_object()) {
48✔
1633
            simdjson::dom::object args_obj;
46✔
1634
            if (ar.get_object().get(args_obj) == simdjson::SUCCESS)
46✔
1635
                for (auto kv : args_obj) b.cols[w].emplace(kv.key);
158✔
1636
        }
23✔
1637
    }
24✔
1638

1639
    if (name0 == "start" || name0 == "end") {
630✔
1640
        auto cr = root["cat"];
52✔
1641
        auto pp = root["pid"];
52✔
1642
        if (!cr.error() && cr.is_string() &&
130!
1643
            cr.get_string().value_unsafe() == "dftracer" && !pp.error()) {
104!
1644
            auto p = static_cast<std::int64_t>(json_number(pp.value_unsafe()));
52✔
1645
            (name0 == "start" ? b.app_start[w] : b.app_end[w])
78✔
1646
                .emplace(p, std::string(event));
78!
1647
        }
26✔
1648
    }
26✔
1649

1650
    std::int64_t pid = 0, tid = 0;
630✔
1651
    auto pr = root["pid"];
630✔
1652
    if (!pr.error())
630✔
1653
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
630✔
1654

1655
    // Analyze aggregates: whole-trace, ts-independent so they match a live
1656
    // scan.
1657
    {
1658
        auto nr = root["name"];
630✔
1659
        auto cr = root["cat"];
630✔
1660
        if (!nr.error() && nr.is_string()) {
630!
1661
            std::string_view nm = nr.get_string().value_unsafe();
630✔
1662
            fold_group(b.g_name[w], nm, dur);
630!
1663
            if (!cr.error() && cr.is_string() &&
1,260!
1664
                b.name_cat[w].find(nm) == b.name_cat[w].end())
945!
1665
                b.name_cat[w].emplace(
72!
1666
                    std::string(nm),
72!
1667
                    std::string(cr.get_string().value_unsafe()));
72!
1668
        }
315✔
1669
        if (!cr.error() && cr.is_string())
630!
1670
            fold_group(b.g_cat[w], cr.get_string().value_unsafe(), dur);
630!
1671
        thread_local std::string pidkey;
630✔
1672
        pidkey.assign(std::to_string(pid));
630!
1673
        fold_group(b.g_pid[w], pidkey, dur);
630!
1674
        auto ar = root["args"];
630✔
1675
        if (!ar.error() && ar.is_object()) {
630✔
1676
            auto fr = ar["fhash"];
230✔
1677
            if (!fr.error() && fr.is_string())
230!
1678
                fold_group(b.g_fhash[w], fr.get_string().value_unsafe(), dur);
×
1679
        }
115✔
1680
    }
1681

1682
    auto tr = root["ts"];
630✔
1683
    if (tr.error()) return;  // bucketing/counters below need a timestamp
630!
1684
    double ts = json_number(tr.value_unsafe());
630✔
1685
    std::int64_t bucket = static_cast<std::int64_t>(
630✔
1686
        (ts - static_cast<double>(b.t0)) / b.fine_bucket_us);
630✔
1687
    if (bucket < 0) return;
630✔
1688
    if (bucket >= static_cast<std::int64_t>(b.nb_fine)) bucket = b.nb_fine - 1;
630✔
1689
    // Counters live at the finest resolution and are folded down to the coarser
1690
    // levels afterwards, so a zoomed-in counter track needs no live scan.
1691
    auto bi = static_cast<std::size_t>(bucket);
630✔
1692

1693
    auto tir = root["tid"];
630✔
1694
    if (!tir.error())
630✔
1695
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
630✔
1696

1697
    std::uint32_t name_id;
1698
    {
1699
        std::string_view name;
630✔
1700
        auto nr = root["name"];
630✔
1701
        if (!nr.error() && nr.is_string())
630!
1702
            name = nr.get_string().value_unsafe();
630✔
1703
        name_id = b.intern(w, name);
630!
1704
    }
1705

1706
    if (dur >= b.fine_bucket_us && dur > 0) {
630!
1707
        // Wider than a finest-level bucket: folded into a cell it would render
1708
        // as a sliver, so keep it whole and let each request decide whether it
1709
        // is wide enough to draw.
1710
        b.push_long(
945!
1711
            w, VizSummary::AppSpan{static_cast<std::uint64_t>(ts),
2,205!
1712
                                   static_cast<std::uint64_t>(ts + dur), pid,
945✔
1713
                                   tid, name_id, std::string(event)});
945✔
1714
    } else {
315✔
NEW
1715
        auto lane = b.get_lane(w, pid, tid);
×
NEW
1716
        if (lane != SumBuild::NO_LANE) b.fold_cell(w, lane, ts, dur, name_id);
×
1717
    }
1718
    atomic_max(b.gmax_dur, static_cast<std::uint64_t>(dur < 0 ? 0 : dur));
630!
1719

1720
    // Counters: read/write bytes and op counts for POSIX/STDIO/IO events.
1721
    auto cat_r = root["cat"];
630✔
1722
    if (cat_r.error() || !cat_r.is_string()) return;
630!
1723
    std::string_view cat = cat_r.get_string().value_unsafe();
630✔
1724
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
630✔
1725
    atomic_add_double(b.cops[bi], 1.0);
178✔
1726
    double bytes = 0;
178✔
1727
    auto args = root["args"];
178✔
1728
    if (!args.error() && args.is_object()) {
178!
1729
        auto ret = args["ret"];
178✔
1730
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
178✔
1731
    }
89✔
1732
    if (bytes <= 0) return;
178!
1733
    std::string_view name;
178✔
1734
    auto nr = root["name"];
178✔
1735
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
178!
1736
    bool is_write = name.find("write") != std::string_view::npos;
178✔
1737
    bool is_read = name.find("read") != std::string_view::npos;
178✔
1738
    if (is_write)
178✔
1739
        atomic_add_double(b.cwrite[bi], bytes);
38✔
1740
    else if (is_read)
140✔
1741
        atomic_add_double(b.cread[bi], bytes);
116✔
1742
    if ((is_read || is_write) && !args.error() && args.is_object()) {
178!
1743
        auto fr = args["fhash"];
154✔
1744
        if (!fr.error() && fr.is_string())
154!
1745
            b.io_fh[w].emplace(std::string(fr.get_string().value_unsafe()));
×
1746
    }
77✔
1747
}
316✔
1748

1749
}  // namespace
1750

1751
// Build the activity summary by scanning every event once. Blocks the caller
1752
// (the first overview request) for the scan; cached for the server's lifetime.
1753
static coro::CoroTask<void> build_viz_summary(TraceIndex& index) {
56!
1754
    auto summary = std::make_unique<VizSummary>();
21!
1755
    std::uint64_t gmin = index.global_min_timestamp_us();
21✔
1756
    std::uint64_t gmax = index.global_max_timestamp_us();
21✔
1757
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin) {
21✔
1758
        index.set_viz_summary(std::move(summary));
28!
1759
        co_return;
7✔
1760
    }
1761

1762
    std::size_t est_lanes = std::max<std::size_t>(1, index.file_count()) * 8;
21✔
1763
    std::size_t nb = VizSummary::MAX_CELLS / est_lanes;
21✔
1764
    nb = std::clamp(nb, VizSummary::MIN_BUCKETS_PER_LANE,
21!
1765
                    VizSummary::MAX_BUCKETS_PER_LANE);
1766

1767
    SumBuild b;
21!
1768
    b.nb = nb;
21✔
1769
    b.t0 = gmin;
21✔
1770
    b.bucket_us = static_cast<double>(gmax - gmin) / static_cast<double>(nb);
21✔
1771
    b.max_lanes = std::max<std::size_t>(1, VizSummary::MAX_CELLS / nb);
21!
1772
    b.nb_fine = nb << (2 * VizSummary::EXTRA_LEVELS);
21✔
1773
    b.fine_bucket_us =
21✔
1774
        static_cast<double>(gmax - gmin) / static_cast<double>(b.nb_fine);
21✔
1775
    b.cread = std::vector<std::atomic<double>>(b.nb_fine);
21!
1776
    b.cwrite = std::vector<std::atomic<double>>(b.nb_fine);
21!
1777
    b.cops = std::vector<std::atomic<double>>(b.nb_fine);
21!
1778
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
21!
1779
    b.fine_cell_cap =
21✔
1780
        std::max<std::size_t>(1, VizSummary::MAX_FINE_CELLS / slots);
21!
1781
    b.long_cap = std::max<std::size_t>(2, VizSummary::MAX_LONG_EVENTS / slots);
21!
1782
    b.fine.resize(slots);
21✔
1783
    b.long_quota.assign(
14!
1784
        slots, std::max<std::size_t>(1, VizSummary::MAX_LONG_EVENTS /
7!
1785
                                            std::max<std::size_t>(1, nb)));
7!
1786
    b.lane_cache.resize(slots);
7!
1787
    b.name_cache.resize(slots);
7!
1788
    b.g_name.resize(slots);
7!
1789
    b.g_cat.resize(slots);
7!
1790
    b.g_pid.resize(slots);
7!
1791
    b.g_fhash.resize(slots);
7!
1792
    b.fh_parts.resize(slots);
7!
1793
    b.sh_parts.resize(slots);
7!
1794
    b.app_start.resize(slots);
7!
1795
    b.app_end.resize(slots);
7!
1796
    b.long_evs.resize(slots);
7!
1797
    b.cols.resize(slots);
7!
1798
    b.col_seen.resize(slots);
7!
1799
    b.name_cat.resize(slots);
7!
1800
    b.io_fh.resize(slots);
7!
1801
    b.procs.resize(slots);
7!
1802
    b.forks.resize(slots);
7!
1803
    b.hh_parts.resize(slots);
7✔
1804

1805
    ViewDefinition view;
21✔
1806
    view.name = "viz_summary";
21✔
1807
    view.description = "Activity summary build";
7✔
1808
    std::vector<const TraceIndex::FileInfo*> files;
21✔
1809
    files.reserve(index.files().size());
21✔
1810
    for (const auto& f : index.files()) files.push_back(&f);
28!
1811

1812
    auto on_batch = [&b](std::size_t w,
35✔
1813
                         const std::vector<std::string_view>& events) {
316✔
1814
        for (auto ev : events) fold_summary(w, ev, b);
646✔
1815
    };
14✔
1816
    co_await scan_view_events(index, files, view, static_cast<double>(gmin),
28!
1817
                              static_cast<double>(gmax), 0, slots, on_batch,
21!
1818
                              CancelToken{});
21✔
1819

1820
    summary->t_begin = gmin;
7✔
1821
    summary->t_end = gmax;
7✔
1822
    summary->nbuckets = nb;
7✔
1823
    summary->bucket_us = b.bucket_us;
7✔
1824
    summary->max_dur = b.gmax_dur.load(std::memory_order_relaxed);
7✔
1825
    summary->names = std::move(b.names);
7✔
1826
    summary->long_threshold_us = b.fine_bucket_us;
7✔
1827
    // Workers each hold up to their own per-bucket quota, so the union can
1828
    // exceed the budget; compact once more across all of them.
1829
    for (std::size_t w = 1; w < b.long_evs.size(); ++w) {
14✔
1830
        auto& part = b.long_evs[w];
7✔
1831
        for (auto& ev : part) b.long_evs[0].push_back(std::move(ev));
7!
1832
        part.clear();
7✔
1833
        part.shrink_to_fit();
7✔
1834
    }
7✔
1835
    b.long_quota[0] =
7✔
1836
        std::max<std::size_t>(1, VizSummary::MAX_LONG_EVENTS / nb);
7!
1837
    while (b.long_evs[0].size() > VizSummary::MAX_LONG_EVENTS) {
7!
1838
        b.compact_long(0);
×
1839
        if (b.long_evs[0].size() <= VizSummary::MAX_LONG_EVENTS) break;
×
1840
        if (b.long_quota[0] == 1) break;
×
1841
        b.long_quota[0] /= 2;
1842
    }
1843
    summary->long_events = std::move(b.long_evs[0]);
7✔
1844

1845
    // Workers that outgrew their cell budget coarsened independently; the
1846
    // merged pyramid can only be as fine as the coarsest of them.
1847
    unsigned demote = 0;
7✔
1848
    for (auto& acc : b.fine) demote = std::max(demote, acc.demote);
21!
1849
    for (auto& acc : b.fine)
21✔
1850
        while (acc.demote < demote) acc.coarsen();
14!
1851
    std::sort(summary->long_events.begin(), summary->long_events.end(),
7!
1852
              [](const VizSummary::AppSpan& a, const VizSummary::AppSpan& x) {
2,476✔
1853
                  return a.begin < x.begin;
2,476✔
1854
              });
1855

1856
    {
1857
        std::size_t total = 0;
7✔
1858
        for (auto& acc : b.fine) total += acc.cells.size();
21✔
1859
        std::vector<std::pair<std::uint64_t, VizSummary::Cell>> merged;
7✔
1860
        merged.reserve(total);
7!
1861
        for (auto& acc : b.fine) {
21✔
1862
            for (std::size_t i = 0; i < acc.keys.size(); ++i)
14!
1863
                merged.emplace_back(acc.keys[i], acc.cells[i]);
×
1864
            acc = FineAcc{};
14!
1865
        }
14✔
1866
        std::sort(
21!
1867
            merged.begin(), merged.end(),
14✔
NEW
1868
            [](const auto& x, const auto& y) { return x.first < y.first; });
×
1869

1870
        std::size_t nb_fine = b.nb_fine >> (2 * demote);
7✔
1871
        // Counters were folded at the pre-coarsening resolution; bring them to
1872
        // the merged one, then they ride the same 4:1 folds as the cells.
1873
        std::vector<double> cread(nb_fine, 0.0), cwrite(nb_fine, 0.0),
7!
1874
            cops(nb_fine, 0.0);
7!
1875
        for (std::size_t i = 0; i < b.nb_fine; ++i) {
29,360,135✔
1876
            std::size_t j = i >> (2 * demote);
29,360,128✔
1877
            if (j >= nb_fine) j = nb_fine - 1;
29,360,128!
1878
            cread[j] += b.cread[i].load(std::memory_order_relaxed);
29,360,128✔
1879
            cwrite[j] += b.cwrite[i].load(std::memory_order_relaxed);
29,360,128✔
1880
            cops[j] += b.cops[i].load(std::memory_order_relaxed);
29,360,128✔
1881
        }
29,360,128✔
1882

1883
        for (std::size_t l = 0; l <= VizSummary::EXTRA_LEVELS - demote; ++l) {
35✔
1884
            if (l > 0) {
28✔
1885
                // Fold 4:1 into the next coarser level, in place: keys stay
1886
                // sorted because the lane stays in the high bits.
1887
                std::size_t out = 0;
21✔
1888
                for (std::size_t i = 0; i < merged.size(); ++i) {
21!
1889
                    std::uint64_t k = (merged[i].first & ~0xFFFFFFFFULL) |
1890
                                      ((merged[i].first & 0xFFFFFFFFULL) >> 2);
1891
                    if (out > 0 && merged[out - 1].first == k) {
×
1892
                        auto& dst = merged[out - 1].second;
1893
                        const auto& src = merged[i].second;
1894
                        dst.count += src.count;
1895
                        dst.total += src.total;
1896
                        if (src.max_dur > dst.max_dur) {
×
1897
                            dst.max_dur = src.max_dur;
1898
                            dst.name_id = src.name_id;
1899
                        }
1900
                    } else {
1901
                        merged[out] = merged[i];
1902
                        merged[out].first = k;
1903
                        ++out;
1904
                    }
1905
                }
1906
                merged.resize(out);
21!
1907
                nb_fine >>= 2;
21✔
1908
                auto fold = [](const std::vector<double>& src, std::size_t n) {
147✔
1909
                    std::vector<double> dst(n, 0.0);
126!
1910
                    for (std::size_t i = 0; i < src.size(); ++i) {
231,211,134✔
1911
                        std::size_t j = i >> 2;
231,211,008✔
1912
                        dst[j < n ? j : n - 1] += src[i];
231,211,008✔
1913
                    }
115,605,504✔
1914
                    return dst;
126✔
1915
                };
63!
1916
                cread = fold(cread, nb_fine);
21!
1917
                cwrite = fold(cwrite, nb_fine);
21!
1918
                cops = fold(cops, nb_fine);
21!
1919
            } else {
21✔
1920
                std::size_t out = 0;
7✔
1921
                for (std::size_t i = 0; i < merged.size(); ++i) {
7!
1922
                    if (out > 0 && merged[out - 1].first == merged[i].first) {
×
1923
                        auto& dst = merged[out - 1].second;
1924
                        const auto& src = merged[i].second;
1925
                        dst.count += src.count;
1926
                        dst.total += src.total;
1927
                        if (src.max_dur > dst.max_dur) {
×
1928
                            dst.max_dur = src.max_dur;
1929
                            dst.name_id = src.name_id;
1930
                        }
1931
                    } else {
1932
                        merged[out++] = merged[i];
1933
                    }
1934
                }
1935
                merged.resize(out);
7!
1936
            }
7✔
1937

1938
            VizSummary::Level level;
28✔
1939
            level.nbuckets = nb_fine;
28✔
1940
            level.bucket_us =
28✔
1941
                static_cast<double>(gmax - gmin) / static_cast<double>(nb_fine);
28✔
1942
            level.read_bytes = cread;
28!
1943
            level.write_bytes = cwrite;
28!
1944
            level.ops = cops;
28!
1945
            level.lanes.resize(b.lane_keys.size());
28!
1946
            for (std::size_t i = 0; i < b.lane_keys.size(); ++i) {
28!
1947
                level.lanes[i].pid = b.lane_keys[i].pid;
1948
                level.lanes[i].tid = b.lane_keys[i].tid;
1949
            }
1950
            for (const auto& [k, cell] : merged) {
28!
1951
                auto li = static_cast<std::size_t>(k >> 32);
1952
                if (li >= level.lanes.size()) continue;
×
1953
                level.lanes[li].buckets.push_back(
×
1954
                    static_cast<std::uint32_t>(k & 0xFFFFFFFFULL));
1955
                level.lanes[li].cells.push_back(cell);
×
1956
            }
×
1957
            summary->levels.push_back(std::move(level));
28!
1958
        }
28✔
1959
        std::reverse(summary->levels.begin(), summary->levels.end());
7!
1960
    }
7✔
1961

1962
    {
1963
        ankerl::unordered_dense::set<std::string> all_cols;
7!
1964
        for (auto& c : b.cols)
21✔
1965
            for (auto& k : c) all_cols.emplace(k);
60!
1966
        summary->columns.assign(all_cols.begin(), all_cols.end());
7!
1967
        std::sort(summary->columns.begin(), summary->columns.end());
7!
1968
    }
7✔
1969

1970
    // Merge the per-worker Analyze aggregates and sort each by total desc.
1971
    auto finalize_group = [](std::vector<NameMap>& parts) {
63✔
1972
        NameMap merged;
56!
1973
        for (auto& p : parts) {
168✔
1974
            for (auto& kv : p) {
314✔
1975
                auto it = merged.find(kv.first);
202!
1976
                if (it == merged.end())
202!
1977
                    merged.emplace(kv.first, kv.second);
202!
1978
                else
1979
                    it->second.merge_from(kv.second);
×
1980
            }
1981
        }
1982
        std::vector<VizSummary::GroupRow> rows;
56✔
1983
        rows.reserve(merged.size());
56!
1984
        for (auto& kv : merged)
258✔
1985
            rows.push_back({kv.first, kv.second.count, kv.second.total,
303!
1986
                            kv.second.min, kv.second.max});
303!
1987
        std::sort(rows.begin(), rows.end(),
56!
1988
                  [](const VizSummary::GroupRow& lhs,
490✔
1989
                     const VizSummary::GroupRow& rhs) {
1990
                      return lhs.total > rhs.total;
490✔
1991
                  });
1992
        return rows;
84✔
1993
    };
56!
1994
    summary->by_name = finalize_group(b.g_name);
7!
1995
    summary->by_cat = finalize_group(b.g_cat);
7!
1996
    summary->by_pid = finalize_group(b.g_pid);
7!
1997
    summary->by_fhash = finalize_group(b.g_fhash);
7!
1998

1999
    // Resolve file-hash keys to real paths where a metadata record was seen.
2000
    dftracer::utils::StringViewMap<std::string> fh;
7!
2001
    for (auto& part : b.fh_parts)
21✔
2002
        for (auto& kv : part) fh.emplace(kv.first, kv.second);
14!
2003
    for (auto& r : summary->by_fhash) {
7!
2004
        auto it = fh.find(r.key);
×
2005
        if (it != fh.end()) r.key = it->second;
×
2006
    }
2007
    summary->total_files = fh.size();
7✔
2008

2009
    {
2010
        dftracer::utils::StringViewMap<std::string> sh;
7!
2011
        for (auto& part : b.sh_parts)
21✔
2012
            for (auto& kv : part) sh.emplace(kv.first, kv.second);
14!
2013
        ankerl::unordered_dense::map<std::int64_t, std::string> starts, ends;
7!
2014
        for (auto& part : b.app_start)
21✔
2015
            for (auto& kv : part) starts.emplace(kv.first, kv.second);
27!
2016
        for (auto& part : b.app_end)
21✔
2017
            for (auto& kv : part) ends.emplace(kv.first, kv.second);
27!
2018

2019
        auto resolve = [](dftracer::utils::StringViewMap<std::string>& tbl,
85✔
2020
                          const std::string& h) -> std::string {
2021
            auto it = tbl.find(h);
78!
2022
            return it != tbl.end() ? it->second : h;
117!
2023
        };
2024
        simdjson::dom::parser sp, ep;
7✔
2025
        for (auto& [pid, sjson] : starts) {
20✔
2026
            auto eit = ends.find(pid);
13!
2027
            if (eit == ends.end()) continue;
13!
2028
            std::string sbuf(sjson), ebuf(eit->second);
13!
2029
            auto sr = sp.parse(sbuf);
13✔
2030
            auto er = ep.parse(ebuf);
13✔
2031
            if (sr.error() || er.error()) continue;
13!
2032
            auto se = sr.value_unsafe();
13✔
2033
            auto ee = er.value_unsafe();
13✔
2034

2035
            auto num = [](simdjson::dom::element r, const char* k) -> double {
91✔
2036
                auto v = r[k];
78✔
2037
                return v.error() ? 0.0 : json_number(v.value_unsafe());
78✔
2038
            };
2039
            auto sarg = [](simdjson::dom::element r,
143✔
2040
                           const char* k) -> std::string {
2041
                auto a = r["args"];
130✔
2042
                if (a.error()) return "";
130!
2043
                auto v = a[k];
130✔
2044
                return (!v.error() && v.is_string())
91!
2045
                           ? std::string(v.get_string().value_unsafe())
117✔
2046
                           : "";
221!
2047
            };
65✔
2048
            auto narg = [](simdjson::dom::element r, const char* k) -> double {
65✔
2049
                auto a = r["args"];
52✔
2050
                if (a.error()) return 0.0;
52!
2051
                auto v = a[k];
52✔
2052
                return v.error() ? 0.0 : json_number(v.value_unsafe());
52✔
2053
            };
26✔
2054

2055
            auto start_ts = static_cast<std::uint64_t>(num(se, "ts"));
13!
2056
            auto end_ts = static_cast<std::uint64_t>(num(ee, "ts"));
13!
2057
            if (end_ts <= start_ts) continue;
13!
2058
            auto tid = static_cast<std::int64_t>(num(se, "tid"));
13!
2059
            std::string app = resolve(sh, sarg(se, "exec_hash"));
13!
2060
            std::string cmd = resolve(sh, sarg(se, "cmd_hash"));
13!
2061
            std::string cwd = resolve(fh, sarg(se, "cwd"));
13!
2062
            std::string version = sarg(se, "version");
13!
2063
            std::string date = sarg(se, "date");
13!
2064
            auto ppid = static_cast<std::int64_t>(narg(se, "ppid"));
13!
2065
            auto num_events = static_cast<std::int64_t>(narg(ee, "num_events"));
13!
2066
            if (app.empty()) app = "app " + std::to_string(pid);
13!
2067

2068
            auto& jb = scratch_json_builder();
13!
2069
            jb.start_object();
13✔
2070
            jb.append_key_value("name", app);
13!
2071
            jb.append_comma();
13✔
2072
            jb.append_key_value("cat", "dftracer");
13✔
2073
            jb.append_comma();
13✔
2074
            jb.append_key_value("pid", pid);
13✔
2075
            jb.append_comma();
13✔
2076
            jb.append_key_value("tid", tid);
13✔
2077
            jb.append_comma();
13✔
2078
            jb.append_key_value("ts", start_ts);
13✔
2079
            jb.append_comma();
13✔
2080
            jb.append_key_value("dur", end_ts - start_ts);
13✔
2081
            jb.append_comma();
13✔
2082
            jb.append_key_value("ph", "X");
13✔
2083
            jb.append_comma();
13✔
2084
            jb.escape_and_append_with_quotes("args");
13✔
2085
            jb.append_colon();
13✔
2086
            jb.start_object();
13✔
2087
            jb.append_key_value("app", app);
13!
2088
            jb.append_comma();
13✔
2089
            jb.append_key_value("cmd", cmd);
13!
2090
            jb.append_comma();
13✔
2091
            jb.append_key_value("cwd", cwd);
13!
2092
            jb.append_comma();
13✔
2093
            jb.append_key_value("ppid", ppid);
13✔
2094
            jb.append_comma();
13✔
2095
            jb.append_key_value("version", version);
13!
2096
            jb.append_comma();
13✔
2097
            jb.append_key_value("date", date);
13!
2098
            jb.append_comma();
13✔
2099
            jb.append_key_value("num_events", num_events);
13✔
2100
            jb.end_object();
13✔
2101
            jb.end_object();
13✔
2102
            summary->app_spans.push_back(
13!
2103
                {start_ts, end_ts, pid, tid,
26✔
2104
                 std::numeric_limits<std::uint32_t>::max(), std::string(jb)});
26!
2105
        }
13!
2106
    }
7✔
2107

2108
    dftracer::utils::StringViewSet io_fh;
7!
2109
    for (auto& part : b.io_fh)
21✔
2110
        for (auto& k : part) io_fh.emplace(k);
14!
2111
    summary->io_files = io_fh.size();
7✔
2112

2113
    {
2114
        ankerl::unordered_dense::map<std::int64_t, VizSummary::ProcRow> procs;
7!
2115
        for (auto& part : b.procs) {
21✔
2116
            for (auto& [pid, src] : part) {
78✔
2117
                auto it = procs.find(pid);
64!
2118
                if (it == procs.end()) {
64!
2119
                    procs.emplace(pid, src);
64!
2120
                    continue;
64✔
2121
                }
2122
                auto& dst = it->second;
2123
                if (dst.first_ts == 0 ||
×
2124
                    (src.first_ts != 0 && src.first_ts < dst.first_ts))
×
2125
                    dst.first_ts = src.first_ts;
2126
                if (dst.ppid < 0) dst.ppid = src.ppid;
×
2127
                dst.bytes += src.bytes;
2128
                dst.io_ops += src.io_ops;
2129
                dst.io_busy += src.io_busy;
2130
                if (dst.hhash.empty()) dst.hhash = src.hhash;
×
2131
                if (dst.rank.empty()) dst.rank = src.rank;
×
2132
            }
64!
2133
            part.clear();
14!
2134
        }
14✔
2135
        summary->procs.reserve(procs.size());
7!
2136
        for (auto& [pid, row] : procs) summary->procs.push_back(row);
71!
2137
        std::sort(summary->procs.begin(), summary->procs.end(),
7!
2138
                  [](const VizSummary::ProcRow& a,
371✔
2139
                     const VizSummary::ProcRow& x) { return a.pid < x.pid; });
371✔
2140

2141
        for (auto& part : b.forks) {
21✔
2142
            for (auto& f : part) summary->forks.push_back(f);
14!
2143
            part.clear();
14✔
2144
        }
14✔
2145
        std::sort(summary->forks.begin(), summary->forks.end(),
7!
NEW
2146
                  [](const VizSummary::ForkEdge& a,
×
NEW
2147
                     const VizSummary::ForkEdge& x) { return a.ts < x.ts; });
×
2148

2149
        dftracer::utils::StringViewMap<std::string> hh;
7!
2150
        for (auto& part : b.hh_parts)
21✔
2151
            for (auto& kv : part) hh.emplace(kv.first, kv.second);
14!
2152
        summary->hosts.reserve(hh.size());
7!
2153
        for (auto& kv : hh) summary->hosts.emplace_back(kv.first, kv.second);
7!
2154
    }
7✔
2155

2156
    // Merge the per-worker name -> category maps (first writer wins).
2157
    dftracer::utils::StringViewMap<std::string> nc;
7!
2158
    for (auto& part : b.name_cat)
21✔
2159
        for (auto& kv : part) nc.emplace(kv.first, kv.second);
38!
2160
    summary->name_cats.reserve(nc.size());
7!
2161
    for (auto& kv : nc) summary->name_cats.emplace_back(kv.first, kv.second);
31!
2162

2163
    if (!summary->app_spans.empty()) {
7✔
2164
        // Break = dead time between runs: merge app-spans into run clusters and
2165
        // emit every gap between them, no size threshold.
2166
        std::vector<const VizSummary::AppSpan*> spans;
5✔
2167
        spans.reserve(summary->app_spans.size());
5!
2168
        for (const auto& sp : summary->app_spans) spans.push_back(&sp);
18!
2169
        std::sort(spans.begin(), spans.end(),
5!
2170
                  [](const auto* lhs, const auto* rhs) {
24✔
2171
                      return lhs->begin < rhs->begin;
24✔
2172
                  });
2173
        std::vector<std::pair<std::uint64_t, std::uint64_t>> runs;
5✔
2174
        for (const auto* sp : spans) {
18✔
2175
            if (!runs.empty() && sp->begin <= runs.back().second)
13!
2176
                runs.back().second = std::max(runs.back().second, sp->end);
×
2177
            else
2178
                runs.emplace_back(sp->begin, sp->end);
13!
2179
        }
13✔
2180
        for (std::size_t i = 1; i < runs.size(); ++i)
13✔
2181
            if (runs[i].first > runs[i - 1].second)
16!
2182
                summary->idle_gaps.emplace_back(runs[i - 1].second,
16!
2183
                                                runs[i].first);
8✔
2184
    } else if (nb > 0 && summary->bucket_us > 0) {
7!
2185
        // No app-spans (malformed trace): fall back to a lane-activity scan,
2186
        // requiring a gap to be a fraction of active (not total) time.
2187
        std::vector<bool> active(nb, false);
2!
2188
        if (!summary->levels.empty())
2!
2189
            for (const auto& lane : summary->levels.front().lanes)
2!
2190
                for (std::uint32_t bk : lane.buckets)
×
2191
                    if (bk < nb) active[bk] = true;
2!
2192
        for (const auto& ev : summary->long_events) {
252✔
2193
            auto b0 = summary->bucket_of(static_cast<double>(ev.begin));
250!
2194
            auto b1 = summary->bucket_of(static_cast<double>(ev.end));
250!
2195
            if (b0 < 0) continue;
250!
2196
            if (b1 < b0) b1 = b0;
250!
2197
            for (std::int64_t i = b0; i <= b1; ++i)
98,796✔
2198
                active[static_cast<std::size_t>(i)] = true;
98,546!
2199
        }
250!
2200
        std::size_t first = 0, last = 0, active_count = 0;
2✔
2201
        bool any = false;
2✔
2202
        for (std::size_t i = 0; i < nb; ++i)
131,074✔
2203
            if (active[i]) {
196,894!
2204
                if (!any) {
65,822✔
2205
                    first = i;
2✔
2206
                    any = true;
2✔
2207
                }
2✔
2208
                last = i;
65,822✔
2209
                ++active_count;
65,822✔
2210
            }
65,822✔
2211
        if (any) {
2!
2212
            const double active_us =
4✔
2213
                static_cast<double>(active_count) * summary->bucket_us;
2✔
2214
            const double min_gap_us =
2✔
2215
                std::max(3.0 * summary->bucket_us, 0.05 * active_us);
2!
2216
            for (std::size_t i = first; i <= last;) {
65,873✔
2217
                if (active[i]) {
65,871!
2218
                    ++i;
65,822✔
2219
                    continue;
65,822✔
2220
                }
2221
                std::size_t j = i;
49✔
2222
                while (j <= last && !active[j]) ++j;
65,299!
2223
                if (static_cast<double>(j - i) * summary->bucket_us >=
98!
2224
                    min_gap_us) {
49✔
2225
                    auto g0 = summary->t_begin +
98✔
2226
                              static_cast<std::uint64_t>(
2227
                                  static_cast<double>(i) * summary->bucket_us);
49✔
2228
                    auto g1 = summary->t_begin +
98✔
2229
                              static_cast<std::uint64_t>(
2230
                                  static_cast<double>(j) * summary->bucket_us);
49✔
2231
                    summary->idle_gaps.emplace_back(g0, g1);
49!
2232
                }
49✔
2233
                i = j;
49✔
2234
            }
49✔
2235
        }
2✔
2236
    }
2✔
2237

2238
    if (g_shutdown_requested.load(std::memory_order_acquire)) {
7!
2239
        DFTRACER_UTILS_LOG_INFO("viz: summary build abandoned (shutting down)");
×
2240
        co_return;
2241
    }
2242

2243
    std::size_t cells = 0;
7✔
2244
    for (const auto& lane : summary->levels.back().lanes)
7!
2245
        cells += lane.cells.size();
2246
    DFTRACER_UTILS_LOG_INFO(
7!
2247
        "viz: built activity summary (%zu lanes, %zu levels, finest %.0f us "
2248
        "with %zu cells, %zu long events, %zu idle gaps)",
2249
        b.lane_keys.size(), summary->levels.size(),
2250
        summary->levels.back().bucket_us, cells, summary->long_events.size(),
2251
        summary->idle_gaps.size());
2252
    index.set_viz_summary(std::move(summary));
7!
2253
}
147!
2254

2255
// The summary, building it on first use. Requests that arrive during the build
2256
// wait for it: a whole-trace live scan each (the old fallback) costs more than
2257
// the build they are waiting on, and the client opens the timeline with half a
2258
// dozen summary-backed requests at once.
2259
static coro::CoroTask<const VizSummary*> ensure_viz_summary(TraceIndex& index) {
180!
2260
    const VizSummary* s = index.viz_summary();
37!
2261
    if (s) co_return s;
60!
2262
    co_await index.viz_summary_mutex().lock();
28!
2263
    coro::AsyncMutexGuard guard(index.viz_summary_mutex());
21!
2264
    s = index.viz_summary();
21✔
2265
    if (!s) {
7!
2266
        if (!index.load_persisted_viz_summary()) {
7!
2267
            co_await build_viz_summary(index);
28!
2268
            index.persist_viz_summary();
7!
2269
        }
7✔
2270
        s = index.viz_summary();
21✔
2271
    }
7✔
2272
    co_return s;
7!
2273
}
139!
2274

2275
// One aggregate row, uniform over the live-scan and summary paths.
2276
struct StatRow {
2277
    const std::string* key;
2278
    std::uint64_t count;
2279
    double total;
2280
    double min;
2281
    double max;
2282
};
2283

2284
template <typename builder_type>
2285
void tag_invoke(simdjson::serialize_tag, builder_type& b, const StatRow& r) {
18✔
2286
    b.start_object();
18✔
2287
    b.append_key_value("name", *r.key);
18✔
2288
    b.append_comma();
18✔
2289
    b.append_key_value("count", r.count);
18✔
2290
    b.append_comma();
18✔
2291
    b.append_key_value("total", r.total);
18✔
2292
    b.append_comma();
18✔
2293
    b.append_key_value("avg",
27✔
2294
                       r.count ? r.total / static_cast<double>(r.count) : 0.0);
18!
2295
    b.append_comma();
18✔
2296
    b.append_key_value("min", r.min);
18✔
2297
    b.append_comma();
18✔
2298
    b.append_key_value("max", r.max);
18✔
2299
    b.end_object();
18✔
2300
}
18✔
2301

2302
static std::string serialize_stats_body(std::uint64_t total_count,
4✔
2303
                                        double total_dur, double wall,
2304
                                        bool truncated,
2305
                                        const std::vector<StatRow>& rows) {
2306
    auto& b = scratch_json_builder();
4✔
2307
    b.start_object();
4✔
2308
    b.append_key_value("count", total_count);
4✔
2309
    b.append_comma();
4✔
2310
    b.append_key_value("total_dur", total_dur);
4✔
2311
    b.append_comma();
4✔
2312
    b.append_key_value("wall", wall);
4✔
2313
    b.append_comma();
4✔
2314
    b.append_key_value("truncated", truncated);
4✔
2315
    b.append_comma();
4✔
2316
    b.append_key_value("names", rows);
4✔
2317
    b.end_object();
4✔
2318
    return std::string(b);
4!
2319
}
2320

2321
// Scale duration fields (native trace unit -> us) before serialization. The
2322
// `wall` value is derived from client-us begin/end and is already in us.
2323
static void scale_stat_durations(std::vector<StatRow>& rows, double& total_dur,
4✔
2324
                                 TraceIndex::TimeMetric metric) {
2325
    const double us =
2✔
2326
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
4✔
2327
            metric);
2✔
2328
    if (us == 1.0) return;
4✔
2329
    for (auto& r : rows) {
4✔
2330
        r.total *= us;
2✔
2331
        r.min *= us;
2✔
2332
        r.max *= us;
2✔
2333
    }
2334
    total_dur *= us;
2✔
2335
}
2✔
2336

2337
static const std::vector<VizSummary::GroupRow>& summary_group_rows(
4✔
2338
    const VizSummary& s, GroupBy g) {
2339
    switch (g) {
4!
2340
        case GroupBy::Cat:
2341
            return s.by_cat;
×
2342
        case GroupBy::Pid:
2343
            return s.by_pid;
×
2344
        case GroupBy::Fhash:
2345
            return s.by_fhash;
×
2346
        case GroupBy::Name:
4✔
2347
        default:
2348
            return s.by_name;
4✔
2349
    }
2350
}
2✔
2351

2352
// Whole-trace, unfiltered Analyze answers come from the prebuilt summary, so no
2353
// live scan is needed. Any predicate or sub-range forces the live path.
2354
static bool viz_stats_summary_eligible(const QueryParams& p, double begin_abs,
4✔
2355
                                       double end_abs, TraceIndex& index) {
2356
    if (!p.get("query").empty() || !p.get("cat").empty() ||
8!
2357
        !p.get("lanes").empty() || !p.get("filters").empty() ||
4!
2358
        !p.get("file").empty() || !p.get("pid").empty() ||
8!
2359
        !p.get("tid").empty())
6!
2360
        return false;
×
2361
    std::uint64_t gmin = index.global_min_timestamp_us();
4✔
2362
    std::uint64_t gmax = index.global_max_timestamp_us();
4✔
2363
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
4!
2364
        return false;
×
2365
    return begin_abs <= static_cast<double>(gmin) + 1.0 &&
8✔
2366
           end_abs >= static_cast<double>(gmax) - 1.0;
6!
2367
}
2✔
2368

2369
// GET /api/v1/viz/stats: server-side per-name aggregation over a time range.
2370
// Scans in parallel worker coroutines, each folding into its own map, then
2371
// merges single-threaded (no lock). Returns only the small aggregate table.
2372
static coro::CoroTask<HttpResponse> handle_viz_stats(const HttpRequest& req,
16!
2373
                                                     const QueryParams& params,
2374
                                                     TraceIndex& index) {
2!
2375
    if (!params.has("begin") || !params.has("end")) {
6!
2376
        co_return HttpResponse::bad_request(
10!
2377
            "Missing required parameters: begin, end");
8!
2378
    }
2379

2380
    double begin = params.get_double("begin", 0);
6✔
2381
    double end = params.get_double("end", 0);
6✔
2382

2383
    auto query = params.get("query");
6✔
2384
    if (!query.empty() &&
6!
2385
        !utilities::common::query::try_parse(query).has_value()) {
×
2386
        co_return HttpResponse::bad_request("Invalid query: " +
×
2387
                                            std::string(query));
×
2388
    }
2389

2390
    auto ts_norm_param = params.get("ts_normalize");
6✔
2391
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
6!
2392
    std::uint64_t global_min = 0;
6✔
2393
    if (normalize) {
6✔
2394
        global_min = index.global_min_timestamp_us();
2✔
2395
        if (global_min == std::numeric_limits<std::uint64_t>::max())
2!
2396
            global_min = 0;
2397
    }
2✔
2398
    double original_begin = begin;
6✔
2399
    double original_end = end;
6✔
2400
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
6✔
2401
        begin = static_cast<double>(
1✔
2402
            index.us_to_native(static_cast<std::uint64_t>(begin)));
1✔
2403
        end = static_cast<double>(
1✔
2404
            index.us_to_native(static_cast<std::uint64_t>(end)));
1✔
2405
    }
1✔
2406
    if (normalize && global_min > 0) {
6!
2407
        begin += static_cast<double>(global_min);
2✔
2408
        end += static_cast<double>(global_min);
2✔
2409
    }
2✔
2410

2411
    GroupBy group = parse_group_by(params.get("group"));
6!
2412

2413
    // Whole-trace, unfiltered Analyze: answer from the prebuilt summary (built
2414
    // lazily here). Concurrent builds fall through to the live scan below.
2415
    if (viz_stats_summary_eligible(params, begin, end, index)) {
2!
2416
        const VizSummary* s = co_await ensure_viz_summary(index);
8!
2417
        if (s) {
2!
2418
            const auto& gr = summary_group_rows(*s, group);
2!
2419
            std::vector<StatRow> rows;
2✔
2420
            rows.reserve(gr.size());
2!
2421
            std::uint64_t total_count = 0;
2✔
2422
            double total_dur = 0;
2✔
2423
            for (const auto& r : gr) {
11✔
2424
                rows.push_back({&r.key, r.count, r.total, r.min, r.max});
9!
2425
                total_count += r.count;
9✔
2426
                total_dur += r.total;
9✔
2427
            }
9✔
2428
            scale_stat_durations(rows, total_dur, index.time_metric());
2!
2429
            co_return HttpResponse::ok(serialize_stats_body(
4!
2430
                total_count, total_dur, original_end - original_begin, false,
2✔
2431
                rows));
2432
        }
2✔
2433
    }
2!
2434

2435
    // Full detail for stats: no min-duration threshold.
2436
    ViewDefinition view = build_viz_view(params, begin, end, 0);
×
2437

2438
    int limit = params.get_int("limit", 0);
×
2439
    if (limit < 0) limit = 0;
×
2440

2441
    std::vector<const TraceIndex::FileInfo*> target_files =
2442
        select_viz_target_files(index, params, begin, end);
×
2443

2444
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
2445
    std::vector<NameMap> partials(slots);
×
2446
    auto aggregate = [&partials, group](
×
2447
                         std::size_t w,
2448
                         const std::vector<std::string_view>& events) {
2449
        NameMap& local = partials[w];  // owned by worker w only, no lock
×
2450
        for (auto ev : events) fold_event(ev, group, local);
×
2451
    };
×
2452

2453
    bool truncated = co_await scan_view_events(
×
2454
        index, target_files, view, begin, end, limit, slots, aggregate,
×
2455
        req.cancel_token, !params.get("file").empty());
×
2456

2457
    // Single-threaded reduce of the disjoint per-worker maps.
2458
    NameMap merged;
×
2459
    for (auto& p : partials) {
×
2460
        for (auto& kv : p) {
×
2461
            auto it = merged.find(kv.first);
×
2462
            if (it == merged.end())
×
2463
                merged.emplace(kv.first, kv.second);
×
2464
            else
2465
                it->second.merge_from(kv.second);
×
2466
        }
2467
    }
2468

2469
    std::vector<StatRow> rows;
2470
    rows.reserve(merged.size());
×
2471
    std::uint64_t total_count = 0;
2472
    double total_dur = 0;
2473
    for (const auto& kv : merged) {
×
2474
        rows.push_back({&kv.first, kv.second.count, kv.second.total,
×
2475
                        kv.second.min, kv.second.max});
2476
        total_count += kv.second.count;
2477
        total_dur += kv.second.total;
2478
    }
2479
    std::sort(rows.begin(), rows.end(), [](const StatRow& a, const StatRow& b) {
×
2480
        return a.total > b.total;
×
2481
    });
2482

2483
    scale_stat_durations(rows, total_dur, index.time_metric());
×
2484
    co_return HttpResponse::ok(
×
2485
        serialize_stats_body(total_count, total_dur,
×
2486
                             original_end - original_begin, truncated, rows));
2487
}
58!
2488

2489
// GET /api/v1/viz/layers: whole-trace reference data - the operation-name ->
2490
// category map (a property of the name, not the view, so fetched once) plus the
2491
// FH file counts: total declared vs. those an I/O event actually touched.
2492
static coro::CoroTask<HttpResponse> handle_viz_layers(
16!
2493
    const HttpRequest& /*req*/, const QueryParams& /*p*/, TraceIndex& index) {
2!
2494
    const VizSummary* s = co_await ensure_viz_summary(index);
10!
2495
    auto& b = scratch_json_builder();
2!
2496
    b.start_object();
2✔
2497
    b.escape_and_append_with_quotes("layers");
2✔
2498
    b.append_colon();
2✔
2499
    b.start_object();
2✔
2500
    if (s) {
2!
2501
        bool first = true;
2✔
2502
        for (const auto& kv : s->name_cats) {
18✔
2503
            if (!first) b.append_comma();
16✔
2504
            first = false;
16✔
2505
            b.escape_and_append_with_quotes(kv.first);
16✔
2506
            b.append_colon();
16✔
2507
            b.escape_and_append_with_quotes(kv.second);
16✔
2508
        }
16✔
2509
    }
2✔
2510
    b.end_object();
2✔
2511
    b.append_comma();
2✔
2512
    b.append_key_value("total_files",
4✔
2513
                       static_cast<std::int64_t>(s ? s->total_files : 0));
2!
2514
    b.append_comma();
2✔
2515
    b.append_key_value("io_files",
4✔
2516
                       static_cast<std::int64_t>(s ? s->io_files : 0));
2!
2517
    b.end_object();
2✔
2518
    co_return HttpResponse::ok(std::string(b));
2!
2519
}
10!
2520

2521
// One scanned event, reduced to what the call-tree needs.
2522
struct FlameEv {
5✔
2523
    std::int64_t pid = 0;
5✔
2524
    std::int64_t tid = 0;
5✔
2525
    double ts = 0;
5✔
2526
    double dur = 0;
5✔
2527
    std::string name;
2528
};
2529

2530
// A node in the merged call tree: identical name-paths across all lanes fold
2531
// into one node. `total` is inclusive; `self` is total minus nested children.
2532
struct FlameNode {
189!
2533
    std::string name;
2534
    double total = 0;
189✔
2535
    double self = 0;
189✔
2536
    std::uint64_t count = 0;
189✔
2537
    dftracer::utils::StringViewMap<std::uint32_t> kids;
2538
    std::vector<std::uint32_t> children;
2539
};
2540

2541
static bool parse_flame_ev(std::string_view event, FlameEv& out) {
380✔
2542
    thread_local simdjson::dom::parser parser;
380✔
2543
    thread_local std::string buf;
380✔
2544
    buf.assign(event);
380!
2545
    auto res = parser.parse(buf);
380✔
2546
    if (res.error()) return false;
380!
2547
    auto root = res.value_unsafe();
380✔
2548
    if (!root.is_object()) return false;
380✔
2549
    auto dr = root["dur"];
380✔
2550
    if (dr.error()) return false;  // no duration: nothing to place in the tree
380!
2551
    out.dur = json_number(dr.value_unsafe());
380✔
2552
    auto tr = root["ts"];
380✔
2553
    if (tr.error()) return false;
380!
2554
    out.ts = json_number(tr.value_unsafe());
380✔
2555
    auto pr = root["pid"];
380✔
2556
    out.pid = pr.error()
380!
2557
                  ? 0
380!
2558
                  : static_cast<std::int64_t>(json_number(pr.value_unsafe()));
380✔
2559
    auto tir = root["tid"];
380✔
2560
    out.tid = tir.error()
380!
2561
                  ? 0
380!
2562
                  : static_cast<std::int64_t>(json_number(tir.value_unsafe()));
380✔
2563
    auto nr = root["name"];
380✔
2564
    if (!nr.error() && nr.is_string())
380!
2565
        out.name.assign(nr.get_string().value_unsafe());
380!
2566
    else
2567
        out.name.clear();
×
2568
    return true;
380✔
2569
}
190✔
2570

2571
static void serialize_flame_node(simdjson::builder::string_builder& sb,
198✔
2572
                                 std::vector<FlameNode>& arena,
2573
                                 std::uint32_t idx) {
2574
    FlameNode& n = arena[idx];
198✔
2575
    sb.start_object();
198✔
2576
    sb.append_key_value("name", n.name);
198✔
2577
    sb.append_comma();
198✔
2578
    sb.append_key_value("total", n.total);
198✔
2579
    sb.append_comma();
198✔
2580
    sb.append_key_value("self", n.self < 0 ? 0.0 : n.self);
198!
2581
    sb.append_comma();
198✔
2582
    sb.append_key_value("count", n.count);
198✔
2583
    std::sort(n.children.begin(), n.children.end(),
297✔
2584
              [&arena](std::uint32_t a, std::uint32_t b) {
912✔
2585
                  return arena[a].total > arena[b].total;
541✔
2586
              });
2587
    sb.append_comma();
198✔
2588
    sb.escape_and_append_with_quotes("children");
198✔
2589
    sb.append_colon();
198✔
2590
    sb.start_array();
198✔
2591
    for (std::size_t i = 0; i < n.children.size(); ++i) {
390✔
2592
        if (i > 0) sb.append_comma();
192✔
2593
        serialize_flame_node(sb, arena, n.children[i]);
192✔
2594
    }
96✔
2595
    sb.end_array();
198✔
2596
    sb.end_object();
198✔
2597
}
198✔
2598

2599
// Fold one event under `base` in `arena` via the open-stack containment walk.
2600
// kids are keyed by owned name copies (StringViewMap), so the source events may
2601
// be discarded afterwards.
2602
static void fold_flame_event(
380✔
2603
    std::vector<FlameNode>& arena,
2604
    std::vector<std::pair<double, std::uint32_t>>& open, std::uint32_t base,
2605
    const FlameEv& ev) {
2606
    double e_end = ev.ts + (ev.dur > 0 ? ev.dur : 0);
380!
2607
    while (!open.empty() && open.back().first <= ev.ts) open.pop_back();
380!
2608
    std::uint32_t parent = open.empty() ? base : open.back().second;
380!
2609
    std::uint32_t mi;
2610
    auto it = arena[parent].kids.find(ev.name);
380!
2611
    if (it == arena[parent].kids.end()) {
380✔
2612
        mi = static_cast<std::uint32_t>(arena.size());
188✔
2613
        arena.emplace_back();
188!
2614
        arena[mi].name = ev.name;
188!
2615
        arena[parent].kids.emplace(ev.name, mi);
188!
2616
        arena[parent].children.push_back(mi);
188!
2617
    } else {
94✔
2618
        mi = it->second;
192✔
2619
    }
2620
    arena[mi].total += ev.dur;
380✔
2621
    arena[mi].self += ev.dur;
380✔
2622
    arena[mi].count += 1;
380✔
2623
    if (parent != 0) arena[parent].self -= ev.dur;
380✔
2624
    open.push_back({e_end, mi});
380!
2625
}
380✔
2626

2627
// Sort one file's events by (pid,tid,ts,dur) and fold each lane into `arena`.
2628
// Lanes never cross files (one pid per rank file), so this is a complete,
2629
// self-contained partial tree for the file.
2630
static void fold_file_events(
10✔
2631
    std::vector<FlameNode>& arena,
2632
    ankerl::unordered_dense::map<std::int64_t, std::uint32_t>& proc_of,
2633
    std::vector<std::pair<double, std::uint32_t>>& open,
2634
    std::vector<FlameEv>& evs, bool by_process) {
2635
    std::sort(evs.begin(), evs.end(), [](const FlameEv& a, const FlameEv& b) {
395✔
2636
        if (a.pid != b.pid) return a.pid < b.pid;
1,205!
NEW
2637
        if (a.tid != b.tid) return a.tid < b.tid;
×
NEW
2638
        if (a.ts != b.ts) return a.ts < b.ts;
×
NEW
2639
        return a.dur > b.dur;
×
2640
    });
385✔
2641
    std::size_t i = 0;
10✔
2642
    while (i < evs.size()) {
390✔
2643
        std::int64_t pid = evs[i].pid, tid = evs[i].tid;
380✔
2644
        std::uint32_t base = 0;
380✔
2645
        if (by_process) {
380✔
2646
            auto pit = proc_of.find(pid);
140!
2647
            if (pit == proc_of.end()) {
140!
2648
                base = static_cast<std::uint32_t>(arena.size());
140✔
2649
                arena.emplace_back();
140!
2650
                arena[base].name = "P" + std::to_string(pid);
140!
2651
                proc_of.emplace(pid, base);
140!
2652
                arena[0].children.push_back(base);
140!
2653
                arena[0].kids.emplace(arena[base].name, base);
140!
2654
            } else {
70✔
NEW
2655
                base = pit->second;
×
2656
            }
2657
        }
70✔
2658
        open.clear();
380✔
2659
        for (; i < evs.size() && evs[i].pid == pid && evs[i].tid == tid; ++i)
760✔
2660
            fold_flame_event(arena, open, base, evs[i]);
380!
2661
    }
2662
}
10✔
2663

2664
// Merge partial tree `src` (subtree si) into `dst` (node di), summing stats per
2665
// name-path. src node names stay alive for the whole merge.
2666
static void merge_flame_arena(std::vector<FlameNode>& dst, std::uint32_t di,
160✔
2667
                              const std::vector<FlameNode>& src,
2668
                              std::uint32_t si) {
2669
    dst[di].total += src[si].total;
160✔
2670
    dst[di].self += src[si].self;
160✔
2671
    dst[di].count += src[si].count;
160✔
2672
    for (std::uint32_t sc : src[si].children) {
316✔
2673
        std::uint32_t dc;
2674
        auto it = dst[di].kids.find(src[sc].name);
156!
2675
        if (it == dst[di].kids.end()) {
156✔
2676
            dc = static_cast<std::uint32_t>(dst.size());
20✔
2677
            dst.emplace_back();
20!
2678
            dst[dc].name = src[sc].name;
20!
2679
            dst[di].kids.emplace(src[sc].name, dc);
20!
2680
            dst[di].children.push_back(dc);
20!
2681
        } else {
20✔
2682
            dc = it->second;
136✔
2683
        }
2684
        merge_flame_arena(dst, dc, src, sc);
156!
2685
    }
2686
}
160✔
2687

2688
// Streaming call-tree worker: claims whole files (work-stealing via
2689
// `next_file`) and folds each file's events into `out` as a partial tree,
2690
// discarding events per file so peak memory is one file's events, not the whole
2691
// trace. Heavy locals are heap-allocated to keep the coroutine frame small.
2692
static coro::CoroTask<void> calltree_stream_worker(
70!
2693
    const std::vector<const TraceIndex::FileInfo*>* files,
2694
    std::atomic<std::size_t>* next_file, TraceIndex* index,
2695
    const ViewDefinition* view, double begin, double end, bool scan_all_chunks,
2696
    bool by_process, std::int64_t cap, std::atomic<std::int64_t>* produced,
2697
    CancelToken cancel, std::vector<FlameNode>* out) {
5!
2698
    auto& arena = *out;
5✔
2699
    arena.emplace_back();  // partial root (index 0)
5!
2700
    arena[0].name = "all";
5!
2701
    auto file_buf = std::make_unique<std::vector<FlameEv>>();
5!
2702
    auto open =
5✔
2703
        std::make_unique<std::vector<std::pair<double, std::uint32_t>>>();
5!
2704
    auto proc_of = std::make_unique<
5!
2705
        ankerl::unordered_dense::map<std::int64_t, std::uint32_t>>();
2706

2707
    while (true) {
10✔
2708
        if (cancel.cancelled()) co_return;
15!
2709
        if (produced->load(std::memory_order_relaxed) >= cap) co_return;
10!
2710
        std::size_t fi = next_file->fetch_add(1, std::memory_order_relaxed);
10✔
2711
        if (fi >= files->size()) co_return;
10✔
2712
        auto* file_info = (*files)[fi];
5✔
2713
        if (file_info->uncompressed_size == 0 &&
5!
2714
            file_info->num_checkpoints == 0)
2715
            continue;
2716

2717
        ViewBuilderInput builder_input;
5✔
2718
        builder_input.with_view(*view)
10!
2719
            .with_file_path(file_info->path)
5!
2720
            .with_index_path(file_info->has_bloom_data ? file_info->index_path
5!
2721
                                                       : "")
×
2722
            .with_uncompressed_size(file_info->uncompressed_size)
5!
2723
            .with_num_checkpoints(file_info->num_checkpoints)
5!
2724
            .with_bloom_cache(&index->bloom_cache())
5!
2725
            .with_time_range(begin, end)
5!
2726
            .with_scan_all_chunks(scan_all_chunks);
5!
2727
        ViewBuilderUtility builder;
5!
2728
        auto build_output = co_await builder.process(builder_input);
10!
2729
        if (!build_output || !build_output->file_may_match) continue;
5!
2730

2731
        file_buf->clear();
5✔
2732
        for (const auto& c : build_output->candidates) {
30!
2733
            if (cancel.cancelled()) co_return;
5!
2734
            ViewReaderInput reader_input;
5✔
2735
            reader_input.with_file_path(file_info->path)
5!
2736
                .with_index_path(file_info->index_path)
5!
2737
                .with_byte_range(c.start_byte, c.end_byte)
5!
2738
                .with_checkpoint_idx(c.checkpoint_idx)
5!
2739
                .with_view(*view);
5!
2740
            ViewReaderUtility reader;
5!
2741
            auto gen = reader.process(reader_input);
5!
2742
            while (auto batch = co_await gen.next()) {
40!
2743
                FlameEv ev;
5✔
2744
                for (auto e : batch->events)
195✔
2745
                    if (parse_flame_ev(e, ev)) file_buf->push_back(ev);
190!
2746
            }
10✔
2747
        }
25✔
2748

2749
        fold_file_events(arena, *proc_of, *open, *file_buf, by_process);
5!
2750
        produced->fetch_add(static_cast<std::int64_t>(file_buf->size()),
5✔
2751
                            std::memory_order_relaxed);
2752
        file_buf->clear();
5✔
2753
    }
30!
2754
}
85!
2755

2756
// GET /api/v1/viz/calltree: merge events into a flamegraph tree. The hierarchy
2757
// per pid/tid lane comes from ts/dur containment (same nesting the timeline
2758
// draws); identical name-paths fold together across the whole trace.
2759
static coro::CoroTask<HttpResponse> handle_viz_calltree(
18!
2760
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
3!
2761
    if (!params.has("begin") || !params.has("end"))
3!
2762
        co_return HttpResponse::bad_request(
3!
2763
            "Missing required parameters: begin, end");
×
2764

2765
    double begin = params.get_double("begin", 0);
3!
2766
    double end = params.get_double("end", 0);
3!
2767

2768
    auto query = params.get("query");
3!
2769
    if (!query.empty() &&
3!
2770
        !utilities::common::query::try_parse(query).has_value())
×
2771
        co_return HttpResponse::bad_request("Invalid query: " +
×
2772
                                            std::string(query));
×
2773

2774
    auto ts_norm_param = params.get("ts_normalize");
3!
2775
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
2776
    std::uint64_t global_min = 0;
3✔
2777
    if (normalize) {
3!
2778
        global_min = index.global_min_timestamp_us();
3✔
2779
        if (global_min == std::numeric_limits<std::uint64_t>::max())
3!
2780
            global_min = 0;
2781
    }
3✔
2782
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
3!
2783
        begin = static_cast<double>(
2784
            index.us_to_native(static_cast<std::uint64_t>(begin)));
2785
        end = static_cast<double>(
2786
            index.us_to_native(static_cast<std::uint64_t>(end)));
2787
    }
2788
    if (normalize && global_min > 0) {
3!
2789
        begin += static_cast<double>(global_min);
3✔
2790
        end += static_cast<double>(global_min);
3✔
2791
    }
3✔
2792

2793
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
2794
    int limit = params.get_int("limit", 0);
3!
2795
    if (limit < 0) limit = 0;
3!
2796

2797
    std::vector<const TraceIndex::FileInfo*> target_files =
3✔
2798
        select_viz_target_files(index, params, begin, end);
3!
2799

2800
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
2801
    bool by_process = params.get("group") == "pid";
3!
2802
    bool scan_all_chunks = !params.get("file").empty();
3!
2803
    const std::int64_t cap =
6✔
2804
        limit > 0 ? limit : std::numeric_limits<std::int64_t>::max();
3!
2805

2806
    static constexpr const char* CANCELLED_TREE =
2807
        R"({"truncated":true,"tree":{"name":"all","total":0,"self":0,"count":0,"children":[]}})";
2808

2809
    // Stream the tree: each worker claims whole files and folds them into its
2810
    // own partial tree, discarding events per file (bounded memory), then the
2811
    // partials merge. Lanes never cross files, so each partial is complete.
2812
    std::size_t nworkers = std::max<std::size_t>(
6!
2813
        1, std::min(slots, target_files.empty() ? std::size_t{1}
3!
2814
                                                : target_files.size()));
3✔
2815
    std::vector<std::vector<FlameNode>> arenas(nworkers);
3!
2816
    std::atomic<std::size_t> next_file{0};
3✔
2817
    std::atomic<std::int64_t> produced{0};
3✔
2818
    CancelToken cancel = req.cancel_token;
3✔
2819

2820
    {
2821
        CoroScope scope(Executor::current());
3!
2822
        auto* files_ptr = &target_files;
3✔
2823
        auto* index_ptr = &index;
3✔
2824
        auto* view_ptr = &view;
3✔
2825
        auto* next_ptr = &next_file;
3✔
2826
        auto* produced_ptr = &produced;
3✔
2827
        for (std::size_t w = 0; w < nworkers; ++w) {
8✔
2828
            auto* out = &arenas[w];
5✔
2829
            scope.spawn([files_ptr, next_ptr, index_ptr, view_ptr, begin, end,
75!
2830
                         scan_all_chunks, by_process, cap, produced_ptr, cancel,
25✔
2831
                         out](CoroScope&) -> coro::CoroTask<void> {
10!
2832
                co_await calltree_stream_worker(files_ptr, next_ptr, index_ptr,
40!
2833
                                                view_ptr, begin, end,
15✔
2834
                                                scan_all_chunks, by_process,
15✔
2835
                                                cap, produced_ptr, cancel, out);
15✔
2836
            });
20!
2837
        }
5✔
2838
        co_await scope.join();
6!
2839
    }
3!
2840

2841
    if (cancel.cancelled()) co_return HttpResponse::ok(CANCELLED_TREE);
3!
2842
    bool truncated = limit > 0 && produced.load() >= cap;
3!
2843

2844
    for (std::size_t t = 1; t < arenas.size(); ++t)
5✔
2845
        merge_flame_arena(arenas[0], 0, arenas[t], 0);
2!
2846
    std::vector<FlameNode> arena = std::move(arenas[0]);
3✔
2847

2848
    // Process frames are synthetic containers: total/count roll up from their
2849
    // children, self is 0.
2850
    if (by_process) {
3✔
2851
        for (std::uint32_t pnode : arena[0].children) {
41✔
2852
            double t = 0;
40✔
2853
            std::uint64_t c = 0;
40✔
2854
            for (std::uint32_t ch : arena[pnode].children) {
80✔
2855
                t += arena[ch].total;
40✔
2856
                c += arena[ch].count;
40✔
2857
            }
40✔
2858
            arena[pnode].total = t;
40✔
2859
            arena[pnode].count = c;
40✔
2860
            arena[pnode].self = 0;
40✔
2861
        }
40✔
2862
    }
1✔
2863

2864
    double root_total = 0;
3✔
2865
    std::uint64_t root_count = 0;
3✔
2866
    for (std::uint32_t c : arena[0].children) {
59✔
2867
        root_total += arena[c].total;
56✔
2868
        root_count += arena[c].count;
56✔
2869
    }
56✔
2870
    arena[0].total = root_total;
3✔
2871
    arena[0].count = root_count;
3✔
2872
    arena[0].self = 0;
3✔
2873

2874
    // Node total/self are summed native durations; scale to us for display.
2875
    const double dur_us =
3✔
2876
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
3!
2877
            index.time_metric());
3✔
2878
    if (dur_us != 1.0) {
3!
2879
        for (auto& n : arena) {
×
2880
            n.total *= dur_us;
2881
            if (n.self > 0) n.self *= dur_us;
×
2882
        }
2883
    }
2884

2885
    auto& b = scratch_json_builder();
3!
2886
    b.start_object();
3✔
2887
    b.append_key_value("truncated", truncated);
3✔
2888
    b.append_comma();
3✔
2889
    b.escape_and_append_with_quotes("tree");
3✔
2890
    b.append_colon();
3✔
2891
    serialize_flame_node(b, arena, 0);
3!
2892
    b.end_object();
3✔
2893
    co_return HttpResponse::ok(std::string(b));
3!
2894
}
15!
2895

2896
// One log-spaced duration bucket of the histogram response.
2897
struct HistBucket {
2898
    double lo;
2899
    double hi;
2900
    std::uint64_t count;
2901
};
2902

2903
template <typename builder_type>
2904
void tag_invoke(simdjson::serialize_tag, builder_type& b, const HistBucket& h) {
80✔
2905
    b.start_object();
80✔
2906
    b.append_key_value("lo", h.lo);
80✔
2907
    b.append_comma();
80✔
2908
    b.append_key_value("hi", h.hi);
80✔
2909
    b.append_comma();
80✔
2910
    b.append_key_value("count", h.count);
80✔
2911
    b.end_object();
80✔
2912
}
80✔
2913

2914
// GET /api/v1/viz/histogram: the distribution of event durations matching the
2915
// query in [begin, end]. Collects each matching dur, then reports exact
2916
// percentiles and a log-spaced histogram of the shape. The caller narrows to
2917
// one operation by folding its predicate (name == "...") into the query.
2918
static coro::CoroTask<HttpResponse> handle_viz_histogram(
8!
2919
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
1!
2920
    if (!params.has("begin") || !params.has("end"))
3!
2921
        co_return HttpResponse::bad_request(
5!
2922
            "Missing required parameters: begin, end");
4!
2923

2924
    double begin = params.get_double("begin", 0);
3✔
2925
    double end = params.get_double("end", 0);
3✔
2926

2927
    auto query = params.get("query");
3✔
2928
    if (!query.empty() &&
3!
2929
        !utilities::common::query::try_parse(query).has_value())
×
2930
        co_return HttpResponse::bad_request("Invalid query: " +
×
2931
                                            std::string(query));
×
2932

2933
    auto ts_norm_param = params.get("ts_normalize");
3✔
2934
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
2935
    std::uint64_t global_min = 0;
3✔
2936
    if (normalize) {
3✔
2937
        global_min = index.global_min_timestamp_us();
1✔
2938
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
2939
            global_min = 0;
2940
    }
1✔
2941
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
3!
2942
        begin = static_cast<double>(
2943
            index.us_to_native(static_cast<std::uint64_t>(begin)));
2944
        end = static_cast<double>(
2945
            index.us_to_native(static_cast<std::uint64_t>(end)));
2946
    }
2947
    if (normalize && global_min > 0) {
3!
2948
        begin += static_cast<double>(global_min);
1✔
2949
        end += static_cast<double>(global_min);
1✔
2950
    }
1✔
2951

2952
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
2953
    int limit = params.get_int("limit", 0);
3✔
2954
    if (limit < 0) limit = 0;
3!
2955
    int nbuckets = params.get_int("buckets", 40);
3✔
2956
    nbuckets = std::clamp(nbuckets, 4, 200);
3!
2957

2958
    std::vector<const TraceIndex::FileInfo*> target_files =
3✔
2959
        select_viz_target_files(index, params, begin, end);
3!
2960

2961
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
2962
    std::vector<std::vector<double>> partials(slots);
3!
2963
    auto collect = [&partials](std::size_t w,
5✔
2964
                               const std::vector<std::string_view>& events) {
1✔
2965
        thread_local simdjson::dom::parser parser;
2✔
2966
        thread_local std::string buf;
2✔
2967
        auto& out = partials[w];  // worker-owned slot, no lock
2✔
2968
        for (auto e : events) {
102✔
2969
            buf.assign(e);
100!
2970
            auto res = parser.parse(buf);
100✔
2971
            if (res.error()) continue;
100!
2972
            auto root = res.value_unsafe();
100✔
2973
            if (!root.is_object()) continue;
100✔
2974
            auto dr = root["dur"];
100✔
2975
            if (dr.error()) continue;
100!
2976
            out.push_back(json_number(dr.value_unsafe()));
100!
2977
        }
2978
    };
2✔
2979

2980
    bool truncated = co_await scan_view_events(
5!
2981
        index, target_files, view, begin, end, limit, slots, collect,
3!
2982
        req.cancel_token, !params.get("file").empty());
3!
2983

2984
    std::vector<double> all;
1✔
2985
    std::size_t total_n = 0;
1✔
2986
    for (auto& p : partials) total_n += p.size();
3✔
2987
    all.reserve(total_n);
1!
2988
    for (auto& p : partials)
3✔
2989
        for (double d : p) all.push_back(d);
52!
2990
    std::sort(all.begin(), all.end());
1!
2991

2992
    // Durations are in the trace's native unit; scale to us for display.
2993
    const double dur_us =
1✔
2994
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
1!
2995
            index.time_metric());
1✔
2996
    if (dur_us != 1.0)
1!
2997
        for (double& d : all) d *= dur_us;
×
2998

2999
    auto& sb = scratch_json_builder();
1!
3000
    if (all.empty()) {
1!
3001
        sb.start_object();
3002
        sb.append_key_value("count", 0);
3003
        sb.append_comma();
3004
        sb.escape_and_append_with_quotes("buckets");
3005
        sb.append_colon();
3006
        sb.start_array();
3007
        sb.end_array();
3008
        sb.append_comma();
3009
        sb.append_key_value("truncated", truncated);
3010
        sb.end_object();
3011
        co_return HttpResponse::ok(std::string(sb));
×
3012
    }
3013

3014
    std::size_t n = all.size();
1✔
3015
    double vmin = all.front();
1✔
3016
    double vmax = all.back();
1✔
3017
    double sum = 0;
1✔
3018
    for (double d : all) sum += d;
51✔
3019
    double mean = sum / static_cast<double>(n);
1✔
3020
    auto pct = [&all, n](double p) {
9✔
3021
        std::size_t idx =
8✔
3022
            static_cast<std::size_t>(p * static_cast<double>(n - 1));
8✔
3023
        return all[idx];
8✔
3024
    };
3025

3026
    // Log-spaced buckets over [max(vmin,1), vmax]; sub-microsecond durs land in
3027
    // the first bucket.
3028
    double lo = std::max(vmin, 1.0);
1!
3029
    double hi = std::max(vmax, lo * 1.0000001);
1!
3030
    double lr = std::log(hi / lo);
1✔
3031
    std::vector<std::uint64_t> counts(static_cast<std::size_t>(nbuckets), 0);
1!
3032
    for (double d : all) {
51✔
3033
        double v = d < lo ? lo : d;
50!
3034
        std::size_t bi =
100✔
3035
            lr > 0 ? static_cast<std::size_t>(static_cast<double>(nbuckets) *
50!
3036
                                              std::log(v / lo) / lr)
100✔
3037
                   : 0;
3038
        if (bi >= static_cast<std::size_t>(nbuckets)) bi = nbuckets - 1;
50✔
3039
        counts[bi]++;
50✔
3040
    }
50✔
3041

3042
    sb.start_object();
1✔
3043
    sb.append_key_value("count", n);
1✔
3044
    sb.append_comma();
1✔
3045
    sb.append_key_value("min", vmin);
1✔
3046
    sb.append_comma();
1✔
3047
    sb.append_key_value("max", vmax);
1✔
3048
    sb.append_comma();
1✔
3049
    sb.append_key_value("mean", mean);
1✔
3050
    sb.append_comma();
1✔
3051
    sb.append_key_value("p50", pct(0.50));
1!
3052
    sb.append_comma();
1✔
3053
    sb.append_key_value("p90", pct(0.90));
1!
3054
    sb.append_comma();
1✔
3055
    sb.append_key_value("p95", pct(0.95));
1!
3056
    sb.append_comma();
1✔
3057
    sb.append_key_value("p99", pct(0.99));
1!
3058
    sb.append_comma();
1✔
3059
    sb.append_key_value("truncated", truncated);
1✔
3060
    sb.append_comma();
1✔
3061
    std::vector<HistBucket> buckets;
1✔
3062
    buckets.reserve(static_cast<std::size_t>(nbuckets));
1!
3063
    for (int i = 0; i < nbuckets; ++i) {
41✔
3064
        buckets.push_back(
40!
3065
            {lo * std::exp(lr * static_cast<double>(i) / nbuckets),
120✔
3066
             lo * std::exp(lr * static_cast<double>(i + 1) / nbuckets),
40✔
3067
             counts[static_cast<std::size_t>(i)]});
40✔
3068
    }
40✔
3069
    sb.append_key_value("buckets", buckets);
1!
3070
    sb.end_object();
1✔
3071
    co_return HttpResponse::ok(std::string(sb));
1!
3072
}
37!
3073

3074
// A density block as it appears in the response: the map key/aggregate pair
3075
// flattened with `ts` resolved from the column index. `name` borrows the
3076
// aggregate's storage.
3077
struct DensityBlock {
3078
    std::string_view name;
3079
    std::int64_t pid;
3080
    std::int64_t tid;
3081
    double ts;
3082
    double dur;
3083
    std::uint32_t count;
3084
    double total;
3085
    std::uint32_t depth;
3086
    std::string_view group;  // group_by value; omitted from JSON when empty
3087
};
3088

3089
template <typename builder_type>
3090
void tag_invoke(simdjson::serialize_tag, builder_type& b,
360✔
3091
                const DensityBlock& d) {
3092
    b.start_object();
360✔
3093
    if (!d.group.empty()) {
360✔
3094
        b.append_key_value("group", d.group);
80✔
3095
        b.append_comma();
80✔
3096
    }
40✔
3097
    b.append_key_value("name", d.name);
360✔
3098
    b.append_comma();
360✔
3099
    b.append_key_value("pid", d.pid);
360✔
3100
    b.append_comma();
360✔
3101
    b.append_key_value("tid", d.tid);
360✔
3102
    b.append_comma();
360✔
3103
    b.append_key_value("ts", d.ts);
360✔
3104
    b.append_comma();
360✔
3105
    b.append_key_value("dur", d.dur);
360✔
3106
    b.append_comma();
360✔
3107
    b.append_key_value("count", d.count);
360✔
3108
    b.append_comma();
360✔
3109
    b.append_key_value("total", d.total);
360✔
3110
    b.append_comma();
360✔
3111
    b.append_key_value("depth", d.depth);
360✔
3112
    b.end_object();
360✔
3113
}
360✔
3114

3115
// Serialize collected density blocks (+ optional individual events) into the
3116
// /viz/density response body. Shared by the live-scan and summary paths.
3117
static std::string serialize_density_body(
8✔
3118
    const std::vector<std::string>& big, const DensityMap& dens,
3119
    double original_begin, double original_end, double threshold, int limit,
3120
    bool truncated, bool ts_normalized, std::uint64_t display_global_min,
3121
    double max_dur, TraceIndex::TimeMetric metric,
3122
    const std::vector<std::uint32_t>* big_depth = nullptr,
3123
    const ankerl::unordered_dense::map<std::string, std::string>* group_names =
3124
        nullptr) {
3125
    // Blocks are positioned/sized in native threshold units; scale the visible
3126
    // time fields (block ts/dur, duration sums, max_dur) to microseconds.
3127
    const double us =
4✔
3128
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
8✔
3129
            metric);
4✔
3130
    const double threshold_us = threshold * us;
8✔
3131
    max_dur *= us;
8✔
3132
    auto& b = scratch_json_builder();
8✔
3133
    b.start_object();
8✔
3134
    b.escape_and_append_with_quotes("events");
8✔
3135
    b.append_colon();
8✔
3136
    b.start_array();
8✔
3137
    for (std::size_t i = 0; i < big.size(); ++i) {
8!
3138
        if (i > 0) b.append_comma();
×
3139
        // Inject the server-computed depth as a sibling field (events end in
3140
        // }).
3141
        if (big_depth && i < big_depth->size() && !big[i].empty() &&
×
3142
            big[i].back() == '}') {
×
3143
            b.append_raw(std::string_view(big[i]).substr(0, big[i].size() - 1));
×
3144
            b.append_raw(",\"depth\":");
×
3145
            b.append(static_cast<std::uint64_t>((*big_depth)[i]));
×
3146
            b.append_raw("}");
×
3147
        } else {
3148
            b.append_raw(big[i]);
×
3149
        }
3150
    }
3151
    b.end_array();
8✔
3152
    b.append_comma();
8✔
3153
    std::vector<DensityBlock> blocks;
8✔
3154
    blocks.reserve(dens.size());
8!
3155
    for (const auto& [k, a] : dens) {
368✔
3156
        blocks.push_back(
540!
3157
            {a.name, k.pid, k.tid,
1,260✔
3158
             original_begin + static_cast<double>(k.col) * threshold_us,
360✔
3159
             threshold_us, a.count, a.total * us, a.depth, k.group});
1,080✔
3160
    }
3161
    b.append_key_value("density", blocks);
8!
3162
    b.append_comma();
8✔
3163
    b.escape_and_append_with_quotes("metadata");
8✔
3164
    b.append_colon();
8✔
3165
    b.start_object();
8✔
3166
    b.append_key_value("begin", original_begin);
8✔
3167
    b.append_comma();
8✔
3168
    b.append_key_value("end", original_end);
8✔
3169
    b.append_comma();
8✔
3170
    b.append_key_value("count", big.size());
8✔
3171
    b.append_comma();
8✔
3172
    b.append_key_value("limit", limit);
8✔
3173
    b.append_comma();
8✔
3174
    b.append_key_value("density_count", dens.size());
8✔
3175
    b.append_comma();
8✔
3176
    b.append_key_value("truncated", truncated);
8✔
3177
    b.append_comma();
8✔
3178
    b.append_key_value("ts_normalized", ts_normalized);
8✔
3179
    b.append_comma();
8✔
3180
    b.append_key_value("global_min_timestamp_us", display_global_min);
8✔
3181
    b.append_comma();
8✔
3182
    b.append_key_value("max_dur", max_dur);
8✔
3183
    if (group_names && !group_names->empty()) {
8!
NEW
3184
        b.append_comma();
×
NEW
3185
        b.escape_and_append_with_quotes("group_names");
×
NEW
3186
        b.append_colon();
×
NEW
3187
        b.start_object();
×
NEW
3188
        bool first = true;
×
NEW
3189
        for (const auto& [hash, name] : *group_names) {
×
NEW
3190
            if (!first) b.append_comma();
×
NEW
3191
            first = false;
×
NEW
3192
            b.escape_and_append_with_quotes(hash);
×
NEW
3193
            b.append_colon();
×
NEW
3194
            b.escape_and_append_with_quotes(name);
×
3195
        }
NEW
3196
        b.end_object();
×
3197
    }
3198
    b.end_object();
8✔
3199
    b.end_object();
8✔
3200
    return std::string(b);
12!
3201
}
8✔
3202

3203
// The summary is unfiltered, so any server-side predicate forces a live scan.
3204
// pid/tid are exempt: they select whole lanes, which the summary can still do.
3205
static bool viz_summary_eligible(const QueryParams& params) {
42✔
3206
    return params.get("query").empty() && params.get("cat").empty() &&
105!
3207
           params.get("lanes").empty() && params.get("filters").empty() &&
76!
3208
           params.get("file").empty() && params.get("group_by").empty();
72!
3209
}
3210

3211
static coro::CoroTask<void> append_app_spans(std::vector<std::string>& out,
70!
3212
                                             TraceIndex& index, double begin,
3213
                                             double end,
3214
                                             const QueryParams& params) {
10!
3215
    if (!viz_summary_eligible(params)) co_return;
30!
3216
    const VizSummary* s = co_await ensure_viz_summary(index);
20!
3217
    if (!s) co_return;
5!
3218
    auto pid_s = params.get("pid");
5!
3219
    auto tid_s = params.get("tid");
5!
3220
    bool has_pid = !pid_s.empty();
5✔
3221
    bool has_tid = !tid_s.empty();
5✔
3222
    std::int64_t want_pid =
10✔
3223
        has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
5!
3224
    std::int64_t want_tid =
10✔
3225
        has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
5!
3226
    for (const auto& sp : s->app_spans) {
5!
3227
        if (has_pid && sp.pid != want_pid) continue;
×
3228
        if (has_tid && sp.tid != want_tid) continue;
×
3229
        if (static_cast<double>(sp.end) > begin &&
×
3230
            static_cast<double>(sp.begin) < end)
3231
            out.push_back(sp.json);
×
3232
    }
×
3233
}
35!
3234

3235
// Re-aggregate one pyramid level's buckets over [begin_abs, end_abs] into
3236
// pixel-column density blocks. `begin_abs`/`end_abs` are absolute us.
3237
static std::string serve_density_from_summary(
2✔
3238
    const VizSummary& s, const VizSummary::Level& level,
3239
    const QueryParams& params, double begin_abs, double end_abs,
3240
    double original_begin, double original_end, double threshold,
3241
    bool ts_normalized, std::uint64_t display_global_min,
3242
    TraceIndex::TimeMetric metric) {
3243
    auto pid_s = params.get("pid");
2!
3244
    auto tid_s = params.get("tid");
2!
3245
    bool has_pid = !pid_s.empty();
2✔
3246
    bool has_tid = !tid_s.empty();
2✔
3247
    std::int64_t want_pid =
1✔
3248
        has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
2!
3249
    std::int64_t want_tid =
1✔
3250
        has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
2!
3251

3252
    std::int64_t b0 = s.bucket_of(begin_abs, level.bucket_us, level.nbuckets);
2✔
3253
    std::int64_t b1 = s.bucket_of(end_abs, level.bucket_us, level.nbuckets);
2✔
3254
    if (b0 < 0) b0 = 0;
2✔
3255
    if (b1 < 0) b1 = static_cast<std::int64_t>(level.nbuckets) - 1;
2✔
3256

3257
    DensityMap dens;
2!
3258
    auto add_cell = [&](std::int64_t pid, std::int64_t tid, std::int64_t col,
1✔
3259
                        std::uint64_t count, double total,
3260
                        const VizSummary::Cell& cell) {
NEW
3261
        if (count == 0 && total <= 0) return;
×
NEW
3262
        auto& a = dens[DensityKey{pid, tid, col, {}}];
×
NEW
3263
        a.count += count;
×
NEW
3264
        a.total += total;
×
NEW
3265
        if (static_cast<double>(cell.max_dur) > a.max_dur) {
×
NEW
3266
            a.max_dur = static_cast<double>(cell.max_dur);
×
NEW
3267
            if (cell.name_id < s.names.size()) a.name = s.names[cell.name_id];
×
3268
        }
3269
    };
1✔
3270
    for (const auto& lane : level.lanes) {
2!
UNCOV
3271
        if (has_pid && lane.pid != want_pid) continue;
×
UNCOV
3272
        if (has_tid && lane.tid != want_tid) continue;
×
NEW
3273
        auto lo = std::lower_bound(lane.buckets.begin(), lane.buckets.end(),
×
NEW
3274
                                   static_cast<std::uint32_t>(b0));
×
NEW
3275
        for (auto it = lo;
×
NEW
3276
             it != lane.buckets.end() && *it <= static_cast<std::uint32_t>(b1);
×
NEW
3277
             ++it) {
×
3278
            const auto& cell =
NEW
3279
                lane.cells[static_cast<std::size_t>(it - lane.buckets.begin())];
×
3280
            // A bucket never exceeds one column, so it falls in one column or
3281
            // straddles two. Split it by overlap rather than dumping it whole
3282
            // into the column holding its center, which would misplace a third
3283
            // of the events once buckets and columns are of similar size.
NEW
3284
            double bstart = static_cast<double>(s.t_begin) +
×
NEW
3285
                            static_cast<double>(*it) * level.bucket_us;
×
UNCOV
3286
            std::int64_t col =
×
NEW
3287
                static_cast<std::int64_t>((bstart - begin_abs) / threshold);
×
NEW
3288
            double edge = begin_abs + static_cast<double>(col + 1) * threshold;
×
NEW
3289
            double head = edge - bstart;
×
NEW
3290
            if (head >= level.bucket_us) {
×
NEW
3291
                add_cell(lane.pid, lane.tid, col, cell.count,
×
NEW
3292
                         static_cast<double>(cell.total), cell);
×
NEW
3293
                continue;
×
3294
            }
NEW
3295
            double f = head / level.bucket_us;
×
3296
            auto c0 = static_cast<std::uint64_t>(
NEW
3297
                std::llround(static_cast<double>(cell.count) * f));
×
NEW
3298
            if (c0 > cell.count) c0 = cell.count;
×
NEW
3299
            double t0 = static_cast<double>(cell.total) * f;
×
NEW
3300
            add_cell(lane.pid, lane.tid, col, c0, t0, cell);
×
NEW
3301
            add_cell(lane.pid, lane.tid, col + 1, cell.count - c0,
×
NEW
3302
                     static_cast<double>(cell.total) - t0, cell);
×
3303
        }
3304
    }
3305

3306
    // Long events at least one pixel wide are drawn as real spans; the rest
3307
    // fold into density blocks, which is the split a live scan makes. The
3308
    // widest win when there are more than a response can carry.
3309
    std::vector<const VizSummary::AppSpan*> wide;
2✔
3310
    auto fold_span = [&](const VizSummary::AppSpan& sp) {
101✔
3311
        auto dur = static_cast<double>(sp.end - sp.begin);
100✔
3312
        std::int64_t col = static_cast<std::int64_t>(
100✔
3313
            (static_cast<double>(sp.begin) - begin_abs) / threshold);
100✔
3314
        auto& a = dens[DensityKey{sp.pid, sp.tid, col, {}}];
100!
3315
        a.count += 1;
100✔
3316
        a.total += dur;
100✔
3317
        if (dur > a.max_dur) {
100✔
3318
            a.max_dur = dur;
100✔
3319
            if (sp.name_id < s.names.size()) a.name = s.names[sp.name_id];
100✔
3320
        }
50✔
3321
    };
100✔
3322
    for (const auto& sp : s.long_events) {
102✔
3323
        if (has_pid && sp.pid != want_pid) continue;
100!
3324
        if (has_tid && sp.tid != want_tid) continue;
100!
3325
        if (static_cast<double>(sp.end) <= begin_abs ||
100!
3326
            static_cast<double>(sp.begin) >= end_abs)
100!
NEW
3327
            continue;
×
3328
        if (static_cast<double>(sp.end - sp.begin) >= threshold)
100✔
NEW
3329
            wide.push_back(&sp);
×
3330
        else
3331
            fold_span(sp);
100!
3332
    }
3333
    if (wide.size() > VizSummary::MAX_RESPONSE_SPANS) {
2!
NEW
3334
        std::nth_element(
×
NEW
3335
            wide.begin(), wide.begin() + VizSummary::MAX_RESPONSE_SPANS,
×
3336
            wide.end(),
NEW
3337
            [](const VizSummary::AppSpan* a, const VizSummary::AppSpan* b) {
×
NEW
3338
                return a->end - a->begin > b->end - b->begin;
×
3339
            });
NEW
3340
        for (std::size_t i = VizSummary::MAX_RESPONSE_SPANS; i < wide.size();
×
3341
             ++i)
NEW
3342
            fold_span(*wide[i]);
×
NEW
3343
        wide.resize(VizSummary::MAX_RESPONSE_SPANS);
×
3344
    }
3345

3346
    std::vector<std::string> spans;
2✔
3347
    ankerl::unordered_dense::set<std::int64_t> span_lanes;
2!
3348
    for (const auto& sp : s.app_spans) {
2✔
3349
        if (has_pid && sp.pid != want_pid) continue;
×
3350
        if (has_tid && sp.tid != want_tid) continue;
×
3351
        if (static_cast<double>(sp.end) <= begin_abs ||
×
3352
            static_cast<double>(sp.begin) >= end_abs)
×
3353
            continue;
×
3354
        span_lanes.insert((sp.pid << 20) ^ sp.tid);
×
NEW
3355
        spans.push_back(
×
NEW
3356
            ts_normalized && display_global_min > 0
×
NEW
3357
                ? normalize_event_ts(sp.json, display_global_min, metric)
×
NEW
3358
                : sp.json);
×
3359
    }
3360
    // App spans get an injected depth 0; long events after them do not, so the
3361
    // client stacks overlapping wide events instead of piling them on one row.
3362
    const std::size_t napp_spans = spans.size();
2✔
3363
    spans.reserve(spans.size() + wide.size());
2!
3364
    for (const auto* sp : wide) {
2✔
NEW
3365
        span_lanes.insert((sp->pid << 20) ^ sp->tid);
×
NEW
3366
        spans.push_back(
×
NEW
3367
            ts_normalized && display_global_min > 0
×
NEW
3368
                ? normalize_event_ts(sp->json, display_global_min, metric)
×
NEW
3369
                : sp->json);
×
3370
    }
3371
    // Folded child activity sits one row below its app span, matching the
3372
    // containment nesting the live path computes.
3373
    if (!span_lanes.empty())
2✔
3374
        for (auto& [k, a] : dens)
×
3375
            if (span_lanes.count((k.pid << 20) ^ k.tid)) a.depth = 1;
×
3376

3377
    std::vector<std::uint32_t> span_depth(napp_spans, 0);
2!
3378
    return serialize_density_body(
1!
3379
        spans, dens, original_begin, original_end, threshold, 0, false,
1✔
3380
        ts_normalized, display_global_min, static_cast<double>(s.max_dur),
2✔
3381
        metric, &span_depth);
3!
3382
}
2✔
3383

3384
// GET /api/v1/viz/density: like /viz/events, but instead of dropping sub-pixel
3385
// events it buckets them per (pid, tid, pixel-column) into density blocks so
3386
// zoomed-out views still show where activity is. Returns full-size events (with
3387
// args, for the detail panel) plus the aggregated density blocks.
3388
static coro::CoroTask<HttpResponse> handle_viz_density(
54!
3389
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
5!
3390
    if (!params.has("begin") || !params.has("end")) {
5!
3391
        co_return HttpResponse::bad_request(
5!
3392
            "Missing required parameters: begin, end");
×
3393
    }
3394

3395
    double begin = params.get_double("begin", 0);
5!
3396
    double end = params.get_double("end", 0);
5!
3397
    int summary = params.get_int("summary", 2);
5!
3398
    if (summary < 1) summary = 1;
5!
3399

3400
    auto query = params.get("query");
5!
3401
    if (!query.empty() &&
5!
3402
        !utilities::common::query::try_parse(query).has_value()) {
×
3403
        co_return HttpResponse::bad_request("Invalid query: " +
×
3404
                                            std::string(query));
×
3405
    }
3406

3407
    // Optional density grouping column. A well-formed name that matches no
3408
    // event field is fine (all blocks land in the client's "(none)" group);
3409
    // only malformed names are rejected.
3410
    std::string group_col(params.get("group_by"));
5!
3411
    if (group_col.size() > 64)
5!
3412
        co_return HttpResponse::bad_request("group_by too long");
×
3413
    for (char c : group_col) {
26✔
3414
        if (!std::isalnum(static_cast<unsigned char>(c)) && c != '_' &&
21!
3415
            c != '.' && c != '-')
1!
3416
            co_return HttpResponse::bad_request("Invalid group_by column");
1!
3417
    }
21✔
3418

3419
    // Hash columns auto-resolve for display: group values stay raw hashes, and
3420
    // a group_names map (hash -> resolved name) rides along in the metadata.
3421
    // resolved.*/r.* aliases from the query DSL map to their hash columns.
3422
    using HashType = utilities::indexer::IndexDatabase::HashType;
3423
    std::optional<HashType> resolve_type;
12✔
3424
    {
3425
        // "args.fhash" behaves like "fhash": bare names fall back into args
3426
        // during extraction, and the resolve mapping matches either spelling.
3427
        if (group_col.rfind("args.", 0) == 0 &&
12!
3428
            group_col.find('.', 5) == std::string::npos)
3429
            group_col = group_col.substr(5);
×
3430
        auto is = [&](std::string_view a) { return group_col == a; };
108✔
3431
        if (is("resolved.fpath") || is("r.fpath"))
12!
3432
            group_col = "fhash";
2✔
3433
        else if (is("resolved.cwd") || is("r.cwd"))
4!
3434
            group_col = "cwd";
×
3435
        else if (is("resolved.hostname") || is("r.hostname") ||
4!
3436
                 is("resolved.host") || is("r.host"))
4!
3437
            group_col = "hhash";
×
3438
        else if (is("resolved.exec") || is("r.exec"))
4!
3439
            group_col = "exec_hash";
×
3440
        else if (is("resolved.cmd") || is("r.cmd"))
4!
3441
            group_col = "cmd_hash";
×
3442
        if (group_col == "fhash" || group_col == "cwd")
12!
3443
            resolve_type = HashType::FILE;
8!
3444
        else if (group_col == "hhash")
4!
3445
            resolve_type = HashType::HOST;
×
3446
        else if (group_col == "shash" || group_col == "exec_hash" ||
4!
3447
                 group_col == "cmd_hash")
4✔
3448
            resolve_type = HashType::STRING;
×
3449
    }
12✔
3450

3451
    auto ts_norm_param = params.get("ts_normalize");
12✔
3452
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
12!
3453
    std::uint64_t global_min = 0;
12✔
3454
    if (normalize) {
12✔
3455
        global_min = index.global_min_timestamp_us();
4✔
3456
        if (global_min == std::numeric_limits<std::uint64_t>::max())
4!
3457
            global_min = 0;
3458
    }
4✔
3459
    double original_begin = begin;
12✔
3460
    double original_end = end;
12✔
3461
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
12!
3462
        begin = static_cast<double>(
3463
            index.us_to_native(static_cast<std::uint64_t>(begin)));
3464
        end = static_cast<double>(
3465
            index.us_to_native(static_cast<std::uint64_t>(end)));
3466
    }
3467
    if (normalize && global_min > 0) {
12!
3468
        begin += static_cast<double>(global_min);
4✔
3469
        end += static_cast<double>(global_min);
4✔
3470
    }
4✔
3471

3472
    // Bucket width (== the ~1px cutoff), in the client's actual pixels.
3473
    int width = params.get_int("width", DEFAULT_VIEWPORT_WIDTH);
12!
3474
    width = std::clamp(width, MIN_VIEWPORT_WIDTH, MAX_VIEWPORT_WIDTH);
4✔
3475
    double threshold =
24✔
3476
        duration_threshold(begin, end, static_cast<unsigned>(summary),
24✔
3477
                           static_cast<unsigned>(width));
12✔
3478

3479
    // Unfiltered views come from the prebuilt summary pyramid (no event cap),
3480
    // built lazily here; concurrent requests fall through to a live scan, as do
3481
    // zooms finer than the pyramid's finest level.
3482
    if (threshold > 0 && viz_summary_eligible(params)) {
12!
3483
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
3484
        if (s && s->t_end > s->t_begin) {
1!
3485
            if (const VizSummary::Level* level = s->level_for(threshold)) {
2!
3486
                DFTRACER_UTILS_LOG_DEBUG(
1!
3487
                    "viz: density threshold %.0f us served from level "
3488
                    "%.0f us",
3489
                    threshold, level->bucket_us);
3490
                co_return HttpResponse::ok(serve_density_from_summary(
1!
3491
                    *s, *level, params, begin, end, original_begin,
1✔
3492
                    original_end, threshold, global_min > 0,
1✔
3493
                    index.native_to_us(index.global_min_timestamp_us()),
1!
3494
                    index.time_metric()));
1✔
3495
            }
3496
        }
3497
    }
1!
3498

3499
    // summary=1 means "no aggregation", but the viewport is still finite.
3500
    // Fold sub-pixel events into density blocks instead of dropping them.
3501
    if (threshold <= 0 && end > begin)
9!
3502
        threshold = (end - begin) / static_cast<double>(width);
1✔
3503

3504
    // Enclosing events are fetched by a separate pass (below) so a large
3505
    // `lookback` can never starve the in-window scan's budget.
3506
    double lookback = params.get_double("lookback", 0);
9✔
3507
    if (lookback < 0) lookback = 0;
9!
3508

3509
    // The client feeds back the longest event in the trace as `lookback`, so
3510
    // honoring it literally rescans everything before the window - 24s against
3511
    // 0.5s for the window itself on a large trace. Every event that wide is
3512
    // already in the summary's long-event list, so take enclosers from there
3513
    // and scan back only far enough to catch the ones too short to be listed.
3514
    const VizSummary* enc_summary = nullptr;
9✔
3515
    if (lookback > 0 && viz_summary_eligible(params)) {
9!
3516
        const VizSummary* s = co_await ensure_viz_summary(index);
×
3517
        if (s != nullptr && s->long_threshold_us > 0) {
×
3518
            enc_summary = s;
3519
            lookback = std::min(lookback, s->long_threshold_us);
×
3520
        }
3521
    }
×
3522

3523
    double scan_begin = begin - lookback;
9✔
3524
    if (scan_begin < 0) scan_begin = 0;
9!
3525

3526
    int limit = params.get_int("limit", 0);
9✔
3527
    if (limit < 0) limit = 0;
9!
3528

3529
    struct Acc {
6✔
3530
        std::vector<std::string> big;
3531
        std::vector<double> big_dur;  // parallel to `big`
3532
        DensityMap dens;
3533
        double max_dur = 0;
6✔
3534
    };
3535
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
9!
3536
    std::vector<Acc> accs(slots);
9!
3537

3538
    // Pass 1 (in-window): scan [begin, end] with the full budget so earlier
3539
    // events never consume it. Small events fold into density blocks.
3540
    auto on_batch = [&accs, &group_col, threshold, begin](
19✔
3541
                        std::size_t w,
3542
                        const std::vector<std::string_view>& events) {
385✔
3543
        Acc& acc = accs[w];  // worker-owned slot, no lock
10✔
3544
        for (auto ev : events) {
390✔
3545
            double dur = 0;
380✔
3546
            if (!fold_density(ev, threshold, begin, acc.dens, &dur,
570!
3547
                              group_col)) {
190✔
3548
                acc.big.emplace_back(ev);
×
3549
                acc.big_dur.push_back(dur);
×
3550
            }
3551
            if (dur > acc.max_dur) acc.max_dur = dur;
380✔
3552
        }
3553
    };
10✔
3554
    ViewDefinition win_view = build_viz_view(params, begin, end, 0);
9!
3555
    std::vector<const TraceIndex::FileInfo*> win_files =
9✔
3556
        select_viz_target_files(index, params, begin, end);
9!
3557
    bool single_file = !params.get("file").empty();
9✔
3558
    bool truncated = co_await scan_view_events(
12!
3559
        index, win_files, win_view, begin, end, limit, slots, on_batch,
9!
3560
        req.cancel_token, single_file);
9✔
3561

3562
    // Pass 2 (enclosers): keep only events still open at `begin`
3563
    // (ts < begin <= ts + dur); these ancestor bars set containment depth.
3564
    if (scan_begin < begin) {
9!
3565
        // Wide enclosers come from the summary below; excluding them here
3566
        // keeps them from being served twice.
3567
        double skip_from = enc_summary != nullptr
×
3568
                               ? enc_summary->long_threshold_us
3569
                               : std::numeric_limits<double>::infinity();
NEW
3570
        auto on_batch_enc = [&accs, begin, skip_from](
×
3571
                                std::size_t w,
3572
                                const std::vector<std::string_view>& events) {
3573
            Acc& acc = accs[w];
×
3574
            for (auto ev : events) {
×
3575
                double ts = 0, dur = 0;
×
3576
                if (!parse_ts_dur(ev, ts, dur)) continue;
×
3577
                if (dur > acc.max_dur) acc.max_dur = dur;
×
NEW
3578
                if (ts < begin && ts + dur > begin && dur < skip_from) {
×
3579
                    acc.big.emplace_back(ev);
×
3580
                    acc.big_dur.push_back(dur);
×
3581
                }
3582
            }
3583
        };
×
3584
        ViewDefinition enc_view = build_viz_view(params, scan_begin, begin, 0);
×
3585
        std::vector<const TraceIndex::FileInfo*> enc_files =
3586
            select_viz_target_files(index, params, scan_begin, begin);
×
3587
        bool enc_trunc = co_await scan_view_events(
×
3588
            index, enc_files, enc_view, scan_begin, begin, limit, slots,
3589
            on_batch_enc, req.cancel_token, single_file);
×
3590
        truncated = truncated || enc_trunc;
×
3591
    }
×
3592

3593
    std::vector<std::string> big;
9✔
3594
    std::vector<double> big_dur;
9✔
3595
    DensityMap dens;
9!
3596
    // Longest event scanned (folded ones included); the client feeds it back as
3597
    // `lookback` so deep zooms still catch long enclosing events.
3598
    double max_dur = 0;
9✔
3599
    for (auto& acc : accs) {
15✔
3600
        if (acc.max_dur > max_dur) max_dur = acc.max_dur;
6✔
3601
        for (auto& d : acc.big_dur) big_dur.push_back(d);
6!
3602
        for (auto& s : acc.big) big.emplace_back(std::move(s));
6!
3603
        for (auto& kv : acc.dens) {
196✔
3604
            auto it = dens.find(kv.first);
190!
3605
            if (it == dens.end())
190✔
3606
                dens.emplace(kv.first, std::move(kv.second));
130!
3607
            else
3608
                it->second.merge_from(kv.second);
60!
3609
        }
190✔
3610
    }
6✔
3611
    // Enclosers wide enough to be listed in the summary, found without the
3612
    // scan back toward the start of the trace that finding them live takes.
3613
    if (enc_summary != nullptr) {
9!
3614
        auto pid_s = params.get("pid");
×
3615
        auto tid_s = params.get("tid");
×
3616
        bool has_pid = !pid_s.empty();
3617
        bool has_tid = !tid_s.empty();
3618
        std::int64_t want_pid =
3619
            has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
×
3620
        std::int64_t want_tid =
3621
            has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
×
3622
        for (const auto& sp : enc_summary->long_events) {
×
3623
            if (static_cast<double>(sp.begin) >= begin ||
×
3624
                static_cast<double>(sp.end) <= begin)
3625
                continue;
3626
            if (has_pid && sp.pid != want_pid) continue;
×
3627
            if (has_tid && sp.tid != want_tid) continue;
×
3628
            auto dur = static_cast<double>(sp.end - sp.begin);
3629
            if (dur > max_dur) max_dur = dur;
×
3630
            big.push_back(sp.json);
×
3631
            big_dur.push_back(dur);
×
3632
        }
×
3633
    }
3634

3635
    // scan_view_events overshoots its cap (checked per batch), so clamp here.
3636
    // Keep the longest events: those are the slices wide enough to see.
3637
    if (limit > 0 && big.size() > static_cast<std::size_t>(limit)) {
9!
3638
        const auto keep = static_cast<std::size_t>(limit);
3639
        std::vector<std::size_t> idx(big.size());
×
3640
        std::iota(idx.begin(), idx.end(), std::size_t{0});
×
3641
        std::nth_element(idx.begin(), idx.begin() + static_cast<long>(keep),
×
3642
                         idx.end(), [&big_dur](std::size_t a, std::size_t b) {
×
3643
                             return big_dur[a] > big_dur[b];
×
3644
                         });
3645
        idx.resize(keep);
×
3646
        std::sort(idx.begin(), idx.end());
×
3647
        std::vector<std::string> kept;
3648
        kept.reserve(keep);
×
3649
        for (std::size_t i : idx) kept.emplace_back(std::move(big[i]));
×
3650
        big.swap(kept);
3651
        truncated = true;
3652
    }
3653

3654
    drop_unreferenced_hash_records(big);
9!
3655

3656
    co_await append_app_spans(big, index, begin, end, params);
12!
3657

3658
    // Resolve hash group values (fhash -> path, hhash -> host, ...) for the
3659
    // groups actually present; unresolvable hashes fall back to the raw value
3660
    // on the client.
3661
    ankerl::unordered_dense::map<std::string, std::string> group_names;
3!
3662
    if (resolve_type && !group_col.empty()) {
3!
3663
        ankerl::unordered_dense::set<std::string> raw_groups;
×
3664
        for (const auto& [k, a] : dens)
×
3665
            if (!k.group.empty()) raw_groups.insert(k.group);
×
3666
        for (const auto& e : big) {
×
3667
            auto g = extract_group_from_line(e, group_col);
×
3668
            if (!g.empty()) raw_groups.insert(std::move(g));
×
3669
        }
3670
        if (!raw_groups.empty()) {
×
3671
            try {
3672
                ankerl::unordered_dense::set<std::string> roots;
×
3673
                for (const auto* f : win_files)
×
3674
                    if (!f->index_path.empty()) roots.insert(f->index_path);
×
3675
                for (const auto& r : roots) {
×
3676
                    utilities::indexer::IndexDatabase db(
×
3677
                        r, rocksdb::RocksDatabase::OpenMode::ReadOnly);
3678
                    auto table = db.query_hash_table(*resolve_type);
×
3679
                    for (const auto& g : raw_groups) {
×
3680
                        auto it = table.find(g);
×
3681
                        if (it != table.end())
×
3682
                            group_names.emplace(g, it->second);
×
3683
                    }
3684
                }
3685
            } catch (...) {
3686
                // No usable hash table: raw hashes still group correctly.
3687
            }
×
3688
        }
3689
    }
3690

3691
    // Stable per-event/-block depth (computed in absolute space, before ts
3692
    // normalization rewrites the big strings; order is preserved in place).
3693
    std::vector<std::uint32_t> big_depth =
3✔
3694
        assign_view_depths(big, dens, begin, threshold);
3!
3695
    if (global_min > 0 || index.time_metric() != TraceIndex::TimeMetric::US) {
3!
3696
        for (auto& e : big)
3!
3697
            e = normalize_event_ts(e, global_min, index.time_metric());
×
3698
    }
3✔
3699

3700
    co_return HttpResponse::ok(serialize_density_body(
6!
3701
        big, dens, original_begin, original_end, threshold, limit, truncated,
3✔
3702
        global_min > 0, index.native_to_us(index.global_min_timestamp_us()),
3!
3703
        max_dur, index.time_metric(), &big_depth,
3✔
3704
        group_names.empty() ? nullptr : &group_names));
3!
3705
}
125!
3706

3707
static std::string serialize_counters_body(const std::vector<double>& read,
2✔
3708
                                           const std::vector<double>& write,
3709
                                           const std::vector<double>& ops,
3710
                                           double original_begin,
3711
                                           double original_end, int buckets,
3712
                                           double bucket_us, bool truncated) {
3713
    auto& b = scratch_json_builder();
2✔
3714
    b.start_object();
2✔
3715
    b.append_key_value("begin", original_begin);
2✔
3716
    b.append_comma();
2✔
3717
    b.append_key_value("end", original_end);
2✔
3718
    b.append_comma();
2✔
3719
    b.append_key_value("buckets", static_cast<std::int64_t>(buckets));
2✔
3720
    b.append_comma();
2✔
3721
    b.append_key_value("bucket_us", bucket_us);
2✔
3722
    b.append_comma();
2✔
3723
    b.append_key_value("truncated", truncated);
2✔
3724
    b.append_comma();
2✔
3725
    b.append_key_value("read_bytes", read);
2✔
3726
    b.append_comma();
2✔
3727
    b.append_key_value("write_bytes", write);
2✔
3728
    b.append_comma();
2✔
3729
    b.append_key_value("ops", ops);
2✔
3730
    b.end_object();
2✔
3731
    return std::string(b);
2!
3732
}
3733

3734
// Re-aggregate the summary's finest counter buckets into `buckets` output
3735
// buckets over [begin_abs, end_abs] (absolute us).
3736
static std::string serve_counters_from_summary(
2✔
3737
    const VizSummary& s, const VizSummary::Level& level, double begin_abs,
3738
    double end_abs, double original_begin, double original_end, int buckets,
3739
    double bucket_us_out, TraceIndex::TimeMetric metric) {
3740
    std::vector<double> read(buckets, 0.0), write(buckets, 0.0),
2!
3741
        ops(buckets, 0.0);
2!
3742
    std::int64_t fb0 = s.bucket_of(begin_abs, level.bucket_us, level.nbuckets);
2✔
3743
    std::int64_t fb1 = s.bucket_of(end_abs, level.bucket_us, level.nbuckets);
2✔
3744
    if (fb0 < 0) fb0 = 0;
2✔
3745
    if (fb1 < 0) fb1 = static_cast<std::int64_t>(level.nbuckets) - 1;
2✔
3746
    for (std::int64_t fb = fb0; fb <= fb1; ++fb) {
131,074✔
3747
        double center = static_cast<double>(s.t_begin) +
196,608✔
3748
                        (static_cast<double>(fb) + 0.5) * level.bucket_us;
131,072✔
3749
        long oi = static_cast<long>((center - begin_abs) / bucket_us_out);
131,072✔
3750
        if (oi < 0 || oi >= buckets) continue;
131,072!
3751
        auto f = static_cast<std::size_t>(fb);
131,072✔
3752
        read[oi] += level.read_bytes[f];
131,072✔
3753
        write[oi] += level.write_bytes[f];
131,072✔
3754
        ops[oi] += level.ops[f];
131,072✔
3755
    }
65,536✔
3756
    return serialize_counters_body(
1!
3757
        read, write, ops, original_begin, original_end, buckets,
1✔
3758
        bucket_us_out *
1✔
3759
            dftracer::utils::utilities::composites::dft::time_metric_us_scale(
2!
3760
                metric),
1✔
3761
        false);
2!
3762
}
2✔
3763

3764
// GET /api/v1/viz/counters: per-bucket read/write bytes and I/O op counts over
3765
// a time range, for bandwidth/IOPS counter tracks. Aggregated server-side in
3766
// parallel (per-worker arrays merged after join).
3767
static coro::CoroTask<HttpResponse> handle_viz_counters(
8!
3768
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
1!
3769
    if (!params.has("begin") || !params.has("end")) {
3!
3770
        co_return HttpResponse::bad_request(
5!
3771
            "Missing required parameters: begin, end");
4!
3772
    }
3773

3774
    double begin = params.get_double("begin", 0);
3✔
3775
    double end = params.get_double("end", 0);
3✔
3776
    int buckets = params.get_int("buckets", 800);
3✔
3777
    if (buckets < 16) buckets = 16;
3!
3778
    if (buckets > 4000) buckets = 4000;
3!
3779

3780
    auto query = params.get("query");
3✔
3781
    if (!query.empty() &&
3!
3782
        !utilities::common::query::try_parse(query).has_value()) {
×
3783
        co_return HttpResponse::bad_request("Invalid query: " +
×
3784
                                            std::string(query));
×
3785
    }
3786

3787
    auto ts_norm_param = params.get("ts_normalize");
3✔
3788
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
3789
    std::uint64_t global_min = 0;
3✔
3790
    if (normalize) {
3✔
3791
        global_min = index.global_min_timestamp_us();
1✔
3792
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
3793
            global_min = 0;
3794
    }
1✔
3795
    double original_begin = begin;
3✔
3796
    double original_end = end;
3✔
3797
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
3!
3798
        begin = static_cast<double>(
3799
            index.us_to_native(static_cast<std::uint64_t>(begin)));
×
3800
        end = static_cast<double>(
3801
            index.us_to_native(static_cast<std::uint64_t>(end)));
×
3802
    }
3803
    if (normalize && global_min > 0) {
3!
3804
        begin += static_cast<double>(global_min);
1✔
3805
        end += static_cast<double>(global_min);
1✔
3806
    }
1✔
3807

3808
    double bucket_us = (end - begin) / static_cast<double>(buckets);
3✔
3809
    if (bucket_us <= 0) bucket_us = 1;
3!
3810

3811
    // Zoomed-out, unfiltered counter tracks come from the activity summary.
3812
    if (viz_summary_eligible(params)) {
3!
3813
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
3814
        if (s != nullptr && s->t_end > s->t_begin) {
1!
3815
            if (const VizSummary::Level* level = s->level_for(bucket_us)) {
2!
3816
                co_return HttpResponse::ok(serve_counters_from_summary(
2!
3817
                    *s, *level, begin, end, original_begin, original_end,
1✔
3818
                    buckets, bucket_us, index.time_metric()));
1✔
3819
            }
3820
        }
3821
    }
1!
3822

3823
    ViewDefinition view = build_viz_view(params, begin, end, 0);
×
3824
    // No cap: only zoomed-in or filtered counter queries reach the live path.
3825
    int limit = params.get_int("limit", 0);
×
3826
    if (limit < 0) limit = 0;
×
3827

3828
    std::vector<const TraceIndex::FileInfo*> target_files =
3829
        select_viz_target_files(index, params, begin, end);
×
3830

3831
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
3832
    std::vector<CounterAcc> accs(slots);
×
3833
    for (auto& a : accs) a.init(static_cast<std::size_t>(buckets));
×
3834
    auto on_batch = [&accs, begin, bucket_us, buckets](
×
3835
                        std::size_t w,
3836
                        const std::vector<std::string_view>& events) {
3837
        CounterAcc& acc = accs[w];  // worker-owned, no lock
×
3838
        for (auto ev : events)
×
3839
            fold_counter(ev, begin, bucket_us,
×
3840
                         static_cast<std::size_t>(buckets), acc);
3841
    };
×
3842

3843
    bool truncated = co_await scan_view_events(
×
3844
        index, target_files, view, begin, end, limit, slots, on_batch,
×
3845
        req.cancel_token, !params.get("file").empty());
×
3846

3847
    CounterAcc total;
3848
    total.init(static_cast<std::size_t>(buckets));
×
3849
    for (auto& a : accs) total.merge_from(a);
×
3850

3851
    co_return HttpResponse::ok(serialize_counters_body(
×
3852
        total.read_bytes, total.write_bytes, total.ops, original_begin,
3853
        original_end, buckets,
3854
        bucket_us *
3855
            dftracer::utils::utilities::composites::dft::time_metric_us_scale(
×
3856
                index.time_metric()),
3857
        truncated));
3858
}
29!
3859

3860
// Process-spawning calls: dftracer POSIX (exact "fork"/"clone"/...) and kernel
3861
// syscalls ("__arm64_sys_clone"). Excludes library helpers like ibv_*fork* and
3862
// register_tm_clones, which contain "fork"/"clone" but don't spawn.
3863

3864
// One process in the proctree response. `host` borrows the hostname table;
3865
// `rank` is null when the trace carries no "PR" metadata for the pid, and the
3866
// key is then omitted.
3867
struct ProcNode {
3868
    std::int64_t pid;
3869
    std::int64_t parent;
3870
    std::uint64_t spawn_ts;
3871
    std::uint64_t first_ts;
3872
    std::string_view host;
3873
    std::uint64_t bytes;
3874
    std::uint64_t io_ops;
3875
    double io_busy;
3876
    const std::string* rank;
3877
};
3878

3879
template <typename builder_type>
3880
void tag_invoke(simdjson::serialize_tag, builder_type& b, const ProcNode& n) {
100✔
3881
    b.start_object();
100✔
3882
    b.append_key_value("pid", n.pid);
100✔
3883
    b.append_comma();
100✔
3884
    b.append_key_value("parent", n.parent);
100✔
3885
    b.append_comma();
100✔
3886
    b.append_key_value("spawn_ts", n.spawn_ts);
100✔
3887
    b.append_comma();
100✔
3888
    b.append_key_value("first_ts", n.first_ts);
100✔
3889
    b.append_comma();
100✔
3890
    b.append_key_value("host", n.host);
100✔
3891
    b.append_comma();
100✔
3892
    b.append_key_value("bytes", n.bytes);
100✔
3893
    b.append_comma();
100✔
3894
    b.append_key_value("io_ops", n.io_ops);
100✔
3895
    b.append_comma();
100✔
3896
    b.append_key_value("io_busy", n.io_busy);
100✔
3897
    if (n.rank) {
100✔
3898
        b.append_comma();
×
3899
        b.append_key_value("rank", *n.rank);
×
3900
    }
3901
    b.end_object();
100✔
3902
}
100✔
3903

3904
// GET /api/v1/viz/proctree: infer the process fork hierarchy. The traces record
3905
// the fork/clone in the parent but not the child pid, so link each process to
3906
// the nearest preceding clone in another process (child start follows the clone
3907
// by microseconds). Respects ?file= for per-node trees on multi-node traces.
3908
static coro::CoroTask<HttpResponse> handle_viz_proctree(
8!
3909
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
1!
3910
    std::uint64_t gmin = index.global_min_timestamp_us();
3!
3911
    std::uint64_t gmax = index.global_max_timestamp_us();
3!
3912
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
3✔
3913
        co_return HttpResponse::ok("{\"nodes\":[]}");
5!
3914

3915
    auto ts_norm_param = params.get("ts_normalize");
3✔
3916
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
3917
    std::uint64_t base = normalize ? gmin : 0;
3!
3918

3919
    struct Acc {
2!
3920
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
3921
        // Explicit parent from the process metadata's args.ppid.
3922
        ankerl::unordered_dense::map<std::int64_t, std::int64_t> ppid;
3923
        // (ts, parent_pid, child_pid): child_pid is args.ret when the fork
3924
        // event records it (dftracer POSIX), else -1 to fall back to inference.
3925
        std::vector<std::tuple<std::uint64_t, std::int64_t, std::int64_t>>
3926
            forks;
3927
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
3928
        // I/O operation count and busy time (us) per process, over I/O-category
3929
        // (POSIX/STDIO/IO) events only.
3930
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
3931
        ankerl::unordered_dense::map<std::int64_t, double> io_busy;
3932
        ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
3933
        // pid -> rank, from "PR" metadata (args.name == "rank").
3934
        ankerl::unordered_dense::map<std::int64_t, std::string> rank;
3935
        dftracer::utils::StringViewMap<std::string> hh;
3936
    };
3937
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3✔
3938
    std::vector<Acc> accs(slots);
3!
3939
    auto on_batch = [&accs](std::size_t w,
3✔
3940
                            const std::vector<std::string_view>& events) {
UNCOV
3941
        thread_local simdjson::dom::parser parser;
×
UNCOV
3942
        thread_local std::string buf;
×
UNCOV
3943
        Acc& acc = accs[w];
×
UNCOV
3944
        for (auto ev : events) {
×
UNCOV
3945
            buf.assign(ev);
×
UNCOV
3946
            auto res = parser.parse(buf);
×
UNCOV
3947
            if (res.error()) continue;
×
UNCOV
3948
            auto root = res.value_unsafe();
×
UNCOV
3949
            if (!root.is_object()) continue;
×
3950
            // HH metadata (hhash -> hostname) carries no ts; handle it first.
3951
            {
UNCOV
3952
                auto nm = root["name"];
×
UNCOV
3953
                if (!nm.error() && nm.is_string() &&
×
UNCOV
3954
                    nm.get_string().value_unsafe() == "HH") {
×
3955
                    auto a = root["args"];
×
3956
                    if (!a.error() && a.is_object()) {
×
3957
                        auto v = a["value"];
×
3958
                        auto n = a["name"];
×
3959
                        if (!v.error() && v.is_string() && !n.error() &&
×
3960
                            n.is_string())
×
3961
                            acc.hh.emplace(
×
3962
                                std::string(v.get_string().value_unsafe()),
×
3963
                                std::string(n.get_string().value_unsafe()));
×
3964
                    }
3965
                    continue;
×
3966
                }
3967
            }
3968
            // PR metadata (pid -> rank) also carries no ts.
3969
            {
UNCOV
3970
                auto nm = root["name"];
×
UNCOV
3971
                if (!nm.error() && nm.is_string() &&
×
UNCOV
3972
                    nm.get_string().value_unsafe() == "PR") {
×
3973
                    auto a = root["args"];
×
3974
                    auto pp = root["pid"];
×
3975
                    if (!a.error() && a.is_object() && !pp.error()) {
×
3976
                        auto an = a["name"];
×
3977
                        auto av = a["value"];
×
3978
                        if (!an.error() && an.is_string() &&
×
3979
                            an.get_string().value_unsafe() == "rank" &&
×
3980
                            !av.error() && av.is_string())
×
3981
                            acc.rank.emplace(
×
3982
                                static_cast<std::int64_t>(
×
3983
                                    json_number(pp.value_unsafe())),
×
3984
                                std::string(av.get_string().value_unsafe()));
×
3985
                    }
3986
                    continue;
×
3987
                }
3988
            }
UNCOV
3989
            auto pr = root["pid"];
×
UNCOV
3990
            auto tr = root["ts"];
×
UNCOV
3991
            if (pr.error() || tr.error()) continue;
×
3992
            auto pid =
UNCOV
3993
                static_cast<std::int64_t>(json_number(pr.value_unsafe()));
×
3994
            auto ts =
UNCOV
3995
                static_cast<std::uint64_t>(json_number(tr.value_unsafe()));
×
UNCOV
3996
            auto it = acc.first_ts.find(pid);
×
UNCOV
3997
            if (it == acc.first_ts.end())
×
UNCOV
3998
                acc.first_ts.emplace(pid, ts);
×
3999
            else if (ts < it->second)
×
4000
                it->second = ts;
×
4001

UNCOV
4002
            auto nr = root["name"];
×
UNCOV
4003
            std::string_view name;
×
UNCOV
4004
            if (!nr.error() && nr.is_string())
×
UNCOV
4005
                name = nr.get_string().value_unsafe();
×
4006

UNCOV
4007
            std::int64_t child = -1;
×
UNCOV
4008
            double ret = 0;
×
UNCOV
4009
            auto args = root["args"];
×
UNCOV
4010
            if (!args.error() && args.is_object()) {
×
UNCOV
4011
                auto ppr = args["ppid"];
×
UNCOV
4012
                if (!ppr.error()) {
×
4013
                    auto pp = static_cast<std::int64_t>(
4014
                        json_number(ppr.value_unsafe()));
×
4015
                    if (pp > 0 && pp != pid) acc.ppid[pid] = pp;
×
4016
                }
UNCOV
4017
                auto rr = args["ret"];
×
UNCOV
4018
                if (!rr.error()) {
×
UNCOV
4019
                    ret = json_number(rr.value_unsafe());
×
UNCOV
4020
                    child = static_cast<std::int64_t>(ret);
×
4021
                }
UNCOV
4022
                auto hr = args["hhash"];
×
UNCOV
4023
                if (!hr.error() && hr.is_string() &&
×
UNCOV
4024
                    acc.pid_hhash.find(pid) == acc.pid_hhash.end())
×
UNCOV
4025
                    acc.pid_hhash.emplace(
×
UNCOV
4026
                        pid, std::string(hr.get_string().value_unsafe()));
×
4027
            }
4028
            // I/O bytes per process: args.ret on read/write ops.
UNCOV
4029
            if (ret > 0 && (name.find("read") != std::string_view::npos ||
×
UNCOV
4030
                            name.find("write") != std::string_view::npos))
×
UNCOV
4031
                acc.bytes[pid] += static_cast<std::uint64_t>(ret);
×
4032

UNCOV
4033
            auto cr = root["cat"];
×
UNCOV
4034
            std::string_view cat;
×
UNCOV
4035
            if (!cr.error() && cr.is_string())
×
UNCOV
4036
                cat = cr.get_string().value_unsafe();
×
UNCOV
4037
            if (cat == "POSIX" || cat == "STDIO" || cat == "IO") {
×
UNCOV
4038
                acc.io_ops[pid] += 1;
×
UNCOV
4039
                auto dr = root["dur"];
×
UNCOV
4040
                if (!dr.error())
×
UNCOV
4041
                    acc.io_busy[pid] += json_number(dr.value_unsafe());
×
4042
            }
4043

UNCOV
4044
            if (!name.empty() && is_fork_syscall(name))
×
4045
                acc.forks.emplace_back(ts, pid, child > 0 ? child : -1);
×
4046
        }
UNCOV
4047
    };
×
4048

4049
    // The per-process totals are exact and whole-trace, so the summary answers
4050
    // this identically to a scan; only a single-file view has to scan.
4051
    bool single_file = !params.get("file").empty();
3✔
4052
    const VizSummary* summary =
4✔
4053
        single_file ? nullptr : co_await ensure_viz_summary(index);
5!
4054
    if (summary != nullptr) {
1!
4055
        Acc& acc = accs[0];
1✔
4056
        for (const auto& p : summary->procs) {
51✔
4057
            if (p.first_ts != 0) acc.first_ts.emplace(p.pid, p.first_ts);
50!
4058
            if (p.ppid > 0) acc.ppid.emplace(p.pid, p.ppid);
50!
4059
            if (p.bytes != 0) acc.bytes.emplace(p.pid, p.bytes);
50!
4060
            if (p.io_ops != 0) acc.io_ops.emplace(p.pid, p.io_ops);
50!
4061
            if (p.io_busy != 0) acc.io_busy.emplace(p.pid, p.io_busy);
50!
4062
            if (!p.hhash.empty()) acc.pid_hhash.emplace(p.pid, p.hhash);
50!
4063
            if (!p.rank.empty()) acc.rank.emplace(p.pid, p.rank);
50!
4064
        }
50✔
4065
        for (const auto& f : summary->forks)
1!
4066
            acc.forks.emplace_back(f.ts, f.pid, f.child);
×
4067
        for (const auto& h : summary->hosts) acc.hh.emplace(h.first, h.second);
1!
4068
    } else {
1✔
4069
        ViewDefinition view;
4070
        view.name = "viz_proctree";
×
4071
        view.with_query("ts >= " + std::to_string(gmin) +
×
4072
                        " and ts <= " + std::to_string(gmax));
×
4073
        auto files =
4074
            select_viz_target_files(index, params, static_cast<double>(gmin),
×
4075
                                    static_cast<double>(gmax));
4076
        co_await scan_view_events(index, files, view, static_cast<double>(gmin),
×
4077
                                  static_cast<double>(gmax), 0, slots, on_batch,
×
4078
                                  req.cancel_token, single_file);
4079
    }
×
4080

4081
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
1!
4082
    ankerl::unordered_dense::map<std::int64_t, std::int64_t> parent_of;
1!
4083
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> spawn_of;
1!
4084
    std::vector<std::pair<std::uint64_t, std::int64_t>> inf_forks;  // (ts, pid)
1✔
4085
    for (auto& a : accs) {
3✔
4086
        for (auto& kv : a.first_ts) {
52✔
4087
            auto it = first_ts.find(kv.first);
50!
4088
            if (it == first_ts.end())
50!
4089
                first_ts.emplace(kv.first, kv.second);
50!
4090
            else if (kv.second < it->second)
×
4091
                it->second = kv.second;
4092
        }
50✔
4093
        // Explicit edges from the fork event's args.ret (child pid + spawn ts).
4094
        for (auto& [ts, ppid, child] : a.forks) {
2!
4095
            if (child > 0) {
×
4096
                parent_of[child] = ppid;
×
4097
                spawn_of[child] = ts;
×
4098
            } else {
4099
                inf_forks.emplace_back(ts, ppid);
×
4100
            }
4101
        }
4102
    }
2✔
4103
    // args.ppid metadata fills in any process not linked by a fork event.
4104
    for (auto& a : accs)
3✔
4105
        for (auto& kv : a.ppid)
2!
4106
            if (parent_of.find(kv.first) == parent_of.end())
×
4107
                parent_of.emplace(kv.first, kv.second);
2!
4108
    std::sort(inf_forks.begin(), inf_forks.end());
1!
4109

4110
    // Merge host (hhash -> hostname resolved) and I/O bytes per process.
4111
    dftracer::utils::StringViewMap<std::string> hh;
1!
4112
    ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
1!
4113
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
1!
4114
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
1!
4115
    ankerl::unordered_dense::map<std::int64_t, double> io_busy;
1!
4116
    ankerl::unordered_dense::map<std::int64_t, std::string> rank;
1!
4117
    for (auto& a : accs) {
3✔
4118
        for (auto& kv : a.hh) hh.emplace(kv.first, kv.second);
2!
4119
        for (auto& kv : a.pid_hhash) pid_hhash.emplace(kv.first, kv.second);
52!
4120
        for (auto& kv : a.bytes) bytes[kv.first] += kv.second;
40!
4121
        for (auto& kv : a.io_ops) io_ops[kv.first] += kv.second;
52!
4122
        for (auto& kv : a.io_busy) io_busy[kv.first] += kv.second;
52!
4123
        for (auto& kv : a.rank) rank.emplace(kv.first, kv.second);
2!
4124
    }
2✔
4125

4126
    std::vector<std::pair<std::uint64_t, std::int64_t>>
1✔
4127
        procs;  // (first_ts, pid)
1✔
4128
    procs.reserve(first_ts.size());
1!
4129
    for (auto& kv : first_ts) procs.emplace_back(kv.second, kv.first);
51!
4130
    std::sort(procs.begin(), procs.end());
1!
4131

4132
    // Time-inference fallback (traces without args.ret/ppid): link a process to
4133
    // the nearest preceding clone in another process.
4134
    std::vector<bool> used(inf_forks.size(), false);
1!
4135
    std::vector<ProcNode> nodes;
1✔
4136
    nodes.reserve(procs.size());
1!
4137
    const double proc_dur_us =
2✔
4138
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
1!
4139
            index.time_metric());
1!
4140
    for (auto& [fts, pid] : procs) {
51✔
4141
        std::int64_t parent = -1;
50✔
4142
        std::uint64_t spawn_ts = 0;
50✔
4143
        auto pit = parent_of.find(pid);
50!
4144
        auto sit = spawn_of.find(pid);
50!
4145
        // A fork cannot spawn a child that already existed before it: reject
4146
        // such edges (pid reuse across runs) and fall back to time inference.
4147
        bool valid = pit != parent_of.end() &&
50!
4148
                     (sit == spawn_of.end() || sit->second <= fts);
×
4149
        if (valid) {
50!
4150
            parent = pit->second;
4151
            if (sit != spawn_of.end()) spawn_ts = sit->second - base;
×
4152
        } else {
4153
            auto hi = std::upper_bound(
100!
4154
                inf_forks.begin(), inf_forks.end(),
100✔
4155
                std::make_pair(fts, std::numeric_limits<std::int64_t>::max()));
50!
4156
            for (auto it = hi; it != inf_forks.begin();) {
50!
4157
                --it;
4158
                auto idx = static_cast<std::size_t>(it - inf_forks.begin());
4159
                if (!used[idx] && it->second != pid) {
×
4160
                    used[idx] = true;
×
4161
                    parent = it->second;
4162
                    spawn_ts = it->first - base;
4163
                    break;
4164
                }
4165
            }
×
4166
        }
50✔
4167
        std::string_view host;
50✔
4168
        auto hp = pid_hhash.find(pid);
50!
4169
        if (hp != pid_hhash.end()) {
50!
4170
            auto hn = hh.find(hp->second);
50!
4171
            if (hn != hh.end()) host = hn->second;
50!
4172
        }
50✔
4173
        auto bp = bytes.find(pid);
50!
4174
        auto op = io_ops.find(pid);
50!
4175
        auto ib = io_busy.find(pid);
50!
4176
        auto rk = rank.find(pid);
50!
4177
        nodes.push_back({pid, parent, index.native_to_us(spawn_ts),
250!
4178
                         index.native_to_us(fts - base), host,
50!
4179
                         bp != bytes.end() ? bp->second : 0,
50✔
4180
                         op != io_ops.end() ? op->second : 0,
50!
4181
                         (ib != io_busy.end() ? ib->second : 0.0) * proc_dur_us,
50!
4182
                         rk != rank.end() ? &rk->second : nullptr});
50!
4183
    }
50✔
4184

4185
    auto& sb = scratch_json_builder();
1!
4186
    sb.start_object();
1✔
4187
    sb.append_key_value("nodes", nodes);
1!
4188
    sb.end_object();
1✔
4189
    co_return HttpResponse::ok(std::string(sb));
1!
4190
}
17!
4191

4192
// GET /api/v1/viz/columns: the complete set of groupable columns in the trace
4193
// (top-level scalar fields + args keys), harvested during the summary scan.
4194
static coro::CoroTask<HttpResponse> handle_viz_columns(
6!
4195
    const HttpRequest& /*req*/, const QueryParams& /*params*/,
4196
    TraceIndex& index) {
1!
4197
    // Prefer the durable set stored at index build (available immediately, no
4198
    // scan). Fall back to the summary harvest for indexes built before column
4199
    // discovery existed.
4200
    std::vector<std::string> columns;
1✔
4201
    bool ready = true;
1✔
4202
    {
4203
        ankerl::unordered_dense::set<std::string> roots;
1!
4204
        for (const auto& f : index.files())
3✔
4205
            if (!f.index_path.empty()) roots.insert(f.index_path);
2!
4206
        ankerl::unordered_dense::set<std::string> merged;
1!
4207
        for (const auto& r : roots) {
2✔
4208
            try {
4209
                utilities::indexer::IndexDatabase db(
2!
4210
                    r, rocksdb::RocksDatabase::OpenMode::ReadOnly);
1✔
4211
                for (auto& c : db.query_all_columns())
5!
4212
                    merged.emplace(std::move(c));
4!
4213
            } catch (...) {
1✔
4214
            }
×
4215
        }
1✔
4216
        columns.assign(merged.begin(), merged.end());
1!
4217
        std::sort(columns.begin(), columns.end());
1!
4218
    }
1✔
4219
    if (columns.empty()) {
1!
4220
        const VizSummary* s = co_await ensure_viz_summary(index);
1!
4221
        if (s) columns = s->columns;
×
4222
        ready = s != nullptr;  // false => client retries after summary builds
4223
    }
×
4224

4225
    auto& b = scratch_json_builder();
1!
4226
    b.start_object();
1✔
4227
    b.escape_and_append_with_quotes("columns");
1✔
4228
    b.append_colon();
1✔
4229
    b.start_array();
1✔
4230
    bool first = true;
1✔
4231
    for (const auto& c : columns) {
5✔
4232
        if (!first) b.append_comma();
4✔
4233
        first = false;
4✔
4234
        b.escape_and_append_with_quotes(c);
4✔
4235
    }
4✔
4236
    b.end_array();
1✔
4237
    b.append_comma();
1✔
4238
    b.append_key_value("ready", ready);
1✔
4239
    b.end_object();
1✔
4240
    co_return HttpResponse::ok(std::string(b));
1!
4241
}
3!
4242

4243
static coro::CoroTask<HttpResponse> handle_viz_breaks(
56!
4244
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
7!
4245
    auto ts_norm_param = params.get("ts_normalize");
21✔
4246
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
21!
4247
    std::uint64_t global_min = 0;
21✔
4248
    if (normalize) {
21✔
4249
        global_min = index.global_min_timestamp_us();
7✔
4250
        if (global_min == std::numeric_limits<std::uint64_t>::max())
7!
4251
            global_min = 0;
4252
    }
7✔
4253
    const VizSummary* s = co_await ensure_viz_summary(index);
35!
4254
    auto& b = scratch_json_builder();
7!
4255
    b.start_object();
7✔
4256
    b.escape_and_append_with_quotes("gaps");
7✔
4257
    b.append_colon();
7✔
4258
    b.start_array();
7✔
4259
    if (s) {
7!
4260
        bool first = true;
7✔
4261
        for (const auto& g : s->idle_gaps) {
65✔
4262
            if (!first) b.append_comma();
58✔
4263
            first = false;
58✔
4264
            b.start_object();
58✔
4265
            b.append_key_value("begin",
58✔
4266
                               index.native_to_us(g.first - global_min));
58!
4267
            b.append_comma();
58✔
4268
            b.append_key_value("end",
58✔
4269
                               index.native_to_us(g.second - global_min));
58!
4270
            b.end_object();
58✔
4271
        }
58✔
4272
    }
7✔
4273
    b.end_array();
7✔
4274
    b.append_comma();
7✔
4275
    b.append_key_value("multi_run", s && !s->idle_gaps.empty());
7!
4276
    b.end_object();
7✔
4277
    co_return HttpResponse::ok(std::string(b));
7!
4278
}
63!
4279

4280
void register_viz_api(Router& router, TraceIndex& index) {
22✔
4281
    auto* index_ptr = &index;
22✔
4282
    const RouteParam BEGIN{"begin", "Window start (us)", true, "0"};
22!
4283
    const RouteParam END{"end", "Window end (us)", true, "999999999"};
22!
4284
    const RouteParam SUMMARY{"summary", "LOD level (1=full detail)", true, "1"};
22!
4285

4286
    router.get(
33!
4287
        "/api/v1/viz/proctree",
11!
4288
        [index_ptr](const HttpRequest& req,
19!
4289
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
4290
            co_return co_await handle_viz_proctree(req, params, *index_ptr);
5!
4291
        },
4!
4292
        RouteDoc{
66!
4293
            "Inferred process/fork hierarchy with host, rank, and I/O.",
11!
4294
            "Visualization",
11!
4295
            {{"file", "Limit to one trace file", false, ""}},
11!
4296
            R"({"nodes":[{"pid":100,"parent":-1,"host":"node01","rank":"0",)"
11!
4297
            R"("bytes":16384,"io_ops":4,"io_busy":600.0}]})"});
22✔
4298

4299
    router.get(
33!
4300
        "/api/v1/viz/counters",
11!
4301
        [index_ptr](const HttpRequest& req,
19!
4302
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
4303
            co_return co_await handle_viz_counters(req, params, *index_ptr);
5!
4304
        },
4!
4305
        RouteDoc{"Read/write bytes and I/O op counts per time bucket.",
88!
4306
                 "Visualization",
11!
4307
                 {BEGIN, END, SUMMARY},
11!
4308
                 R"({"buckets":[{"ts":0,"read_bytes":4096,"write_bytes":0,)"
11!
4309
                 R"("read_ops":1,"write_ops":0}]})"});
44✔
4310

4311
    router.get(
33!
4312
        "/api/v1/viz/breaks",
11!
4313
        [index_ptr](const HttpRequest& req,
67!
4314
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
7!
4315
            co_return co_await handle_viz_breaks(req, params, *index_ptr);
35!
4316
        },
28!
4317
        RouteDoc{
66!
4318
            "Globally-idle time gaps and multi-run detection.",
11!
4319
            "Visualization",
11!
4320
            {{"ts_normalize", "Normalize to global min (default on)", false,
11!
4321
              "1"}},
11!
4322
            R"({"gaps":[{"begin":50000,"end":900000}],"multi_run":true})"});
33!
4323

4324
    router.get(
33!
4325
        "/api/v1/viz/columns",
11!
4326
        [index_ptr](const HttpRequest& req,
19!
4327
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
4328
            co_return co_await handle_viz_columns(req, params, *index_ptr);
5!
4329
        },
4!
4330
        RouteDoc{"Groupable columns present in the trace.",
33!
4331
                 "Visualization",
11!
4332
                 {},
11✔
4333
                 R"({"columns":["cat","name","mhost","fhash"],"ready":true})"});
11!
4334

4335
    router.get(
33!
4336
        "/api/v1/viz/events",
11!
4337
        [index_ptr](const HttpRequest& req,
75!
4338
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
8!
4339
            co_return co_await handle_viz_events(req, params, *index_ptr);
40!
4340
        },
32!
4341
        RouteDoc{"Events for rendering: time-windowed, LOD-aggregated.",
121!
4342
                 "Visualization",
11!
4343
                 {BEGIN,
44!
4344
                  END,
11!
4345
                  SUMMARY,
11!
4346
                  {"pid", "Filter by process id", false, ""},
11!
4347
                  {"cat", "Filter by category", false, ""},
11!
4348
                  {"query", "DSL predicate, e.g. dur >= 1000", false, ""}},
11!
4349
                 R"({"events":[],"metadata":{"begin":0,"end":1000000,)"
11!
4350
                 R"("count":42,"truncated":false,"ts_normalized":true}})"});
77✔
4351

4352
    router.get(
33!
4353
        "/api/v1/viz/density",
11!
4354
        [index_ptr](const HttpRequest& req,
51!
4355
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
5!
4356
            co_return co_await handle_viz_density(req, params, *index_ptr);
25!
4357
        },
20!
4358
        RouteDoc{"Sub-pixel events bucketed into density blocks.",
99!
4359
                 "Visualization",
11!
4360
                 {BEGIN,
33!
4361
                  END,
11!
4362
                  {"summary", "LOD level", true, "2"},
11!
4363
                  {"width", "Canvas width in px (sets the 1px fold cutoff)",
11!
4364
                   false, "1920"}},
11!
4365
                 R"({"events":[],"density":[{"pid":100,"tid":100,"ts":0,)"
11!
4366
                 R"("dur":24457,"count":910,"total":22044,"depth":0}]})"});
55✔
4367

4368
    router.get(
33!
4369
        "/api/v1/viz/stats",
11!
4370
        [index_ptr](const HttpRequest& req,
27!
4371
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
2!
4372
            co_return co_await handle_viz_stats(req, params, *index_ptr);
10!
4373
        },
8!
4374
        RouteDoc{"Per-name aggregation over a time range (Analyze).",
88!
4375
                 "Visualization",
11!
4376
                 {BEGIN, END, SUMMARY},
11!
4377
                 R"({"count":100,"total_dur":5000,"names":[{"name":"read",)"
11!
4378
                 R"("count":50,"total":2500,"avg":50,"min":10,"max":90}]})"});
44✔
4379

4380
    router.get(
33!
4381
        "/api/v1/viz/calltree",
11!
4382
        [index_ptr](const HttpRequest& req,
35!
4383
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
3!
4384
            co_return co_await handle_viz_calltree(req, params, *index_ptr);
15!
4385
        },
12!
4386
        RouteDoc{
99!
4387
            "Merged flamegraph tree from ts/dur containment.",
11!
4388
            "Visualization",
11!
4389
            {BEGIN,
22!
4390
             END,
11!
4391
             SUMMARY,
11!
4392
             {"group", "Set to 'pid' to keep processes separate", false, ""}},
11!
4393
            R"({"name":"root","total":5000,"self":0,"count":0,)"
11!
4394
            R"("children":[{"name":"read","total":2500,"self":2500,)"
4395
            R"("count":50}]})"});
55✔
4396

4397
    router.get(
33!
4398
        "/api/v1/viz/histogram",
11!
4399
        [index_ptr](const HttpRequest& req,
19!
4400
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
4401
            co_return co_await handle_viz_histogram(req, params, *index_ptr);
5!
4402
        },
4!
4403
        RouteDoc{"Duration distribution: percentiles + log-spaced buckets.",
99!
4404
                 "Visualization",
11!
4405
                 {BEGIN,
22!
4406
                  END,
11!
4407
                  SUMMARY,
11!
4408
                  {"query", "DSL predicate to narrow to one op", false, ""}},
11!
4409
                 R"({"min":10,"max":900,"p50":150,"p99":880,"buckets":[]})"});
66!
4410

4411
    router.get(
33!
4412
        "/api/v1/viz/layers",
11!
4413
        [index_ptr](const HttpRequest& req,
27!
4414
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
2!
4415
            co_return co_await handle_viz_layers(req, params, *index_ptr);
10!
4416
        },
8!
4417
        RouteDoc{"Operation-name to category map; declared vs I/O files.",
33!
4418
                 "Visualization",
11!
4419
                 {},
11✔
4420
                 R"({"layers":{"read":"POSIX","write":"POSIX"},)"
11!
4421
                 R"("total_files":2,"io_files":2})"});
4422
}
22✔
4423

4424
}  // namespace dftracer::utils::server
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc