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

llnl / dftracer-utils / 30067416336

24 Jul 2026 04:40AM UTC coverage: 50.693% (-2.0%) from 52.66%
30067416336

Pull #99

github

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

18063 of 48203 branches covered (37.47%)

Branch coverage included in aggregate %.

1514 of 2204 new or added lines in 58 files covered. (68.69%)

741 existing lines in 114 files now uncovered.

23715 of 34210 relevant lines covered (69.32%)

39842.55 hits per line

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

43.8
/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",
46
                                                                    "SH"};
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,
400✔
55
                               std::uint64_t value) {
56
    auto pos = json.find(key);
400✔
57
    if (pos == std::string::npos) return false;
400!
58
    pos += key.size();
400✔
59
    while (pos < json.size() && std::isspace(json[pos])) ++pos;
400!
60
    auto end_pos = pos;
400✔
61
    while (end_pos < json.size() &&
7,120!
62
           (std::isdigit(json[end_pos]) || json[end_pos] == '-')) {
3,560!
63
        ++end_pos;
3,160✔
64
    }
65
    if (end_pos == pos) return false;
400!
66
    json.replace(pos, end_pos - pos, std::to_string(value));
400!
67
    return true;
400✔
68
}
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,
200✔
75
                                      std::uint64_t offset,
76
                                      TraceIndex::TimeMetric metric) {
77
    thread_local simdjson::dom::parser tl_parser;
200✔
78
    auto result = tl_parser.parse(event_json);
200✔
79
    if (result.error()) return event_json;
200!
80

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

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

87
    std::uint64_t old_ts = 0;
200✔
88
    if (ts_result.is_uint64()) {
200!
89
        old_ts = ts_result.get_uint64().value_unsafe();
200✔
UNCOV
90
    } else if (ts_result.is_int64()) {
×
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 =
100
        scale_between(metric, TM::US, old_ts >= offset ? old_ts - offset : 0);
200!
101

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

105
    if (metric != TM::US) {
200!
106
        auto dur_result = root["dur"];
200✔
107
        if (!dur_result.error() && dur_result.is_uint64()) {
200!
108
            rewrite_uint_field(
200!
109
                modified, "\"dur\":",
110
                scale_between(metric, TM::US, dur_result.get_uint64().value()));
400!
111
        }
112
    }
113
    return modified;
200✔
114
}
200✔
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,
11✔
119
                                 unsigned viewport_width = 1920) {
120
    if (level <= 1) return 0.0;
11✔
121
    double range = end - begin;
3✔
122
    return range /
123
           (static_cast<double>(viewport_width) * static_cast<double>(level));
3✔
124
}
125

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

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

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

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

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

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

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

172
            if (!field_result.value_unsafe().is_string()) continue;
1!
173
            const char* field =
174
                field_result.value_unsafe().get_c_str().value_unsafe();
1✔
175
            auto val = extract_json_value(value_result.value_unsafe());
1!
176
            if (!val.empty()) append_lane_clause(dsl, field, val);
1!
177
        }
1✔
UNCOV
178
    } else if (root.is_object()) {
×
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
}
195

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

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

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

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

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

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

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

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

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

234
        std::string query_op;
2✔
235
        if (op_str == "=")
2✔
236
            query_op = "==";
1!
237
        else if (op_str == ">=")
1!
238
            query_op = ">=";
1!
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 ";
2!
249
        bool numeric = !val.empty() && (std::isdigit(val[0]) || val[0] == '-');
2!
250
        if (numeric || query_op != "==") {
2!
251
            dsl += field_str + " " + query_op + " " + val;
2!
252
        } else {
253
            dsl += field_str + " " + query_op + " \"" + val + "\"";
×
254
        }
255
    }
2!
256
}
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,
14✔
262
                                     double end, double min_dur) {
263
    ViewDefinition view;
14✔
264
    view.name = "viz_query";
14!
265
    view.description = "Visualization query";
14!
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;
14✔
272
    dsl.reserve(128);
14!
273
    char numbuf[20];  // max digits of a uint64_t
274
    auto append_u64 = [&](std::uint64_t v) {
35✔
275
        dsl.append(numbuf, to_chars_u64(numbuf, numbuf + sizeof(numbuf), v));
35✔
276
    };
49✔
277

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

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

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

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

302
    auto cat = params.get("cat");
14!
303
    if (!cat.empty()) {
14!
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");
14!
311
    if (!query.empty()) {
14!
312
        dsl += " and (";
×
313
        dsl += query;
×
314
        dsl += ')';
×
315
    }
316

317
    view.with_query(dsl);
14!
318
    return view;
28✔
319
}
14✔
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(
10✔
324
    TraceIndex& index, const QueryParams& params, double begin, double end) {
325
    auto target_files = collect_candidate_files(index, params);
10✔
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;
10!
330

331
    if (begin > 0 || end > 0) {
10!
332
        std::vector<const TraceIndex::FileInfo*> filtered;
10✔
333
        filtered.reserve(target_files.size());
10!
334
        for (auto* fi : target_files) {
24✔
335
            if (fi->min_timestamp_us == 0 && fi->max_timestamp_us == 0) {
14!
336
                filtered.push_back(fi);
×
337
                continue;
×
338
            }
339
            double fi_min = static_cast<double>(fi->min_timestamp_us);
14✔
340
            double fi_max = static_cast<double>(fi->max_timestamp_us);
14✔
341
            if (fi_max < begin || fi_min > end) continue;
14!
342
            filtered.push_back(fi);
14!
343
        }
344
        target_files = std::move(filtered);
10✔
345
    }
10✔
346
    return target_files;
10✔
UNCOV
347
}
×
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,
7✔
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) {
7!
361
        for (auto& event : events) {
206✔
362
            event = normalize_event_ts(event, global_min, metric);
200!
363
        }
364
    }
365

366
    auto& b = scratch_json_builder();
7✔
367
    b.start_object();
7✔
368
    b.escape_and_append_with_quotes("events");
7✔
369
    b.append_colon();
7✔
370
    b.start_array();
7✔
371
    for (std::size_t i = 0; i < events.size(); ++i) {
207✔
372
        if (i > 0) b.append_comma();
200✔
373
        b.append_raw(events[i]);  // Already JSON
200✔
374
    }
375
    b.end_array();
7✔
376
    b.append_comma();
7✔
377
    b.escape_and_append_with_quotes("metadata");
7✔
378
    b.append_colon();
7✔
379
    b.start_object();
7✔
380
    b.append_key_value("begin", meta_begin);
7✔
381
    b.append_comma();
7✔
382
    b.append_key_value("end", meta_end);
7✔
383
    b.append_comma();
7✔
384
    b.append_key_value("count", events.size());
7✔
385
    b.append_comma();
7✔
386
    b.append_key_value("limit", limit);
7✔
387
    b.append_comma();
7✔
388
    b.append_key_value("truncated", truncated);
7✔
389
    b.append_comma();
7✔
390
    b.append_key_value("ts_normalized", global_min > 0);
7✔
391
    b.append_comma();
7✔
392
    b.append_key_value("global_min_timestamp_us", display_global_min);
7✔
393
    b.end_object();
7✔
394
    b.end_object();
7✔
395
    return std::string(b);
7!
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(
17!
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) {
420
    const std::int64_t cap =
421
        limit > 0 ? limit : std::numeric_limits<std::int64_t>::max();
422
    std::atomic<std::int64_t> produced{0};
423

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

434
    CoroScope scope(executor);
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,
17!
440
                 produced_ptr, cap, begin, end, scan_all_chunks,
441
                 cancel](CoroScope&) mutable -> coro::CoroTask<void> {
442
        auto guard = ch.guard();
443
        for (auto* file_info : *target_files_ptr) {
444
            if (cancel.cancelled()) co_return;
445
            if (produced_ptr->load(std::memory_order_relaxed) >= cap) co_return;
446
            if (file_info->uncompressed_size == 0 &&
447
                file_info->num_checkpoints == 0)
448
                continue;
449

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

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

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

477
    for (std::size_t w = 0; w < num_workers; ++w) {
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,
34!
481
                     cancel](CoroScope&) -> coro::CoroTask<void> {
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()) {
487
                if (cancel.cancelled()) continue;
488
                if (produced_ptr->load(std::memory_order_relaxed) >= cap)
489
                    continue;
490

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

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

515
    co_await scope.join();
516
    co_return produced.load(std::memory_order_relaxed) >= cap;
517
}
34!
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,
8!
527
                                                      const QueryParams& params,
528
                                                      TraceIndex& index) {
529
    // Required: begin, end, summary
530
    if (!params.has("begin") || !params.has("end") || !params.has("summary")) {
531
        co_return HttpResponse::bad_request(
532
            "Missing required parameters: begin, end, summary");
533
    }
534

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

540
    // Validate the optional raw DSL query before it is spliced into the view.
541
    auto query = params.get("query");
542
    if (!query.empty() &&
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");
550
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
551

552
    std::uint64_t global_min = 0;
553
    if (normalize) {
554
        global_min = index.global_min_timestamp_us();
555
        if (global_min == std::numeric_limits<std::uint64_t>::max()) {
556
            global_min = 0;  // No valid bounds, skip normalization
557
        }
558
    }
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;
564
    double original_end = end;
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) {
568
        begin = static_cast<double>(
569
            index.us_to_native(static_cast<std::uint64_t>(begin)));
570
        end = static_cast<double>(
571
            index.us_to_native(static_cast<std::uint64_t>(end)));
572
    }
573
    if (normalize && global_min > 0) {
574
        begin += static_cast<double>(global_min);
575
        end += static_cast<double>(global_min);
576
    }
577

578
    double min_dur =
579
        duration_threshold(begin, end, static_cast<unsigned>(summary));
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) {
585
        int px_width = params.get_int("width", DEFAULT_VIEWPORT_WIDTH);
586
        px_width = std::clamp(px_width, MIN_VIEWPORT_WIDTH, MAX_VIEWPORT_WIDTH);
587
        min_dur = (end - begin) / static_cast<double>(px_width);
588
    }
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
    // lookback is in us; convert to native so scan_begin (native) is right.
594
    double lookback = params.get_double("lookback", 0);
595
    if (lookback < 0) lookback = 0;
596
    if (lookback > 0 && index.time_metric() != TraceIndex::TimeMetric::US)
597
        lookback = static_cast<double>(
598
            index.us_to_native(static_cast<std::uint64_t>(lookback)));
599
    double scan_begin = begin - lookback;
600
    if (scan_begin < 0) scan_begin = 0;
601

602
    ViewDefinition view = build_viz_view(params, scan_begin, end, min_dur);
603

604
    // Optional limit: 0 (default) means no limit.
605
    int limit = params.get_int("limit", 0);
606
    if (limit < 0) limit = 0;
607

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

658
    std::vector<const TraceIndex::FileInfo*> target_files =
659
        select_viz_target_files(index, params, scan_begin, end);
660

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

672
    bool truncated = co_await scan_view_events(
673
        index, target_files, view, scan_begin, end, limit, slots, collect,
674
        req.cancel_token, !params.get("file").empty());
675

676
    std::vector<std::string> collected_events;
677
    for (auto& p : partials) {
678
        for (auto& s : p) collected_events.emplace_back(std::move(s));
679
    }
680
    if (limit > 0 && static_cast<int>(collected_events.size()) > limit) {
681
        collected_events.resize(static_cast<std::size_t>(limit));
682
        truncated = true;
683
    }
684

685
    co_await append_app_spans(collected_events, index, begin, end, params);
686

687
    std::string body = build_viz_events_body(
688
        collected_events, global_min, original_begin, original_end, limit,
689
        truncated, index.native_to_us(index.global_min_timestamp_us()),
690
        index.time_metric());
691
    co_return HttpResponse::ok(body);
692
}
16!
693

694
namespace {
695

696
struct NameStat {
697
    std::uint64_t count = 0;
698
    double total = 0;
699
    double min = 0;
700
    double max = 0;
701
    void merge_from(const NameStat& o) {
40✔
702
        if (count == 0) {
40!
703
            min = o.min;
×
704
            max = o.max;
×
705
        } else if (o.count > 0) {
40!
706
            min = std::min(min, o.min);
40✔
707
            max = std::max(max, o.max);
40✔
708
        }
709
        count += o.count;
40✔
710
        total += o.total;
40✔
711
    }
40✔
712
};
713

714
using NameMap = dftracer::utils::StringViewMap<NameStat>;
715

716
static double json_number(simdjson::dom::element el);
717

718
enum class GroupBy { Name, Cat, Pid, Fhash };
719

720
static GroupBy parse_group_by(std::string_view g) {
2✔
721
    if (g == "cat") return GroupBy::Cat;
2!
722
    if (g == "pid") return GroupBy::Pid;
2!
723
    if (g == "fhash" || g == "file") return GroupBy::Fhash;
2!
724
    return GroupBy::Name;
2✔
725
}
726

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

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

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

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

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

801
struct DensityKeyHash {
802
    std::uint64_t operator()(const DensityKey& k) const noexcept {
1,192✔
803
        std::uint64_t h = 1469598103934665603ULL;
1,192✔
804
        auto mix = [&](std::uint64_t v) {
4,768✔
805
            h ^= v;
4,768✔
806
            h *= 1099511628211ULL;
4,768✔
807
        };
5,960✔
808
        mix(static_cast<std::uint64_t>(k.pid));
1,192✔
809
        mix(static_cast<std::uint64_t>(k.tid));
1,192✔
810
        mix(static_cast<std::uint64_t>(k.col));
1,192✔
811
        mix(std::hash<std::string>{}(k.group));
1,192✔
812
        return h;
1,192✔
813
    }
814
};
815

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

833
using DensityMap =
834
    ankerl::unordered_dense::map<DensityKey, DensityAgg, DensityKeyHash>;
835

836
static double json_number(simdjson::dom::element el) {
4,302✔
837
    if (el.is_uint64())
4,302!
838
        return static_cast<double>(el.get_uint64().value_unsafe());
4,302✔
839
    if (el.is_int64())
×
840
        return static_cast<double>(el.get_int64().value_unsafe());
×
841
    if (el.is_double()) return el.get_double().value_unsafe();
×
842
    return 0;
×
843
}
844

845
// Composite group-value separator: joins the per-column values of a multi-key
846
// group_by ("cat,fhash") into one lane key. Never appears in field values.
847
static constexpr char GROUP_SEP = '\x1f';
848

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

895
// Group value for one or more columns. A comma-separated `col` ("cat,fhash")
896
// yields the per-column values joined by GROUP_SEP so lanes split by the tuple;
897
// each component keeps its raw value (hashes stay hashes) for client-side
898
// resolution.
899
static std::string extract_group_value(simdjson::dom::element root,
140✔
900
                                       std::string_view col) {
901
    if (col.find(',') == std::string_view::npos)
140!
902
        return extract_one_group_value(root, col);
140!
NEW
903
    std::string out;
×
NEW
904
    std::size_t start = 0;
×
NEW
905
    bool first = true;
×
NEW
906
    while (start <= col.size()) {
×
NEW
907
        auto comma = col.find(',', start);
×
NEW
908
        auto part = col.substr(start, comma == std::string_view::npos
×
NEW
909
                                          ? col.size() - start
×
910
                                          : comma - start);
NEW
911
        if (!first) out.push_back(GROUP_SEP);
×
NEW
912
        first = false;
×
NEW
913
        out += extract_one_group_value(root, part);
×
NEW
914
        if (comma == std::string_view::npos) break;
×
NEW
915
        start = comma + 1;
×
916
    }
NEW
917
    return out;
×
NEW
918
}
×
919

NEW
920
static std::string extract_group_from_line(std::string_view event,
×
921
                                           std::string_view col) {
NEW
922
    thread_local simdjson::dom::parser parser;
×
NEW
923
    thread_local std::string buf;
×
NEW
924
    buf.assign(event);
×
NEW
925
    auto res = parser.parse(buf);
×
NEW
926
    if (res.error()) return "";
×
NEW
927
    auto root = res.value_unsafe();
×
NEW
928
    if (!root.is_object()) return "";
×
NEW
929
    return extract_group_value(root, col);
×
930
}
931

932
// Fold a small event into the density map. Returns false (keep as an individual
933
// event) when the event has no duration or is at/above the `threshold`.
934
static bool fold_density(std::string_view event, double threshold, double begin,
190✔
935
                         DensityMap& dens, double* out_dur = nullptr,
936
                         std::string_view group_col = {}) {
937
    thread_local simdjson::dom::parser parser;
190✔
938
    thread_local std::string buf;
190✔
939
    buf.assign(event);
190!
940
    auto res = parser.parse(buf);
190✔
941
    if (res.error()) return false;
190!
942
    auto root = res.value_unsafe();
190✔
943
    if (!root.is_object()) return false;
190!
944

945
    auto dr = root["dur"];
190✔
946
    if (dr.error()) return false;
190!
947
    double dur = json_number(dr.value_unsafe());
190✔
948
    if (out_dur) *out_dur = dur;
190!
949
    if (threshold <= 0 || dur >= threshold) return false;
190!
950

951
    double ts = 0;
190✔
952
    auto tr = root["ts"];
190✔
953
    if (!tr.error()) ts = json_number(tr.value_unsafe());
190!
954
    std::int64_t pid = 0;
190✔
955
    auto pr = root["pid"];
190✔
956
    if (!pr.error())
190!
957
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
190✔
958
    std::int64_t tid = 0;
190✔
959
    auto tir = root["tid"];
190✔
960
    if (!tir.error())
190!
961
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
190✔
962
    std::string_view name;
190✔
963
    auto nr = root["name"];
190✔
964
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
190!
965

966
    std::int64_t col = static_cast<std::int64_t>((ts - begin) / threshold);
190✔
967
    std::string group;
190✔
968
    if (!group_col.empty()) group = extract_group_value(root, group_col);
190!
969
    DensityKey k{pid, tid, col, std::move(group)};
190✔
970
    auto it = dens.find(k);
190!
971
    if (it == dens.end()) it = dens.emplace(std::move(k), DensityAgg{}).first;
190!
972
    auto& a = it->second;
190✔
973
    a.count += 1;
190✔
974
    a.total += dur;
190✔
975
    if (dur > a.max_dur) {
190!
976
        a.max_dur = dur;
190✔
977
        a.name.assign(name);
190!
978
    }
979
    return true;
190✔
980
}
190✔
981

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

988
static void drop_unreferenced_hash_records(std::vector<std::string>& big) {
3✔
989
    thread_local simdjson::dom::parser parser;
3✔
990
    thread_local std::string buf;
3✔
991
    ankerl::unordered_dense::set<std::string> referenced;
3!
992
    std::vector<bool> is_decl(big.size(), false);
3!
993

994
    for (std::size_t i = 0; i < big.size(); ++i) {
3!
NEW
995
        buf.assign(big[i]);
×
NEW
996
        auto res = parser.parse(buf);
×
NEW
997
        if (res.error()) continue;
×
NEW
998
        auto root = res.value_unsafe();
×
NEW
999
        if (!root.is_object()) continue;
×
NEW
1000
        auto nr = root["name"];
×
NEW
1001
        std::string_view name;
×
NEW
1002
        if (!nr.error() && nr.is_string())
×
NEW
1003
            name = nr.get_string().value_unsafe();
×
NEW
1004
        auto args = root["args"];
×
NEW
1005
        if (args.error() || !args.is_object()) continue;
×
NEW
1006
        if (name == "FH" || name == "HH") {
×
NEW
1007
            is_decl[i] = true;
×
NEW
1008
            continue;
×
1009
        }
NEW
1010
        for (const char* key : {"fhash", "hhash"}) {
×
NEW
1011
            auto v = args[key];
×
NEW
1012
            if (!v.error() && v.is_string())
×
NEW
1013
                referenced.emplace(v.get_string().value_unsafe());
×
1014
        }
1015
    }
1016

1017
    std::size_t out = 0;
3✔
1018
    std::size_t carried = 0;
3✔
1019
    for (std::size_t i = 0; i < big.size(); ++i) {
3!
NEW
1020
        if (is_decl[i]) {
×
NEW
1021
            buf.assign(big[i]);
×
NEW
1022
            auto res = parser.parse(buf);
×
NEW
1023
            bool keep = false;
×
NEW
1024
            if (!res.error()) {
×
NEW
1025
                auto v = res.value_unsafe()["args"]["value"];
×
NEW
1026
                if (!v.error() && v.is_string())
×
NEW
1027
                    keep = referenced.count(
×
NEW
1028
                               std::string(v.get_string().value_unsafe())) != 0;
×
1029
            }
NEW
1030
            if (!keep) {
×
NEW
1031
                if (carried >= MAX_UNREFERENCED_HASH_RECORDS) continue;
×
NEW
1032
                ++carried;
×
1033
            }
1034
        }
NEW
1035
        if (out != i) big[out] = std::move(big[i]);
×
NEW
1036
        ++out;
×
1037
    }
1038
    big.resize(out);
3!
1039
}
3✔
1040

1041
// Parse just the ts and dur of an event. Returns false for metadata/instant
1042
// events that lack either field.
1043
static bool parse_ts_dur(std::string_view event, double& ts, double& dur) {
×
1044
    thread_local simdjson::dom::parser parser;
×
1045
    thread_local std::string buf;
×
1046
    buf.assign(event);
×
1047
    auto res = parser.parse(buf);
×
1048
    if (res.error()) return false;
×
1049
    auto root = res.value_unsafe();
×
1050
    if (!root.is_object()) return false;
×
1051
    auto dr = root["dur"];
×
1052
    auto tr = root["ts"];
×
1053
    if (dr.error() || tr.error()) return false;
×
1054
    dur = json_number(dr.value_unsafe());
×
1055
    ts = json_number(tr.value_unsafe());
×
1056
    return true;
×
1057
}
1058

1059
// Parse pid, tid, ts, dur of an event. Returns false for metadata/instant
1060
// events that lack ts or dur.
1061
static bool parse_lane_ts_dur(std::string_view event, std::int64_t& pid,
×
1062
                              std::int64_t& tid, double& ts, double& dur) {
1063
    thread_local simdjson::dom::parser parser;
×
1064
    thread_local std::string buf;
×
1065
    buf.assign(event);
×
1066
    auto res = parser.parse(buf);
×
1067
    if (res.error()) return false;
×
1068
    auto root = res.value_unsafe();
×
1069
    if (!root.is_object()) return false;
×
1070
    auto dr = root["dur"];
×
1071
    auto tr = root["ts"];
×
1072
    if (dr.error() || tr.error()) return false;
×
1073
    dur = json_number(dr.value_unsafe());
×
1074
    ts = json_number(tr.value_unsafe());
×
1075
    auto pr = root["pid"];
×
1076
    pid = pr.error()
×
UNCOV
1077
              ? 0
×
1078
              : static_cast<std::int64_t>(json_number(pr.value_unsafe()));
×
1079
    auto tir = root["tid"];
×
1080
    tid = tir.error()
×
UNCOV
1081
              ? 0
×
1082
              : static_cast<std::int64_t>(json_number(tir.value_unsafe()));
×
1083
    return true;
×
1084
}
1085

1086
// Containment depth per event/block, stable across zoom because an event's
1087
// ancestors are always longer and so survive any threshold that kept it. A
1088
// block is nested only under big events that fully cover its bucket interval
1089
// [ts, ts+threshold]; a bucket-sized sibling that merely overlaps it is not an
1090
// ancestor, so folded events stay on their sibling's row.
1091
static std::vector<std::uint32_t> assign_view_depths(
3✔
1092
    const std::vector<std::string>& big, DensityMap& dens, double begin,
1093
    double threshold) {
1094
    std::vector<std::uint32_t> depth(big.size(), 0);
3!
1095

1096
    struct Item {
1097
        double ts;          // interval start (big) or bucket left edge (block)
1098
        double end;         // big end, or bucket right edge for a block query
1099
        int big_idx;        // index into `big`, or -1 for a density query
1100
        DensityAgg* block;  // set for a density query, null for a big event
1101
    };
1102
    struct LaneKey {
1103
        std::int64_t pid, tid;
1104
        bool operator==(const LaneKey& o) const {
×
1105
            return pid == o.pid && tid == o.tid;
×
1106
        }
1107
    };
1108
    struct LaneHash {
1109
        std::uint64_t operator()(const LaneKey& k) const noexcept {
280✔
1110
            std::uint64_t h = 1469598103934665603ULL;
280✔
1111
            h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
280✔
1112
            h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
280✔
1113
            return h;
280✔
1114
        }
1115
    };
1116
    ankerl::unordered_dense::map<LaneKey, std::vector<Item>, LaneHash> lanes;
3!
1117

1118
    for (std::size_t i = 0; i < big.size(); ++i) {
3!
1119
        std::int64_t pid = 0, tid = 0;
×
1120
        double ts = 0, dur = 0;
×
1121
        if (!parse_lane_ts_dur(big[i], pid, tid, ts, dur)) continue;
×
1122
        double end = ts + (dur > 0 ? dur : 0);
×
1123
        lanes[LaneKey{pid, tid}].push_back(
×
1124
            Item{ts, end, static_cast<int>(i), nullptr});
×
1125
    }
1126
    if (threshold > 0) {
3!
1127
        for (auto& kv : dens) {
133✔
1128
            double lo = begin + static_cast<double>(kv.first.col) * threshold;
130✔
1129
            lanes[LaneKey{kv.first.pid, kv.first.tid}].push_back(
130!
1130
                Item{lo, lo + threshold, -1, &kv.second});
130!
1131
        }
1132
    }
1133

1134
    for (auto& kv : lanes) {
133✔
1135
        auto& items = kv.second;
130✔
1136
        // Opens sort before queries at equal ts so a block sitting exactly at a
1137
        // parent's start counts that parent as an ancestor.
1138
        std::sort(items.begin(), items.end(), [](const Item& a, const Item& b) {
130!
1139
            if (a.ts != b.ts) return a.ts < b.ts;
×
1140
            return (a.block == nullptr) && (b.block != nullptr);
×
1141
        });
1142
        // Greedy lowest-free-row packing: staggered overlapping spans reuse
1143
        // rows (real concurrency) instead of one row each, while nested events
1144
        // still cascade.
1145
        std::vector<double> row_end;  // end time occupying each row
130✔
1146
        for (auto& it : items) {
260✔
1147
            if (it.block) {
130!
1148
                std::size_t r = 0;
130✔
1149
                while (r < row_end.size() && row_end[r] > it.end) ++r;
130!
1150
                it.block->depth = static_cast<std::uint32_t>(r);
130✔
1151
            } else {
NEW
1152
                std::size_t r = 0;
×
NEW
1153
                while (r < row_end.size() && row_end[r] > it.ts) ++r;
×
NEW
1154
                if (r == row_end.size())
×
NEW
1155
                    row_end.push_back(it.end);
×
1156
                else
NEW
1157
                    row_end[r] = it.end;
×
1158
                depth[static_cast<std::size_t>(it.big_idx)] =
×
1159
                    static_cast<std::uint32_t>(r);
1160
            }
1161
        }
1162
    }
130✔
1163
    return depth;
6✔
1164
}
3✔
1165

1166
// --- Counters (bandwidth / IOPS over time) ---
1167
// Per-bucket read/write bytes and I/O op counts, aggregated from POSIX/STDIO/IO
1168
// events (bytes come from args.ret).
1169
struct CounterAcc {
1170
    std::vector<double> read_bytes;
1171
    std::vector<double> write_bytes;
1172
    std::vector<double> ops;
1173
    void init(std::size_t n) {
×
1174
        read_bytes.assign(n, 0.0);
×
1175
        write_bytes.assign(n, 0.0);
×
1176
        ops.assign(n, 0.0);
×
1177
    }
×
1178
    void merge_from(const CounterAcc& o) {
×
1179
        for (std::size_t i = 0; i < ops.size(); ++i) {
×
1180
            read_bytes[i] += o.read_bytes[i];
×
1181
            write_bytes[i] += o.write_bytes[i];
×
1182
            ops[i] += o.ops[i];
×
1183
        }
1184
    }
×
1185
};
1186

1187
static void fold_counter(std::string_view event, double begin, double bucket_us,
×
1188
                         std::size_t buckets, CounterAcc& acc) {
1189
    thread_local simdjson::dom::parser parser;
×
1190
    thread_local std::string buf;
×
1191
    buf.assign(event);
×
1192
    auto res = parser.parse(buf);
×
1193
    if (res.error()) return;
×
1194
    auto root = res.value_unsafe();
×
1195
    if (!root.is_object()) return;
×
1196

1197
    auto cat_r = root["cat"];
×
1198
    if (cat_r.error() || !cat_r.is_string()) return;
×
1199
    std::string_view cat = cat_r.get_string().value_unsafe();
×
1200
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
×
1201

1202
    auto ts_r = root["ts"];
×
1203
    if (ts_r.error()) return;
×
1204
    double ts = json_number(ts_r.value_unsafe());
×
1205
    long col = static_cast<long>((ts - begin) / bucket_us);
×
1206
    if (col < 0 || col >= static_cast<long>(buckets)) return;
×
1207

1208
    acc.ops[col] += 1.0;
×
1209

1210
    std::string_view name;
×
1211
    auto name_r = root["name"];
×
1212
    if (!name_r.error() && name_r.is_string())
×
1213
        name = name_r.get_string().value_unsafe();
×
1214

1215
    double bytes = 0;
×
1216
    auto args = root["args"];
×
1217
    if (!args.error() && args.is_object()) {
×
1218
        auto ret = args["ret"];
×
1219
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
×
1220
    }
1221
    if (bytes <= 0) return;
×
1222
    if (name.find("write") != std::string_view::npos)
×
1223
        acc.write_bytes[col] += bytes;
×
1224
    else if (name.find("read") != std::string_view::npos)
×
1225
        acc.read_bytes[col] += bytes;
×
1226
}
1227

1228
}  // namespace
1229

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

1235
namespace {
1236

1237
struct PidTid {
1238
    std::int64_t pid;
1239
    std::int64_t tid;
UNCOV
1240
    bool operator==(const PidTid& o) const {
×
UNCOV
1241
        return pid == o.pid && tid == o.tid;
×
1242
    }
1243
};
1244

1245
struct PidTidHash {
UNCOV
1246
    std::uint64_t operator()(const PidTid& k) const noexcept {
×
UNCOV
1247
        std::uint64_t h = 1469598103934665603ULL;
×
UNCOV
1248
        h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
×
UNCOV
1249
        h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
×
UNCOV
1250
        return h;
×
1251
    }
1252
};
1253

1254
// One worker's sparse cells at the finest level, keyed by lane and bucket.
1255
// `demote` coarsens the whole accumulator by 4x when it outgrows its budget;
1256
// since every level is an exact 4:1 fold of the one below, workers can sit at
1257
// different depths and still merge.
1258
struct FineAcc {
1259
    ankerl::unordered_dense::map<std::uint64_t, std::uint32_t> slot;
1260
    std::vector<std::uint64_t> keys;
1261
    std::vector<VizSummary::Cell> cells;
1262
    unsigned demote = 0;
1263

NEW
1264
    static std::uint64_t key_of(std::uint32_t lane, std::uint64_t bucket) {
×
NEW
1265
        return (static_cast<std::uint64_t>(lane) << 32) | bucket;
×
1266
    }
1267

NEW
1268
    void add(std::uint32_t lane, std::uint64_t bucket, double dur,
×
1269
             std::uint32_t name_id) {
NEW
1270
        std::uint64_t k = key_of(lane, bucket >> (2 * demote));
×
NEW
1271
        auto it = slot.find(k);
×
1272
        std::uint32_t i;
NEW
1273
        if (it != slot.end()) {
×
NEW
1274
            i = it->second;
×
1275
        } else {
NEW
1276
            i = static_cast<std::uint32_t>(cells.size());
×
NEW
1277
            slot.emplace(k, i);
×
NEW
1278
            keys.push_back(k);
×
NEW
1279
            cells.emplace_back();
×
1280
        }
NEW
1281
        auto& c = cells[i];
×
NEW
1282
        c.count += 1;
×
NEW
1283
        c.total += static_cast<std::uint64_t>(dur < 0 ? 0 : dur);
×
NEW
1284
        std::uint32_t d = dur >= 4294967295.0
×
NEW
1285
                              ? 4294967295u
×
NEW
1286
                              : static_cast<std::uint32_t>(dur < 0 ? 0 : dur);
×
NEW
1287
        if (d > c.max_dur || c.count == 1) {
×
NEW
1288
            c.max_dur = d;
×
NEW
1289
            c.name_id = name_id;
×
1290
        }
NEW
1291
    }
×
1292

NEW
1293
    void coarsen() {
×
NEW
1294
        ++demote;
×
NEW
1295
        ankerl::unordered_dense::map<std::uint64_t, std::uint32_t> ns;
×
NEW
1296
        std::vector<std::uint64_t> nk;
×
NEW
1297
        std::vector<VizSummary::Cell> nc;
×
NEW
1298
        ns.reserve(keys.size() / 2);
×
NEW
1299
        for (std::size_t i = 0; i < keys.size(); ++i) {
×
NEW
1300
            std::uint64_t k = key_of(static_cast<std::uint32_t>(keys[i] >> 32),
×
NEW
1301
                                     (keys[i] & 0xFFFFFFFFULL) >> 2);
×
NEW
1302
            auto it = ns.find(k);
×
NEW
1303
            if (it == ns.end()) {
×
NEW
1304
                ns.emplace(k, static_cast<std::uint32_t>(nc.size()));
×
NEW
1305
                nk.push_back(k);
×
NEW
1306
                nc.push_back(cells[i]);
×
1307
            } else {
NEW
1308
                auto& dst = nc[it->second];
×
NEW
1309
                const auto& src = cells[i];
×
NEW
1310
                dst.count += src.count;
×
NEW
1311
                dst.total += src.total;
×
NEW
1312
                if (src.max_dur > dst.max_dur) {
×
NEW
1313
                    dst.max_dur = src.max_dur;
×
NEW
1314
                    dst.name_id = src.name_id;
×
1315
                }
1316
            }
1317
        }
NEW
1318
        slot = std::move(ns);
×
NEW
1319
        keys = std::move(nk);
×
NEW
1320
        cells = std::move(nc);
×
UNCOV
1321
    }
×
1322
};
1323

1324
struct SumBuild {
1325
    std::size_t nb = 0;             // level 0 (counter grid)
1326
    double bucket_us = 1;
1327
    std::size_t nb_fine = 0;        // finest level, before any coarsening
1328
    double fine_bucket_us = 1;
1329
    std::size_t max_lanes = 0;
1330
    std::size_t fine_cell_cap = 0;  // per worker
1331
    std::size_t long_cap = 0;       // per worker
1332
    std::uint64_t t0 = 0;
1333

1334
    std::mutex lane_mtx;
1335
    ankerl::unordered_dense::map<PidTid, std::uint32_t, PidTidHash> lane_of;
1336
    std::vector<PidTid> lane_keys;
1337

1338
    std::vector<std::atomic<double>> cread;
1339
    std::vector<std::atomic<double>> cwrite;
1340
    std::vector<std::atomic<double>> cops;
1341
    std::atomic<std::uint64_t> gmax_dur{0};
1342

1343
    std::mutex name_mtx;
1344
    dftracer::utils::StringViewMap<std::uint32_t> name_of;
1345
    std::vector<std::string> names;
1346

1347
    // Per-worker caches so the hot path never locks.
1348
    std::vector<ankerl::unordered_dense::map<PidTid, std::uint32_t, PidTidHash>>
1349
        lane_cache;
1350
    std::vector<dftracer::utils::StringViewMap<std::uint32_t>> name_cache;
1351

1352
    std::vector<FineAcc> fine;
1353
    std::vector<std::size_t> long_quota;  // per level-0 bucket, per worker
1354

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

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

1364
    std::vector<dftracer::utils::StringViewMap<std::string>> sh_parts;
1365
    std::vector<ankerl::unordered_dense::map<std::int64_t, std::string>>
1366
        app_start, app_end;
1367

1368
    // Per-pid application name from the CM "app" config record. Traces that put
1369
    // the app name in CM (not the start event's exec_hash) rely on this for the
1370
    // synthetic app-span label.
1371
    std::vector<ankerl::unordered_dense::map<std::int64_t, std::string>> cm_app;
1372

1373
    // Per-worker events at least one finest-level bucket wide, kept whole (see
1374
    // VizSummary::long_events).
1375
    std::vector<std::vector<VizSummary::AppSpan>> long_evs;
1376

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

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

1385
    // Per-worker file-hashes seen on a read/write op (files with real data
1386
    // I/O).
1387
    std::vector<dftracer::utils::StringViewSet> io_fh;
1388

1389
    // Per-worker process-hierarchy state (see VizSummary::procs), keyed by pid
1390
    // so it stays small however many events a process emits.
1391
    std::vector<ankerl::unordered_dense::map<std::int64_t, VizSummary::ProcRow>>
1392
        procs;
1393
    std::vector<std::vector<VizSummary::ForkEdge>> forks;
1394
    std::vector<dftracer::utils::StringViewMap<std::string>> hh_parts;
1395

1396
    VizSummary::ProcRow& proc_of(std::size_t w, std::int64_t pid) {
385✔
1397
        auto& m = procs[w];
385✔
1398
        auto it = m.find(pid);
385!
1399
        if (it == m.end()) {
385✔
1400
            it = m.emplace(pid, VizSummary::ProcRow{}).first;
134!
1401
            it->second.pid = pid;
134✔
1402
        }
1403
        return it->second;
385✔
1404
    }
1405

1406
    static constexpr std::uint32_t NO_LANE =
1407
        std::numeric_limits<std::uint32_t>::max();
1408

NEW
1409
    std::uint32_t get_lane(std::size_t w, std::int64_t pid, std::int64_t tid) {
×
UNCOV
1410
        PidTid key{pid, tid};
×
UNCOV
1411
        auto& cache = lane_cache[w];
×
UNCOV
1412
        auto ci = cache.find(key);
×
UNCOV
1413
        if (ci != cache.end()) return ci->second;
×
UNCOV
1414
        std::lock_guard<std::mutex> lk(lane_mtx);
×
UNCOV
1415
        auto it = lane_of.find(key);
×
1416
        std::uint32_t lane;
UNCOV
1417
        if (it != lane_of.end()) {
×
NEW
1418
            lane = it->second;
×
1419
        } else {
NEW
1420
            if (lane_keys.size() >= max_lanes)
×
NEW
1421
                return NO_LANE;  // budget reached; drop further lanes
×
NEW
1422
            lane = static_cast<std::uint32_t>(lane_keys.size());
×
NEW
1423
            lane_of.emplace(key, lane);
×
NEW
1424
            lane_keys.push_back(key);
×
1425
        }
UNCOV
1426
        cache.emplace(key, lane);
×
UNCOV
1427
        return lane;
×
UNCOV
1428
    }
×
1429

1430
    // Fold an event into the worker's cells, coarsening when over budget.
NEW
1431
    void fold_cell(std::size_t w, std::uint32_t lane, double ts, double dur,
×
1432
                   std::uint32_t name_id) {
NEW
1433
        auto& acc = fine[w];
×
NEW
1434
        double rel = (ts - static_cast<double>(t0)) / fine_bucket_us;
×
NEW
1435
        auto bucket = rel <= 0 ? 0 : static_cast<std::uint64_t>(rel);
×
NEW
1436
        if (bucket >= nb_fine) bucket = nb_fine - 1;
×
NEW
1437
        acc.add(lane, bucket, dur, name_id);
×
NEW
1438
        while (acc.cells.size() > fine_cell_cap &&
×
NEW
1439
               acc.demote < VizSummary::EXTRA_LEVELS)
×
NEW
1440
            acc.coarsen();
×
NEW
1441
    }
×
1442

NEW
1443
    std::uint64_t coarse_bucket(std::uint64_t ts) const {
×
NEW
1444
        double rel =
×
NEW
1445
            (static_cast<double>(ts) - static_cast<double>(t0)) / bucket_us;
×
NEW
1446
        if (rel <= 0) return 0;
×
NEW
1447
        auto bk = static_cast<std::uint64_t>(rel);
×
NEW
1448
        return bk >= nb ? nb - 1 : bk;
×
1449
    }
1450

1451
    // Trim a worker's long events to `long_quota` per level-0 bucket, keeping
1452
    // the widest and folding the rest into cells. A global "widest wins" cap
1453
    // would spend the whole budget on a few enormous events and leave zoomed-in
1454
    // windows empty, so the budget is spread over time instead.
NEW
1455
    void compact_long(std::size_t w) {
×
NEW
1456
        auto& lv = long_evs[w];
×
NEW
1457
        std::sort(
×
1458
            lv.begin(), lv.end(),
NEW
1459
            [this](const VizSummary::AppSpan& a, const VizSummary::AppSpan& x) {
×
NEW
1460
                auto ba = coarse_bucket(a.begin);
×
NEW
1461
                auto bx = coarse_bucket(x.begin);
×
NEW
1462
                if (ba != bx) return ba < bx;
×
NEW
1463
                return a.end - a.begin > x.end - x.begin;
×
1464
            });
NEW
1465
        std::size_t out = 0, run = 0;
×
NEW
1466
        std::uint64_t cur = std::numeric_limits<std::uint64_t>::max();
×
NEW
1467
        for (std::size_t i = 0; i < lv.size(); ++i) {
×
NEW
1468
            auto bk = coarse_bucket(lv[i].begin);
×
NEW
1469
            if (bk != cur) {
×
NEW
1470
                cur = bk;
×
NEW
1471
                run = 0;
×
1472
            }
NEW
1473
            if (run < long_quota[w]) {
×
NEW
1474
                ++run;
×
NEW
1475
                if (out != i) lv[out] = std::move(lv[i]);
×
NEW
1476
                ++out;
×
NEW
1477
                continue;
×
1478
            }
NEW
1479
            auto lane = get_lane(w, lv[i].pid, lv[i].tid);
×
NEW
1480
            if (lane != NO_LANE)
×
NEW
1481
                fold_cell(w, lane, static_cast<double>(lv[i].begin),
×
NEW
1482
                          static_cast<double>(lv[i].end - lv[i].begin),
×
NEW
1483
                          lv[i].name_id);
×
1484
        }
NEW
1485
        lv.resize(out);
×
NEW
1486
    }
×
1487

1488
    void push_long(std::size_t w, VizSummary::AppSpan&& span) {
385✔
1489
        auto& lv = long_evs[w];
385✔
1490
        lv.push_back(std::move(span));
385✔
1491
        if (lv.size() < long_cap) return;
385!
NEW
1492
        compact_long(w);
×
NEW
1493
        if (lv.size() > long_cap / 2 && long_quota[w] > 1) {
×
NEW
1494
            long_quota[w] /= 2;
×
NEW
1495
            compact_long(w);
×
1496
        }
1497
    }
1498

1499
    std::uint32_t intern(std::size_t w, std::string_view name) {
385✔
1500
        auto& cache = name_cache[w];
385✔
1501
        auto ci = cache.find(name);
385!
1502
        if (ci != cache.end()) return ci->second;
385✔
1503
        std::lock_guard<std::mutex> lk(name_mtx);
40!
1504
        auto it = name_of.find(name);
40!
1505
        std::uint32_t id;
1506
        if (it != name_of.end()) {
40✔
1507
            id = it->second;
8✔
1508
        } else {
1509
            id = static_cast<std::uint32_t>(names.size());
32✔
1510
            names.emplace_back(name);
32!
1511
            name_of.emplace(std::string(name), id);
32!
1512
        }
1513
        cache.emplace(std::string(name), id);
40!
1514
        return id;
40✔
1515
    }
40✔
1516
};
1517

1518
template <class T>
1519
static void atomic_max(std::atomic<T>& a, T v) {
385✔
1520
    T cur = a.load(std::memory_order_relaxed);
385✔
1521
    while (v > cur &&
489!
1522
           !a.compare_exchange_weak(cur, v, std::memory_order_relaxed)) {
208!
1523
    }
1524
}
385✔
1525

1526
static void atomic_add_double(std::atomic<double>& a, double v) {
289✔
1527
    double cur = a.load(std::memory_order_relaxed);
289✔
1528
    while (!a.compare_exchange_weak(cur, cur + v, std::memory_order_relaxed)) {
289!
1529
    }
1530
}
289✔
1531

1532
// Fold one (key, dur) sample into a group aggregate, mirroring fold_event.
1533
static void fold_group(NameMap& m, std::string_view key, double dur) {
1,155✔
1534
    auto it = m.find(key);
1,155!
1535
    if (it == m.end()) {
1,155✔
1536
        it = m.emplace(std::string(key), NameStat{}).first;
191!
1537
        it->second.min = dur;
191✔
1538
        it->second.max = dur;
191✔
1539
    } else {
1540
        it->second.min = std::min(it->second.min, dur);
964✔
1541
        it->second.max = std::max(it->second.max, dur);
964✔
1542
    }
1543
    it->second.count += 1;
1,155✔
1544
    it->second.total += dur;
1,155✔
1545
}
1,155✔
1546

1547
static bool is_fork_syscall(std::string_view name) {
385✔
1548
    return name == "fork" || name == "vfork" || name == "clone" ||
1,155!
1549
           name == "clone3" || name == "posix_spawn" ||
1,155!
1550
           name == "posix_spawnp" ||
770!
1551
           name.find("sys_clone") != std::string_view::npos ||
770!
1552
           name.find("sys_fork") != std::string_view::npos ||
1,155!
1553
           name.find("sys_vfork") != std::string_view::npos;
770✔
1554
}
1555

1556
static void fold_summary(std::size_t w, std::string_view event, SumBuild& b) {
386✔
1557
    thread_local simdjson::dom::parser parser;
386✔
1558
    thread_local std::string buf;
386✔
1559
    buf.assign(event);
386!
1560
    auto res = parser.parse(buf);
386✔
1561
    if (res.error()) return;
613!
1562
    auto root = res.value_unsafe();
386✔
1563
    if (!root.is_object()) return;
386!
1564

1565
    std::string_view name0;
386✔
1566
    {
1567
        auto nr = root["name"];
386✔
1568
        if (!nr.error() && nr.is_string())
386!
1569
            name0 = nr.get_string().value_unsafe();
386✔
1570
    }
1571
    // Hash and rank metadata carry no ts/dur, so they are harvested before the
1572
    // gates below drop such records.
1573
    if (name0 == "FH" || name0 == "SH" || name0 == "HH") {
386!
1574
        auto args = root["args"];
×
1575
        if (!args.error() && args.is_object()) {
×
1576
            // Data events on some traces carry no args, so metadata records are
1577
            // the only source of a process's host hash; capture it here.
NEW
1578
            auto pp = root["pid"];
×
NEW
1579
            auto hr = args["hhash"];
×
NEW
1580
            if (!pp.error() && !hr.error() && hr.is_string()) {
×
1581
                auto pid =
NEW
1582
                    static_cast<std::int64_t>(json_number(pp.value_unsafe()));
×
NEW
1583
                auto& row = b.proc_of(w, pid);
×
NEW
1584
                if (row.hhash.empty())
×
NEW
1585
                    row.hhash = std::string(hr.get_string().value_unsafe());
×
1586
            }
1587
            auto v = args["value"];
×
1588
            auto n = args["name"];
×
1589
            if (!v.error() && v.is_string() && !n.error() && n.is_string()) {
×
NEW
1590
                auto& tbl = name0 == "FH"   ? b.fh_parts[w]
×
NEW
1591
                            : name0 == "SH" ? b.sh_parts[w]
×
NEW
1592
                                            : b.hh_parts[w];
×
1593
                tbl.emplace(std::string(v.get_string().value_unsafe()),
×
1594
                            std::string(n.get_string().value_unsafe()));
×
1595
            }
1596
        }
1597
        return;
×
1598
    }
1599
    if (name0 == "PR") {
386!
NEW
1600
        auto args = root["args"];
×
NEW
1601
        auto pp = root["pid"];
×
NEW
1602
        if (!args.error() && args.is_object() && !pp.error()) {
×
NEW
1603
            auto an = args["name"];
×
NEW
1604
            auto av = args["value"];
×
NEW
1605
            if (!an.error() && an.is_string() &&
×
NEW
1606
                an.get_string().value_unsafe() == "rank" && !av.error() &&
×
NEW
1607
                av.is_string()) {
×
1608
                auto pid =
NEW
1609
                    static_cast<std::int64_t>(json_number(pp.value_unsafe()));
×
NEW
1610
                auto& row = b.proc_of(w, pid);
×
NEW
1611
                if (row.rank.empty())
×
NEW
1612
                    row.rank = std::string(av.get_string().value_unsafe());
×
1613
            }
1614
        }
NEW
1615
        return;
×
1616
    }
1617
    if (name0 == "CM") {
386✔
1618
        auto args = root["args"];
1✔
1619
        auto pp = root["pid"];
1✔
1620
        if (!args.error() && args.is_object() && !pp.error()) {
1!
1621
            auto an = args["name"];
1✔
1622
            auto av = args["value"];
1✔
1623
            if (!an.error() && an.is_string() &&
3!
1624
                an.get_string().value_unsafe() == "app" && !av.error() &&
3!
NEW
1625
                av.is_string()) {
×
1626
                auto pid =
NEW
1627
                    static_cast<std::int64_t>(json_number(pp.value_unsafe()));
×
NEW
1628
                b.cm_app[w].emplace(
×
NEW
1629
                    pid, std::string(av.get_string().value_unsafe()));
×
1630
            }
1631
        }
1632
        return;
1✔
1633
    }
1634

1635
    auto dr = root["dur"];
385✔
1636
    bool has_dur = !dr.error();
385✔
1637
    double dur = has_dur ? json_number(dr.value_unsafe()) : 0;
385!
1638

1639
    // Process hierarchy (see VizSummary::procs). Runs before the dur gate so it
1640
    // sees exactly what a live proctree scan sees.
1641
    {
1642
        auto pr = root["pid"];
385✔
1643
        auto tr = root["ts"];
385✔
1644
        if (!pr.error() && !tr.error()) {
385!
1645
            auto pid =
1646
                static_cast<std::int64_t>(json_number(pr.value_unsafe()));
385✔
1647
            auto ts =
1648
                static_cast<std::uint64_t>(json_number(tr.value_unsafe()));
385✔
1649
            auto& row = b.proc_of(w, pid);
385!
1650
            if (row.first_ts == 0 || ts < row.first_ts) row.first_ts = ts;
385!
1651

1652
            double ret = 0;
385✔
1653
            auto args = root["args"];
385✔
1654
            if (!args.error() && args.is_object()) {
385!
1655
                auto ppr = args["ppid"];
185✔
1656
                if (!ppr.error()) {
185✔
1657
                    auto pp = static_cast<std::int64_t>(
1658
                        json_number(ppr.value_unsafe()));
13✔
1659
                    if (pp > 0 && pp != pid) row.ppid = pp;
13!
1660
                }
1661
                auto rr = args["ret"];
185✔
1662
                if (!rr.error()) ret = json_number(rr.value_unsafe());
185✔
1663
                auto hr = args["hhash"];
185✔
1664
                if (!hr.error() && hr.is_string() && row.hhash.empty())
185!
1665
                    row.hhash = std::string(hr.get_string().value_unsafe());
133!
1666
            }
1667
            if (ret > 0 && (name0.find("read") != std::string_view::npos ||
460✔
1668
                            name0.find("write") != std::string_view::npos))
75✔
1669
                row.bytes += static_cast<std::uint64_t>(ret);
130✔
1670

1671
            auto cr = root["cat"];
385✔
1672
            if (!cr.error() && cr.is_string()) {
385!
1673
                auto cat = cr.get_string().value_unsafe();
385✔
1674
                if (cat == "POSIX" || cat == "STDIO" || cat == "IO") {
385!
1675
                    row.io_ops += 1;
159✔
1676
                    if (has_dur) row.io_busy += dur;
159!
1677
                }
1678
            }
1679
            if (!name0.empty() && is_fork_syscall(name0))
385!
NEW
1680
                b.forks[w].push_back(VizSummary::ForkEdge{
×
NEW
1681
                    ts, pid, ret > 0 ? static_cast<std::int64_t>(ret) : -1});
×
1682
        }
1683
    }
1684

1685
    if (!has_dur) return;
385!
1686

1687
    // Column discovery: harvest the schema once per distinct event name (same
1688
    // name => same keys), so this is O(distinct names), not O(events).
1689
    if (b.col_seen[w].find(name0) == b.col_seen[w].end()) {
385!
1690
        b.col_seen[w].emplace(name0);
40!
1691
        static const ankerl::unordered_dense::set<std::string_view> SKIP = {
1692
            "pid", "tid", "ts", "dur", "ph", "id", "args"};
40!
1693
        auto obj = root.get_object();
40✔
1694
        if (!obj.error())
40!
1695
            for (auto kv : obj.value_unsafe())
399✔
1696
                if (SKIP.find(kv.key) == SKIP.end()) b.cols[w].emplace(kv.key);
359!
1697
        auto ar = root["args"];
40✔
1698
        if (!ar.error() && ar.is_object()) {
40!
1699
            simdjson::dom::object args_obj;
39✔
1700
            if (ar.get_object().get(args_obj) == simdjson::SUCCESS)
39!
1701
                for (auto kv : args_obj) b.cols[w].emplace(kv.key);
127!
1702
        }
1703
    }
1704

1705
    if (name0 == "start" || name0 == "end") {
385✔
1706
        auto cr = root["cat"];
26✔
1707
        auto pp = root["pid"];
26✔
1708
        if (!cr.error() && cr.is_string() &&
78!
1709
            cr.get_string().value_unsafe() == "dftracer" && !pp.error()) {
78!
1710
            auto p = static_cast<std::int64_t>(json_number(pp.value_unsafe()));
26✔
1711
            (name0 == "start" ? b.app_start[w] : b.app_end[w])
52✔
1712
                .emplace(p, std::string(event));
52!
1713
        }
1714
    }
1715

1716
    std::int64_t pid = 0, tid = 0;
385✔
1717
    auto pr = root["pid"];
385✔
1718
    if (!pr.error())
385!
1719
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
385✔
1720

1721
    // Analyze aggregates: whole-trace, ts-independent so they match a live
1722
    // scan.
1723
    {
1724
        auto nr = root["name"];
385✔
1725
        auto cr = root["cat"];
385✔
1726
        if (!nr.error() && nr.is_string()) {
385!
1727
            std::string_view nm = nr.get_string().value_unsafe();
385✔
1728
            fold_group(b.g_name[w], nm, dur);
385!
1729
            if (!cr.error() && cr.is_string() &&
770!
1730
                b.name_cat[w].find(nm) == b.name_cat[w].end())
770!
1731
                b.name_cat[w].emplace(
80!
1732
                    std::string(nm),
80!
1733
                    std::string(cr.get_string().value_unsafe()));
80!
1734
        }
1735
        if (!cr.error() && cr.is_string())
385!
1736
            fold_group(b.g_cat[w], cr.get_string().value_unsafe(), dur);
385!
1737
        thread_local std::string pidkey;
385✔
1738
        pidkey.assign(std::to_string(pid));
385!
1739
        fold_group(b.g_pid[w], pidkey, dur);
385!
1740
        auto ar = root["args"];
385✔
1741
        if (!ar.error() && ar.is_object()) {
385!
1742
            auto fr = ar["fhash"];
185✔
1743
            if (!fr.error() && fr.is_string())
185!
1744
                fold_group(b.g_fhash[w], fr.get_string().value_unsafe(), dur);
×
1745
        }
1746
    }
1747

1748
    auto tr = root["ts"];
385✔
1749
    if (tr.error()) return;  // bucketing/counters below need a timestamp
385!
1750
    double ts = json_number(tr.value_unsafe());
385✔
1751
    std::int64_t bucket = static_cast<std::int64_t>(
385✔
1752
        (ts - static_cast<double>(b.t0)) / b.fine_bucket_us);
385✔
1753
    if (bucket < 0) return;
385!
1754
    if (bucket >= static_cast<std::int64_t>(b.nb_fine)) bucket = b.nb_fine - 1;
385!
1755
    // Counters live at the finest resolution and are folded down to the coarser
1756
    // levels afterwards, so a zoomed-in counter track needs no live scan.
1757
    auto bi = static_cast<std::size_t>(bucket);
385✔
1758

1759
    auto tir = root["tid"];
385✔
1760
    if (!tir.error())
385!
1761
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
385✔
1762

1763
    std::uint32_t name_id;
1764
    {
1765
        std::string_view name;
385✔
1766
        auto nr = root["name"];
385✔
1767
        if (!nr.error() && nr.is_string())
385!
1768
            name = nr.get_string().value_unsafe();
385✔
1769
        name_id = b.intern(w, name);
385!
1770
    }
1771

1772
    if (dur >= b.fine_bucket_us && dur > 0) {
385!
1773
        // Wider than a finest-level bucket: folded into a cell it would render
1774
        // as a sliver, so keep it whole and let each request decide whether it
1775
        // is wide enough to draw.
1776
        b.push_long(
385!
1777
            w, VizSummary::AppSpan{static_cast<std::uint64_t>(ts),
770!
1778
                                   static_cast<std::uint64_t>(ts + dur), pid,
385✔
1779
                                   tid, name_id, std::string(event)});
1780
    } else {
NEW
1781
        auto lane = b.get_lane(w, pid, tid);
×
NEW
1782
        if (lane != SumBuild::NO_LANE) b.fold_cell(w, lane, ts, dur, name_id);
×
1783
    }
1784
    atomic_max(b.gmax_dur, static_cast<std::uint64_t>(dur < 0 ? 0 : dur));
385!
1785

1786
    // Counters: read/write bytes and op counts for POSIX/STDIO/IO events.
1787
    auto cat_r = root["cat"];
385✔
1788
    if (cat_r.error() || !cat_r.is_string()) return;
385!
1789
    std::string_view cat = cat_r.get_string().value_unsafe();
385✔
1790
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
385!
1791
    atomic_add_double(b.cops[bi], 1.0);
159✔
1792
    double bytes = 0;
159✔
1793
    auto args = root["args"];
159✔
1794
    if (!args.error() && args.is_object()) {
159!
1795
        auto ret = args["ret"];
159✔
1796
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
159!
1797
    }
1798
    if (bytes <= 0) return;
159!
1799
    std::string_view name;
159✔
1800
    auto nr = root["name"];
159✔
1801
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
159!
1802
    bool is_write = name.find("write") != std::string_view::npos;
159✔
1803
    bool is_read = name.find("read") != std::string_view::npos;
159✔
1804
    if (is_write)
159✔
1805
        atomic_add_double(b.cwrite[bi], bytes);
46✔
1806
    else if (is_read)
113✔
1807
        atomic_add_double(b.cread[bi], bytes);
84✔
1808
    if ((is_read || is_write) && !args.error() && args.is_object()) {
159!
1809
        auto fr = args["fhash"];
130✔
1810
        if (!fr.error() && fr.is_string())
130!
1811
            b.io_fh[w].emplace(std::string(fr.get_string().value_unsafe()));
×
1812
    }
1813
}
1814

1815
}  // namespace
1816

1817
// Build the activity summary by scanning every event once. Blocks the caller
1818
// (the first overview request) for the scan; cached for the server's lifetime.
1819
static coro::CoroTask<void> build_viz_summary(TraceIndex& index) {
10!
1820
    auto summary = std::make_unique<VizSummary>();
1821
    std::uint64_t gmin = index.global_min_timestamp_us();
1822
    std::uint64_t gmax = index.global_max_timestamp_us();
1823
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin) {
1824
        index.set_viz_summary(std::move(summary));
1825
        co_return;
1826
    }
1827

1828
    std::size_t est_lanes = std::max<std::size_t>(1, index.file_count()) * 8;
1829
    std::size_t nb = VizSummary::MAX_CELLS / est_lanes;
1830
    nb = std::clamp(nb, VizSummary::MIN_BUCKETS_PER_LANE,
1831
                    VizSummary::MAX_BUCKETS_PER_LANE);
1832

1833
    SumBuild b;
1834
    b.nb = nb;
1835
    b.t0 = gmin;
1836
    b.bucket_us = static_cast<double>(gmax - gmin) / static_cast<double>(nb);
1837
    b.max_lanes = std::max<std::size_t>(1, VizSummary::MAX_CELLS / nb);
1838
    b.nb_fine = nb << (2 * VizSummary::EXTRA_LEVELS);
1839
    b.fine_bucket_us =
1840
        static_cast<double>(gmax - gmin) / static_cast<double>(b.nb_fine);
1841
    b.cread = std::vector<std::atomic<double>>(b.nb_fine);
1842
    b.cwrite = std::vector<std::atomic<double>>(b.nb_fine);
1843
    b.cops = std::vector<std::atomic<double>>(b.nb_fine);
1844
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
1845
    b.fine_cell_cap =
1846
        std::max<std::size_t>(1, VizSummary::MAX_FINE_CELLS / slots);
1847
    b.long_cap = std::max<std::size_t>(2, VizSummary::MAX_LONG_EVENTS / slots);
1848
    b.fine.resize(slots);
1849
    b.long_quota.assign(
1850
        slots, std::max<std::size_t>(1, VizSummary::MAX_LONG_EVENTS /
1851
                                            std::max<std::size_t>(1, nb)));
1852
    b.lane_cache.resize(slots);
1853
    b.name_cache.resize(slots);
1854
    b.g_name.resize(slots);
1855
    b.g_cat.resize(slots);
1856
    b.g_pid.resize(slots);
1857
    b.g_fhash.resize(slots);
1858
    b.fh_parts.resize(slots);
1859
    b.sh_parts.resize(slots);
1860
    b.app_start.resize(slots);
1861
    b.app_end.resize(slots);
1862
    b.cm_app.resize(slots);
1863
    b.long_evs.resize(slots);
1864
    b.cols.resize(slots);
1865
    b.col_seen.resize(slots);
1866
    b.name_cat.resize(slots);
1867
    b.io_fh.resize(slots);
1868
    b.procs.resize(slots);
1869
    b.forks.resize(slots);
1870
    b.hh_parts.resize(slots);
1871

1872
    ViewDefinition view;
1873
    view.name = "viz_summary";
1874
    view.description = "Activity summary build";
1875
    // The summary aggregates all hash metadata (hosts, file paths); traces
1876
    // whose data events carry no hash args would otherwise never surface it.
1877
    view.with_emit_all_metadata(true);
1878
    std::vector<const TraceIndex::FileInfo*> files;
1879
    files.reserve(index.files().size());
1880
    for (const auto& f : index.files()) files.push_back(&f);
1881

1882
    auto on_batch = [&b](std::size_t w,
9✔
1883
                         const std::vector<std::string_view>& events) {
386✔
1884
        for (auto ev : events) fold_summary(w, ev, b);
395!
1885
    };
9✔
1886
    co_await scan_view_events(index, files, view, static_cast<double>(gmin),
1887
                              static_cast<double>(gmax), 0, slots, on_batch,
1888
                              CancelToken{});
1889

1890
    summary->t_begin = gmin;
1891
    summary->t_end = gmax;
1892
    summary->nbuckets = nb;
1893
    summary->bucket_us = b.bucket_us;
1894
    summary->max_dur = b.gmax_dur.load(std::memory_order_relaxed);
1895
    summary->names = std::move(b.names);
1896
    summary->long_threshold_us = b.fine_bucket_us;
1897
    // Workers each hold up to their own per-bucket quota, so the union can
1898
    // exceed the budget; compact once more across all of them.
1899
    for (std::size_t w = 1; w < b.long_evs.size(); ++w) {
1900
        auto& part = b.long_evs[w];
1901
        for (auto& ev : part) b.long_evs[0].push_back(std::move(ev));
1902
        part.clear();
1903
        part.shrink_to_fit();
1904
    }
1905
    b.long_quota[0] =
1906
        std::max<std::size_t>(1, VizSummary::MAX_LONG_EVENTS / nb);
1907
    while (b.long_evs[0].size() > VizSummary::MAX_LONG_EVENTS) {
1908
        b.compact_long(0);
1909
        if (b.long_evs[0].size() <= VizSummary::MAX_LONG_EVENTS) break;
1910
        if (b.long_quota[0] == 1) break;
1911
        b.long_quota[0] /= 2;
1912
    }
1913
    summary->long_events = std::move(b.long_evs[0]);
1914

1915
    // Workers that outgrew their cell budget coarsened independently; the
1916
    // merged pyramid can only be as fine as the coarsest of them.
1917
    unsigned demote = 0;
1918
    for (auto& acc : b.fine) demote = std::max(demote, acc.demote);
1919
    for (auto& acc : b.fine)
1920
        while (acc.demote < demote) acc.coarsen();
1921
    std::sort(summary->long_events.begin(), summary->long_events.end(),
1922
              [](const VizSummary::AppSpan& a, const VizSummary::AppSpan& x) {
2,411✔
1923
                  return a.begin < x.begin;
2,411✔
1924
              });
1925

1926
    {
1927
        std::size_t total = 0;
1928
        for (auto& acc : b.fine) total += acc.cells.size();
1929
        std::vector<std::pair<std::uint64_t, VizSummary::Cell>> merged;
1930
        merged.reserve(total);
1931
        for (auto& acc : b.fine) {
1932
            for (std::size_t i = 0; i < acc.keys.size(); ++i)
1933
                merged.emplace_back(acc.keys[i], acc.cells[i]);
1934
            acc = FineAcc{};
1935
        }
1936
        std::sort(
1937
            merged.begin(), merged.end(),
NEW
1938
            [](const auto& x, const auto& y) { return x.first < y.first; });
×
1939

1940
        std::size_t nb_fine = b.nb_fine >> (2 * demote);
1941
        // Counters were folded at the pre-coarsening resolution; bring them to
1942
        // the merged one, then they ride the same 4:1 folds as the cells.
1943
        std::vector<double> cread(nb_fine, 0.0), cwrite(nb_fine, 0.0),
1944
            cops(nb_fine, 0.0);
1945
        for (std::size_t i = 0; i < b.nb_fine; ++i) {
1946
            std::size_t j = i >> (2 * demote);
1947
            if (j >= nb_fine) j = nb_fine - 1;
1948
            cread[j] += b.cread[i].load(std::memory_order_relaxed);
1949
            cwrite[j] += b.cwrite[i].load(std::memory_order_relaxed);
1950
            cops[j] += b.cops[i].load(std::memory_order_relaxed);
1951
        }
1952

1953
        for (std::size_t l = 0; l <= VizSummary::EXTRA_LEVELS - demote; ++l) {
1954
            if (l > 0) {
1955
                // Fold 4:1 into the next coarser level, in place: keys stay
1956
                // sorted because the lane stays in the high bits.
1957
                std::size_t out = 0;
1958
                for (std::size_t i = 0; i < merged.size(); ++i) {
1959
                    std::uint64_t k = (merged[i].first & ~0xFFFFFFFFULL) |
1960
                                      ((merged[i].first & 0xFFFFFFFFULL) >> 2);
1961
                    if (out > 0 && merged[out - 1].first == k) {
1962
                        auto& dst = merged[out - 1].second;
1963
                        const auto& src = merged[i].second;
1964
                        dst.count += src.count;
1965
                        dst.total += src.total;
1966
                        if (src.max_dur > dst.max_dur) {
1967
                            dst.max_dur = src.max_dur;
1968
                            dst.name_id = src.name_id;
1969
                        }
1970
                    } else {
1971
                        merged[out] = merged[i];
1972
                        merged[out].first = k;
1973
                        ++out;
1974
                    }
1975
                }
1976
                merged.resize(out);
1977
                nb_fine >>= 2;
1978
                auto fold = [](const std::vector<double>& src, std::size_t n) {
90✔
1979
                    std::vector<double> dst(n, 0.0);
90!
1980
                    for (std::size_t i = 0; i < src.size(); ++i) {
165,150,810✔
1981
                        std::size_t j = i >> 2;
165,150,720✔
1982
                        dst[j < n ? j : n - 1] += src[i];
165,150,720!
1983
                    }
1984
                    return dst;
90✔
1985
                };
1986
                cread = fold(cread, nb_fine);
1987
                cwrite = fold(cwrite, nb_fine);
1988
                cops = fold(cops, nb_fine);
1989
            } else {
1990
                std::size_t out = 0;
1991
                for (std::size_t i = 0; i < merged.size(); ++i) {
1992
                    if (out > 0 && merged[out - 1].first == merged[i].first) {
1993
                        auto& dst = merged[out - 1].second;
1994
                        const auto& src = merged[i].second;
1995
                        dst.count += src.count;
1996
                        dst.total += src.total;
1997
                        if (src.max_dur > dst.max_dur) {
1998
                            dst.max_dur = src.max_dur;
1999
                            dst.name_id = src.name_id;
2000
                        }
2001
                    } else {
2002
                        merged[out++] = merged[i];
2003
                    }
2004
                }
2005
                merged.resize(out);
2006
            }
2007

2008
            VizSummary::Level level;
2009
            level.nbuckets = nb_fine;
2010
            level.bucket_us =
2011
                static_cast<double>(gmax - gmin) / static_cast<double>(nb_fine);
2012
            level.read_bytes = cread;
2013
            level.write_bytes = cwrite;
2014
            level.ops = cops;
2015
            level.lanes.resize(b.lane_keys.size());
2016
            for (std::size_t i = 0; i < b.lane_keys.size(); ++i) {
2017
                level.lanes[i].pid = b.lane_keys[i].pid;
2018
                level.lanes[i].tid = b.lane_keys[i].tid;
2019
            }
2020
            for (const auto& [k, cell] : merged) {
2021
                auto li = static_cast<std::size_t>(k >> 32);
2022
                if (li >= level.lanes.size()) continue;
2023
                level.lanes[li].buckets.push_back(
2024
                    static_cast<std::uint32_t>(k & 0xFFFFFFFFULL));
2025
                level.lanes[li].cells.push_back(cell);
2026
            }
2027
            summary->levels.push_back(std::move(level));
2028
        }
2029
        std::reverse(summary->levels.begin(), summary->levels.end());
2030
    }
2031

2032
    {
2033
        ankerl::unordered_dense::set<std::string> all_cols;
2034
        for (auto& c : b.cols)
2035
            for (auto& k : c) all_cols.emplace(k);
2036
        summary->columns.assign(all_cols.begin(), all_cols.end());
2037
        std::sort(summary->columns.begin(), summary->columns.end());
2038
    }
2039

2040
    // Merge the per-worker Analyze aggregates and sort each by total desc.
2041
    auto finalize_group = [](std::vector<NameMap>& parts) {
40✔
2042
        NameMap merged;
40!
2043
        for (auto& p : parts) {
120✔
2044
            for (auto& kv : p) {
271✔
2045
                auto it = merged.find(kv.first);
191!
2046
                if (it == merged.end())
191✔
2047
                    merged.emplace(kv.first, kv.second);
151!
2048
                else
2049
                    it->second.merge_from(kv.second);
40!
2050
            }
2051
        }
2052
        std::vector<VizSummary::GroupRow> rows;
40✔
2053
        rows.reserve(merged.size());
40!
2054
        for (auto& kv : merged)
191✔
2055
            rows.push_back({kv.first, kv.second.count, kv.second.total,
302!
2056
                            kv.second.min, kv.second.max});
151!
2057
        std::sort(rows.begin(), rows.end(),
40!
2058
                  [](const VizSummary::GroupRow& lhs,
530✔
2059
                     const VizSummary::GroupRow& rhs) {
2060
                      return lhs.total > rhs.total;
530✔
2061
                  });
2062
        return rows;
80✔
2063
    };
40✔
2064
    summary->by_name = finalize_group(b.g_name);
2065
    summary->by_cat = finalize_group(b.g_cat);
2066
    summary->by_pid = finalize_group(b.g_pid);
2067
    summary->by_fhash = finalize_group(b.g_fhash);
2068

2069
    // Resolve file-hash keys to real paths where a metadata record was seen.
2070
    dftracer::utils::StringViewMap<std::string> fh;
2071
    for (auto& part : b.fh_parts)
2072
        for (auto& kv : part) fh.emplace(kv.first, kv.second);
2073
    for (auto& r : summary->by_fhash) {
2074
        auto it = fh.find(r.key);
2075
        if (it != fh.end()) r.key = it->second;
2076
    }
2077
    summary->total_files = fh.size();
2078

2079
    {
2080
        dftracer::utils::StringViewMap<std::string> sh;
2081
        for (auto& part : b.sh_parts)
2082
            for (auto& kv : part) sh.emplace(kv.first, kv.second);
2083
        ankerl::unordered_dense::map<std::int64_t, std::string> starts, ends;
2084
        for (auto& part : b.app_start)
2085
            for (auto& kv : part) starts.emplace(kv.first, kv.second);
2086
        for (auto& part : b.app_end)
2087
            for (auto& kv : part) ends.emplace(kv.first, kv.second);
2088
        ankerl::unordered_dense::map<std::int64_t, std::string> cm_apps;
2089
        for (auto& part : b.cm_app)
2090
            for (auto& kv : part) cm_apps.emplace(kv.first, kv.second);
2091

2092
        auto resolve = [](dftracer::utils::StringViewMap<std::string>& tbl,
39✔
2093
                          const std::string& h) -> std::string {
2094
            auto it = tbl.find(h);
39!
2095
            return it != tbl.end() ? it->second : h;
78!
2096
        };
2097
        simdjson::dom::parser sp, ep;
2098
        for (auto& [pid, sjson] : starts) {
2099
            auto eit = ends.find(pid);
2100
            if (eit == ends.end()) continue;
2101
            std::string sbuf(sjson), ebuf(eit->second);
2102
            auto sr = sp.parse(sbuf);
2103
            auto er = ep.parse(ebuf);
2104
            if (sr.error() || er.error()) continue;
2105
            auto se = sr.value_unsafe();
2106
            auto ee = er.value_unsafe();
2107

2108
            auto num = [](simdjson::dom::element r, const char* k) -> double {
39✔
2109
                auto v = r[k];
39✔
2110
                return v.error() ? 0.0 : json_number(v.value_unsafe());
39!
2111
            };
2112
            auto sarg = [](simdjson::dom::element r,
65✔
2113
                           const char* k) -> std::string {
2114
                auto a = r["args"];
65✔
2115
                if (a.error()) return "";
65!
2116
                auto v = a[k];
65✔
2117
                return (!v.error() && v.is_string())
26!
2118
                           ? std::string(v.get_string().value_unsafe())
91✔
2119
                           : "";
182!
2120
            };
2121
            auto narg = [](simdjson::dom::element r, const char* k) -> double {
26✔
2122
                auto a = r["args"];
26✔
2123
                if (a.error()) return 0.0;
26!
2124
                auto v = a[k];
26✔
2125
                return v.error() ? 0.0 : json_number(v.value_unsafe());
26!
2126
            };
2127

2128
            auto start_ts = static_cast<std::uint64_t>(num(se, "ts"));
2129
            auto end_ts = static_cast<std::uint64_t>(num(ee, "ts"));
2130
            if (end_ts <= start_ts) continue;
2131
            auto tid = static_cast<std::int64_t>(num(se, "tid"));
2132
            std::string app = resolve(sh, sarg(se, "exec_hash"));
2133
            std::string cmd = resolve(sh, sarg(se, "cmd_hash"));
2134
            std::string cwd = resolve(fh, sarg(se, "cwd"));
2135
            std::string version = sarg(se, "version");
2136
            std::string date = sarg(se, "date");
2137
            auto ppid = static_cast<std::int64_t>(narg(se, "ppid"));
2138
            auto num_events = static_cast<std::int64_t>(narg(ee, "num_events"));
2139
            if (app.empty()) {
2140
                auto ci = cm_apps.find(pid);
2141
                if (ci != cm_apps.end()) app = ci->second;
2142
            }
2143
            if (app.empty()) app = "app " + std::to_string(pid);
2144

2145
            auto& jb = scratch_json_builder();
2146
            jb.start_object();
2147
            jb.append_key_value("name", app);
2148
            jb.append_comma();
2149
            jb.append_key_value("cat", "dftracer");
2150
            jb.append_comma();
2151
            jb.append_key_value("pid", pid);
2152
            jb.append_comma();
2153
            jb.append_key_value("tid", tid);
2154
            jb.append_comma();
2155
            jb.append_key_value("ts", start_ts);
2156
            jb.append_comma();
2157
            jb.append_key_value("dur", end_ts - start_ts);
2158
            jb.append_comma();
2159
            jb.append_key_value("ph", "X");
2160
            jb.append_comma();
2161
            jb.escape_and_append_with_quotes("args");
2162
            jb.append_colon();
2163
            jb.start_object();
2164
            jb.append_key_value("app", app);
2165
            jb.append_comma();
2166
            jb.append_key_value("cmd", cmd);
2167
            jb.append_comma();
2168
            jb.append_key_value("cwd", cwd);
2169
            jb.append_comma();
2170
            jb.append_key_value("ppid", ppid);
2171
            jb.append_comma();
2172
            jb.append_key_value("version", version);
2173
            jb.append_comma();
2174
            jb.append_key_value("date", date);
2175
            jb.append_comma();
2176
            jb.append_key_value("num_events", num_events);
2177
            jb.end_object();
2178
            jb.end_object();
2179
            summary->app_spans.push_back(
2180
                {start_ts, end_ts, pid, tid,
2181
                 std::numeric_limits<std::uint32_t>::max(), std::string(jb)});
2182
        }
2183
    }
2184

2185
    dftracer::utils::StringViewSet io_fh;
2186
    for (auto& part : b.io_fh)
2187
        for (auto& k : part) io_fh.emplace(k);
2188
    summary->io_files = io_fh.size();
2189

2190
    {
2191
        ankerl::unordered_dense::map<std::int64_t, VizSummary::ProcRow> procs;
2192
        for (auto& part : b.procs) {
2193
            for (auto& [pid, src] : part) {
2194
                auto it = procs.find(pid);
2195
                if (it == procs.end()) {
2196
                    procs.emplace(pid, src);
2197
                    continue;
2198
                }
2199
                auto& dst = it->second;
2200
                if (dst.first_ts == 0 ||
2201
                    (src.first_ts != 0 && src.first_ts < dst.first_ts))
2202
                    dst.first_ts = src.first_ts;
2203
                if (dst.ppid < 0) dst.ppid = src.ppid;
2204
                dst.bytes += src.bytes;
2205
                dst.io_ops += src.io_ops;
2206
                dst.io_busy += src.io_busy;
2207
                if (dst.hhash.empty()) dst.hhash = src.hhash;
2208
                if (dst.rank.empty()) dst.rank = src.rank;
2209
            }
2210
            part.clear();
2211
        }
2212
        summary->procs.reserve(procs.size());
2213
        for (auto& [pid, row] : procs) summary->procs.push_back(row);
2214
        std::sort(summary->procs.begin(), summary->procs.end(),
2215
                  [](const VizSummary::ProcRow& a,
461✔
2216
                     const VizSummary::ProcRow& x) { return a.pid < x.pid; });
461✔
2217

2218
        for (auto& part : b.forks) {
2219
            for (auto& f : part) summary->forks.push_back(f);
2220
            part.clear();
2221
        }
2222
        std::sort(summary->forks.begin(), summary->forks.end(),
NEW
2223
                  [](const VizSummary::ForkEdge& a,
×
NEW
2224
                     const VizSummary::ForkEdge& x) { return a.ts < x.ts; });
×
2225

2226
        dftracer::utils::StringViewMap<std::string> hh;
2227
        for (auto& part : b.hh_parts)
2228
            for (auto& kv : part) hh.emplace(kv.first, kv.second);
2229
        summary->hosts.reserve(hh.size());
2230
        for (auto& kv : hh) summary->hosts.emplace_back(kv.first, kv.second);
2231
    }
2232

2233
    // Merge the per-worker name -> category maps (first writer wins).
2234
    dftracer::utils::StringViewMap<std::string> nc;
2235
    for (auto& part : b.name_cat)
2236
        for (auto& kv : part) nc.emplace(kv.first, kv.second);
2237
    summary->name_cats.reserve(nc.size());
2238
    for (auto& kv : nc) summary->name_cats.emplace_back(kv.first, kv.second);
2239

2240
    if (!summary->app_spans.empty()) {
2241
        // Break = dead time between runs: merge app-spans into run clusters and
2242
        // emit every gap between them, no size threshold.
2243
        std::vector<const VizSummary::AppSpan*> spans;
2244
        spans.reserve(summary->app_spans.size());
2245
        for (const auto& sp : summary->app_spans) spans.push_back(&sp);
2246
        std::sort(spans.begin(), spans.end(),
2247
                  [](const auto* lhs, const auto* rhs) {
16✔
2248
                      return lhs->begin < rhs->begin;
16✔
2249
                  });
2250
        std::vector<std::pair<std::uint64_t, std::uint64_t>> runs;
2251
        for (const auto* sp : spans) {
2252
            if (!runs.empty() && sp->begin <= runs.back().second)
2253
                runs.back().second = std::max(runs.back().second, sp->end);
2254
            else
2255
                runs.emplace_back(sp->begin, sp->end);
2256
        }
2257
        for (std::size_t i = 1; i < runs.size(); ++i)
2258
            if (runs[i].first > runs[i - 1].second)
2259
                summary->idle_gaps.emplace_back(runs[i - 1].second,
2260
                                                runs[i].first);
2261
    } else if (nb > 0 && summary->bucket_us > 0) {
2262
        // No app-spans (malformed trace): fall back to a lane-activity scan,
2263
        // requiring a gap to be a fraction of active (not total) time.
2264
        std::vector<bool> active(nb, false);
2265
        if (!summary->levels.empty())
2266
            for (const auto& lane : summary->levels.front().lanes)
2267
                for (std::uint32_t bk : lane.buckets)
2268
                    if (bk < nb) active[bk] = true;
2269
        for (const auto& ev : summary->long_events) {
2270
            auto b0 = summary->bucket_of(static_cast<double>(ev.begin));
2271
            auto b1 = summary->bucket_of(static_cast<double>(ev.end));
2272
            if (b0 < 0) continue;
2273
            if (b1 < b0) b1 = b0;
2274
            for (std::int64_t i = b0; i <= b1; ++i)
2275
                active[static_cast<std::size_t>(i)] = true;
2276
        }
2277
        std::size_t first = 0, last = 0, active_count = 0;
2278
        bool any = false;
2279
        for (std::size_t i = 0; i < nb; ++i)
2280
            if (active[i]) {
2281
                if (!any) {
2282
                    first = i;
2283
                    any = true;
2284
                }
2285
                last = i;
2286
                ++active_count;
2287
            }
2288
        if (any) {
2289
            const double active_us =
2290
                static_cast<double>(active_count) * summary->bucket_us;
2291
            const double min_gap_us =
2292
                std::max(3.0 * summary->bucket_us, 0.05 * active_us);
2293
            for (std::size_t i = first; i <= last;) {
2294
                if (active[i]) {
2295
                    ++i;
2296
                    continue;
2297
                }
2298
                std::size_t j = i;
2299
                while (j <= last && !active[j]) ++j;
2300
                if (static_cast<double>(j - i) * summary->bucket_us >=
2301
                    min_gap_us) {
2302
                    auto g0 = summary->t_begin +
2303
                              static_cast<std::uint64_t>(
2304
                                  static_cast<double>(i) * summary->bucket_us);
2305
                    auto g1 = summary->t_begin +
2306
                              static_cast<std::uint64_t>(
2307
                                  static_cast<double>(j) * summary->bucket_us);
2308
                    summary->idle_gaps.emplace_back(g0, g1);
2309
                }
2310
                i = j;
2311
            }
2312
        }
2313
    }
2314

2315
    if (g_shutdown_requested.load(std::memory_order_acquire)) {
2316
        DFTRACER_UTILS_LOG_INFO("viz: summary build abandoned (shutting down)");
2317
        co_return;
2318
    }
2319

2320
    std::size_t cells = 0;
2321
    for (const auto& lane : summary->levels.back().lanes)
2322
        cells += lane.cells.size();
2323
    DFTRACER_UTILS_LOG_INFO(
2324
        "viz: built activity summary (%zu lanes, %zu levels, finest %.0f us "
2325
        "with %zu cells, %zu long events, %zu idle gaps)",
2326
        b.lane_keys.size(), summary->levels.size(),
2327
        summary->levels.back().bucket_us, cells, summary->long_events.size(),
2328
        summary->idle_gaps.size());
2329
    index.set_viz_summary(std::move(summary));
2330
}
20!
2331

2332
// The summary, building it on first use. Requests that arrive during the build
2333
// wait for it: a whole-trace live scan each (the old fallback) costs more than
2334
// the build they are waiting on, and the client opens the timeline with half a
2335
// dozen summary-backed requests at once.
2336
static coro::CoroTask<const VizSummary*> ensure_viz_summary(TraceIndex& index) {
34!
2337
    const VizSummary* s = index.viz_summary();
2338
    if (s) co_return s;
2339
    co_await index.viz_summary_mutex().lock();
2340
    coro::AsyncMutexGuard guard(index.viz_summary_mutex());
2341
    s = index.viz_summary();
2342
    if (!s) {
2343
        if (!index.load_persisted_viz_summary()) {
2344
            co_await build_viz_summary(index);
2345
            index.persist_viz_summary();
2346
        }
2347
        s = index.viz_summary();
2348
    }
2349
    co_return s;
2350
}
68!
2351

2352
coro::CoroTask<void> prewarm_viz_summary(TraceIndex& index) {
11!
2353
    co_await ensure_viz_summary(index);
2354
}
22!
2355

2356
// One aggregate row, uniform over the live-scan and summary paths.
2357
struct StatRow {
2358
    const std::string* key;
2359
    std::uint64_t count;
2360
    double total;
2361
    double min;
2362
    double max;
2363
};
2364

2365
template <typename builder_type>
2366
void tag_invoke(simdjson::serialize_tag, builder_type& b, const StatRow& r) {
9✔
2367
    b.start_object();
9✔
2368
    b.append_key_value("name", *r.key);
9✔
2369
    b.append_comma();
9✔
2370
    b.append_key_value("count", r.count);
9✔
2371
    b.append_comma();
9✔
2372
    b.append_key_value("total", r.total);
9✔
2373
    b.append_comma();
9✔
2374
    b.append_key_value("avg",
9✔
2375
                       r.count ? r.total / static_cast<double>(r.count) : 0.0);
9!
2376
    b.append_comma();
9✔
2377
    b.append_key_value("min", r.min);
9✔
2378
    b.append_comma();
9✔
2379
    b.append_key_value("max", r.max);
9✔
2380
    b.end_object();
9✔
2381
}
9✔
2382

2383
static std::string serialize_stats_body(std::uint64_t total_count,
2✔
2384
                                        double total_dur, double wall,
2385
                                        bool truncated,
2386
                                        const std::vector<StatRow>& rows) {
2387
    auto& b = scratch_json_builder();
2✔
2388
    b.start_object();
2✔
2389
    b.append_key_value("count", total_count);
2✔
2390
    b.append_comma();
2✔
2391
    b.append_key_value("total_dur", total_dur);
2✔
2392
    b.append_comma();
2✔
2393
    b.append_key_value("wall", wall);
2✔
2394
    b.append_comma();
2✔
2395
    b.append_key_value("truncated", truncated);
2✔
2396
    b.append_comma();
2✔
2397
    b.append_key_value("names", rows);
2✔
2398
    b.end_object();
2✔
2399
    return std::string(b);
2!
2400
}
2401

2402
// Scale duration fields (native trace unit -> us) before serialization. The
2403
// `wall` value is derived from client-us begin/end and is already in us.
2404
static void scale_stat_durations(std::vector<StatRow>& rows, double& total_dur,
2✔
2405
                                 TraceIndex::TimeMetric metric) {
2406
    const double us =
2407
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
2✔
2408
            metric);
2409
    if (us == 1.0) return;
2✔
2410
    for (auto& r : rows) {
2✔
2411
        r.total *= us;
1✔
2412
        r.min *= us;
1✔
2413
        r.max *= us;
1✔
2414
    }
2415
    total_dur *= us;
1✔
2416
}
2417

2418
static const std::vector<VizSummary::GroupRow>& summary_group_rows(
2✔
2419
    const VizSummary& s, GroupBy g) {
2420
    switch (g) {
2!
UNCOV
2421
        case GroupBy::Cat:
×
2422
            return s.by_cat;
×
UNCOV
2423
        case GroupBy::Pid:
×
2424
            return s.by_pid;
×
UNCOV
2425
        case GroupBy::Fhash:
×
2426
            return s.by_fhash;
×
2427
        case GroupBy::Name:
2✔
2428
        default:
2429
            return s.by_name;
2✔
2430
    }
2431
}
2432

2433
// Whole-trace, unfiltered Analyze answers come from the prebuilt summary, so no
2434
// live scan is needed. Any predicate or sub-range forces the live path.
2435
static bool viz_stats_summary_eligible(const QueryParams& p, double begin_abs,
2✔
2436
                                       double end_abs, TraceIndex& index) {
2437
    if (!p.get("query").empty() || !p.get("cat").empty() ||
4!
2438
        !p.get("lanes").empty() || !p.get("filters").empty() ||
2!
2439
        !p.get("file").empty() || !p.get("pid").empty() ||
6!
2440
        !p.get("tid").empty())
4!
2441
        return false;
×
2442
    std::uint64_t gmin = index.global_min_timestamp_us();
2✔
2443
    std::uint64_t gmax = index.global_max_timestamp_us();
2✔
2444
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
2!
2445
        return false;
×
2446
    return begin_abs <= static_cast<double>(gmin) + 1.0 &&
4!
2447
           end_abs >= static_cast<double>(gmax) - 1.0;
4!
2448
}
2449

2450
// GET /api/v1/viz/stats: server-side per-name aggregation over a time range.
2451
// Scans in parallel worker coroutines, each folding into its own map, then
2452
// merges single-threaded (no lock). Returns only the small aggregate table.
2453
static coro::CoroTask<HttpResponse> handle_viz_stats(const HttpRequest& req,
2!
2454
                                                     const QueryParams& params,
2455
                                                     TraceIndex& index) {
2456
    if (!params.has("begin") || !params.has("end")) {
2457
        co_return HttpResponse::bad_request(
2458
            "Missing required parameters: begin, end");
2459
    }
2460

2461
    double begin = params.get_double("begin", 0);
2462
    double end = params.get_double("end", 0);
2463

2464
    auto query = params.get("query");
2465
    if (!query.empty() &&
2466
        !utilities::common::query::try_parse(query).has_value()) {
2467
        co_return HttpResponse::bad_request("Invalid query: " +
2468
                                            std::string(query));
2469
    }
2470

2471
    auto ts_norm_param = params.get("ts_normalize");
2472
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
2473
    std::uint64_t global_min = 0;
2474
    if (normalize) {
2475
        global_min = index.global_min_timestamp_us();
2476
        if (global_min == std::numeric_limits<std::uint64_t>::max())
2477
            global_min = 0;
2478
    }
2479
    double original_begin = begin;
2480
    double original_end = end;
2481
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
2482
        begin = static_cast<double>(
2483
            index.us_to_native(static_cast<std::uint64_t>(begin)));
2484
        end = static_cast<double>(
2485
            index.us_to_native(static_cast<std::uint64_t>(end)));
2486
    }
2487
    if (normalize && global_min > 0) {
2488
        begin += static_cast<double>(global_min);
2489
        end += static_cast<double>(global_min);
2490
    }
2491

2492
    GroupBy group = parse_group_by(params.get("group"));
2493

2494
    const std::string cache_key =
2495
        std::string(req.path) + "?" + params.canonical_key();
2496
    if (auto hit = index.viz_cache().get(cache_key))
2497
        co_return HttpResponse::ok(std::move(*hit));
2498

2499
    // Whole-trace, unfiltered Analyze: answer from the prebuilt summary (built
2500
    // lazily here). Concurrent builds fall through to the live scan below.
2501
    if (viz_stats_summary_eligible(params, begin, end, index)) {
2502
        const VizSummary* s = co_await ensure_viz_summary(index);
2503
        if (s) {
2504
            const auto& gr = summary_group_rows(*s, group);
2505
            std::vector<StatRow> rows;
2506
            rows.reserve(gr.size());
2507
            std::uint64_t total_count = 0;
2508
            double total_dur = 0;
2509
            for (const auto& r : gr) {
2510
                rows.push_back({&r.key, r.count, r.total, r.min, r.max});
2511
                total_count += r.count;
2512
                total_dur += r.total;
2513
            }
2514
            scale_stat_durations(rows, total_dur, index.time_metric());
2515
            co_return HttpResponse::ok(serialize_stats_body(
2516
                total_count, total_dur, original_end - original_begin, false,
2517
                rows));
2518
        }
2519
    }
2520

2521
    // Full detail for stats: no min-duration threshold.
2522
    ViewDefinition view = build_viz_view(params, begin, end, 0);
2523
    view.with_include_metadata(false);  // aggregate only; skip ph=M records
2524

2525
    int limit = params.get_int("limit", 0);
2526
    if (limit < 0) limit = 0;
2527

2528
    std::vector<const TraceIndex::FileInfo*> target_files =
2529
        select_viz_target_files(index, params, begin, end);
2530

2531
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
2532
    std::vector<NameMap> partials(slots);
2533
    auto aggregate = [&partials, group](
×
2534
                         std::size_t w,
UNCOV
2535
                         const std::vector<std::string_view>& events) {
×
2536
        NameMap& local = partials[w];  // owned by worker w only, no lock
×
2537
        for (auto ev : events) fold_event(ev, group, local);
×
2538
    };
×
2539

2540
    bool truncated = co_await scan_view_events(
2541
        index, target_files, view, begin, end, limit, slots, aggregate,
2542
        req.cancel_token, !params.get("file").empty());
2543

2544
    // Single-threaded reduce of the disjoint per-worker maps.
2545
    NameMap merged;
2546
    for (auto& p : partials) {
2547
        for (auto& kv : p) {
2548
            auto it = merged.find(kv.first);
2549
            if (it == merged.end())
2550
                merged.emplace(kv.first, kv.second);
2551
            else
2552
                it->second.merge_from(kv.second);
2553
        }
2554
    }
2555

2556
    std::vector<StatRow> rows;
2557
    rows.reserve(merged.size());
2558
    std::uint64_t total_count = 0;
2559
    double total_dur = 0;
2560
    for (const auto& kv : merged) {
2561
        rows.push_back({&kv.first, kv.second.count, kv.second.total,
2562
                        kv.second.min, kv.second.max});
2563
        total_count += kv.second.count;
2564
        total_dur += kv.second.total;
2565
    }
2566
    std::sort(rows.begin(), rows.end(), [](const StatRow& a, const StatRow& b) {
×
2567
        return a.total > b.total;
×
2568
    });
2569

2570
    scale_stat_durations(rows, total_dur, index.time_metric());
2571
    std::string body = serialize_stats_body(
2572
        total_count, total_dur, original_end - original_begin, truncated, rows);
2573
    if (!req.cancel_token.cancelled()) index.viz_cache().put(cache_key, body);
2574
    co_return HttpResponse::ok(std::move(body));
2575
}
4!
2576

2577
// GET /api/v1/viz/layers: whole-trace reference data - the operation-name ->
2578
// category map (a property of the name, not the view, so fetched once) plus the
2579
// FH file counts: total declared vs. those an I/O event actually touched.
2580
static coro::CoroTask<HttpResponse> handle_viz_layers(
2!
2581
    const HttpRequest& /*req*/, const QueryParams& /*p*/, TraceIndex& index) {
2582
    const VizSummary* s = co_await ensure_viz_summary(index);
2583
    auto& b = scratch_json_builder();
2584
    b.start_object();
2585
    b.escape_and_append_with_quotes("layers");
2586
    b.append_colon();
2587
    b.start_object();
2588
    if (s) {
2589
        bool first = true;
2590
        for (const auto& kv : s->name_cats) {
2591
            if (!first) b.append_comma();
2592
            first = false;
2593
            b.escape_and_append_with_quotes(kv.first);
2594
            b.append_colon();
2595
            b.escape_and_append_with_quotes(kv.second);
2596
        }
2597
    }
2598
    b.end_object();
2599
    b.append_comma();
2600
    b.append_key_value("total_files",
2601
                       static_cast<std::int64_t>(s ? s->total_files : 0));
2602
    b.append_comma();
2603
    b.append_key_value("io_files",
2604
                       static_cast<std::int64_t>(s ? s->io_files : 0));
2605
    b.end_object();
2606
    co_return HttpResponse::ok(std::string(b));
2607
}
4!
2608

2609
// One scanned event, reduced to what the call-tree needs.
2610
struct FlameEv {
2611
    std::int64_t pid = 0;
2612
    std::int64_t tid = 0;
2613
    double ts = 0;
2614
    double dur = 0;
2615
    std::string name;
2616
};
2617

2618
// A node in the merged call tree: identical name-paths across all lanes fold
2619
// into one node. `total` is inclusive; `self` is total minus nested children.
2620
struct FlameNode {
2621
    std::string name;
2622
    double total = 0;
2623
    double self = 0;
2624
    std::uint64_t count = 0;
2625
    dftracer::utils::StringViewMap<std::uint32_t> kids;
2626
    std::vector<std::uint32_t> children;
2627
};
2628

2629
static bool parse_flame_ev(std::string_view event, FlameEv& out) {
190✔
2630
    thread_local simdjson::dom::parser parser;
190✔
2631
    auto res = parser.parse(event.data(), event.size());
190✔
2632
    if (res.error()) return false;
190!
2633
    auto root = res.value_unsafe();
190✔
2634
    if (!root.is_object()) return false;
190!
2635
    auto dr = root["dur"];
190✔
2636
    if (dr.error()) return false;  // no duration: nothing to place in the tree
190!
2637
    out.dur = json_number(dr.value_unsafe());
190✔
2638
    auto tr = root["ts"];
190✔
2639
    if (tr.error()) return false;
190!
2640
    out.ts = json_number(tr.value_unsafe());
190✔
2641
    auto pr = root["pid"];
190✔
2642
    out.pid = pr.error()
190✔
2643
                  ? 0
380!
2644
                  : static_cast<std::int64_t>(json_number(pr.value_unsafe()));
190✔
2645
    auto tir = root["tid"];
190✔
2646
    out.tid = tir.error()
190✔
2647
                  ? 0
380!
2648
                  : static_cast<std::int64_t>(json_number(tir.value_unsafe()));
190✔
2649
    auto nr = root["name"];
190✔
2650
    if (!nr.error() && nr.is_string())
190!
2651
        out.name.assign(nr.get_string().value_unsafe());
190!
2652
    else
2653
        out.name.clear();
×
2654
    return true;
190✔
2655
}
2656

2657
static void serialize_flame_node(simdjson::builder::string_builder& sb,
99✔
2658
                                 std::vector<FlameNode>& arena,
2659
                                 std::uint32_t idx) {
2660
    FlameNode& n = arena[idx];
99✔
2661
    sb.start_object();
99✔
2662
    sb.append_key_value("name", n.name);
99✔
2663
    sb.append_comma();
99✔
2664
    sb.append_key_value("total", n.total);
99✔
2665
    sb.append_comma();
99✔
2666
    sb.append_key_value("self", n.self < 0 ? 0.0 : n.self);
99!
2667
    sb.append_comma();
99✔
2668
    sb.append_key_value("count", n.count);
99✔
2669
    std::sort(n.children.begin(), n.children.end(),
99✔
2670
              [&arena](std::uint32_t a, std::uint32_t b) {
544✔
2671
                  return arena[a].total > arena[b].total;
272✔
2672
              });
2673
    sb.append_comma();
99✔
2674
    sb.escape_and_append_with_quotes("children");
99✔
2675
    sb.append_colon();
99✔
2676
    sb.start_array();
99✔
2677
    for (std::size_t i = 0; i < n.children.size(); ++i) {
195✔
2678
        if (i > 0) sb.append_comma();
96✔
2679
        serialize_flame_node(sb, arena, n.children[i]);
96✔
2680
    }
2681
    sb.end_array();
99✔
2682
    sb.end_object();
99✔
2683
}
99✔
2684

2685
// Fold one event under `base` in `arena` via the open-stack containment walk.
2686
// kids are keyed by owned name copies (StringViewMap), so the source events may
2687
// be discarded afterwards.
2688
static void fold_flame_event(
190✔
2689
    std::vector<FlameNode>& arena,
2690
    std::vector<std::pair<double, std::uint32_t>>& open, std::uint32_t base,
2691
    const FlameEv& ev) {
2692
    double e_end = ev.ts + (ev.dur > 0 ? ev.dur : 0);
190!
2693
    while (!open.empty() && open.back().first <= ev.ts) open.pop_back();
190!
2694
    std::uint32_t parent = open.empty() ? base : open.back().second;
190!
2695
    std::uint32_t mi;
2696
    auto it = arena[parent].kids.find(ev.name);
190!
2697
    if (it == arena[parent].kids.end()) {
190✔
2698
        mi = static_cast<std::uint32_t>(arena.size());
86✔
2699
        arena.emplace_back();
86!
2700
        arena[mi].name = ev.name;
86!
2701
        arena[parent].kids.emplace(ev.name, mi);
86!
2702
        arena[parent].children.push_back(mi);
86!
2703
    } else {
2704
        mi = it->second;
104✔
2705
    }
2706
    arena[mi].total += ev.dur;
190✔
2707
    arena[mi].self += ev.dur;
190✔
2708
    arena[mi].count += 1;
190✔
2709
    if (parent != 0) arena[parent].self -= ev.dur;
190✔
2710
    open.push_back({e_end, mi});
190!
2711
}
190✔
2712

2713
// Sort one file's events by (pid,tid,ts,dur) and fold each lane into `arena`.
2714
// Lanes never cross files (one pid per rank file), so this is a complete,
2715
// self-contained partial tree for the file.
2716
static void fold_file_events(
5✔
2717
    std::vector<FlameNode>& arena,
2718
    ankerl::unordered_dense::map<std::int64_t, std::uint32_t>& proc_of,
2719
    std::vector<std::pair<double, std::uint32_t>>& open,
2720
    std::vector<FlameEv>& evs, bool by_process) {
2721
    std::sort(evs.begin(), evs.end(), [](const FlameEv& a, const FlameEv& b) {
5✔
2722
        if (a.pid != b.pid) return a.pid < b.pid;
820!
NEW
2723
        if (a.tid != b.tid) return a.tid < b.tid;
×
NEW
2724
        if (a.ts != b.ts) return a.ts < b.ts;
×
NEW
2725
        return a.dur > b.dur;
×
2726
    });
2727
    std::size_t i = 0;
5✔
2728
    while (i < evs.size()) {
195✔
2729
        std::int64_t pid = evs[i].pid, tid = evs[i].tid;
190✔
2730
        std::uint32_t base = 0;
190✔
2731
        if (by_process) {
190✔
2732
            auto pit = proc_of.find(pid);
70!
2733
            if (pit == proc_of.end()) {
70!
2734
                base = static_cast<std::uint32_t>(arena.size());
70✔
2735
                arena.emplace_back();
70!
2736
                arena[base].name = "P" + std::to_string(pid);
70!
2737
                proc_of.emplace(pid, base);
70!
2738
                arena[0].children.push_back(base);
70!
2739
                arena[0].kids.emplace(arena[base].name, base);
70!
2740
            } else {
NEW
2741
                base = pit->second;
×
2742
            }
2743
        }
2744
        open.clear();
190✔
2745
        for (; i < evs.size() && evs[i].pid == pid && evs[i].tid == tid; ++i)
380!
2746
            fold_flame_event(arena, open, base, evs[i]);
190!
2747
    }
2748
}
5✔
2749

2750
// Merge partial tree `src` (subtree si) into `dst` (node di), summing stats per
2751
// name-path. src node names stay alive for the whole merge.
2752
static void merge_flame_arena(std::vector<FlameNode>& dst, std::uint32_t di,
82✔
2753
                              const std::vector<FlameNode>& src,
2754
                              std::uint32_t si) {
2755
    dst[di].total += src[si].total;
82✔
2756
    dst[di].self += src[si].self;
82✔
2757
    dst[di].count += src[si].count;
82✔
2758
    for (std::uint32_t sc : src[si].children) {
162✔
2759
        std::uint32_t dc;
2760
        auto it = dst[di].kids.find(src[sc].name);
80!
2761
        if (it == dst[di].kids.end()) {
80✔
2762
            dc = static_cast<std::uint32_t>(dst.size());
20✔
2763
            dst.emplace_back();
20!
2764
            dst[dc].name = src[sc].name;
20!
2765
            dst[di].kids.emplace(src[sc].name, dc);
20!
2766
            dst[di].children.push_back(dc);
20!
2767
        } else {
2768
            dc = it->second;
60✔
2769
        }
2770
        merge_flame_arena(dst, dc, src, sc);
80!
2771
    }
2772
}
82✔
2773

2774
// Streaming call-tree worker: claims whole files (work-stealing via
2775
// `next_file`) and folds each file's events into `out` as a partial tree,
2776
// discarding events per file so peak memory is one file's events, not the whole
2777
// trace. Heavy locals are heap-allocated to keep the coroutine frame small.
2778
static coro::CoroTask<void> calltree_stream_worker(
5!
2779
    const std::vector<const TraceIndex::FileInfo*>* files,
2780
    std::atomic<std::size_t>* next_file, TraceIndex* index,
2781
    const ViewDefinition* view, double begin, double end, bool scan_all_chunks,
2782
    bool by_process, std::int64_t cap, std::atomic<std::int64_t>* produced,
2783
    CancelToken cancel, std::vector<FlameNode>* out) {
2784
    auto& arena = *out;
2785
    arena.emplace_back();  // partial root (index 0)
2786
    arena[0].name = "all";
2787
    auto file_buf = std::make_unique<std::vector<FlameEv>>();
2788
    auto open =
2789
        std::make_unique<std::vector<std::pair<double, std::uint32_t>>>();
2790
    auto proc_of = std::make_unique<
2791
        ankerl::unordered_dense::map<std::int64_t, std::uint32_t>>();
2792

2793
    while (true) {
2794
        if (cancel.cancelled()) co_return;
2795
        if (produced->load(std::memory_order_relaxed) >= cap) co_return;
2796
        std::size_t fi = next_file->fetch_add(1, std::memory_order_relaxed);
2797
        if (fi >= files->size()) co_return;
2798
        auto* file_info = (*files)[fi];
2799
        if (file_info->uncompressed_size == 0 &&
2800
            file_info->num_checkpoints == 0)
2801
            continue;
2802

2803
        ViewBuilderInput builder_input;
2804
        builder_input.with_view(*view)
2805
            .with_file_path(file_info->path)
2806
            .with_index_path(file_info->has_bloom_data ? file_info->index_path
2807
                                                       : "")
2808
            .with_uncompressed_size(file_info->uncompressed_size)
2809
            .with_num_checkpoints(file_info->num_checkpoints)
2810
            .with_bloom_cache(&index->bloom_cache())
2811
            .with_time_range(begin, end)
2812
            .with_scan_all_chunks(scan_all_chunks);
2813
        ViewBuilderUtility builder;
2814
        auto build_output = co_await builder.process(builder_input);
2815
        if (!build_output || !build_output->file_may_match) continue;
2816

2817
        file_buf->clear();
2818
        for (const auto& c : build_output->candidates) {
2819
            if (cancel.cancelled()) co_return;
2820
            ViewReaderInput reader_input;
2821
            reader_input.with_file_path(file_info->path)
2822
                .with_index_path(file_info->index_path)
2823
                .with_byte_range(c.start_byte, c.end_byte)
2824
                .with_checkpoint_idx(c.checkpoint_idx)
2825
                .with_view(*view);
2826
            ViewReaderUtility reader;
2827
            auto gen = reader.process(reader_input);
2828
            while (auto batch = co_await gen.next()) {
2829
                FlameEv ev;
2830
                for (auto e : batch->events)
2831
                    if (parse_flame_ev(e, ev)) file_buf->push_back(ev);
2832
            }
2833
        }
2834

2835
        fold_file_events(arena, *proc_of, *open, *file_buf, by_process);
2836
        produced->fetch_add(static_cast<std::int64_t>(file_buf->size()),
2837
                            std::memory_order_relaxed);
2838
        file_buf->clear();
2839
    }
2840
}
10!
2841

2842
// GET /api/v1/viz/calltree: merge events into a flamegraph tree. The hierarchy
2843
// per pid/tid lane comes from ts/dur containment (same nesting the timeline
2844
// draws); identical name-paths fold together across the whole trace.
2845
static coro::CoroTask<HttpResponse> handle_viz_calltree(
3!
2846
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
2847
    if (!params.has("begin") || !params.has("end"))
2848
        co_return HttpResponse::bad_request(
2849
            "Missing required parameters: begin, end");
2850

2851
    double begin = params.get_double("begin", 0);
2852
    double end = params.get_double("end", 0);
2853

2854
    auto query = params.get("query");
2855
    if (!query.empty() &&
2856
        !utilities::common::query::try_parse(query).has_value())
2857
        co_return HttpResponse::bad_request("Invalid query: " +
2858
                                            std::string(query));
2859

2860
    auto ts_norm_param = params.get("ts_normalize");
2861
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
2862
    std::uint64_t global_min = 0;
2863
    if (normalize) {
2864
        global_min = index.global_min_timestamp_us();
2865
        if (global_min == std::numeric_limits<std::uint64_t>::max())
2866
            global_min = 0;
2867
    }
2868
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
2869
        begin = static_cast<double>(
2870
            index.us_to_native(static_cast<std::uint64_t>(begin)));
2871
        end = static_cast<double>(
2872
            index.us_to_native(static_cast<std::uint64_t>(end)));
2873
    }
2874
    if (normalize && global_min > 0) {
2875
        begin += static_cast<double>(global_min);
2876
        end += static_cast<double>(global_min);
2877
    }
2878

2879
    ViewDefinition view = build_viz_view(params, begin, end, 0);
2880
    // The flame tree keys on ts/dur containment and ignores ph=M metadata, so
2881
    // drop it at the reader to engage the no-metadata fast path.
2882
    view.with_include_metadata(false);
2883
    int limit = params.get_int("limit", 0);
2884
    if (limit < 0) limit = 0;
2885

2886
    std::vector<const TraceIndex::FileInfo*> target_files =
2887
        select_viz_target_files(index, params, begin, end);
2888

2889
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
2890
    bool by_process = params.get("group") == "pid";
2891
    bool scan_all_chunks = !params.get("file").empty();
2892
    const std::int64_t cap =
2893
        limit > 0 ? limit : std::numeric_limits<std::int64_t>::max();
2894

2895
    const std::string cache_key =
2896
        std::string(req.path) + "?" + params.canonical_key();
2897
    if (auto hit = index.viz_cache().get(cache_key))
2898
        co_return HttpResponse::ok(std::move(*hit));
2899

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

2903
    // Stream the tree: each worker claims whole files and folds them into its
2904
    // own partial tree, discarding events per file (bounded memory), then the
2905
    // partials merge. Lanes never cross files, so each partial is complete.
2906
    std::size_t nworkers = std::max<std::size_t>(
2907
        1, std::min(slots, target_files.empty() ? std::size_t{1}
2908
                                                : target_files.size()));
2909
    std::vector<std::vector<FlameNode>> arenas(nworkers);
2910
    std::atomic<std::size_t> next_file{0};
2911
    std::atomic<std::int64_t> produced{0};
2912
    CancelToken cancel = req.cancel_token;
2913

2914
    {
2915
        CoroScope scope(Executor::current());
2916
        auto* files_ptr = &target_files;
2917
        auto* index_ptr = &index;
2918
        auto* view_ptr = &view;
2919
        auto* next_ptr = &next_file;
2920
        auto* produced_ptr = &produced;
2921
        for (std::size_t w = 0; w < nworkers; ++w) {
2922
            auto* out = &arenas[w];
2923
            scope.spawn([files_ptr, next_ptr, index_ptr, view_ptr, begin, end,
5!
2924
                         scan_all_chunks, by_process, cap, produced_ptr, cancel,
2925
                         out](CoroScope&) -> coro::CoroTask<void> {
2926
                co_await calltree_stream_worker(files_ptr, next_ptr, index_ptr,
2927
                                                view_ptr, begin, end,
2928
                                                scan_all_chunks, by_process,
2929
                                                cap, produced_ptr, cancel, out);
2930
            });
10!
2931
        }
2932
        co_await scope.join();
2933
    }
2934

2935
    if (cancel.cancelled()) co_return HttpResponse::ok(CANCELLED_TREE);
2936
    bool truncated = limit > 0 && produced.load() >= cap;
2937

2938
    for (std::size_t t = 1; t < arenas.size(); ++t)
2939
        merge_flame_arena(arenas[0], 0, arenas[t], 0);
2940
    std::vector<FlameNode> arena = std::move(arenas[0]);
2941

2942
    // Process frames are synthetic containers: total/count roll up from their
2943
    // children, self is 0.
2944
    if (by_process) {
2945
        for (std::uint32_t pnode : arena[0].children) {
2946
            double t = 0;
2947
            std::uint64_t c = 0;
2948
            for (std::uint32_t ch : arena[pnode].children) {
2949
                t += arena[ch].total;
2950
                c += arena[ch].count;
2951
            }
2952
            arena[pnode].total = t;
2953
            arena[pnode].count = c;
2954
            arena[pnode].self = 0;
2955
        }
2956
    }
2957

2958
    double root_total = 0;
2959
    std::uint64_t root_count = 0;
2960
    for (std::uint32_t c : arena[0].children) {
2961
        root_total += arena[c].total;
2962
        root_count += arena[c].count;
2963
    }
2964
    arena[0].total = root_total;
2965
    arena[0].count = root_count;
2966
    arena[0].self = 0;
2967

2968
    // Node total/self are summed native durations; scale to us for display.
2969
    const double dur_us =
2970
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
2971
            index.time_metric());
2972
    if (dur_us != 1.0) {
2973
        for (auto& n : arena) {
2974
            n.total *= dur_us;
2975
            if (n.self > 0) n.self *= dur_us;
2976
        }
2977
    }
2978

2979
    auto& b = scratch_json_builder();
2980
    b.start_object();
2981
    b.append_key_value("truncated", truncated);
2982
    b.append_comma();
2983
    b.escape_and_append_with_quotes("tree");
2984
    b.append_colon();
2985
    serialize_flame_node(b, arena, 0);
2986
    b.end_object();
2987
    std::string body(b);
2988
    index.viz_cache().put(cache_key, body);
2989
    co_return HttpResponse::ok(std::move(body));
2990
}
6!
2991

2992
// One log-spaced duration bucket of the histogram response.
2993
struct HistBucket {
2994
    double lo;
2995
    double hi;
2996
    std::uint64_t count;
2997
};
2998

2999
template <typename builder_type>
3000
void tag_invoke(simdjson::serialize_tag, builder_type& b, const HistBucket& h) {
40✔
3001
    b.start_object();
40✔
3002
    b.append_key_value("lo", h.lo);
40✔
3003
    b.append_comma();
40✔
3004
    b.append_key_value("hi", h.hi);
40✔
3005
    b.append_comma();
40✔
3006
    b.append_key_value("count", h.count);
40✔
3007
    b.end_object();
40✔
3008
}
40✔
3009

3010
// GET /api/v1/viz/histogram: the distribution of event durations matching the
3011
// query in [begin, end]. Collects each matching dur, then reports exact
3012
// percentiles and a log-spaced histogram of the shape. The caller narrows to
3013
// one operation by folding its predicate (name == "...") into the query.
3014
static coro::CoroTask<HttpResponse> handle_viz_histogram(
1!
3015
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
3016
    if (!params.has("begin") || !params.has("end"))
3017
        co_return HttpResponse::bad_request(
3018
            "Missing required parameters: begin, end");
3019

3020
    double begin = params.get_double("begin", 0);
3021
    double end = params.get_double("end", 0);
3022

3023
    auto query = params.get("query");
3024
    if (!query.empty() &&
3025
        !utilities::common::query::try_parse(query).has_value())
3026
        co_return HttpResponse::bad_request("Invalid query: " +
3027
                                            std::string(query));
3028

3029
    auto ts_norm_param = params.get("ts_normalize");
3030
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3031
    std::uint64_t global_min = 0;
3032
    if (normalize) {
3033
        global_min = index.global_min_timestamp_us();
3034
        if (global_min == std::numeric_limits<std::uint64_t>::max())
3035
            global_min = 0;
3036
    }
3037
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
3038
        begin = static_cast<double>(
3039
            index.us_to_native(static_cast<std::uint64_t>(begin)));
3040
        end = static_cast<double>(
3041
            index.us_to_native(static_cast<std::uint64_t>(end)));
3042
    }
3043
    if (normalize && global_min > 0) {
3044
        begin += static_cast<double>(global_min);
3045
        end += static_cast<double>(global_min);
3046
    }
3047

3048
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3049
    view.with_include_metadata(false);  // aggregate only; skip ph=M records
3050
    int limit = params.get_int("limit", 0);
3051
    if (limit < 0) limit = 0;
3052
    int nbuckets = params.get_int("buckets", 40);
3053
    nbuckets = std::clamp(nbuckets, 4, 200);
3054

3055
    const std::string cache_key =
3056
        std::string(req.path) + "?" + params.canonical_key();
3057
    if (auto hit = index.viz_cache().get(cache_key))
3058
        co_return HttpResponse::ok(std::move(*hit));
3059

3060
    std::vector<const TraceIndex::FileInfo*> target_files =
3061
        select_viz_target_files(index, params, begin, end);
3062

3063
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3064
    std::vector<std::vector<double>> partials(slots);
3065
    auto collect = [&partials](std::size_t w,
1✔
3066
                               const std::vector<std::string_view>& events) {
1✔
3067
        thread_local simdjson::dom::parser parser;
1!
3068
        thread_local std::string buf;
1!
3069
        auto& out = partials[w];  // worker-owned slot, no lock
1✔
3070
        for (auto e : events) {
51✔
3071
            buf.assign(e);
50!
3072
            auto res = parser.parse(buf);
50✔
3073
            if (res.error()) continue;
50!
3074
            auto root = res.value_unsafe();
50✔
3075
            if (!root.is_object()) continue;
50!
3076
            auto dr = root["dur"];
50✔
3077
            if (dr.error()) continue;
50!
3078
            out.push_back(json_number(dr.value_unsafe()));
50!
3079
        }
3080
    };
1✔
3081

3082
    bool truncated = co_await scan_view_events(
3083
        index, target_files, view, begin, end, limit, slots, collect,
3084
        req.cancel_token, !params.get("file").empty());
3085

3086
    std::vector<double> all;
3087
    std::size_t total_n = 0;
3088
    for (auto& p : partials) total_n += p.size();
3089
    all.reserve(total_n);
3090
    for (auto& p : partials)
3091
        for (double d : p) all.push_back(d);
3092
    std::sort(all.begin(), all.end());
3093

3094
    // Durations are in the trace's native unit; scale to us for display.
3095
    const double dur_us =
3096
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
3097
            index.time_metric());
3098
    if (dur_us != 1.0)
3099
        for (double& d : all) d *= dur_us;
3100

3101
    auto& sb = scratch_json_builder();
3102
    if (all.empty()) {
3103
        sb.start_object();
3104
        sb.append_key_value("count", 0);
3105
        sb.append_comma();
3106
        sb.escape_and_append_with_quotes("buckets");
3107
        sb.append_colon();
3108
        sb.start_array();
3109
        sb.end_array();
3110
        sb.append_comma();
3111
        sb.append_key_value("truncated", truncated);
3112
        sb.end_object();
3113
        std::string body(sb);
3114
        if (!req.cancel_token.cancelled())
3115
            index.viz_cache().put(cache_key, body);
3116
        co_return HttpResponse::ok(std::move(body));
3117
    }
3118

3119
    std::size_t n = all.size();
3120
    double vmin = all.front();
3121
    double vmax = all.back();
3122
    double sum = 0;
3123
    for (double d : all) sum += d;
3124
    double mean = sum / static_cast<double>(n);
3125
    auto pct = [&all, n](double p) {
4✔
3126
        std::size_t idx =
4✔
3127
            static_cast<std::size_t>(p * static_cast<double>(n - 1));
4✔
3128
        return all[idx];
4✔
3129
    };
3130

3131
    // Log-spaced buckets over [max(vmin,1), vmax]; sub-microsecond durs land in
3132
    // the first bucket.
3133
    double lo = std::max(vmin, 1.0);
3134
    double hi = std::max(vmax, lo * 1.0000001);
3135
    double lr = std::log(hi / lo);
3136
    std::vector<std::uint64_t> counts(static_cast<std::size_t>(nbuckets), 0);
3137
    for (double d : all) {
3138
        double v = d < lo ? lo : d;
3139
        std::size_t bi =
3140
            lr > 0 ? static_cast<std::size_t>(static_cast<double>(nbuckets) *
3141
                                              std::log(v / lo) / lr)
3142
                   : 0;
3143
        if (bi >= static_cast<std::size_t>(nbuckets)) bi = nbuckets - 1;
3144
        counts[bi]++;
3145
    }
3146

3147
    sb.start_object();
3148
    sb.append_key_value("count", n);
3149
    sb.append_comma();
3150
    sb.append_key_value("min", vmin);
3151
    sb.append_comma();
3152
    sb.append_key_value("max", vmax);
3153
    sb.append_comma();
3154
    sb.append_key_value("mean", mean);
3155
    sb.append_comma();
3156
    sb.append_key_value("p50", pct(0.50));
3157
    sb.append_comma();
3158
    sb.append_key_value("p90", pct(0.90));
3159
    sb.append_comma();
3160
    sb.append_key_value("p95", pct(0.95));
3161
    sb.append_comma();
3162
    sb.append_key_value("p99", pct(0.99));
3163
    sb.append_comma();
3164
    sb.append_key_value("truncated", truncated);
3165
    sb.append_comma();
3166
    std::vector<HistBucket> buckets;
3167
    buckets.reserve(static_cast<std::size_t>(nbuckets));
3168
    for (int i = 0; i < nbuckets; ++i) {
3169
        buckets.push_back(
3170
            {lo * std::exp(lr * static_cast<double>(i) / nbuckets),
3171
             lo * std::exp(lr * static_cast<double>(i + 1) / nbuckets),
3172
             counts[static_cast<std::size_t>(i)]});
3173
    }
3174
    sb.append_key_value("buckets", buckets);
3175
    sb.end_object();
3176
    std::string body(sb);
3177
    if (!req.cancel_token.cancelled()) index.viz_cache().put(cache_key, body);
3178
    co_return HttpResponse::ok(std::move(body));
3179
}
2!
3180

3181
// A density block as it appears in the response: the map key/aggregate pair
3182
// flattened with `ts` resolved from the column index. `name` borrows the
3183
// aggregate's storage.
3184
struct DensityBlock {
3185
    std::string_view name;
3186
    std::int64_t pid;
3187
    std::int64_t tid;
3188
    double ts;
3189
    double dur;
3190
    std::uint32_t count;
3191
    double total;
3192
    std::uint32_t depth;
3193
    std::string_view group;  // group_by value; omitted from JSON when empty
3194
};
3195

3196
template <typename builder_type>
3197
void tag_invoke(simdjson::serialize_tag, builder_type& b,
180✔
3198
                const DensityBlock& d) {
3199
    b.start_object();
180✔
3200
    if (!d.group.empty()) {
180✔
3201
        b.append_key_value("group", d.group);
40✔
3202
        b.append_comma();
40✔
3203
    }
3204
    b.append_key_value("name", d.name);
180✔
3205
    b.append_comma();
180✔
3206
    b.append_key_value("pid", d.pid);
180✔
3207
    b.append_comma();
180✔
3208
    b.append_key_value("tid", d.tid);
180✔
3209
    b.append_comma();
180✔
3210
    b.append_key_value("ts", d.ts);
180✔
3211
    b.append_comma();
180✔
3212
    b.append_key_value("dur", d.dur);
180✔
3213
    b.append_comma();
180✔
3214
    b.append_key_value("count", d.count);
180✔
3215
    b.append_comma();
180✔
3216
    b.append_key_value("total", d.total);
180✔
3217
    b.append_comma();
180✔
3218
    b.append_key_value("depth", d.depth);
180✔
3219
    b.end_object();
180✔
3220
}
180✔
3221

3222
// Serialize collected density blocks (+ optional individual events) into the
3223
// /viz/density response body. Shared by the live-scan and summary paths.
3224
static std::string serialize_density_body(
4✔
3225
    const std::vector<std::string>& big, const DensityMap& dens,
3226
    double original_begin, double original_end, double threshold, int limit,
3227
    bool truncated, bool ts_normalized, std::uint64_t display_global_min,
3228
    double max_dur, TraceIndex::TimeMetric metric,
3229
    const std::vector<std::uint32_t>* big_depth = nullptr,
3230
    const ankerl::unordered_dense::map<std::string, std::string>* group_names =
3231
        nullptr) {
3232
    // Blocks are positioned/sized in native threshold units; scale the visible
3233
    // time fields (block ts/dur, duration sums, max_dur) to microseconds.
3234
    const double us =
3235
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
4✔
3236
            metric);
3237
    const double threshold_us = threshold * us;
4✔
3238
    max_dur *= us;
4✔
3239
    auto& b = scratch_json_builder();
4✔
3240
    b.start_object();
4✔
3241
    b.escape_and_append_with_quotes("events");
4✔
3242
    b.append_colon();
4✔
3243
    b.start_array();
4✔
3244
    for (std::size_t i = 0; i < big.size(); ++i) {
4!
3245
        if (i > 0) b.append_comma();
×
3246
        // Inject the server-computed depth as a sibling field (events end in
3247
        // }).
3248
        if (big_depth && i < big_depth->size() && !big[i].empty() &&
×
3249
            big[i].back() == '}') {
×
3250
            b.append_raw(std::string_view(big[i]).substr(0, big[i].size() - 1));
×
3251
            b.append_raw(",\"depth\":");
×
3252
            b.append(static_cast<std::uint64_t>((*big_depth)[i]));
×
3253
            b.append_raw("}");
×
3254
        } else {
3255
            b.append_raw(big[i]);
×
3256
        }
3257
    }
3258
    b.end_array();
4✔
3259
    b.append_comma();
4✔
3260
    std::vector<DensityBlock> blocks;
4✔
3261
    blocks.reserve(dens.size());
4!
3262
    for (const auto& [k, a] : dens) {
184✔
3263
        blocks.push_back(
360!
3264
            {a.name, k.pid, k.tid,
180✔
3265
             original_begin + static_cast<double>(k.col) * threshold_us,
180✔
3266
             threshold_us, a.count, a.total * us, a.depth, k.group});
180✔
3267
    }
3268
    b.append_key_value("density", blocks);
4!
3269
    b.append_comma();
4✔
3270
    b.escape_and_append_with_quotes("metadata");
4✔
3271
    b.append_colon();
4✔
3272
    b.start_object();
4✔
3273
    b.append_key_value("begin", original_begin);
4✔
3274
    b.append_comma();
4✔
3275
    b.append_key_value("end", original_end);
4✔
3276
    b.append_comma();
4✔
3277
    b.append_key_value("count", big.size());
4✔
3278
    b.append_comma();
4✔
3279
    b.append_key_value("limit", limit);
4✔
3280
    b.append_comma();
4✔
3281
    b.append_key_value("density_count", dens.size());
4✔
3282
    b.append_comma();
4✔
3283
    b.append_key_value("truncated", truncated);
4✔
3284
    b.append_comma();
4✔
3285
    b.append_key_value("ts_normalized", ts_normalized);
4✔
3286
    b.append_comma();
4✔
3287
    b.append_key_value("global_min_timestamp_us", display_global_min);
4✔
3288
    b.append_comma();
4✔
3289
    b.append_key_value("max_dur", max_dur);
4✔
3290
    if (group_names && !group_names->empty()) {
4!
NEW
3291
        b.append_comma();
×
NEW
3292
        b.escape_and_append_with_quotes("group_names");
×
NEW
3293
        b.append_colon();
×
NEW
3294
        b.start_object();
×
NEW
3295
        bool first = true;
×
NEW
3296
        for (const auto& [hash, name] : *group_names) {
×
NEW
3297
            if (!first) b.append_comma();
×
NEW
3298
            first = false;
×
NEW
3299
            b.escape_and_append_with_quotes(hash);
×
NEW
3300
            b.append_colon();
×
NEW
3301
            b.escape_and_append_with_quotes(name);
×
3302
        }
NEW
3303
        b.end_object();
×
3304
    }
3305
    b.end_object();
4✔
3306
    b.end_object();
4✔
3307
    return std::string(b);
8!
3308
}
4✔
3309

3310
// The summary is unfiltered, so any server-side predicate forces a live scan.
3311
// pid/tid are exempt: they select whole lanes, which the summary can still do.
3312
static bool viz_summary_eligible(const QueryParams& params) {
21✔
3313
    return params.get("query").empty() && params.get("cat").empty() &&
63!
3314
           params.get("lanes").empty() && params.get("filters").empty() &&
55!
3315
           params.get("file").empty() && params.get("group_by").empty();
57!
3316
}
3317

3318
static coro::CoroTask<void> append_app_spans(std::vector<std::string>& out,
10!
3319
                                             TraceIndex& index, double begin,
3320
                                             double end,
3321
                                             const QueryParams& params) {
3322
    if (!viz_summary_eligible(params)) co_return;
3323
    const VizSummary* s = co_await ensure_viz_summary(index);
3324
    if (!s) co_return;
3325
    auto pid_s = params.get("pid");
3326
    auto tid_s = params.get("tid");
3327
    bool has_pid = !pid_s.empty();
3328
    bool has_tid = !tid_s.empty();
3329
    std::int64_t want_pid =
3330
        has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
3331
    std::int64_t want_tid =
3332
        has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
3333
    for (const auto& sp : s->app_spans) {
3334
        if (has_pid && sp.pid != want_pid) continue;
3335
        if (has_tid && sp.tid != want_tid) continue;
3336
        if (static_cast<double>(sp.end) > begin &&
3337
            static_cast<double>(sp.begin) < end)
3338
            out.push_back(sp.json);
3339
    }
3340
}
20!
3341

3342
// Re-aggregate one pyramid level's buckets over [begin_abs, end_abs] into
3343
// pixel-column density blocks. `begin_abs`/`end_abs` are absolute us.
3344
static std::string serve_density_from_summary(
1✔
3345
    const VizSummary& s, const VizSummary::Level& level,
3346
    const QueryParams& params, double begin_abs, double end_abs,
3347
    double original_begin, double original_end, double threshold,
3348
    bool ts_normalized, std::uint64_t display_global_min,
3349
    TraceIndex::TimeMetric metric) {
3350
    // normalize_event_ts subtracts a NATIVE-unit offset from the (native) event
3351
    // ts, but display_global_min is in us; recover the native value so long
3352
    // events aren't shifted off-screen on non-us traces (the us round-trip
3353
    // loses at most sub-us, invisible on the timeline).
3354
    const std::uint64_t native_global_min =
3355
        dftracer::utils::utilities::composites::dft::scale_between(
1✔
3356
            TraceIndex::TimeMetric::US, metric, display_global_min);
3357
    auto pid_s = params.get("pid");
1!
3358
    auto tid_s = params.get("tid");
1!
3359
    bool has_pid = !pid_s.empty();
1✔
3360
    bool has_tid = !tid_s.empty();
1✔
3361
    std::int64_t want_pid =
3362
        has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
1!
3363
    std::int64_t want_tid =
3364
        has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
1!
3365

3366
    std::int64_t b0 = s.bucket_of(begin_abs, level.bucket_us, level.nbuckets);
1✔
3367
    std::int64_t b1 = s.bucket_of(end_abs, level.bucket_us, level.nbuckets);
1✔
3368
    if (b0 < 0) b0 = 0;
1!
3369
    if (b1 < 0) b1 = static_cast<std::int64_t>(level.nbuckets) - 1;
1!
3370

3371
    DensityMap dens;
1!
NEW
3372
    auto add_cell = [&](std::int64_t pid, std::int64_t tid, std::int64_t col,
×
3373
                        std::uint64_t count, double total,
3374
                        const VizSummary::Cell& cell) {
NEW
3375
        if (count == 0 && total <= 0) return;
×
NEW
3376
        auto& a = dens[DensityKey{pid, tid, col, {}}];
×
NEW
3377
        a.count += count;
×
NEW
3378
        a.total += total;
×
NEW
3379
        if (static_cast<double>(cell.max_dur) > a.max_dur) {
×
NEW
3380
            a.max_dur = static_cast<double>(cell.max_dur);
×
NEW
3381
            if (cell.name_id < s.names.size()) a.name = s.names[cell.name_id];
×
3382
        }
3383
    };
1✔
3384
    for (const auto& lane : level.lanes) {
1!
UNCOV
3385
        if (has_pid && lane.pid != want_pid) continue;
×
UNCOV
3386
        if (has_tid && lane.tid != want_tid) continue;
×
NEW
3387
        auto lo = std::lower_bound(lane.buckets.begin(), lane.buckets.end(),
×
NEW
3388
                                   static_cast<std::uint32_t>(b0));
×
NEW
3389
        for (auto it = lo;
×
NEW
3390
             it != lane.buckets.end() && *it <= static_cast<std::uint32_t>(b1);
×
NEW
3391
             ++it) {
×
3392
            const auto& cell =
NEW
3393
                lane.cells[static_cast<std::size_t>(it - lane.buckets.begin())];
×
3394
            // A bucket never exceeds one column, so it falls in one column or
3395
            // straddles two. Split it by overlap rather than dumping it whole
3396
            // into the column holding its center, which would misplace a third
3397
            // of the events once buckets and columns are of similar size.
NEW
3398
            double bstart = static_cast<double>(s.t_begin) +
×
NEW
3399
                            static_cast<double>(*it) * level.bucket_us;
×
UNCOV
3400
            std::int64_t col =
×
NEW
3401
                static_cast<std::int64_t>((bstart - begin_abs) / threshold);
×
NEW
3402
            double edge = begin_abs + static_cast<double>(col + 1) * threshold;
×
NEW
3403
            double head = edge - bstart;
×
NEW
3404
            if (head >= level.bucket_us) {
×
NEW
3405
                add_cell(lane.pid, lane.tid, col, cell.count,
×
NEW
3406
                         static_cast<double>(cell.total), cell);
×
NEW
3407
                continue;
×
3408
            }
NEW
3409
            double f = head / level.bucket_us;
×
3410
            auto c0 = static_cast<std::uint64_t>(
NEW
3411
                std::llround(static_cast<double>(cell.count) * f));
×
NEW
3412
            if (c0 > cell.count) c0 = cell.count;
×
NEW
3413
            double t0 = static_cast<double>(cell.total) * f;
×
NEW
3414
            add_cell(lane.pid, lane.tid, col, c0, t0, cell);
×
NEW
3415
            add_cell(lane.pid, lane.tid, col + 1, cell.count - c0,
×
NEW
3416
                     static_cast<double>(cell.total) - t0, cell);
×
3417
        }
3418
    }
3419

3420
    // Long events at least one pixel wide are drawn as real spans; the rest
3421
    // fold into density blocks, which is the split a live scan makes. The
3422
    // widest win when there are more than a response can carry.
3423
    std::vector<const VizSummary::AppSpan*> wide;
1✔
3424
    auto fold_span = [&](const VizSummary::AppSpan& sp) {
50✔
3425
        auto dur = static_cast<double>(sp.end - sp.begin);
50✔
3426
        std::int64_t col = static_cast<std::int64_t>(
50✔
3427
            (static_cast<double>(sp.begin) - begin_abs) / threshold);
50✔
3428
        auto& a = dens[DensityKey{sp.pid, sp.tid, col, {}}];
50!
3429
        a.count += 1;
50✔
3430
        a.total += dur;
50✔
3431
        if (dur > a.max_dur) {
50!
3432
            a.max_dur = dur;
50✔
3433
            if (sp.name_id < s.names.size()) a.name = s.names[sp.name_id];
50!
3434
        }
3435
    };
50✔
3436
    for (const auto& sp : s.long_events) {
51✔
3437
        if (has_pid && sp.pid != want_pid) continue;
50!
3438
        if (has_tid && sp.tid != want_tid) continue;
50!
3439
        if (static_cast<double>(sp.end) <= begin_abs ||
50!
3440
            static_cast<double>(sp.begin) >= end_abs)
50!
NEW
3441
            continue;
×
3442
        if (static_cast<double>(sp.end - sp.begin) >= threshold)
50!
NEW
3443
            wide.push_back(&sp);
×
3444
        else
3445
            fold_span(sp);
50!
3446
    }
3447
    if (wide.size() > VizSummary::MAX_RESPONSE_SPANS) {
1!
NEW
3448
        std::nth_element(
×
NEW
3449
            wide.begin(), wide.begin() + VizSummary::MAX_RESPONSE_SPANS,
×
3450
            wide.end(),
NEW
3451
            [](const VizSummary::AppSpan* a, const VizSummary::AppSpan* b) {
×
NEW
3452
                return a->end - a->begin > b->end - b->begin;
×
3453
            });
NEW
3454
        for (std::size_t i = VizSummary::MAX_RESPONSE_SPANS; i < wide.size();
×
3455
             ++i)
NEW
3456
            fold_span(*wide[i]);
×
NEW
3457
        wide.resize(VizSummary::MAX_RESPONSE_SPANS);
×
3458
    }
3459

3460
    std::vector<std::string> spans;
1✔
3461
    ankerl::unordered_dense::set<std::int64_t> span_lanes;
1!
3462
    for (const auto& sp : s.app_spans) {
1!
3463
        if (has_pid && sp.pid != want_pid) continue;
×
3464
        if (has_tid && sp.tid != want_tid) continue;
×
3465
        if (static_cast<double>(sp.end) <= begin_abs ||
×
3466
            static_cast<double>(sp.begin) >= end_abs)
×
3467
            continue;
×
3468
        span_lanes.insert((sp.pid << 20) ^ sp.tid);
×
NEW
3469
        spans.push_back(
×
NEW
3470
            ts_normalized && display_global_min > 0
×
NEW
3471
                ? normalize_event_ts(sp.json, native_global_min, metric)
×
NEW
3472
                : sp.json);
×
3473
    }
3474
    // App spans get an injected depth 0; long events after them do not, so the
3475
    // client stacks overlapping wide events instead of piling them on one row.
3476
    const std::size_t napp_spans = spans.size();
1✔
3477
    spans.reserve(spans.size() + wide.size());
1!
3478
    for (const auto* sp : wide) {
1!
NEW
3479
        span_lanes.insert((sp->pid << 20) ^ sp->tid);
×
NEW
3480
        spans.push_back(
×
NEW
3481
            ts_normalized && display_global_min > 0
×
NEW
3482
                ? normalize_event_ts(sp->json, native_global_min, metric)
×
NEW
3483
                : sp->json);
×
3484
    }
3485
    // Folded child activity sits one row below its app span, matching the
3486
    // containment nesting the live path computes.
3487
    if (!span_lanes.empty())
1!
3488
        for (auto& [k, a] : dens)
×
3489
            if (span_lanes.count((k.pid << 20) ^ k.tid)) a.depth = 1;
×
3490

3491
    std::vector<std::uint32_t> span_depth(napp_spans, 0);
1!
3492
    return serialize_density_body(
3493
        spans, dens, original_begin, original_end, threshold, 0, false,
3494
        ts_normalized, display_global_min, static_cast<double>(s.max_dur),
1✔
3495
        metric, &span_depth);
2!
3496
}
1✔
3497

3498
// Canonicalize one group column: resolved.*/r.* aliases map to their hash
3499
// column and "args.x" to "x". Sets `rt` to the hash type when the canonical
3500
// column is a hash (so its values can be resolved to names for display).
3501
static std::string canonicalize_group_col(
4✔
3502
    std::string col,
3503
    std::optional<utilities::indexer::IndexDatabase::HashType>& rt) {
3504
    using HashType = utilities::indexer::IndexDatabase::HashType;
3505
    if (col.rfind("args.", 0) == 0 && col.find('.', 5) == std::string::npos)
4!
NEW
3506
        col = col.substr(5);
×
3507
    auto is = [&](std::string_view a) { return col == a; };
48✔
3508
    if (is("resolved.fpath") || is("r.fpath"))
4!
NEW
3509
        col = "fhash";
×
3510
    else if (is("resolved.cwd") || is("r.cwd"))
4!
NEW
3511
        col = "cwd";
×
3512
    else if (is("resolved.hostname") || is("r.hostname") ||
12!
3513
             is("resolved.host") || is("r.host"))
12!
NEW
3514
        col = "hhash";
×
3515
    else if (is("resolved.exec") || is("r.exec"))
4!
NEW
3516
        col = "exec_hash";
×
3517
    else if (is("resolved.cmd") || is("r.cmd"))
4!
NEW
3518
        col = "cmd_hash";
×
3519
    rt = std::nullopt;
4✔
3520
    if (col == "fhash" || col == "cwd")
4!
NEW
3521
        rt = HashType::FILE;
×
3522
    else if (col == "hhash")
4!
NEW
3523
        rt = HashType::HOST;
×
3524
    else if (col == "shash" || col == "exec_hash" || col == "cmd_hash")
4!
NEW
3525
        rt = HashType::STRING;
×
3526
    return col;
8✔
3527
}
3528

3529
// GET /api/v1/viz/density: like /viz/events, but instead of dropping sub-pixel
3530
// events it buckets them per (pid, tid, pixel-column) into density blocks so
3531
// zoomed-out views still show where activity is. Returns full-size events (with
3532
// args, for the detail panel) plus the aggregated density blocks.
3533
static coro::CoroTask<HttpResponse> handle_viz_density(
5!
3534
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
3535
    if (!params.has("begin") || !params.has("end")) {
3536
        co_return HttpResponse::bad_request(
3537
            "Missing required parameters: begin, end");
3538
    }
3539

3540
    double begin = params.get_double("begin", 0);
3541
    double end = params.get_double("end", 0);
3542
    int summary = params.get_int("summary", 2);
3543
    if (summary < 1) summary = 1;
3544

3545
    auto query = params.get("query");
3546
    if (!query.empty() &&
3547
        !utilities::common::query::try_parse(query).has_value()) {
3548
        co_return HttpResponse::bad_request("Invalid query: " +
3549
                                            std::string(query));
3550
    }
3551

3552
    // Optional density grouping column. A well-formed name that matches no
3553
    // event field is fine (all blocks land in the client's "(none)" group);
3554
    // only malformed names are rejected.
3555
    std::string group_col(params.get("group_by"));
3556
    if (group_col.size() > 128)
3557
        co_return HttpResponse::bad_request("group_by too long");
3558
    for (char c : group_col) {
3559
        if (!std::isalnum(static_cast<unsigned char>(c)) && c != '_' &&
3560
            c != '.' && c != '-' && c != ',')
3561
            co_return HttpResponse::bad_request("Invalid group_by column");
3562
    }
3563

3564
    // Hash columns auto-resolve for display: group values stay raw hashes, and
3565
    // a group_names map (hash -> resolved name) rides along in the metadata.
3566
    // resolved.*/r.* aliases map to their hash columns. group_by may list
3567
    // several comma-separated columns; canonicalize each and record its hash
3568
    // type so the composite value's components resolve independently.
3569
    using HashType = utilities::indexer::IndexDatabase::HashType;
3570
    std::vector<std::optional<HashType>> resolve_types;
3571
    {
3572
        std::string rebuilt;
3573
        std::size_t start = 0;
3574
        while (start <= group_col.size()) {
3575
            auto comma = group_col.find(',', start);
3576
            std::string part(group_col.substr(
3577
                start, comma == std::string::npos ? group_col.size() - start
3578
                                                  : comma - start));
3579
            std::optional<HashType> rt;
3580
            std::string canon = canonicalize_group_col(std::move(part), rt);
3581
            if (!rebuilt.empty()) rebuilt.push_back(',');
3582
            rebuilt += canon;
3583
            resolve_types.push_back(rt);
3584
            if (comma == std::string::npos) break;
3585
            start = comma + 1;
3586
        }
3587
        group_col = std::move(rebuilt);
3588
    }
3589
    bool any_resolve = false;
3590
    for (const auto& rt : resolve_types)
3591
        if (rt) any_resolve = true;
3592

3593
    auto ts_norm_param = params.get("ts_normalize");
3594
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3595
    std::uint64_t global_min = 0;
3596
    if (normalize) {
3597
        global_min = index.global_min_timestamp_us();
3598
        if (global_min == std::numeric_limits<std::uint64_t>::max())
3599
            global_min = 0;
3600
    }
3601
    double original_begin = begin;
3602
    double original_end = end;
3603
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
3604
        begin = static_cast<double>(
3605
            index.us_to_native(static_cast<std::uint64_t>(begin)));
3606
        end = static_cast<double>(
3607
            index.us_to_native(static_cast<std::uint64_t>(end)));
3608
    }
3609
    if (normalize && global_min > 0) {
3610
        begin += static_cast<double>(global_min);
3611
        end += static_cast<double>(global_min);
3612
    }
3613

3614
    // Bucket width (== the ~1px cutoff), in the client's actual pixels.
3615
    int width = params.get_int("width", DEFAULT_VIEWPORT_WIDTH);
3616
    width = std::clamp(width, MIN_VIEWPORT_WIDTH, MAX_VIEWPORT_WIDTH);
3617
    double threshold =
3618
        duration_threshold(begin, end, static_cast<unsigned>(summary),
3619
                           static_cast<unsigned>(width));
3620

3621
    const std::string cache_key =
3622
        std::string(req.path) + "?" + params.canonical_key();
3623
    if (auto hit = index.viz_cache().get(cache_key))
3624
        co_return HttpResponse::ok(std::move(*hit));
3625

3626
    // Unfiltered views come from the prebuilt summary pyramid (no event cap),
3627
    // built lazily here; concurrent requests fall through to a live scan, as do
3628
    // zooms finer than the pyramid's finest level.
3629
    if (threshold > 0 && viz_summary_eligible(params)) {
3630
        const VizSummary* s = co_await ensure_viz_summary(index);
3631
        if (s && s->t_end > s->t_begin) {
3632
            if (const VizSummary::Level* level = s->level_for(threshold)) {
3633
                DFTRACER_UTILS_LOG_DEBUG(
3634
                    "viz: density threshold %.0f us served from level "
3635
                    "%.0f us",
3636
                    threshold, level->bucket_us);
3637
                co_return HttpResponse::ok(serve_density_from_summary(
3638
                    *s, *level, params, begin, end, original_begin,
3639
                    original_end, threshold, global_min > 0,
3640
                    index.native_to_us(index.global_min_timestamp_us()),
3641
                    index.time_metric()));
3642
            }
3643
        }
3644
    }
3645

3646
    // summary=1 means "no aggregation", but the viewport is still finite.
3647
    // Fold sub-pixel events into density blocks instead of dropping them.
3648
    if (threshold <= 0 && end > begin)
3649
        threshold = (end - begin) / static_cast<double>(width);
3650

3651
    // Enclosing events are fetched by a separate pass (below) so a large
3652
    // `lookback` can never starve the in-window scan's budget. lookback arrives
3653
    // in us; convert to the trace's native unit so scan_begin (native) is right
3654
    // - otherwise on non-us traces the scan-back is off by the unit scale and
3655
    // long events that enclose the window are never read.
3656
    double lookback = params.get_double("lookback", 0);
3657
    if (lookback < 0) lookback = 0;
3658
    if (lookback > 0 && index.time_metric() != TraceIndex::TimeMetric::US)
3659
        lookback = static_cast<double>(
3660
            index.us_to_native(static_cast<std::uint64_t>(lookback)));
3661

3662
    // The client feeds back the longest event in the trace as `lookback`, so
3663
    // honoring it literally rescans everything before the window. Anything that
3664
    // wide is already in the summary's long-event list, so take enclosers from
3665
    // there and scan back only far enough to catch the ones too short to list.
3666
    const VizSummary* enc_summary = nullptr;
3667
    if (lookback > 0 && viz_summary_eligible(params)) {
3668
        const VizSummary* s = co_await ensure_viz_summary(index);
3669
        if (s != nullptr && s->long_threshold_us > 0) {
3670
            enc_summary = s;
3671
            lookback = std::min(lookback, s->long_threshold_us);
3672
        }
3673
    }
3674

3675
    double scan_begin = begin - lookback;
3676
    if (scan_begin < 0) scan_begin = 0;
3677

3678
    int limit = params.get_int("limit", 0);
3679
    if (limit < 0) limit = 0;
3680

3681
    struct Acc {
3682
        std::vector<std::string> big;
3683
        std::vector<double> big_dur;  // parallel to `big`
3684
        DensityMap dens;
3685
        double max_dur = 0;
3686
    };
3687
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3688
    std::vector<Acc> accs(slots);
3689

3690
    // Pass 1 (in-window): scan [begin, end] with the full budget so earlier
3691
    // events never consume it. Small events fold into density blocks.
3692
    auto on_batch = [&accs, &group_col, threshold, begin](
5✔
3693
                        std::size_t w,
3694
                        const std::vector<std::string_view>& events) {
385✔
3695
        Acc& acc = accs[w];  // worker-owned slot, no lock
5✔
3696
        for (auto ev : events) {
195✔
3697
            double dur = 0;
190✔
3698
            if (!fold_density(ev, threshold, begin, acc.dens, &dur,
190!
3699
                              group_col)) {
3700
                acc.big.emplace_back(ev);
×
3701
                acc.big_dur.push_back(dur);
×
3702
            }
3703
            if (dur > acc.max_dur) acc.max_dur = dur;
190!
3704
        }
3705
    };
5✔
3706
    ViewDefinition win_view = build_viz_view(params, begin, end, 0);
3707
    win_view.with_include_metadata(false);  // aggregate only; skip ph=M records
3708
    std::vector<const TraceIndex::FileInfo*> win_files =
3709
        select_viz_target_files(index, params, begin, end);
3710
    bool single_file = !params.get("file").empty();
3711
    bool truncated = co_await scan_view_events(
3712
        index, win_files, win_view, begin, end, limit, slots, on_batch,
3713
        req.cancel_token, single_file);
3714

3715
    // Pass 2 (enclosers): keep only events still open at `begin`
3716
    // (ts < begin <= ts + dur); these ancestor bars set containment depth.
3717
    if (scan_begin < begin) {
3718
        // Wide enclosers come from the summary below; excluding them here
3719
        // keeps them from being served twice.
3720
        double skip_from = enc_summary != nullptr
3721
                               ? enc_summary->long_threshold_us
3722
                               : std::numeric_limits<double>::infinity();
NEW
3723
        auto on_batch_enc = [&accs, begin, skip_from](
×
3724
                                std::size_t w,
UNCOV
3725
                                const std::vector<std::string_view>& events) {
×
3726
            Acc& acc = accs[w];
×
3727
            for (auto ev : events) {
×
3728
                double ts = 0, dur = 0;
×
3729
                if (!parse_ts_dur(ev, ts, dur)) continue;
×
3730
                if (dur > acc.max_dur) acc.max_dur = dur;
×
NEW
3731
                if (ts < begin && ts + dur > begin && dur < skip_from) {
×
3732
                    acc.big.emplace_back(ev);
×
3733
                    acc.big_dur.push_back(dur);
×
3734
                }
3735
            }
3736
        };
×
3737
        ViewDefinition enc_view = build_viz_view(params, scan_begin, begin, 0);
3738
        enc_view.with_include_metadata(false);  // aggregate only; skip ph=M
3739
        std::vector<const TraceIndex::FileInfo*> enc_files =
3740
            select_viz_target_files(index, params, scan_begin, begin);
3741
        bool enc_trunc = co_await scan_view_events(
3742
            index, enc_files, enc_view, scan_begin, begin, limit, slots,
3743
            on_batch_enc, req.cancel_token, single_file);
3744
        truncated = truncated || enc_trunc;
3745
    }
3746

3747
    std::vector<std::string> big;
3748
    std::vector<double> big_dur;
3749
    DensityMap dens;
3750
    // Longest event scanned (folded ones included); the client feeds it back as
3751
    // `lookback` so deep zooms still catch long enclosing events.
3752
    double max_dur = 0;
3753
    for (auto& acc : accs) {
3754
        if (acc.max_dur > max_dur) max_dur = acc.max_dur;
3755
        for (auto& d : acc.big_dur) big_dur.push_back(d);
3756
        for (auto& s : acc.big) big.emplace_back(std::move(s));
3757
        for (auto& kv : acc.dens) {
3758
            auto it = dens.find(kv.first);
3759
            if (it == dens.end())
3760
                dens.emplace(kv.first, std::move(kv.second));
3761
            else
3762
                it->second.merge_from(kv.second);
3763
        }
3764
    }
3765
    // Enclosers wide enough to be listed in the summary, found without the
3766
    // scan back toward the start of the trace that finding them live takes.
3767
    if (enc_summary != nullptr) {
3768
        auto pid_s = params.get("pid");
3769
        auto tid_s = params.get("tid");
3770
        bool has_pid = !pid_s.empty();
3771
        bool has_tid = !tid_s.empty();
3772
        std::int64_t want_pid =
3773
            has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
3774
        std::int64_t want_tid =
3775
            has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
3776
        for (const auto& sp : enc_summary->long_events) {
3777
            if (static_cast<double>(sp.begin) >= begin ||
3778
                static_cast<double>(sp.end) <= begin)
3779
                continue;
3780
            if (has_pid && sp.pid != want_pid) continue;
3781
            if (has_tid && sp.tid != want_tid) continue;
3782
            auto dur = static_cast<double>(sp.end - sp.begin);
3783
            if (dur > max_dur) max_dur = dur;
3784
            big.push_back(sp.json);
3785
            big_dur.push_back(dur);
3786
        }
3787
    }
3788

3789
    // scan_view_events overshoots its cap (checked per batch), so clamp here.
3790
    // Keep the longest events: those are the slices wide enough to see.
3791
    if (limit > 0 && big.size() > static_cast<std::size_t>(limit)) {
3792
        const auto keep = static_cast<std::size_t>(limit);
3793
        std::vector<std::size_t> idx(big.size());
3794
        std::iota(idx.begin(), idx.end(), std::size_t{0});
3795
        std::nth_element(idx.begin(), idx.begin() + static_cast<long>(keep),
3796
                         idx.end(), [&big_dur](std::size_t a, std::size_t b) {
×
3797
                             return big_dur[a] > big_dur[b];
×
3798
                         });
3799
        idx.resize(keep);
3800
        std::sort(idx.begin(), idx.end());
3801
        std::vector<std::string> kept;
3802
        kept.reserve(keep);
3803
        for (std::size_t i : idx) kept.emplace_back(std::move(big[i]));
3804
        big.swap(kept);
3805
        truncated = true;
3806
    }
3807

3808
    drop_unreferenced_hash_records(big);
3809

3810
    co_await append_app_spans(big, index, begin, end, params);
3811

3812
    // Resolve hash group values (fhash -> path, hhash -> host, ...) for the
3813
    // groups actually present; unresolvable hashes fall back to the raw value
3814
    // on the client.
3815
    ankerl::unordered_dense::map<std::string, std::string> group_names;
3816
    if (any_resolve && !group_col.empty()) {
3817
        // Split each composite group key into its per-column components and
3818
        // collect the ones from hash columns; resolve each distinct hash once.
3819
        ankerl::unordered_dense::map<std::string, HashType> to_resolve;
NEW
3820
        auto collect = [&](std::string_view composite) {
×
NEW
3821
            std::size_t start = 0, idx = 0;
×
NEW
3822
            while (start <= composite.size() && idx < resolve_types.size()) {
×
NEW
3823
                auto sep = composite.find(GROUP_SEP, start);
×
3824
                auto part =
NEW
3825
                    composite.substr(start, sep == std::string_view::npos
×
NEW
3826
                                                ? composite.size() - start
×
3827
                                                : sep - start);
NEW
3828
                if (resolve_types[idx] && !part.empty())
×
NEW
3829
                    to_resolve.emplace(std::string(part), *resolve_types[idx]);
×
NEW
3830
                ++idx;
×
NEW
3831
                if (sep == std::string_view::npos) break;
×
NEW
3832
                start = sep + 1;
×
3833
            }
NEW
3834
        };
×
3835
        for (const auto& [k, a] : dens)
3836
            if (!k.group.empty()) collect(k.group);
3837
        for (const auto& e : big) {
3838
            auto g = extract_group_from_line(e, group_col);
3839
            if (!g.empty()) collect(g);
3840
        }
3841
        for (const auto& [hash, type] : to_resolve) {
3842
            auto name = index.resolve_hash(type, hash);
3843
            if (!name.empty()) group_names.emplace(hash, std::move(name));
3844
        }
3845
    }
3846

3847
    // Stable per-event/-block depth (computed in absolute space, before ts
3848
    // normalization rewrites the big strings; order is preserved in place).
3849
    std::vector<std::uint32_t> big_depth =
3850
        assign_view_depths(big, dens, begin, threshold);
3851
    if (global_min > 0 || index.time_metric() != TraceIndex::TimeMetric::US) {
3852
        for (auto& e : big)
3853
            e = normalize_event_ts(e, global_min, index.time_metric());
3854
    }
3855

3856
    std::string body = serialize_density_body(
3857
        big, dens, original_begin, original_end, threshold, limit, truncated,
3858
        global_min > 0, index.native_to_us(index.global_min_timestamp_us()),
3859
        max_dur, index.time_metric(), &big_depth,
3860
        group_names.empty() ? nullptr : &group_names);
3861
    if (!req.cancel_token.cancelled()) index.viz_cache().put(cache_key, body);
3862
    co_return HttpResponse::ok(std::move(body));
3863
}
10!
3864

3865
static std::string serialize_counters_body(const std::vector<double>& read,
1✔
3866
                                           const std::vector<double>& write,
3867
                                           const std::vector<double>& ops,
3868
                                           double original_begin,
3869
                                           double original_end, int buckets,
3870
                                           double bucket_us, bool truncated) {
3871
    auto& b = scratch_json_builder();
1✔
3872
    b.start_object();
1✔
3873
    b.append_key_value("begin", original_begin);
1✔
3874
    b.append_comma();
1✔
3875
    b.append_key_value("end", original_end);
1✔
3876
    b.append_comma();
1✔
3877
    b.append_key_value("buckets", static_cast<std::int64_t>(buckets));
1✔
3878
    b.append_comma();
1✔
3879
    b.append_key_value("bucket_us", bucket_us);
1✔
3880
    b.append_comma();
1✔
3881
    b.append_key_value("truncated", truncated);
1✔
3882
    b.append_comma();
1✔
3883
    b.append_key_value("read_bytes", read);
1✔
3884
    b.append_comma();
1✔
3885
    b.append_key_value("write_bytes", write);
1✔
3886
    b.append_comma();
1✔
3887
    b.append_key_value("ops", ops);
1✔
3888
    b.end_object();
1✔
3889
    return std::string(b);
1!
3890
}
3891

3892
// Re-aggregate the summary's finest counter buckets into `buckets` output
3893
// buckets over [begin_abs, end_abs] (absolute us).
3894
static std::string serve_counters_from_summary(
1✔
3895
    const VizSummary& s, const VizSummary::Level& level, double begin_abs,
3896
    double end_abs, double original_begin, double original_end, int buckets,
3897
    double bucket_us_out, TraceIndex::TimeMetric metric) {
3898
    std::vector<double> read(buckets, 0.0), write(buckets, 0.0),
1!
3899
        ops(buckets, 0.0);
1!
3900
    std::int64_t fb0 = s.bucket_of(begin_abs, level.bucket_us, level.nbuckets);
1✔
3901
    std::int64_t fb1 = s.bucket_of(end_abs, level.bucket_us, level.nbuckets);
1✔
3902
    if (fb0 < 0) fb0 = 0;
1!
3903
    if (fb1 < 0) fb1 = static_cast<std::int64_t>(level.nbuckets) - 1;
1!
3904
    for (std::int64_t fb = fb0; fb <= fb1; ++fb) {
65,537✔
3905
        double center = static_cast<double>(s.t_begin) +
65,536✔
3906
                        (static_cast<double>(fb) + 0.5) * level.bucket_us;
65,536✔
3907
        long oi = static_cast<long>((center - begin_abs) / bucket_us_out);
65,536✔
3908
        if (oi < 0 || oi >= buckets) continue;
65,536!
3909
        auto f = static_cast<std::size_t>(fb);
65,536✔
3910
        read[oi] += level.read_bytes[f];
65,536✔
3911
        write[oi] += level.write_bytes[f];
65,536✔
3912
        ops[oi] += level.ops[f];
65,536✔
3913
    }
3914
    return serialize_counters_body(
3915
        read, write, ops, original_begin, original_end, buckets,
3916
        bucket_us_out *
3917
            dftracer::utils::utilities::composites::dft::time_metric_us_scale(
1✔
3918
                metric),
3919
        false);
2!
3920
}
1✔
3921

3922
// GET /api/v1/viz/counters: per-bucket read/write bytes and I/O op counts over
3923
// a time range, for bandwidth/IOPS counter tracks. Aggregated server-side in
3924
// parallel (per-worker arrays merged after join).
3925
static coro::CoroTask<HttpResponse> handle_viz_counters(
1!
3926
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
3927
    if (!params.has("begin") || !params.has("end")) {
3928
        co_return HttpResponse::bad_request(
3929
            "Missing required parameters: begin, end");
3930
    }
3931

3932
    double begin = params.get_double("begin", 0);
3933
    double end = params.get_double("end", 0);
3934
    int buckets = params.get_int("buckets", 800);
3935
    if (buckets < 16) buckets = 16;
3936
    if (buckets > 4000) buckets = 4000;
3937

3938
    auto query = params.get("query");
3939
    if (!query.empty() &&
3940
        !utilities::common::query::try_parse(query).has_value()) {
3941
        co_return HttpResponse::bad_request("Invalid query: " +
3942
                                            std::string(query));
3943
    }
3944

3945
    auto ts_norm_param = params.get("ts_normalize");
3946
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3947
    std::uint64_t global_min = 0;
3948
    if (normalize) {
3949
        global_min = index.global_min_timestamp_us();
3950
        if (global_min == std::numeric_limits<std::uint64_t>::max())
3951
            global_min = 0;
3952
    }
3953
    double original_begin = begin;
3954
    double original_end = end;
3955
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
3956
        begin = static_cast<double>(
3957
            index.us_to_native(static_cast<std::uint64_t>(begin)));
3958
        end = static_cast<double>(
3959
            index.us_to_native(static_cast<std::uint64_t>(end)));
3960
    }
3961
    if (normalize && global_min > 0) {
3962
        begin += static_cast<double>(global_min);
3963
        end += static_cast<double>(global_min);
3964
    }
3965

3966
    double bucket_us = (end - begin) / static_cast<double>(buckets);
3967
    if (bucket_us <= 0) bucket_us = 1;
3968

3969
    const std::string cache_key =
3970
        std::string(req.path) + "?" + params.canonical_key();
3971
    if (auto hit = index.viz_cache().get(cache_key))
3972
        co_return HttpResponse::ok(std::move(*hit));
3973

3974
    // Zoomed-out, unfiltered counter tracks come from the activity summary.
3975
    if (viz_summary_eligible(params)) {
3976
        const VizSummary* s = co_await ensure_viz_summary(index);
3977
        if (s != nullptr && s->t_end > s->t_begin) {
3978
            if (const VizSummary::Level* level = s->level_for(bucket_us)) {
3979
                co_return HttpResponse::ok(serve_counters_from_summary(
3980
                    *s, *level, begin, end, original_begin, original_end,
3981
                    buckets, bucket_us, index.time_metric()));
3982
            }
3983
        }
3984
    }
3985

3986
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3987
    view.with_include_metadata(false);  // aggregate only; skip ph=M records
3988
    // No cap: only zoomed-in or filtered counter queries reach the live path.
3989
    int limit = params.get_int("limit", 0);
3990
    if (limit < 0) limit = 0;
3991

3992
    std::vector<const TraceIndex::FileInfo*> target_files =
3993
        select_viz_target_files(index, params, begin, end);
3994

3995
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3996
    std::vector<CounterAcc> accs(slots);
3997
    for (auto& a : accs) a.init(static_cast<std::size_t>(buckets));
3998
    auto on_batch = [&accs, begin, bucket_us, buckets](
×
3999
                        std::size_t w,
UNCOV
4000
                        const std::vector<std::string_view>& events) {
×
4001
        CounterAcc& acc = accs[w];  // worker-owned, no lock
×
4002
        for (auto ev : events)
×
4003
            fold_counter(ev, begin, bucket_us,
×
4004
                         static_cast<std::size_t>(buckets), acc);
4005
    };
×
4006

4007
    bool truncated = co_await scan_view_events(
4008
        index, target_files, view, begin, end, limit, slots, on_batch,
4009
        req.cancel_token, !params.get("file").empty());
4010

4011
    CounterAcc total;
4012
    total.init(static_cast<std::size_t>(buckets));
4013
    for (auto& a : accs) total.merge_from(a);
4014

4015
    std::string body = serialize_counters_body(
4016
        total.read_bytes, total.write_bytes, total.ops, original_begin,
4017
        original_end, buckets,
4018
        bucket_us *
4019
            dftracer::utils::utilities::composites::dft::time_metric_us_scale(
4020
                index.time_metric()),
4021
        truncated);
4022
    if (!req.cancel_token.cancelled()) index.viz_cache().put(cache_key, body);
4023
    co_return HttpResponse::ok(std::move(body));
4024
}
2!
4025

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

4030
// One process in the proctree response. `host` borrows the hostname table;
4031
// `rank` is null when the trace carries no "PR" metadata for the pid, and the
4032
// key is then omitted.
4033
struct ProcNode {
4034
    std::int64_t pid;
4035
    std::int64_t parent;
4036
    std::uint64_t spawn_ts;
4037
    std::uint64_t first_ts;
4038
    std::string_view host;
4039
    std::uint64_t bytes;
4040
    std::uint64_t io_ops;
4041
    double io_busy;
4042
    const std::string* rank;
4043
};
4044

4045
template <typename builder_type>
4046
void tag_invoke(simdjson::serialize_tag, builder_type& b, const ProcNode& n) {
50✔
4047
    b.start_object();
50✔
4048
    b.append_key_value("pid", n.pid);
50✔
4049
    b.append_comma();
50✔
4050
    b.append_key_value("parent", n.parent);
50✔
4051
    b.append_comma();
50✔
4052
    b.append_key_value("spawn_ts", n.spawn_ts);
50✔
4053
    b.append_comma();
50✔
4054
    b.append_key_value("first_ts", n.first_ts);
50✔
4055
    b.append_comma();
50✔
4056
    b.append_key_value("host", n.host);
50✔
4057
    b.append_comma();
50✔
4058
    b.append_key_value("bytes", n.bytes);
50✔
4059
    b.append_comma();
50✔
4060
    b.append_key_value("io_ops", n.io_ops);
50✔
4061
    b.append_comma();
50✔
4062
    b.append_key_value("io_busy", n.io_busy);
50✔
4063
    if (n.rank) {
50!
4064
        b.append_comma();
×
4065
        b.append_key_value("rank", *n.rank);
×
4066
    }
4067
    b.end_object();
50✔
4068
}
50✔
4069

4070
// GET /api/v1/viz/proctree: infer the process fork hierarchy. The traces record
4071
// the fork/clone in the parent but not the child pid, so link each process to
4072
// the nearest preceding clone in another process (child start follows the clone
4073
// by microseconds). Respects ?file= for per-node trees on multi-node traces.
4074
static coro::CoroTask<HttpResponse> handle_viz_proctree(
1!
4075
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
4076
    std::uint64_t gmin = index.global_min_timestamp_us();
4077
    std::uint64_t gmax = index.global_max_timestamp_us();
4078
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
4079
        co_return HttpResponse::ok("{\"nodes\":[]}");
4080

4081
    auto ts_norm_param = params.get("ts_normalize");
4082
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
4083
    std::uint64_t base = normalize ? gmin : 0;
4084

4085
    struct Acc {
4086
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
4087
        // Explicit parent from the process metadata's args.ppid.
4088
        ankerl::unordered_dense::map<std::int64_t, std::int64_t> ppid;
4089
        // (ts, parent_pid, child_pid): child_pid is args.ret when the fork
4090
        // event records it (dftracer POSIX), else -1 to fall back to inference.
4091
        std::vector<std::tuple<std::uint64_t, std::int64_t, std::int64_t>>
4092
            forks;
4093
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
4094
        // I/O operation count and busy time (us) per process, over I/O-category
4095
        // (POSIX/STDIO/IO) events only.
4096
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
4097
        ankerl::unordered_dense::map<std::int64_t, double> io_busy;
4098
        ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
4099
        // pid -> rank, from "PR" metadata (args.name == "rank").
4100
        ankerl::unordered_dense::map<std::int64_t, std::string> rank;
4101
        dftracer::utils::StringViewMap<std::string> hh;
4102
    };
4103
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
4104
    std::vector<Acc> accs(slots);
UNCOV
4105
    auto on_batch = [&accs](std::size_t w,
×
UNCOV
4106
                            const std::vector<std::string_view>& events) {
×
UNCOV
4107
        thread_local simdjson::dom::parser parser;
×
UNCOV
4108
        thread_local std::string buf;
×
UNCOV
4109
        Acc& acc = accs[w];
×
UNCOV
4110
        for (auto ev : events) {
×
UNCOV
4111
            buf.assign(ev);
×
UNCOV
4112
            auto res = parser.parse(buf);
×
UNCOV
4113
            if (res.error()) continue;
×
UNCOV
4114
            auto root = res.value_unsafe();
×
UNCOV
4115
            if (!root.is_object()) continue;
×
4116
            // HH metadata (hhash -> hostname) carries no ts; handle it first.
4117
            {
UNCOV
4118
                auto nm = root["name"];
×
UNCOV
4119
                if (!nm.error() && nm.is_string() &&
×
UNCOV
4120
                    nm.get_string().value_unsafe() == "HH") {
×
4121
                    auto a = root["args"];
×
4122
                    if (!a.error() && a.is_object()) {
×
4123
                        auto v = a["value"];
×
4124
                        auto n = a["name"];
×
4125
                        if (!v.error() && v.is_string() && !n.error() &&
×
4126
                            n.is_string())
×
4127
                            acc.hh.emplace(
×
4128
                                std::string(v.get_string().value_unsafe()),
×
4129
                                std::string(n.get_string().value_unsafe()));
×
4130
                    }
4131
                    continue;
×
UNCOV
4132
                }
×
4133
            }
4134
            // PR metadata (pid -> rank) also carries no ts.
4135
            {
UNCOV
4136
                auto nm = root["name"];
×
UNCOV
4137
                if (!nm.error() && nm.is_string() &&
×
UNCOV
4138
                    nm.get_string().value_unsafe() == "PR") {
×
4139
                    auto a = root["args"];
×
4140
                    auto pp = root["pid"];
×
4141
                    if (!a.error() && a.is_object() && !pp.error()) {
×
4142
                        auto an = a["name"];
×
4143
                        auto av = a["value"];
×
4144
                        if (!an.error() && an.is_string() &&
×
4145
                            an.get_string().value_unsafe() == "rank" &&
×
4146
                            !av.error() && av.is_string())
×
4147
                            acc.rank.emplace(
×
4148
                                static_cast<std::int64_t>(
×
4149
                                    json_number(pp.value_unsafe())),
×
4150
                                std::string(av.get_string().value_unsafe()));
×
4151
                    }
4152
                    continue;
×
UNCOV
4153
                }
×
4154
            }
UNCOV
4155
            auto pr = root["pid"];
×
UNCOV
4156
            auto tr = root["ts"];
×
UNCOV
4157
            if (pr.error() || tr.error()) continue;
×
4158
            auto pid =
UNCOV
4159
                static_cast<std::int64_t>(json_number(pr.value_unsafe()));
×
4160
            auto ts =
UNCOV
4161
                static_cast<std::uint64_t>(json_number(tr.value_unsafe()));
×
UNCOV
4162
            auto it = acc.first_ts.find(pid);
×
UNCOV
4163
            if (it == acc.first_ts.end())
×
UNCOV
4164
                acc.first_ts.emplace(pid, ts);
×
4165
            else if (ts < it->second)
×
4166
                it->second = ts;
×
4167

UNCOV
4168
            auto nr = root["name"];
×
UNCOV
4169
            std::string_view name;
×
UNCOV
4170
            if (!nr.error() && nr.is_string())
×
UNCOV
4171
                name = nr.get_string().value_unsafe();
×
4172

UNCOV
4173
            std::int64_t child = -1;
×
UNCOV
4174
            double ret = 0;
×
UNCOV
4175
            auto args = root["args"];
×
UNCOV
4176
            if (!args.error() && args.is_object()) {
×
UNCOV
4177
                auto ppr = args["ppid"];
×
UNCOV
4178
                if (!ppr.error()) {
×
4179
                    auto pp = static_cast<std::int64_t>(
4180
                        json_number(ppr.value_unsafe()));
×
4181
                    if (pp > 0 && pp != pid) acc.ppid[pid] = pp;
×
4182
                }
UNCOV
4183
                auto rr = args["ret"];
×
UNCOV
4184
                if (!rr.error()) {
×
UNCOV
4185
                    ret = json_number(rr.value_unsafe());
×
UNCOV
4186
                    child = static_cast<std::int64_t>(ret);
×
4187
                }
UNCOV
4188
                auto hr = args["hhash"];
×
UNCOV
4189
                if (!hr.error() && hr.is_string() &&
×
UNCOV
4190
                    acc.pid_hhash.find(pid) == acc.pid_hhash.end())
×
UNCOV
4191
                    acc.pid_hhash.emplace(
×
UNCOV
4192
                        pid, std::string(hr.get_string().value_unsafe()));
×
4193
            }
4194
            // I/O bytes per process: args.ret on read/write ops.
UNCOV
4195
            if (ret > 0 && (name.find("read") != std::string_view::npos ||
×
UNCOV
4196
                            name.find("write") != std::string_view::npos))
×
UNCOV
4197
                acc.bytes[pid] += static_cast<std::uint64_t>(ret);
×
4198

UNCOV
4199
            auto cr = root["cat"];
×
UNCOV
4200
            std::string_view cat;
×
UNCOV
4201
            if (!cr.error() && cr.is_string())
×
UNCOV
4202
                cat = cr.get_string().value_unsafe();
×
UNCOV
4203
            if (cat == "POSIX" || cat == "STDIO" || cat == "IO") {
×
UNCOV
4204
                acc.io_ops[pid] += 1;
×
UNCOV
4205
                auto dr = root["dur"];
×
UNCOV
4206
                if (!dr.error())
×
UNCOV
4207
                    acc.io_busy[pid] += json_number(dr.value_unsafe());
×
4208
            }
4209

UNCOV
4210
            if (!name.empty() && is_fork_syscall(name))
×
4211
                acc.forks.emplace_back(ts, pid, child > 0 ? child : -1);
×
4212
        }
UNCOV
4213
    };
×
4214

4215
    // The per-process totals are exact and whole-trace, so the summary answers
4216
    // this identically to a scan; only a single-file view has to scan.
4217
    bool single_file = !params.get("file").empty();
4218
    const VizSummary* summary =
4219
        single_file ? nullptr : co_await ensure_viz_summary(index);
4220
    if (summary != nullptr) {
4221
        Acc& acc = accs[0];
4222
        for (const auto& p : summary->procs) {
4223
            if (p.first_ts != 0) acc.first_ts.emplace(p.pid, p.first_ts);
4224
            if (p.ppid > 0) acc.ppid.emplace(p.pid, p.ppid);
4225
            if (p.bytes != 0) acc.bytes.emplace(p.pid, p.bytes);
4226
            if (p.io_ops != 0) acc.io_ops.emplace(p.pid, p.io_ops);
4227
            if (p.io_busy != 0) acc.io_busy.emplace(p.pid, p.io_busy);
4228
            if (!p.hhash.empty()) acc.pid_hhash.emplace(p.pid, p.hhash);
4229
            if (!p.rank.empty()) acc.rank.emplace(p.pid, p.rank);
4230
        }
4231
        for (const auto& f : summary->forks)
4232
            acc.forks.emplace_back(f.ts, f.pid, f.child);
4233
        for (const auto& h : summary->hosts) acc.hh.emplace(h.first, h.second);
4234
    } else {
4235
        ViewDefinition view;
4236
        view.name = "viz_proctree";
4237
        view.with_query("ts >= " + std::to_string(gmin) +
4238
                        " and ts <= " + std::to_string(gmax));
4239
        auto files =
4240
            select_viz_target_files(index, params, static_cast<double>(gmin),
4241
                                    static_cast<double>(gmax));
4242
        co_await scan_view_events(index, files, view, static_cast<double>(gmin),
4243
                                  static_cast<double>(gmax), 0, slots, on_batch,
4244
                                  req.cancel_token, single_file);
4245
    }
4246

4247
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
4248
    ankerl::unordered_dense::map<std::int64_t, std::int64_t> parent_of;
4249
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> spawn_of;
4250
    std::vector<std::pair<std::uint64_t, std::int64_t>> inf_forks;  // (ts, pid)
4251
    for (auto& a : accs) {
4252
        for (auto& kv : a.first_ts) {
4253
            auto it = first_ts.find(kv.first);
4254
            if (it == first_ts.end())
4255
                first_ts.emplace(kv.first, kv.second);
4256
            else if (kv.second < it->second)
4257
                it->second = kv.second;
4258
        }
4259
        // Explicit edges from the fork event's args.ret (child pid + spawn ts).
4260
        for (auto& [ts, ppid, child] : a.forks) {
4261
            if (child > 0) {
4262
                parent_of[child] = ppid;
4263
                spawn_of[child] = ts;
4264
            } else {
4265
                inf_forks.emplace_back(ts, ppid);
4266
            }
4267
        }
4268
    }
4269
    // args.ppid metadata fills in any process not linked by a fork event.
4270
    for (auto& a : accs)
4271
        for (auto& kv : a.ppid)
4272
            if (parent_of.find(kv.first) == parent_of.end())
4273
                parent_of.emplace(kv.first, kv.second);
4274
    std::sort(inf_forks.begin(), inf_forks.end());
4275

4276
    // Merge host (hhash -> hostname resolved) and I/O bytes per process.
4277
    dftracer::utils::StringViewMap<std::string> hh;
4278
    ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
4279
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
4280
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
4281
    ankerl::unordered_dense::map<std::int64_t, double> io_busy;
4282
    ankerl::unordered_dense::map<std::int64_t, std::string> rank;
4283
    for (auto& a : accs) {
4284
        for (auto& kv : a.hh) hh.emplace(kv.first, kv.second);
4285
        for (auto& kv : a.pid_hhash) pid_hhash.emplace(kv.first, kv.second);
4286
        for (auto& kv : a.bytes) bytes[kv.first] += kv.second;
4287
        for (auto& kv : a.io_ops) io_ops[kv.first] += kv.second;
4288
        for (auto& kv : a.io_busy) io_busy[kv.first] += kv.second;
4289
        for (auto& kv : a.rank) rank.emplace(kv.first, kv.second);
4290
    }
4291

4292
    std::vector<std::pair<std::uint64_t, std::int64_t>>
4293
        procs;  // (first_ts, pid)
4294
    procs.reserve(first_ts.size());
4295
    for (auto& kv : first_ts) procs.emplace_back(kv.second, kv.first);
4296
    std::sort(procs.begin(), procs.end());
4297

4298
    // Time-inference fallback (traces without args.ret/ppid): link a process to
4299
    // the nearest preceding clone in another process.
4300
    std::vector<bool> used(inf_forks.size(), false);
4301
    std::vector<ProcNode> nodes;
4302
    nodes.reserve(procs.size());
4303
    const double proc_dur_us =
4304
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
4305
            index.time_metric());
4306
    for (auto& [fts, pid] : procs) {
4307
        std::int64_t parent = -1;
4308
        std::uint64_t spawn_ts = 0;
4309
        auto pit = parent_of.find(pid);
4310
        auto sit = spawn_of.find(pid);
4311
        // A fork cannot spawn a child that already existed before it: reject
4312
        // such edges (pid reuse across runs) and fall back to time inference.
4313
        bool valid = pit != parent_of.end() &&
4314
                     (sit == spawn_of.end() || sit->second <= fts);
4315
        if (valid) {
4316
            parent = pit->second;
4317
            if (sit != spawn_of.end()) spawn_ts = sit->second - base;
4318
        } else {
4319
            auto hi = std::upper_bound(
4320
                inf_forks.begin(), inf_forks.end(),
4321
                std::make_pair(fts, std::numeric_limits<std::int64_t>::max()));
4322
            for (auto it = hi; it != inf_forks.begin();) {
4323
                --it;
4324
                auto idx = static_cast<std::size_t>(it - inf_forks.begin());
4325
                if (!used[idx] && it->second != pid) {
4326
                    used[idx] = true;
4327
                    parent = it->second;
4328
                    spawn_ts = it->first - base;
4329
                    break;
4330
                }
4331
            }
4332
        }
4333
        std::string_view host;
4334
        auto hp = pid_hhash.find(pid);
4335
        if (hp != pid_hhash.end()) {
4336
            auto hn = hh.find(hp->second);
4337
            if (hn != hh.end()) host = hn->second;
4338
        }
4339
        auto bp = bytes.find(pid);
4340
        auto op = io_ops.find(pid);
4341
        auto ib = io_busy.find(pid);
4342
        auto rk = rank.find(pid);
4343
        nodes.push_back({pid, parent, index.native_to_us(spawn_ts),
4344
                         index.native_to_us(fts > base ? fts - base : 0), host,
4345
                         bp != bytes.end() ? bp->second : 0,
4346
                         op != io_ops.end() ? op->second : 0,
4347
                         (ib != io_busy.end() ? ib->second : 0.0) * proc_dur_us,
4348
                         rk != rank.end() ? &rk->second : nullptr});
4349
    }
4350

4351
    auto& sb = scratch_json_builder();
4352
    sb.start_object();
4353
    sb.append_key_value("nodes", nodes);
4354
    sb.end_object();
4355
    co_return HttpResponse::ok(std::string(sb));
4356
}
2!
4357

4358
// GET /api/v1/viz/columns: the complete set of groupable columns in the trace
4359
// (top-level scalar fields + args keys), harvested during the summary scan.
4360
static coro::CoroTask<HttpResponse> handle_viz_columns(
1!
4361
    const HttpRequest& /*req*/, const QueryParams& /*params*/,
4362
    TraceIndex& index) {
4363
    // Prefer the durable set stored at index build (available immediately, no
4364
    // scan). Fall back to the summary harvest for indexes built before column
4365
    // discovery existed.
4366
    std::vector<std::string> columns;
4367
    bool ready = true;
4368
    {
4369
        ankerl::unordered_dense::set<std::string> roots;
4370
        for (const auto& f : index.files())
4371
            if (!f.index_path.empty()) roots.insert(f.index_path);
4372
        ankerl::unordered_dense::set<std::string> merged;
4373
        for (const auto& r : roots) {
4374
            try {
4375
                utilities::indexer::IndexDatabase db(
4376
                    r, rocksdb::RocksDatabase::OpenMode::ReadOnly);
4377
                for (auto& c : db.query_all_columns())
4378
                    merged.emplace(std::move(c));
4379
            } catch (...) {
4380
            }
4381
        }
4382
        columns.assign(merged.begin(), merged.end());
4383
        std::sort(columns.begin(), columns.end());
4384
    }
4385
    if (columns.empty()) {
4386
        const VizSummary* s = co_await ensure_viz_summary(index);
4387
        if (s) columns = s->columns;
4388
        ready = s != nullptr;  // false => client retries after summary builds
4389
    }
4390

4391
    auto& b = scratch_json_builder();
4392
    b.start_object();
4393
    b.escape_and_append_with_quotes("columns");
4394
    b.append_colon();
4395
    b.start_array();
4396
    bool first = true;
4397
    for (const auto& c : columns) {
4398
        if (!first) b.append_comma();
4399
        first = false;
4400
        b.escape_and_append_with_quotes(c);
4401
    }
4402
    b.end_array();
4403
    b.append_comma();
4404
    b.append_key_value("ready", ready);
4405
    b.end_object();
4406
    co_return HttpResponse::ok(std::string(b));
4407
}
2!
4408

4409
static coro::CoroTask<HttpResponse> handle_viz_breaks(
7!
4410
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
4411
    auto ts_norm_param = params.get("ts_normalize");
4412
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
4413
    std::uint64_t global_min = 0;
4414
    if (normalize) {
4415
        global_min = index.global_min_timestamp_us();
4416
        if (global_min == std::numeric_limits<std::uint64_t>::max())
4417
            global_min = 0;
4418
    }
4419
    const VizSummary* s = co_await ensure_viz_summary(index);
4420
    auto& b = scratch_json_builder();
4421
    b.start_object();
4422
    b.escape_and_append_with_quotes("gaps");
4423
    b.append_colon();
4424
    b.start_array();
4425
    if (s) {
4426
        bool first = true;
4427
        for (const auto& g : s->idle_gaps) {
4428
            if (!first) b.append_comma();
4429
            first = false;
4430
            b.start_object();
4431
            b.append_key_value("begin",
4432
                               index.native_to_us(g.first - global_min));
4433
            b.append_comma();
4434
            b.append_key_value("end",
4435
                               index.native_to_us(g.second - global_min));
4436
            b.end_object();
4437
        }
4438
    }
4439
    b.end_array();
4440
    b.append_comma();
4441
    b.append_key_value("multi_run", s && !s->idle_gaps.empty());
4442
    b.end_object();
4443
    co_return HttpResponse::ok(std::string(b));
4444
}
14!
4445

4446
void register_viz_api(Router& router, TraceIndex& index) {
11✔
4447
    auto* index_ptr = &index;
11✔
4448
    const RouteParam BEGIN{"begin", "Window start (us)", true, "0"};
11!
4449
    const RouteParam END{"end", "Window end (us)", true, "999999999"};
11!
4450
    const RouteParam SUMMARY{"summary", "LOD level (1=full detail)", true, "1"};
11!
4451

4452
    router.get(
11!
4453
        "/api/v1/viz/proctree",
4454
        [index_ptr](const HttpRequest& req,
1!
4455
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4456
            co_return co_await handle_viz_proctree(req, params, *index_ptr);
4457
        },
2!
4458
        RouteDoc{
44!
4459
            "Inferred process/fork hierarchy with host, rank, and I/O.",
4460
            "Visualization",
4461
            {{"file", "Limit to one trace file", false, ""}},
4462
            R"({"nodes":[{"pid":100,"parent":-1,"host":"node01","rank":"0",)"
4463
            R"("bytes":16384,"io_ops":4,"io_busy":600.0}]})"});
22✔
4464

4465
    router.get(
11!
4466
        "/api/v1/viz/counters",
4467
        [index_ptr](const HttpRequest& req,
1!
4468
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4469
            co_return co_await handle_viz_counters(req, params, *index_ptr);
4470
        },
2!
4471
        RouteDoc{"Read/write bytes and I/O op counts per time bucket.",
66!
4472
                 "Visualization",
4473
                 {BEGIN, END, SUMMARY},
4474
                 R"({"buckets":[{"ts":0,"read_bytes":4096,"write_bytes":0,)"
4475
                 R"("read_ops":1,"write_ops":0}]})"});
44✔
4476

4477
    router.get(
11!
4478
        "/api/v1/viz/breaks",
4479
        [index_ptr](const HttpRequest& req,
7!
4480
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4481
            co_return co_await handle_viz_breaks(req, params, *index_ptr);
4482
        },
14!
4483
        RouteDoc{
44!
4484
            "Globally-idle time gaps and multi-run detection.",
4485
            "Visualization",
4486
            {{"ts_normalize", "Normalize to global min (default on)", false,
4487
              "1"}},
4488
            R"({"gaps":[{"begin":50000,"end":900000}],"multi_run":true})"});
22✔
4489

4490
    router.get(
11!
4491
        "/api/v1/viz/columns",
4492
        [index_ptr](const HttpRequest& req,
1!
4493
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4494
            co_return co_await handle_viz_columns(req, params, *index_ptr);
4495
        },
2!
4496
        RouteDoc{"Groupable columns present in the trace.",
22!
4497
                 "Visualization",
4498
                 {},
4499
                 R"({"columns":["cat","name","mhost","fhash"],"ready":true})"});
4500

4501
    router.get(
11!
4502
        "/api/v1/viz/events",
4503
        [index_ptr](const HttpRequest& req,
8!
4504
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4505
            co_return co_await handle_viz_events(req, params, *index_ptr);
4506
        },
16!
4507
        RouteDoc{"Events for rendering: time-windowed, LOD-aggregated.",
99!
4508
                 "Visualization",
4509
                 {BEGIN,
4510
                  END,
4511
                  SUMMARY,
4512
                  {"pid", "Filter by process id", false, ""},
4513
                  {"cat", "Filter by category", false, ""},
4514
                  {"query", "DSL predicate, e.g. dur >= 1000", false, ""}},
4515
                 R"({"events":[],"metadata":{"begin":0,"end":1000000,)"
4516
                 R"("count":42,"truncated":false,"ts_normalized":true}})"});
77✔
4517

4518
    router.get(
11!
4519
        "/api/v1/viz/density",
4520
        [index_ptr](const HttpRequest& req,
5!
4521
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4522
            co_return co_await handle_viz_density(req, params, *index_ptr);
4523
        },
10!
4524
        RouteDoc{"Sub-pixel events bucketed into density blocks.",
77!
4525
                 "Visualization",
4526
                 {BEGIN,
4527
                  END,
4528
                  {"summary", "LOD level", true, "2"},
4529
                  {"width", "Canvas width in px (sets the 1px fold cutoff)",
4530
                   false, "1920"}},
4531
                 R"({"events":[],"density":[{"pid":100,"tid":100,"ts":0,)"
4532
                 R"("dur":24457,"count":910,"total":22044,"depth":0}]})"});
55✔
4533

4534
    router.get(
11!
4535
        "/api/v1/viz/stats",
4536
        [index_ptr](const HttpRequest& req,
2!
4537
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4538
            co_return co_await handle_viz_stats(req, params, *index_ptr);
4539
        },
4!
4540
        RouteDoc{"Per-name aggregation over a time range (Analyze).",
66!
4541
                 "Visualization",
4542
                 {BEGIN, END, SUMMARY},
4543
                 R"({"count":100,"total_dur":5000,"names":[{"name":"read",)"
4544
                 R"("count":50,"total":2500,"avg":50,"min":10,"max":90}]})"});
44✔
4545

4546
    router.get(
11!
4547
        "/api/v1/viz/calltree",
4548
        [index_ptr](const HttpRequest& req,
3!
4549
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4550
            co_return co_await handle_viz_calltree(req, params, *index_ptr);
4551
        },
6!
4552
        RouteDoc{
77!
4553
            "Merged flamegraph tree from ts/dur containment.",
4554
            "Visualization",
4555
            {BEGIN,
4556
             END,
4557
             SUMMARY,
4558
             {"group", "Set to 'pid' to keep processes separate", false, ""}},
4559
            R"({"name":"root","total":5000,"self":0,"count":0,)"
4560
            R"("children":[{"name":"read","total":2500,"self":2500,)"
4561
            R"("count":50}]})"});
55✔
4562

4563
    router.get(
11!
4564
        "/api/v1/viz/histogram",
4565
        [index_ptr](const HttpRequest& req,
1!
4566
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4567
            co_return co_await handle_viz_histogram(req, params, *index_ptr);
4568
        },
2!
4569
        RouteDoc{"Duration distribution: percentiles + log-spaced buckets.",
77!
4570
                 "Visualization",
4571
                 {BEGIN,
4572
                  END,
4573
                  SUMMARY,
4574
                  {"query", "DSL predicate to narrow to one op", false, ""}},
4575
                 R"({"min":10,"max":900,"p50":150,"p99":880,"buckets":[]})"});
55✔
4576

4577
    router.get(
11!
4578
        "/api/v1/viz/layers",
4579
        [index_ptr](const HttpRequest& req,
2!
4580
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4581
            co_return co_await handle_viz_layers(req, params, *index_ptr);
4582
        },
4!
4583
        RouteDoc{"Operation-name to category map; declared vs I/O files.",
22!
4584
                 "Visualization",
4585
                 {},
4586
                 R"({"layers":{"read":"POSIX","write":"POSIX"},)"
4587
                 R"("total_files":2,"io_files":2})"});
4588
}
11✔
4589

4590
}  // 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