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

llnl / dftracer-utils / 29811479573

21 Jul 2026 07:42AM UTC coverage: 52.899% (+0.2%) from 52.66%
29811479573

Pull #99

github

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

41156 of 100801 branches covered (40.83%)

Branch coverage included in aggregate %.

1065 of 1185 new or added lines in 30 files covered. (89.87%)

23 existing lines in 5 files now uncovered.

36312 of 45644 relevant lines covered (79.55%)

23458.91 hits per line

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

58.18
/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/trace_index.h>
12
#include <dftracer/utils/server/viz_api.h>
13
#include <dftracer/utils/utilities/common/json/json_doc_guard.h>
14
#include <dftracer/utils/utilities/common/json/json_value.h>
15
#include <dftracer/utils/utilities/common/query/query.h>
16
#include <dftracer/utils/utilities/composites/dft/views/view_builder_utility.h>
17
#include <dftracer/utils/utilities/composites/dft/views/view_definition.h>
18
#include <dftracer/utils/utilities/composites/dft/views/view_reader_utility.h>
19
#include <dftracer/utils/utilities/fileio/lines/sources/async_streaming_gz_line_generator.h>
20
#include <dftracer/utils/utilities/indexer/index_database.h>
21
#include <simdjson.h>
22

23
#include <algorithm>
24
#include <atomic>
25
#include <cstddef>
26
#include <cstdint>
27
#include <cstdlib>
28
#include <deque>
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",
280!
46
                                                                    "SH"};
280!
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,
1,200✔
55
                               std::uint64_t value) {
56
    auto pos = json.find(key);
1,200✔
57
    if (pos == std::string::npos) return false;
1,200✔
58
    pos += key.size();
1,200✔
59
    while (pos < json.size() && std::isspace(json[pos])) ++pos;
1,200!
60
    auto end_pos = pos;
1,200✔
61
    while (end_pos < json.size() &&
17,880✔
62
           (std::isdigit(json[end_pos]) || json[end_pos] == '-')) {
11,520!
63
        ++end_pos;
10,320✔
64
    }
65
    if (end_pos == pos) return false;
1,200!
66
    json.replace(pos, end_pos - pos, std::to_string(value));
1,200!
67
    return true;
1,200✔
68
}
600✔
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,
802✔
75
                                      std::uint64_t offset,
76
                                      TraceIndex::TimeMetric metric) {
77
    thread_local simdjson::dom::parser tl_parser;
802✔
78
    auto result = tl_parser.parse(event_json);
802✔
79
    if (result.error()) return event_json;
802!
80

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

408
// Scan events matching `view` within [begin, end] across `target_files`,
409
// invoking `on_batch(slot, events)` for each batch. `on_batch` must be
410
// thread-safe across slots: it is called concurrently from worker coroutines,
411
// but each worker owns its own slot so per-slot state needs no lock. Returns
412
// true if scanning stopped early because `limit` (0 = unlimited) was reached.
413
static coro::CoroTask<bool> scan_view_events(
102!
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) {
17!
420
    const std::int64_t cap =
34✔
421
        limit > 0 ? limit : std::numeric_limits<std::int64_t>::max();
17✔
422
    std::atomic<std::int64_t> produced{0};
17✔
423

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

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

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

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

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

477
    for (std::size_t w = 0; w < num_workers; ++w) {
51✔
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,
336!
481
                     cancel](CoroScope&) -> coro::CoroTask<void> {
68!
482
            while (auto item = co_await chan->receive()) {
228!
483
                if (cancel.cancelled()) co_return;
16!
484
                if (produced_ptr->load(std::memory_order_relaxed) >= cap)
16!
485
                    co_return;
486

487
                ViewReaderInput reader_input;
16✔
488
                reader_input.with_file_path(item->file->path)
16!
489
                    .with_index_path(item->file->index_path)
16!
490
                    .with_byte_range(item->start_byte, item->end_byte)
16!
491
                    .with_checkpoint_idx(item->checkpoint_idx)
16!
492
                    .with_view(*view_ptr);
16!
493

494
                ViewReaderUtility reader;
16!
495
                auto gen = reader.process(reader_input);
16!
496
                while (auto batch = co_await gen.next()) {
128!
497
                    if (batch->events.empty()) continue;
16!
498
                    if (cancel.cancelled()) co_return;
16!
499
                    if (produced_ptr->load(std::memory_order_relaxed) >= cap)
16!
500
                        break;
501
                    (*on_batch_ptr)(w, batch->events);
16!
502
                    produced_ptr->fetch_add(
32✔
503
                        static_cast<std::int64_t>(batch->events.size()),
16✔
504
                        std::memory_order_relaxed);
505
                }
32✔
506
            }
114!
507
            co_return;
34✔
508
        });
388!
509
    }
34✔
510

511
    co_await scope.join();
51!
512
    co_return produced.load(std::memory_order_relaxed) >= cap;
17!
513
}
85!
514

515
static coro::CoroTask<void> append_app_spans(std::vector<std::string>& out,
516
                                             TraceIndex& index, double begin,
517
                                             double end,
518
                                             const QueryParams& params);
519

520
static coro::CoroTask<HttpResponse> handle_viz_events(const HttpRequest& req,
88!
521
                                                      const QueryParams& params,
522
                                                      TraceIndex& index) {
8!
523
    // Required: begin, end, summary
524
    if (!params.has("begin") || !params.has("end") || !params.has("summary")) {
20!
525
        co_return HttpResponse::bad_request(
35!
526
            "Missing required parameters: begin, end, summary");
27!
527
    }
528

529
    double begin = params.get_double("begin", 0);
21✔
530
    double end = params.get_double("end", 0);
21✔
531
    int summary = params.get_int("summary", 1);
21✔
532
    if (summary < 1) summary = 1;
21!
533

534
    // Validate the optional raw DSL query before it is spliced into the view.
535
    auto query = params.get("query");
21✔
536
    if (!query.empty() &&
21!
537
        !utilities::common::query::try_parse(query).has_value()) {
×
538
        co_return HttpResponse::bad_request("Invalid query: " +
×
539
                                            std::string(query));
×
540
    }
541

542
    // Timestamp normalization: default ON, opt-out with ?ts_normalize=0
543
    auto ts_norm_param = params.get("ts_normalize");
21✔
544
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
21!
545

546
    std::uint64_t global_min = 0;
21✔
547
    if (normalize) {
21✔
548
        global_min = index.global_min_timestamp_us();
6✔
549
        if (global_min == std::numeric_limits<std::uint64_t>::max()) {
6!
550
            global_min = 0;  // No valid bounds, skip normalization
551
        }
552
    }
6✔
553

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

572
    double min_dur =
21✔
573
        duration_threshold(begin, end, static_cast<unsigned>(summary));
21!
574

575
    // Overlap, bounded: look back at most `lookback` (the longest event's
576
    // duration, supplied by the client) so events that started before the
577
    // window but extend into it are included, without scanning to time 0.
578
    double lookback = params.get_double("lookback", 0);
21✔
579
    if (lookback < 0) lookback = 0;
21!
580
    double scan_begin = begin - lookback;
21✔
581
    if (scan_begin < 0) scan_begin = 0;
21!
582

583
    ViewDefinition view = build_viz_view(params, scan_begin, end, min_dur);
21!
584

585
    // Optional limit: 0 (default) means no limit.
586
    int limit = params.get_int("limit", 0);
21✔
587
    if (limit < 0) limit = 0;
21!
588

589
    std::vector<const TraceIndex::FileInfo*> target_files =
21✔
590
        select_viz_target_files(index, params, scan_begin, end);
21!
591

592
    // Each worker slot collects into its own vector (no shared state, no lock);
593
    // the slots are concatenated single-threaded after the scan.
594
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
21!
595
    std::vector<std::vector<std::string>> partials(slots);
21!
596
    auto collect = [&partials](std::size_t w,
29✔
597
                               const std::vector<std::string_view>& events) {
4✔
598
        auto& out = partials[w];
8✔
599
        out.reserve(out.size() + events.size());
8✔
600
        for (auto ev : events) out.emplace_back(ev);
710✔
601
    };
8✔
602

603
    bool truncated = co_await scan_view_events(
35!
604
        index, target_files, view, scan_begin, end, limit, slots, collect,
21!
605
        req.cancel_token, !params.get("file").empty());
21!
606

607
    std::vector<std::string> collected_events;
21✔
608
    for (auto& p : partials) {
35✔
609
        for (auto& s : p) collected_events.emplace_back(std::move(s));
365!
610
    }
14✔
611
    if (limit > 0 && static_cast<int>(collected_events.size()) > limit) {
21!
612
        collected_events.resize(static_cast<std::size_t>(limit));
×
613
        truncated = true;
614
    }
615

616
    co_await append_app_spans(collected_events, index, begin, end, params);
28!
617

618
    std::string body = build_viz_events_body(
7!
619
        collected_events, global_min, original_begin, original_end, limit,
7✔
620
        truncated, index.native_to_us(index.global_min_timestamp_us()),
7!
621
        index.time_metric());
7✔
622
    co_return HttpResponse::ok(body);
7!
623
}
313!
624

625
namespace {
626

627
struct NameStat {
628
    std::uint64_t count = 0;
629
    double total = 0;
630
    double min = 0;
631
    double max = 0;
632
    void merge_from(const NameStat& o) {
×
633
        if (count == 0) {
×
634
            min = o.min;
×
635
            max = o.max;
×
636
        } else if (o.count > 0) {
×
637
            min = std::min(min, o.min);
×
638
            max = std::max(max, o.max);
×
639
        }
640
        count += o.count;
×
641
        total += o.total;
×
642
    }
×
643
};
644

645
using NameMap = dftracer::utils::StringViewMap<NameStat>;
646

647
static double json_number(simdjson::dom::element el);
648

649
enum class GroupBy { Name, Cat, Pid, Fhash };
650

651
static GroupBy parse_group_by(std::string_view g) {
4✔
652
    if (g == "cat") return GroupBy::Cat;
4!
653
    if (g == "pid") return GroupBy::Pid;
4!
654
    if (g == "fhash" || g == "file") return GroupBy::Fhash;
4!
655
    return GroupBy::Name;
4✔
656
}
2✔
657

658
// Parse one event's dur and grouping key, folding it into a worker-local map.
659
static void fold_event(std::string_view event, GroupBy group, NameMap& local) {
×
660
    thread_local simdjson::dom::parser parser;
×
661
    thread_local std::string buf;
×
662
    thread_local std::string keybuf;
×
663
    buf.assign(event);
×
664
    auto res = parser.parse(buf);
×
665
    if (res.error()) return;
×
666
    auto root = res.value_unsafe();
×
667
    if (!root.is_object()) return;
×
668

669
    auto dur_r = root["dur"];
×
670
    if (dur_r.error()) return;  // skip instant/metadata events (no duration)
×
671
    double dur = 0;
×
672
    if (dur_r.is_uint64())
×
673
        dur = static_cast<double>(dur_r.get_uint64().value_unsafe());
×
674
    else if (dur_r.is_int64())
×
675
        dur = static_cast<double>(dur_r.get_int64().value_unsafe());
×
676
    else if (dur_r.is_double())
×
677
        dur = dur_r.get_double().value_unsafe();
×
678
    else
679
        return;
×
680

681
    std::string_view name;
×
682
    if (group == GroupBy::Name) {
×
683
        auto r = root["name"];
×
684
        if (!r.error() && r.is_string()) name = r.get_string().value_unsafe();
×
685
    } else if (group == GroupBy::Cat) {
×
686
        auto r = root["cat"];
×
687
        if (!r.error() && r.is_string()) name = r.get_string().value_unsafe();
×
688
    } else if (group == GroupBy::Pid) {
×
689
        auto r = root["pid"];
×
690
        if (!r.error()) {
×
691
            keybuf = std::to_string(
×
692
                static_cast<std::int64_t>(json_number(r.value_unsafe())));
×
693
            name = keybuf;
×
694
        }
695
    } else {  // Fhash
696
        auto args = root["args"];
×
697
        if (!args.error() && args.is_object()) {
×
698
            auto r = args["fhash"];
×
699
            if (!r.error() && r.is_string())
×
700
                name = r.get_string().value_unsafe();
×
701
        }
702
        if (name.empty()) return;  // only I/O events have a file hash
×
703
    }
704

705
    auto it = local.find(name);
×
706
    if (it == local.end()) {
×
707
        it = local.emplace(std::string(name), NameStat{}).first;
×
708
        it->second.min = dur;
×
709
        it->second.max = dur;
×
710
    } else {
711
        it->second.min = std::min(it->second.min, dur);
×
712
        it->second.max = std::max(it->second.max, dur);
×
713
    }
714
    it->second.count += 1;
×
715
    it->second.total += dur;
×
716
}
717

718
// Sub-pixel events are bucketed by (pid, tid, pixel-column) instead of dropped,
719
// so zoomed-out views still show where activity is. The live path also assigns
720
// each bucket a containment depth (see assign_view_depths) so blocks stack
721
// under their enclosing events instead of collapsing to row 0.
722
struct DensityKey {
723
    std::int64_t pid;
724
    std::int64_t tid;
725
    std::int64_t col;
726
    std::string group;  // group_by value; "" when grouping is off or missing
727
    bool operator==(const DensityKey& o) const {
127✔
728
        return pid == o.pid && tid == o.tid && col == o.col && group == o.group;
127!
729
    }
730
};
731

732
struct DensityKeyHash {
733
    std::uint64_t operator()(const DensityKey& k) const noexcept {
2,184✔
734
        std::uint64_t h = 1469598103934665603ULL;
2,184✔
735
        auto mix = [&](std::uint64_t v) {
9,828✔
736
            h ^= v;
8,736✔
737
            h *= 1099511628211ULL;
8,736✔
738
        };
9,828✔
739
        mix(static_cast<std::uint64_t>(k.pid));
2,184!
740
        mix(static_cast<std::uint64_t>(k.tid));
2,184!
741
        mix(static_cast<std::uint64_t>(k.col));
2,184!
742
        mix(std::hash<std::string>{}(k.group));
2,184!
743
        return h;
2,184✔
744
    }
745
};
746

747
struct DensityAgg {
748
    std::uint32_t count = 0;
749
    double total = 0;
750
    double max_dur = -1;
751
    std::uint32_t depth = 0;  // containment depth, set by assign_view_depths
752
    std::string
753
        name;  // representative: name of the longest event in the bucket
754
    void merge_from(const DensityAgg& o) {
120✔
755
        count += o.count;
120✔
756
        total += o.total;
120✔
757
        if (o.max_dur > max_dur) {
120✔
758
            max_dur = o.max_dur;
×
759
            name = o.name;
×
760
        }
761
    }
120✔
762
};
763

764
using DensityMap =
765
    ankerl::unordered_dense::map<DensityKey, DensityAgg, DensityKeyHash>;
766

767
static double json_number(simdjson::dom::element el) {
6,120✔
768
    if (el.is_uint64())
6,120!
769
        return static_cast<double>(el.get_uint64().value_unsafe());
6,120✔
770
    if (el.is_int64())
×
771
        return static_cast<double>(el.get_int64().value_unsafe());
×
772
    if (el.is_double()) return el.get_double().value_unsafe();
×
773
    return 0;
×
774
}
3,060✔
775

776
// Value of `col` in the event for group_by, as a display string. Dotted paths
777
// walk nested objects; bare names fall back into "args" (the canonical home of
778
// domain fields). Anything missing or non-scalar yields "" so events are never
779
// dropped - the client renders those under "(none)".
780
static std::string extract_group_value(simdjson::dom::element root,
280✔
781
                                       std::string_view col) {
782
    auto scalar = [](simdjson::dom::element el) -> std::string {
280✔
783
        if (el.is_string()) return std::string(el.get_string().value_unsafe());
210!
NEW
784
        if (el.is_int64()) return std::to_string(el.get_int64().value_unsafe());
×
NEW
785
        if (el.is_uint64())
×
NEW
786
            return std::to_string(el.get_uint64().value_unsafe());
×
NEW
787
        if (el.is_double())
×
NEW
788
            return std::to_string(el.get_double().value_unsafe());
×
NEW
789
        if (el.is_bool())
×
NEW
790
            return el.get_bool().value_unsafe() ? "true" : "false";
×
NEW
791
        return "";
×
792
    };
70✔
793
    auto walk = [&](std::string_view path,
420✔
794
                    simdjson::dom::element& out) -> bool {
795
        simdjson::dom::element cur = root;
280✔
796
        std::size_t start = 0;
280✔
797
        while (start <= path.size()) {
280✔
798
            auto dot = path.find('.', start);
280✔
799
            auto key = path.substr(start, dot == std::string_view::npos
420!
800
                                              ? path.size() - start
280✔
801
                                              : dot - start);
802
            if (!key.empty()) {
280✔
803
                auto next = cur[key];
280✔
804
                if (next.error()) return false;
280✔
805
                cur = next.value_unsafe();
140✔
806
            }
70✔
807
            if (dot == std::string_view::npos) break;
140✔
NEW
808
            start = dot + 1;
×
809
        }
810
        out = cur;
140✔
811
        return true;
140✔
812
    };
280✔
813
    simdjson::dom::element v;
280✔
814
    if (walk(col, v)) return scalar(v);
280!
815
    if (col.find('.') == std::string_view::npos) {
140✔
816
        auto nested = root["args"][col];
140✔
817
        if (!nested.error()) return scalar(nested.value_unsafe());
140!
818
    }
70✔
819
    return "";
140!
820
}
140✔
821

NEW
822
static std::string extract_group_from_line(std::string_view event,
×
823
                                           std::string_view col) {
NEW
824
    thread_local simdjson::dom::parser parser;
×
NEW
825
    thread_local std::string buf;
×
NEW
826
    buf.assign(event);
×
NEW
827
    auto res = parser.parse(buf);
×
NEW
828
    if (res.error()) return "";
×
NEW
829
    auto root = res.value_unsafe();
×
NEW
830
    if (!root.is_object()) return "";
×
NEW
831
    return extract_group_value(root, col);
×
832
}
833

834
// Fold a small event into the density map. Returns false (keep as an individual
835
// event) when the event has no duration or is at/above the `threshold`.
836
static bool fold_density(std::string_view event, double threshold, double begin,
380✔
837
                         DensityMap& dens, double* out_dur = nullptr,
838
                         std::string_view group_col = {}) {
839
    thread_local simdjson::dom::parser parser;
380✔
840
    thread_local std::string buf;
380✔
841
    buf.assign(event);
380!
842
    auto res = parser.parse(buf);
380✔
843
    if (res.error()) return false;
380!
844
    auto root = res.value_unsafe();
380✔
845
    if (!root.is_object()) return false;
380✔
846

847
    auto dr = root["dur"];
380✔
848
    if (dr.error()) return false;
380!
849
    double dur = json_number(dr.value_unsafe());
380✔
850
    if (out_dur) *out_dur = dur;
380✔
851
    if (threshold <= 0 || dur >= threshold) return false;
380!
852

853
    double ts = 0;
380✔
854
    auto tr = root["ts"];
380✔
855
    if (!tr.error()) ts = json_number(tr.value_unsafe());
380✔
856
    std::int64_t pid = 0;
380✔
857
    auto pr = root["pid"];
380✔
858
    if (!pr.error())
380✔
859
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
380✔
860
    std::int64_t tid = 0;
380✔
861
    auto tir = root["tid"];
380✔
862
    if (!tir.error())
380✔
863
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
380✔
864
    std::string_view name;
380✔
865
    auto nr = root["name"];
380✔
866
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
380!
867

868
    std::int64_t col = static_cast<std::int64_t>((ts - begin) / threshold);
380✔
869
    std::string group;
380✔
870
    if (!group_col.empty()) group = extract_group_value(root, group_col);
380!
871
    DensityKey k{pid, tid, col, std::move(group)};
380✔
872
    auto it = dens.find(k);
380!
873
    if (it == dens.end()) it = dens.emplace(std::move(k), DensityAgg{}).first;
380!
874
    auto& a = it->second;
380✔
875
    a.count += 1;
380✔
876
    a.total += dur;
380✔
877
    if (dur > a.max_dur) {
380!
878
        a.max_dur = dur;
380✔
879
        a.name.assign(name);
380!
880
    }
190✔
881
    return true;
380✔
882
}
380✔
883

884
// Parse just the ts and dur of an event. Returns false for metadata/instant
885
// events that lack either field.
886
static bool parse_ts_dur(std::string_view event, double& ts, double& dur) {
×
887
    thread_local simdjson::dom::parser parser;
×
888
    thread_local std::string buf;
×
889
    buf.assign(event);
×
890
    auto res = parser.parse(buf);
×
891
    if (res.error()) return false;
×
892
    auto root = res.value_unsafe();
×
893
    if (!root.is_object()) return false;
×
894
    auto dr = root["dur"];
×
895
    auto tr = root["ts"];
×
896
    if (dr.error() || tr.error()) return false;
×
897
    dur = json_number(dr.value_unsafe());
×
898
    ts = json_number(tr.value_unsafe());
×
899
    return true;
×
900
}
901

902
// Parse pid, tid, ts, dur of an event. Returns false for metadata/instant
903
// events that lack ts or dur.
904
static bool parse_lane_ts_dur(std::string_view event, std::int64_t& pid,
×
905
                              std::int64_t& tid, double& ts, double& dur) {
906
    thread_local simdjson::dom::parser parser;
×
907
    thread_local std::string buf;
×
908
    buf.assign(event);
×
909
    auto res = parser.parse(buf);
×
910
    if (res.error()) return false;
×
911
    auto root = res.value_unsafe();
×
912
    if (!root.is_object()) return false;
×
913
    auto dr = root["dur"];
×
914
    auto tr = root["ts"];
×
915
    if (dr.error() || tr.error()) return false;
×
916
    dur = json_number(dr.value_unsafe());
×
917
    ts = json_number(tr.value_unsafe());
×
918
    auto pr = root["pid"];
×
919
    pid = pr.error()
×
920
              ? 0
×
921
              : static_cast<std::int64_t>(json_number(pr.value_unsafe()));
×
922
    auto tir = root["tid"];
×
923
    tid = tir.error()
×
924
              ? 0
×
925
              : static_cast<std::int64_t>(json_number(tir.value_unsafe()));
×
926
    return true;
×
927
}
928

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

939
    struct Item {
940
        double ts;          // interval start (big) or bucket left edge (block)
941
        double end;         // big end, or bucket right edge for a block query
942
        int big_idx;        // index into `big`, or -1 for a density query
943
        DensityAgg* block;  // set for a density query, null for a big event
944
    };
945
    struct LaneKey {
946
        std::int64_t pid, tid;
947
        bool operator==(const LaneKey& o) const {
×
948
            return pid == o.pid && tid == o.tid;
×
949
        }
950
    };
951
    struct LaneHash {
952
        std::uint64_t operator()(const LaneKey& k) const noexcept {
560✔
953
            std::uint64_t h = 1469598103934665603ULL;
560✔
954
            h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
560✔
955
            h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
560✔
956
            return h;
560✔
957
        }
958
    };
959
    ankerl::unordered_dense::map<LaneKey, std::vector<Item>, LaneHash> lanes;
6!
960

961
    for (std::size_t i = 0; i < big.size(); ++i) {
6!
962
        std::int64_t pid = 0, tid = 0;
×
963
        double ts = 0, dur = 0;
×
964
        if (!parse_lane_ts_dur(big[i], pid, tid, ts, dur)) continue;
×
965
        double end = ts + (dur > 0 ? dur : 0);
×
966
        lanes[LaneKey{pid, tid}].push_back(
×
967
            Item{ts, end, static_cast<int>(i), nullptr});
×
968
    }
969
    if (threshold > 0) {
6✔
970
        for (auto& kv : dens) {
266✔
971
            double lo = begin + static_cast<double>(kv.first.col) * threshold;
260✔
972
            lanes[LaneKey{kv.first.pid, kv.first.tid}].push_back(
260!
973
                Item{lo, lo + threshold, -1, &kv.second});
260!
974
        }
975
    }
3✔
976

977
    for (auto& kv : lanes) {
266✔
978
        auto& items = kv.second;
260✔
979
        // Opens sort before queries at equal ts so a block sitting exactly at a
980
        // parent's start counts that parent as an ancestor.
981
        std::sort(items.begin(), items.end(), [](const Item& a, const Item& b) {
260!
982
            if (a.ts != b.ts) return a.ts < b.ts;
×
983
            return (a.block == nullptr) && (b.block != nullptr);
×
984
        });
985
        // Greedy lowest-free-row packing: staggered overlapping spans reuse
986
        // rows (real concurrency) instead of one row each, while nested events
987
        // still cascade.
988
        std::vector<double> row_end;  // end time occupying each row
260✔
989
        for (auto& it : items) {
520✔
990
            if (it.block) {
260!
991
                std::size_t r = 0;
260✔
992
                while (r < row_end.size() && row_end[r] > it.end) ++r;
260!
993
                it.block->depth = static_cast<std::uint32_t>(r);
260✔
994
            } else {
130✔
NEW
995
                std::size_t r = 0;
×
NEW
996
                while (r < row_end.size() && row_end[r] > it.ts) ++r;
×
NEW
997
                if (r == row_end.size())
×
NEW
998
                    row_end.push_back(it.end);
×
999
                else
NEW
1000
                    row_end[r] = it.end;
×
1001
                depth[static_cast<std::size_t>(it.big_idx)] =
×
1002
                    static_cast<std::uint32_t>(r);
1003
            }
1004
        }
1005
    }
260✔
1006
    return depth;
9✔
1007
}
6!
1008

1009
// --- Counters (bandwidth / IOPS over time) ---
1010
// Per-bucket read/write bytes and I/O op counts, aggregated from POSIX/STDIO/IO
1011
// events (bytes come from args.ret).
1012
struct CounterAcc {
1013
    std::vector<double> read_bytes;
1014
    std::vector<double> write_bytes;
1015
    std::vector<double> ops;
1016
    void init(std::size_t n) {
×
1017
        read_bytes.assign(n, 0.0);
×
1018
        write_bytes.assign(n, 0.0);
×
1019
        ops.assign(n, 0.0);
×
1020
    }
×
1021
    void merge_from(const CounterAcc& o) {
×
1022
        for (std::size_t i = 0; i < ops.size(); ++i) {
×
1023
            read_bytes[i] += o.read_bytes[i];
×
1024
            write_bytes[i] += o.write_bytes[i];
×
1025
            ops[i] += o.ops[i];
×
1026
        }
1027
    }
×
1028
};
1029

1030
static void fold_counter(std::string_view event, double begin, double bucket_us,
×
1031
                         std::size_t buckets, CounterAcc& acc) {
1032
    thread_local simdjson::dom::parser parser;
×
1033
    thread_local std::string buf;
×
1034
    buf.assign(event);
×
1035
    auto res = parser.parse(buf);
×
1036
    if (res.error()) return;
×
1037
    auto root = res.value_unsafe();
×
1038
    if (!root.is_object()) return;
×
1039

1040
    auto cat_r = root["cat"];
×
1041
    if (cat_r.error() || !cat_r.is_string()) return;
×
1042
    std::string_view cat = cat_r.get_string().value_unsafe();
×
1043
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
×
1044

1045
    auto ts_r = root["ts"];
×
1046
    if (ts_r.error()) return;
×
1047
    double ts = json_number(ts_r.value_unsafe());
×
1048
    long col = static_cast<long>((ts - begin) / bucket_us);
×
1049
    if (col < 0 || col >= static_cast<long>(buckets)) return;
×
1050

1051
    acc.ops[col] += 1.0;
×
1052

1053
    std::string_view name;
×
1054
    auto name_r = root["name"];
×
1055
    if (!name_r.error() && name_r.is_string())
×
1056
        name = name_r.get_string().value_unsafe();
×
1057

1058
    double bytes = 0;
×
1059
    auto args = root["args"];
×
1060
    if (!args.error() && args.is_object()) {
×
1061
        auto ret = args["ret"];
×
1062
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
×
1063
    }
1064
    if (bytes <= 0) return;
×
1065
    if (name.find("write") != std::string_view::npos)
×
1066
        acc.write_bytes[col] += bytes;
×
1067
    else if (name.find("read") != std::string_view::npos)
×
1068
        acc.read_bytes[col] += bytes;
×
1069
}
1070

1071
}  // namespace
1072

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

1078
namespace {
1079

1080
struct PidTid {
1081
    std::int64_t pid;
1082
    std::int64_t tid;
1083
    bool operator==(const PidTid& o) const {
16✔
1084
        return pid == o.pid && tid == o.tid;
16!
1085
    }
1086
};
1087

1088
struct PidTidHash {
1089
    std::uint64_t operator()(const PidTid& k) const noexcept {
68✔
1090
        std::uint64_t h = 1469598103934665603ULL;
68✔
1091
        h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
68✔
1092
        h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
68✔
1093
        return h;
68✔
1094
    }
1095
};
1096

1097
struct SumLane {
1098
    std::int64_t pid;
1099
    std::int64_t tid;
1100
    std::vector<std::atomic<std::uint32_t>> count;
1101
    std::vector<std::atomic<std::uint64_t>> total;
1102
    std::vector<std::atomic<std::uint32_t>> maxd;
1103
    std::vector<std::atomic<std::uint32_t>> nameid;
1104
    SumLane(std::size_t nb, std::int64_t p, std::int64_t t)
24✔
1105
        : pid(p), tid(t), count(nb), total(nb), maxd(nb), nameid(nb) {
24!
1106
        for (auto& x : nameid)
1,048,592✔
1107
            x.store(std::numeric_limits<std::uint32_t>::max(),
1,048,576✔
1108
                    std::memory_order_relaxed);
1109
    }
24✔
1110
};
1111

1112
struct SumBuild {
5!
1113
    std::size_t nb = 0;
5✔
1114
    double bucket_us = 1;
5✔
1115
    std::uint64_t t0 = 0;
5✔
1116

1117
    std::mutex lane_mtx;
1118
    ankerl::unordered_dense::map<PidTid, std::size_t, PidTidHash> lane_of;
1119
    std::deque<SumLane> lanes;
1120

1121
    std::vector<std::atomic<double>> cread;
1122
    std::vector<std::atomic<double>> cwrite;
1123
    std::vector<std::atomic<double>> cops;
1124
    std::atomic<std::uint64_t> gmax_dur{0};
5✔
1125

1126
    std::mutex name_mtx;
1127
    dftracer::utils::StringViewMap<std::uint32_t> name_of;
1128
    std::vector<std::string> names;
1129

1130
    // Per-worker caches so the hot path never locks.
1131
    std::vector<ankerl::unordered_dense::map<PidTid, SumLane*, PidTidHash>>
1132
        lane_cache;
1133
    std::vector<dftracer::utils::StringViewMap<std::uint32_t>> name_cache;
1134

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

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

1144
    std::vector<dftracer::utils::StringViewMap<std::string>> sh_parts;
1145
    std::vector<ankerl::unordered_dense::map<std::int64_t, std::string>>
1146
        app_start, app_end;
1147

1148
    // Per-worker events wider than one bucket, kept whole (see
1149
    // VizSummary::long_events).
1150
    std::vector<std::vector<VizSummary::AppSpan>> long_evs;
1151

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

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

1160
    // Per-worker file-hashes seen on a read/write op (files with real data
1161
    // I/O).
1162
    std::vector<dftracer::utils::StringViewSet> io_fh;
1163

1164
    SumLane* get_lane(std::size_t w, std::int64_t pid, std::int64_t tid) {
32✔
1165
        PidTid key{pid, tid};
32✔
1166
        auto& cache = lane_cache[w];
32✔
1167
        auto ci = cache.find(key);
32!
1168
        if (ci != cache.end()) return ci->second;
32✔
1169
        std::lock_guard<std::mutex> lk(lane_mtx);
16!
1170
        auto it = lane_of.find(key);
16!
1171
        SumLane* lane;
1172
        if (it != lane_of.end()) {
16!
1173
            lane = &lanes[it->second];
×
1174
        } else {
1175
            if (lanes.size() * nb >= VizSummary::MAX_CELLS)
16✔
UNCOV
1176
                return nullptr;  // budget reached; drop further lanes
×
1177
            lane_of.emplace(key, lanes.size());
16!
1178
            lanes.emplace_back(nb, pid, tid);
16!
1179
            lane = &lanes.back();
16✔
1180
        }
1181
        cache.emplace(key, lane);
16!
1182
        return lane;
16✔
1183
    }
24✔
1184

1185
    std::uint32_t intern(std::size_t w, std::string_view name) {
32✔
1186
        auto& cache = name_cache[w];
32✔
1187
        auto ci = cache.find(name);
32!
1188
        if (ci != cache.end()) return ci->second;
32✔
1189
        std::lock_guard<std::mutex> lk(name_mtx);
12!
1190
        auto it = name_of.find(name);
12!
1191
        std::uint32_t id;
1192
        if (it != name_of.end()) {
12✔
1193
            id = it->second;
×
1194
        } else {
1195
            id = static_cast<std::uint32_t>(names.size());
12✔
1196
            names.emplace_back(name);
12!
1197
            name_of.emplace(std::string(name), id);
12!
1198
        }
1199
        cache.emplace(std::string(name), id);
12!
1200
        return id;
12✔
1201
    }
22✔
1202
};
1203

1204
template <class T>
1205
static void atomic_max(std::atomic<T>& a, T v) {
612✔
1206
    T cur = a.load(std::memory_order_relaxed);
612✔
1207
    while (v > cur &&
764!
1208
           !a.compare_exchange_weak(cur, v, std::memory_order_relaxed)) {
228!
1209
    }
1210
}
612✔
1211

1212
static void atomic_add_double(std::atomic<double>& a, double v) {
272✔
1213
    double cur = a.load(std::memory_order_relaxed);
272✔
1214
    while (!a.compare_exchange_weak(cur, cur + v, std::memory_order_relaxed)) {
272!
1215
    }
1216
}
272✔
1217

1218
// Fold one (key, dur) sample into a group aggregate, mirroring fold_event.
1219
static void fold_group(NameMap& m, std::string_view key, double dur) {
1,740✔
1220
    auto it = m.find(key);
1,740!
1221
    if (it == m.end()) {
1,740✔
1222
        it = m.emplace(std::string(key), NameStat{}).first;
172!
1223
        it->second.min = dur;
172✔
1224
        it->second.max = dur;
172✔
1225
    } else {
86✔
1226
        it->second.min = std::min(it->second.min, dur);
1,568✔
1227
        it->second.max = std::max(it->second.max, dur);
1,568✔
1228
    }
1229
    it->second.count += 1;
1,740✔
1230
    it->second.total += dur;
1,740✔
1231
}
1,740✔
1232

1233
static void fold_summary(std::size_t w, std::string_view event, SumBuild& b) {
582✔
1234
    thread_local simdjson::dom::parser parser;
582✔
1235
    thread_local std::string buf;
582✔
1236
    buf.assign(event);
582!
1237
    auto res = parser.parse(buf);
582✔
1238
    if (res.error()) return;
799!
1239
    auto root = res.value_unsafe();
582✔
1240
    if (!root.is_object()) return;
582✔
1241

1242
    std::string_view name0;
582✔
1243
    {
1244
        auto nr = root["name"];
582✔
1245
        if (!nr.error() && nr.is_string())
582!
1246
            name0 = nr.get_string().value_unsafe();
582✔
1247
    }
1248
    if (name0 == "FH" || name0 == "SH") {
582!
1249
        auto args = root["args"];
×
1250
        if (!args.error() && args.is_object()) {
×
1251
            auto v = args["value"];
×
1252
            auto n = args["name"];
×
1253
            if (!v.error() && v.is_string() && !n.error() && n.is_string()) {
×
1254
                auto& tbl = name0 == "FH" ? b.fh_parts[w] : b.sh_parts[w];
×
1255
                tbl.emplace(std::string(v.get_string().value_unsafe()),
×
1256
                            std::string(n.get_string().value_unsafe()));
×
1257
            }
1258
        }
1259
        return;
×
1260
    }
1261

1262
    auto dr = root["dur"];
582✔
1263
    if (dr.error()) return;
582✔
1264
    double dur = json_number(dr.value_unsafe());
580✔
1265

1266
    // Column discovery: harvest the schema once per distinct event name (same
1267
    // name => same keys), so this is O(distinct names), not O(events).
1268
    if (b.col_seen[w].find(name0) == b.col_seen[w].end()) {
580✔
1269
        b.col_seen[w].emplace(name0);
36!
1270
        static const ankerl::unordered_dense::set<std::string_view> SKIP = {
18!
1271
            "pid", "tid", "ts", "dur", "ph", "id", "args"};
18!
1272
        auto obj = root.get_object();
36✔
1273
        if (!obj.error())
36✔
1274
            for (auto kv : obj.value_unsafe())
358✔
1275
                if (SKIP.find(kv.key) == SKIP.end()) b.cols[w].emplace(kv.key);
340!
1276
        auto ar = root["args"];
36✔
1277
        if (!ar.error() && ar.is_object()) {
36✔
1278
            simdjson::dom::object args_obj;
34✔
1279
            if (ar.get_object().get(args_obj) == simdjson::SUCCESS)
34✔
1280
                for (auto kv : args_obj) b.cols[w].emplace(kv.key);
114✔
1281
        }
17✔
1282
    }
18✔
1283

1284
    if (name0 == "start" || name0 == "end") {
580✔
1285
        auto cr = root["cat"];
32✔
1286
        auto pp = root["pid"];
32✔
1287
        if (!cr.error() && cr.is_string() &&
80!
1288
            cr.get_string().value_unsafe() == "dftracer" && !pp.error()) {
64!
1289
            auto p = static_cast<std::int64_t>(json_number(pp.value_unsafe()));
32✔
1290
            (name0 == "start" ? b.app_start[w] : b.app_end[w])
48✔
1291
                .emplace(p, std::string(event));
48!
1292
        }
16✔
1293
    }
16✔
1294

1295
    std::int64_t pid = 0, tid = 0;
580✔
1296
    auto pr = root["pid"];
580✔
1297
    if (!pr.error())
580✔
1298
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
580✔
1299

1300
    // Analyze aggregates: whole-trace, ts-independent so they match a live
1301
    // scan.
1302
    {
1303
        auto nr = root["name"];
580✔
1304
        auto cr = root["cat"];
580✔
1305
        if (!nr.error() && nr.is_string()) {
580!
1306
            std::string_view nm = nr.get_string().value_unsafe();
580✔
1307
            fold_group(b.g_name[w], nm, dur);
580!
1308
            if (!cr.error() && cr.is_string() &&
1,160!
1309
                b.name_cat[w].find(nm) == b.name_cat[w].end())
870!
1310
                b.name_cat[w].emplace(
54!
1311
                    std::string(nm),
54!
1312
                    std::string(cr.get_string().value_unsafe()));
54!
1313
        }
290✔
1314
        if (!cr.error() && cr.is_string())
580!
1315
            fold_group(b.g_cat[w], cr.get_string().value_unsafe(), dur);
580!
1316
        thread_local std::string pidkey;
580✔
1317
        pidkey.assign(std::to_string(pid));
580!
1318
        fold_group(b.g_pid[w], pidkey, dur);
580!
1319
        auto ar = root["args"];
580✔
1320
        if (!ar.error() && ar.is_object()) {
580✔
1321
            auto fr = ar["fhash"];
180✔
1322
            if (!fr.error() && fr.is_string())
180!
1323
                fold_group(b.g_fhash[w], fr.get_string().value_unsafe(), dur);
×
1324
        }
90✔
1325
    }
1326

1327
    auto tr = root["ts"];
580✔
1328
    if (tr.error()) return;  // bucketing/counters below need a timestamp
580!
1329
    double ts = json_number(tr.value_unsafe());
580✔
1330
    std::int64_t bucket = static_cast<std::int64_t>(
580✔
1331
        (ts - static_cast<double>(b.t0)) / b.bucket_us);
580✔
1332
    if (bucket < 0) return;
580✔
1333
    if (bucket >= static_cast<std::int64_t>(b.nb)) bucket = b.nb - 1;
580✔
1334
    auto bi = static_cast<std::size_t>(bucket);
580✔
1335

1336
    auto tir = root["tid"];
580✔
1337
    if (!tir.error())
580!
1338
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
580✔
1339

1340
    if (dur >= b.bucket_us && dur > 0) {
580✔
1341
        // Wider than one bucket: folded at the start bucket this would render
1342
        // as a 1px sliver at zoom-out. Keep the event whole; the summary
1343
        // density path serves it as a real span.
1344
        b.long_evs[w].push_back(
1,096!
1345
            VizSummary::AppSpan{static_cast<std::uint64_t>(ts),
1,644!
1346
                                static_cast<std::uint64_t>(ts + dur), pid, tid,
1,096✔
1347
                                std::string(event)});
274✔
1348
    } else if (SumLane* lane = b.get_lane(w, pid, tid)) {
306!
1349
        lane->count[bi].fetch_add(1, std::memory_order_relaxed);
32✔
1350
        lane->total[bi].fetch_add(static_cast<std::uint64_t>(dur < 0 ? 0 : dur),
32!
1351
                                  std::memory_order_relaxed);
1352
        std::uint32_t d = dur >= 4294967295.0
48!
1353
                              ? 4294967295u
32!
1354
                              : static_cast<std::uint32_t>(dur < 0 ? 0 : dur);
32!
1355
        if (d > lane->maxd[bi].load(std::memory_order_relaxed)) {
48✔
1356
            atomic_max(lane->maxd[bi], d);
32✔
1357
            std::string_view name;
32✔
1358
            auto nr = root["name"];
32✔
1359
            if (!nr.error() && nr.is_string())
32!
1360
                name = nr.get_string().value_unsafe();
32✔
1361
            lane->nameid[bi].store(b.intern(w, name),
32!
1362
                                   std::memory_order_relaxed);
1363
        }
16✔
1364
    }
16✔
1365
    atomic_max(b.gmax_dur, static_cast<std::uint64_t>(dur < 0 ? 0 : dur));
580!
1366

1367
    // Counters: read/write bytes and op counts for POSIX/STDIO/IO events.
1368
    auto cat_r = root["cat"];
580✔
1369
    if (cat_r.error() || !cat_r.is_string()) return;
580!
1370
    std::string_view cat = cat_r.get_string().value_unsafe();
580✔
1371
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
580✔
1372
    atomic_add_double(b.cops[bi], 1.0);
148✔
1373
    double bytes = 0;
148✔
1374
    auto args = root["args"];
148✔
1375
    if (!args.error() && args.is_object()) {
148!
1376
        auto ret = args["ret"];
148✔
1377
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
148✔
1378
    }
74✔
1379
    if (bytes <= 0) return;
148!
1380
    std::string_view name;
148✔
1381
    auto nr = root["name"];
148✔
1382
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
148!
1383
    bool is_write = name.find("write") != std::string_view::npos;
148✔
1384
    bool is_read = name.find("read") != std::string_view::npos;
148✔
1385
    if (is_write)
148✔
1386
        atomic_add_double(b.cwrite[bi], bytes);
38✔
1387
    else if (is_read)
110✔
1388
        atomic_add_double(b.cread[bi], bytes);
86✔
1389
    if ((is_read || is_write) && !args.error() && args.is_object()) {
148!
1390
        auto fr = args["fhash"];
124✔
1391
        if (!fr.error() && fr.is_string())
124!
1392
            b.io_fh[w].emplace(std::string(fr.get_string().value_unsafe()));
×
1393
    }
62✔
1394
}
291✔
1395

1396
}  // namespace
1397

1398
// Build the activity summary by scanning every event once. Blocks the caller
1399
// (the first overview request) for the scan; cached for the server's lifetime.
1400
static coro::CoroTask<void> build_viz_summary(TraceIndex& index) {
40!
1401
    auto summary = std::make_unique<VizSummary>();
15!
1402
    std::uint64_t gmin = index.global_min_timestamp_us();
15✔
1403
    std::uint64_t gmax = index.global_max_timestamp_us();
15✔
1404
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin) {
15✔
1405
        index.set_viz_summary(std::move(summary));
20!
1406
        co_return;
5✔
1407
    }
1408

1409
    std::size_t est_lanes = std::max<std::size_t>(1, index.file_count()) * 8;
15✔
1410
    std::size_t nb = VizSummary::MAX_CELLS / est_lanes;
15✔
1411
    nb = std::clamp(nb, VizSummary::MIN_BUCKETS_PER_LANE,
15!
1412
                    VizSummary::MAX_BUCKETS_PER_LANE);
1413

1414
    SumBuild b;
15!
1415
    b.nb = nb;
15✔
1416
    b.t0 = gmin;
15✔
1417
    b.bucket_us = static_cast<double>(gmax - gmin) / static_cast<double>(nb);
15✔
1418
    b.cread = std::vector<std::atomic<double>>(nb);
15!
1419
    b.cwrite = std::vector<std::atomic<double>>(nb);
15!
1420
    b.cops = std::vector<std::atomic<double>>(nb);
15!
1421
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
15!
1422
    b.lane_cache.resize(slots);
15✔
1423
    b.name_cache.resize(slots);
5!
1424
    b.g_name.resize(slots);
5!
1425
    b.g_cat.resize(slots);
5!
1426
    b.g_pid.resize(slots);
5!
1427
    b.g_fhash.resize(slots);
5!
1428
    b.fh_parts.resize(slots);
5!
1429
    b.sh_parts.resize(slots);
5!
1430
    b.app_start.resize(slots);
5!
1431
    b.app_end.resize(slots);
5!
1432
    b.long_evs.resize(slots);
5!
1433
    b.cols.resize(slots);
5!
1434
    b.col_seen.resize(slots);
5!
1435
    b.name_cat.resize(slots);
5!
1436
    b.io_fh.resize(slots);
5✔
1437

1438
    ViewDefinition view;
15✔
1439
    view.name = "viz_summary";
15✔
1440
    view.description = "Activity summary build";
5✔
1441
    std::vector<const TraceIndex::FileInfo*> files;
15✔
1442
    files.reserve(index.files().size());
15✔
1443
    for (const auto& f : index.files()) files.push_back(&f);
20!
1444

1445
    auto on_batch = [&b](std::size_t w,
25✔
1446
                         const std::vector<std::string_view>& events) {
291✔
1447
        for (auto ev : events) fold_summary(w, ev, b);
592✔
1448
    };
10✔
1449
    co_await scan_view_events(index, files, view, static_cast<double>(gmin),
20!
1450
                              static_cast<double>(gmax), 0, slots, on_batch,
15!
1451
                              CancelToken{});
15✔
1452

1453
    summary->t_begin = gmin;
5✔
1454
    summary->t_end = gmax;
5✔
1455
    summary->nbuckets = nb;
5✔
1456
    summary->bucket_us = b.bucket_us;
5✔
1457
    summary->max_dur = b.gmax_dur.load(std::memory_order_relaxed);
5✔
1458
    summary->names = std::move(b.names);
5✔
1459
    summary->read_bytes.assign(nb, 0.0);
5!
1460
    summary->write_bytes.assign(nb, 0.0);
5!
1461
    summary->ops.assign(nb, 0.0);
5!
1462
    for (std::size_t i = 0; i < nb; ++i) {
327,685✔
1463
        summary->read_bytes[i] = b.cread[i].load(std::memory_order_relaxed);
327,680✔
1464
        summary->write_bytes[i] = b.cwrite[i].load(std::memory_order_relaxed);
327,680✔
1465
        summary->ops[i] = b.cops[i].load(std::memory_order_relaxed);
327,680✔
1466
    }
327,680✔
1467
    summary->lanes.reserve(b.lanes.size());
5!
1468
    for (auto& sl : b.lanes) {
13!
1469
        VizSummary::Lane lane;
8✔
1470
        lane.pid = sl.pid;
8✔
1471
        lane.tid = sl.tid;
8✔
1472
        lane.cells.resize(nb);
8!
1473
        for (std::size_t i = 0; i < nb; ++i) {
524,296✔
1474
            lane.cells[i].count = sl.count[i].load(std::memory_order_relaxed);
524,288✔
1475
            lane.cells[i].total = sl.total[i].load(std::memory_order_relaxed);
524,288✔
1476
            lane.cells[i].max_dur = sl.maxd[i].load(std::memory_order_relaxed);
524,288✔
1477
            lane.cells[i].name_id =
524,288✔
1478
                sl.nameid[i].load(std::memory_order_relaxed);
524,288✔
1479
        }
524,288✔
1480
        summary->lanes.push_back(std::move(lane));
8!
1481
    }
8✔
1482

1483
    for (auto& part : b.long_evs)
15✔
1484
        for (auto& ev : part) summary->long_events.push_back(std::move(ev));
284!
1485
    if (summary->long_events.size() > VizSummary::MAX_LONG_EVENTS) {
5!
1486
        // Keep the widest; fold the overflow into start-bucket cells (the
1487
        // pre-cap behavior) so no activity is lost.
1488
        auto& lv = summary->long_events;
1489
        std::sort(
×
1490
            lv.begin(), lv.end(),
NEW
1491
            [](const VizSummary::AppSpan& a, const VizSummary::AppSpan& x) {
×
NEW
1492
                return a.end - a.begin > x.end - x.begin;
×
1493
            });
1494
        ankerl::unordered_dense::map<PidTid, std::size_t, PidTidHash> lane_idx;
×
1495
        for (std::size_t i = 0; i < summary->lanes.size(); ++i)
×
1496
            lane_idx.emplace(
×
1497
                PidTid{summary->lanes[i].pid, summary->lanes[i].tid}, i);
1498
        for (std::size_t i = VizSummary::MAX_LONG_EVENTS; i < lv.size(); ++i) {
×
1499
            const auto& ev = lv[i];
1500
            auto it = lane_idx.find(PidTid{ev.pid, ev.tid});
×
1501
            if (it == lane_idx.end()) continue;
×
1502
            auto bi = summary->bucket_of(static_cast<double>(ev.begin));
×
1503
            if (bi < 0) continue;
×
1504
            auto& cell =
1505
                summary->lanes[it->second].cells[static_cast<std::size_t>(bi)];
1506
            cell.count += 1;
1507
            cell.total += ev.end - ev.begin;
1508
        }
×
1509
        lv.resize(VizSummary::MAX_LONG_EVENTS);
×
1510
    }
1511

1512
    {
1513
        ankerl::unordered_dense::set<std::string> all_cols;
5!
1514
        for (auto& c : b.cols)
15✔
1515
            for (auto& k : c) all_cols.emplace(k);
40!
1516
        summary->columns.assign(all_cols.begin(), all_cols.end());
5!
1517
        std::sort(summary->columns.begin(), summary->columns.end());
5!
1518
    }
5✔
1519

1520
    // Merge the per-worker Analyze aggregates and sort each by total desc.
1521
    auto finalize_group = [](std::vector<NameMap>& parts) {
45✔
1522
        NameMap merged;
40!
1523
        for (auto& p : parts) {
120✔
1524
            for (auto& kv : p) {
252✔
1525
                auto it = merged.find(kv.first);
172!
1526
                if (it == merged.end())
172!
1527
                    merged.emplace(kv.first, kv.second);
172!
1528
                else
1529
                    it->second.merge_from(kv.second);
×
1530
            }
1531
        }
1532
        std::vector<VizSummary::GroupRow> rows;
40✔
1533
        rows.reserve(merged.size());
40!
1534
        for (auto& kv : merged)
212✔
1535
            rows.push_back({kv.first, kv.second.count, kv.second.total,
258!
1536
                            kv.second.min, kv.second.max});
258!
1537
        std::sort(rows.begin(), rows.end(),
40!
1538
                  [](const VizSummary::GroupRow& lhs,
465✔
1539
                     const VizSummary::GroupRow& rhs) {
1540
                      return lhs.total > rhs.total;
465✔
1541
                  });
1542
        return rows;
60✔
1543
    };
40!
1544
    summary->by_name = finalize_group(b.g_name);
5!
1545
    summary->by_cat = finalize_group(b.g_cat);
5!
1546
    summary->by_pid = finalize_group(b.g_pid);
5!
1547
    summary->by_fhash = finalize_group(b.g_fhash);
5!
1548

1549
    // Resolve file-hash keys to real paths where a metadata record was seen.
1550
    dftracer::utils::StringViewMap<std::string> fh;
5!
1551
    for (auto& part : b.fh_parts)
15✔
1552
        for (auto& kv : part) fh.emplace(kv.first, kv.second);
10!
1553
    for (auto& r : summary->by_fhash) {
5!
1554
        auto it = fh.find(r.key);
×
1555
        if (it != fh.end()) r.key = it->second;
×
1556
    }
1557
    summary->total_files = fh.size();
5✔
1558

1559
    {
1560
        dftracer::utils::StringViewMap<std::string> sh;
5!
1561
        for (auto& part : b.sh_parts)
15✔
1562
            for (auto& kv : part) sh.emplace(kv.first, kv.second);
10!
1563
        ankerl::unordered_dense::map<std::int64_t, std::string> starts, ends;
5!
1564
        for (auto& part : b.app_start)
15✔
1565
            for (auto& kv : part) starts.emplace(kv.first, kv.second);
18!
1566
        for (auto& part : b.app_end)
15✔
1567
            for (auto& kv : part) ends.emplace(kv.first, kv.second);
18!
1568

1569
        auto resolve = [](dftracer::utils::StringViewMap<std::string>& tbl,
53✔
1570
                          const std::string& h) -> std::string {
1571
            auto it = tbl.find(h);
48!
1572
            return it != tbl.end() ? it->second : h;
72!
1573
        };
1574
        simdjson::dom::parser sp, ep;
5✔
1575
        for (auto& [pid, sjson] : starts) {
13✔
1576
            auto eit = ends.find(pid);
8!
1577
            if (eit == ends.end()) continue;
8!
1578
            std::string sbuf(sjson), ebuf(eit->second);
8!
1579
            auto sr = sp.parse(sbuf);
8✔
1580
            auto er = ep.parse(ebuf);
8✔
1581
            if (sr.error() || er.error()) continue;
8!
1582
            auto se = sr.value_unsafe();
8✔
1583
            auto ee = er.value_unsafe();
8✔
1584

1585
            auto num = [](simdjson::dom::element r, const char* k) -> double {
56✔
1586
                auto v = r[k];
48✔
1587
                return v.error() ? 0.0 : json_number(v.value_unsafe());
48✔
1588
            };
1589
            auto sarg = [](simdjson::dom::element r,
88✔
1590
                           const char* k) -> std::string {
1591
                auto a = r["args"];
80✔
1592
                if (a.error()) return "";
80!
1593
                auto v = a[k];
80✔
1594
                return (!v.error() && v.is_string())
56!
1595
                           ? std::string(v.get_string().value_unsafe())
72✔
1596
                           : "";
136!
1597
            };
40✔
1598
            auto narg = [](simdjson::dom::element r, const char* k) -> double {
40✔
1599
                auto a = r["args"];
32✔
1600
                if (a.error()) return 0.0;
32!
1601
                auto v = a[k];
32✔
1602
                return v.error() ? 0.0 : json_number(v.value_unsafe());
32✔
1603
            };
16✔
1604

1605
            auto start_ts = static_cast<std::uint64_t>(num(se, "ts"));
8!
1606
            auto end_ts = static_cast<std::uint64_t>(num(ee, "ts"));
8!
1607
            if (end_ts <= start_ts) continue;
8!
1608
            auto tid = static_cast<std::int64_t>(num(se, "tid"));
8!
1609
            std::string app = resolve(sh, sarg(se, "exec_hash"));
8!
1610
            std::string cmd = resolve(sh, sarg(se, "cmd_hash"));
8!
1611
            std::string cwd = resolve(fh, sarg(se, "cwd"));
8!
1612
            std::string version = sarg(se, "version");
8!
1613
            std::string date = sarg(se, "date");
8!
1614
            auto ppid = static_cast<std::int64_t>(narg(se, "ppid"));
8!
1615
            auto num_events = static_cast<std::int64_t>(narg(ee, "num_events"));
8!
1616
            if (app.empty()) app = "app " + std::to_string(pid);
8!
1617

1618
            auto& jb = scratch_json_builder();
8!
1619
            jb.start_object();
8✔
1620
            jb.append_key_value("name", app);
8!
1621
            jb.append_comma();
8✔
1622
            jb.append_key_value("cat", "dftracer");
8✔
1623
            jb.append_comma();
8✔
1624
            jb.append_key_value("pid", pid);
8✔
1625
            jb.append_comma();
8✔
1626
            jb.append_key_value("tid", tid);
8✔
1627
            jb.append_comma();
8✔
1628
            jb.append_key_value("ts", start_ts);
8✔
1629
            jb.append_comma();
8✔
1630
            jb.append_key_value("dur", end_ts - start_ts);
8✔
1631
            jb.append_comma();
8✔
1632
            jb.append_key_value("ph", "X");
8✔
1633
            jb.append_comma();
8✔
1634
            jb.escape_and_append_with_quotes("args");
8✔
1635
            jb.append_colon();
8✔
1636
            jb.start_object();
8✔
1637
            jb.append_key_value("app", app);
8!
1638
            jb.append_comma();
8✔
1639
            jb.append_key_value("cmd", cmd);
8!
1640
            jb.append_comma();
8✔
1641
            jb.append_key_value("cwd", cwd);
8!
1642
            jb.append_comma();
8✔
1643
            jb.append_key_value("ppid", ppid);
8✔
1644
            jb.append_comma();
8✔
1645
            jb.append_key_value("version", version);
8!
1646
            jb.append_comma();
8✔
1647
            jb.append_key_value("date", date);
8!
1648
            jb.append_comma();
8✔
1649
            jb.append_key_value("num_events", num_events);
8✔
1650
            jb.end_object();
8✔
1651
            jb.end_object();
8✔
1652
            summary->app_spans.push_back(
8!
1653
                {start_ts, end_ts, pid, tid, std::string(jb)});
8!
1654
        }
8!
1655
    }
5✔
1656

1657
    dftracer::utils::StringViewSet io_fh;
5!
1658
    for (auto& part : b.io_fh)
15✔
1659
        for (auto& k : part) io_fh.emplace(k);
10!
1660
    summary->io_files = io_fh.size();
5✔
1661

1662
    // Merge the per-worker name -> category maps (first writer wins).
1663
    dftracer::utils::StringViewMap<std::string> nc;
5!
1664
    for (auto& part : b.name_cat)
15✔
1665
        for (auto& kv : part) nc.emplace(kv.first, kv.second);
28!
1666
    summary->name_cats.reserve(nc.size());
5!
1667
    for (auto& kv : nc) summary->name_cats.emplace_back(kv.first, kv.second);
23!
1668

1669
    if (!summary->app_spans.empty()) {
5✔
1670
        // Break = dead time between runs: merge app-spans into run clusters and
1671
        // emit every gap between them, no size threshold.
1672
        std::vector<const VizSummary::AppSpan*> spans;
3✔
1673
        spans.reserve(summary->app_spans.size());
3!
1674
        for (const auto& sp : summary->app_spans) spans.push_back(&sp);
11!
1675
        std::sort(spans.begin(), spans.end(),
3!
1676
                  [](const auto* lhs, const auto* rhs) {
15✔
1677
                      return lhs->begin < rhs->begin;
15✔
1678
                  });
1679
        std::vector<std::pair<std::uint64_t, std::uint64_t>> runs;
3✔
1680
        for (const auto* sp : spans) {
11✔
1681
            if (!runs.empty() && sp->begin <= runs.back().second)
8!
1682
                runs.back().second = std::max(runs.back().second, sp->end);
×
1683
            else
1684
                runs.emplace_back(sp->begin, sp->end);
8!
1685
        }
8✔
1686
        for (std::size_t i = 1; i < runs.size(); ++i)
8✔
1687
            if (runs[i].first > runs[i - 1].second)
10!
1688
                summary->idle_gaps.emplace_back(runs[i - 1].second,
10!
1689
                                                runs[i].first);
5✔
1690
    } else if (nb > 0 && summary->bucket_us > 0) {
5!
1691
        // No app-spans (malformed trace): fall back to a lane-activity scan,
1692
        // requiring a gap to be a fraction of active (not total) time.
1693
        std::vector<bool> active(nb, false);
2!
1694
        for (const auto& lane : summary->lanes)
2!
1695
            for (std::size_t i = 0; i < nb; ++i)
×
1696
                if (lane.cells[i].count) active[i] = true;
×
1697
        std::size_t first = 0, last = 0, active_count = 0;
2✔
1698
        bool any = false;
2✔
1699
        for (std::size_t i = 0; i < nb; ++i)
131,074✔
1700
            if (active[i]) {
131,072!
1701
                if (!any) {
×
1702
                    first = i;
1703
                    any = true;
1704
                }
1705
                last = i;
1706
                ++active_count;
1707
            }
1708
        if (any) {
2!
1709
            const double active_us =
1710
                static_cast<double>(active_count) * summary->bucket_us;
1711
            const double min_gap_us =
1712
                std::max(3.0 * summary->bucket_us, 0.05 * active_us);
×
1713
            for (std::size_t i = first; i <= last;) {
×
1714
                if (active[i]) {
×
1715
                    ++i;
1716
                    continue;
1717
                }
1718
                std::size_t j = i;
1719
                while (j <= last && !active[j]) ++j;
×
1720
                if (static_cast<double>(j - i) * summary->bucket_us >=
×
1721
                    min_gap_us) {
1722
                    auto g0 = summary->t_begin +
1723
                              static_cast<std::uint64_t>(
1724
                                  static_cast<double>(i) * summary->bucket_us);
1725
                    auto g1 = summary->t_begin +
1726
                              static_cast<std::uint64_t>(
1727
                                  static_cast<double>(j) * summary->bucket_us);
1728
                    summary->idle_gaps.emplace_back(g0, g1);
×
1729
                }
1730
                i = j;
1731
            }
1732
        }
1733
    }
2✔
1734

1735
    DFTRACER_UTILS_LOG_INFO(
5!
1736
        "viz: built activity summary (%zu lanes, %zu buckets/lane, %zu idle "
1737
        "gaps)",
1738
        summary->lanes.size(), nb, summary->idle_gaps.size());
1739
    index.set_viz_summary(std::move(summary));
5!
1740
}
105!
1741

1742
// The summary, building it on first use. Null when another request is already
1743
// building it, in which case the caller falls back to a live scan.
1744
static coro::CoroTask<const VizSummary*> ensure_viz_summary(TraceIndex& index) {
100!
1745
    const VizSummary* s = index.viz_summary();
25✔
1746
    if (!s && index.try_begin_summary_build()) {
15!
1747
        co_await build_viz_summary(index);
35!
1748
        s = index.viz_summary();
5!
1749
    }
5✔
1750
    co_return s;
25✔
1751
}
75!
1752

1753
// One aggregate row, uniform over the live-scan and summary paths.
1754
struct StatRow {
1755
    const std::string* key;
1756
    std::uint64_t count;
1757
    double total;
1758
    double min;
1759
    double max;
1760
};
1761

1762
template <typename builder_type>
1763
void tag_invoke(simdjson::serialize_tag, builder_type& b, const StatRow& r) {
18✔
1764
    b.start_object();
18✔
1765
    b.append_key_value("name", *r.key);
18✔
1766
    b.append_comma();
18✔
1767
    b.append_key_value("count", r.count);
18✔
1768
    b.append_comma();
18✔
1769
    b.append_key_value("total", r.total);
18✔
1770
    b.append_comma();
18✔
1771
    b.append_key_value("avg",
27✔
1772
                       r.count ? r.total / static_cast<double>(r.count) : 0.0);
18!
1773
    b.append_comma();
18✔
1774
    b.append_key_value("min", r.min);
18✔
1775
    b.append_comma();
18✔
1776
    b.append_key_value("max", r.max);
18✔
1777
    b.end_object();
18✔
1778
}
18✔
1779

1780
static std::string serialize_stats_body(std::uint64_t total_count,
4✔
1781
                                        double total_dur, double wall,
1782
                                        bool truncated,
1783
                                        const std::vector<StatRow>& rows) {
1784
    auto& b = scratch_json_builder();
4✔
1785
    b.start_object();
4✔
1786
    b.append_key_value("count", total_count);
4✔
1787
    b.append_comma();
4✔
1788
    b.append_key_value("total_dur", total_dur);
4✔
1789
    b.append_comma();
4✔
1790
    b.append_key_value("wall", wall);
4✔
1791
    b.append_comma();
4✔
1792
    b.append_key_value("truncated", truncated);
4✔
1793
    b.append_comma();
4✔
1794
    b.append_key_value("names", rows);
4✔
1795
    b.end_object();
4✔
1796
    return std::string(b);
4!
1797
}
1798

1799
// Scale duration fields (native trace unit -> us) before serialization. The
1800
// `wall` value is derived from client-us begin/end and is already in us.
1801
static void scale_stat_durations(std::vector<StatRow>& rows, double& total_dur,
4✔
1802
                                 TraceIndex::TimeMetric metric) {
1803
    const double us =
2✔
1804
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
4✔
1805
            metric);
2✔
1806
    if (us == 1.0) return;
4✔
1807
    for (auto& r : rows) {
4✔
1808
        r.total *= us;
2✔
1809
        r.min *= us;
2✔
1810
        r.max *= us;
2✔
1811
    }
1812
    total_dur *= us;
2✔
1813
}
2✔
1814

1815
static const std::vector<VizSummary::GroupRow>& summary_group_rows(
4✔
1816
    const VizSummary& s, GroupBy g) {
1817
    switch (g) {
4!
1818
        case GroupBy::Cat:
1819
            return s.by_cat;
×
1820
        case GroupBy::Pid:
1821
            return s.by_pid;
×
1822
        case GroupBy::Fhash:
1823
            return s.by_fhash;
×
1824
        case GroupBy::Name:
4✔
1825
        default:
1826
            return s.by_name;
4✔
1827
    }
1828
}
2✔
1829

1830
// Whole-trace, unfiltered Analyze answers come from the prebuilt summary, so no
1831
// live scan is needed. Any predicate or sub-range forces the live path.
1832
static bool viz_stats_summary_eligible(const QueryParams& p, double begin_abs,
4✔
1833
                                       double end_abs, TraceIndex& index) {
1834
    if (!p.get("query").empty() || !p.get("cat").empty() ||
8!
1835
        !p.get("lanes").empty() || !p.get("filters").empty() ||
4!
1836
        !p.get("file").empty() || !p.get("pid").empty() ||
8!
1837
        !p.get("tid").empty())
6!
1838
        return false;
×
1839
    std::uint64_t gmin = index.global_min_timestamp_us();
4✔
1840
    std::uint64_t gmax = index.global_max_timestamp_us();
4✔
1841
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
4!
1842
        return false;
×
1843
    return begin_abs <= static_cast<double>(gmin) + 1.0 &&
8✔
1844
           end_abs >= static_cast<double>(gmax) - 1.0;
6!
1845
}
2✔
1846

1847
// GET /api/v1/viz/stats: server-side per-name aggregation over a time range.
1848
// Scans in parallel worker coroutines, each folding into its own map, then
1849
// merges single-threaded (no lock). Returns only the small aggregate table.
1850
static coro::CoroTask<HttpResponse> handle_viz_stats(const HttpRequest& req,
16!
1851
                                                     const QueryParams& params,
1852
                                                     TraceIndex& index) {
2!
1853
    if (!params.has("begin") || !params.has("end")) {
6!
1854
        co_return HttpResponse::bad_request(
10!
1855
            "Missing required parameters: begin, end");
8!
1856
    }
1857

1858
    double begin = params.get_double("begin", 0);
6✔
1859
    double end = params.get_double("end", 0);
6✔
1860

1861
    auto query = params.get("query");
6✔
1862
    if (!query.empty() &&
6!
1863
        !utilities::common::query::try_parse(query).has_value()) {
×
1864
        co_return HttpResponse::bad_request("Invalid query: " +
×
1865
                                            std::string(query));
×
1866
    }
1867

1868
    auto ts_norm_param = params.get("ts_normalize");
6✔
1869
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
6!
1870
    std::uint64_t global_min = 0;
6✔
1871
    if (normalize) {
6✔
1872
        global_min = index.global_min_timestamp_us();
2✔
1873
        if (global_min == std::numeric_limits<std::uint64_t>::max())
2!
1874
            global_min = 0;
1875
    }
2✔
1876
    double original_begin = begin;
6✔
1877
    double original_end = end;
6✔
1878
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
6✔
1879
        begin = static_cast<double>(
1✔
1880
            index.us_to_native(static_cast<std::uint64_t>(begin)));
1✔
1881
        end = static_cast<double>(
1✔
1882
            index.us_to_native(static_cast<std::uint64_t>(end)));
1✔
1883
    }
1✔
1884
    if (normalize && global_min > 0) {
6!
1885
        begin += static_cast<double>(global_min);
2✔
1886
        end += static_cast<double>(global_min);
2✔
1887
    }
2✔
1888

1889
    GroupBy group = parse_group_by(params.get("group"));
6!
1890

1891
    // Whole-trace, unfiltered Analyze: answer from the prebuilt summary (built
1892
    // lazily here). Concurrent builds fall through to the live scan below.
1893
    if (viz_stats_summary_eligible(params, begin, end, index)) {
2!
1894
        const VizSummary* s = co_await ensure_viz_summary(index);
8!
1895
        if (s) {
2!
1896
            const auto& gr = summary_group_rows(*s, group);
2!
1897
            std::vector<StatRow> rows;
2✔
1898
            rows.reserve(gr.size());
2!
1899
            std::uint64_t total_count = 0;
2✔
1900
            double total_dur = 0;
2✔
1901
            for (const auto& r : gr) {
11✔
1902
                rows.push_back({&r.key, r.count, r.total, r.min, r.max});
9!
1903
                total_count += r.count;
9✔
1904
                total_dur += r.total;
9✔
1905
            }
9✔
1906
            scale_stat_durations(rows, total_dur, index.time_metric());
2!
1907
            co_return HttpResponse::ok(serialize_stats_body(
4!
1908
                total_count, total_dur, original_end - original_begin, false,
2✔
1909
                rows));
1910
        }
2✔
1911
    }
2!
1912

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

1916
    int limit = params.get_int("limit", 0);
×
1917
    if (limit < 0) limit = 0;
×
1918

1919
    std::vector<const TraceIndex::FileInfo*> target_files =
1920
        select_viz_target_files(index, params, begin, end);
×
1921

1922
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
1923
    std::vector<NameMap> partials(slots);
×
1924
    auto aggregate = [&partials, group](
×
1925
                         std::size_t w,
1926
                         const std::vector<std::string_view>& events) {
1927
        NameMap& local = partials[w];  // owned by worker w only, no lock
×
1928
        for (auto ev : events) fold_event(ev, group, local);
×
1929
    };
×
1930

1931
    bool truncated = co_await scan_view_events(
×
1932
        index, target_files, view, begin, end, limit, slots, aggregate,
×
1933
        req.cancel_token, !params.get("file").empty());
×
1934

1935
    // Single-threaded reduce of the disjoint per-worker maps.
1936
    NameMap merged;
×
1937
    for (auto& p : partials) {
×
1938
        for (auto& kv : p) {
×
1939
            auto it = merged.find(kv.first);
×
1940
            if (it == merged.end())
×
1941
                merged.emplace(kv.first, kv.second);
×
1942
            else
1943
                it->second.merge_from(kv.second);
×
1944
        }
1945
    }
1946

1947
    std::vector<StatRow> rows;
1948
    rows.reserve(merged.size());
×
1949
    std::uint64_t total_count = 0;
1950
    double total_dur = 0;
1951
    for (const auto& kv : merged) {
×
1952
        rows.push_back({&kv.first, kv.second.count, kv.second.total,
×
1953
                        kv.second.min, kv.second.max});
1954
        total_count += kv.second.count;
1955
        total_dur += kv.second.total;
1956
    }
1957
    std::sort(rows.begin(), rows.end(), [](const StatRow& a, const StatRow& b) {
×
1958
        return a.total > b.total;
×
1959
    });
1960

1961
    scale_stat_durations(rows, total_dur, index.time_metric());
×
1962
    co_return HttpResponse::ok(
×
1963
        serialize_stats_body(total_count, total_dur,
×
1964
                             original_end - original_begin, truncated, rows));
1965
}
58!
1966

1967
// GET /api/v1/viz/layers: whole-trace reference data - the operation-name ->
1968
// category map (a property of the name, not the view, so fetched once) plus the
1969
// FH file counts: total declared vs. those an I/O event actually touched.
1970
static coro::CoroTask<HttpResponse> handle_viz_layers(
16!
1971
    const HttpRequest& /*req*/, const QueryParams& /*p*/, TraceIndex& index) {
2!
1972
    const VizSummary* s = co_await ensure_viz_summary(index);
10!
1973
    auto& b = scratch_json_builder();
2!
1974
    b.start_object();
2✔
1975
    b.escape_and_append_with_quotes("layers");
2✔
1976
    b.append_colon();
2✔
1977
    b.start_object();
2✔
1978
    if (s) {
2!
1979
        bool first = true;
2✔
1980
        for (const auto& kv : s->name_cats) {
18✔
1981
            if (!first) b.append_comma();
16✔
1982
            first = false;
16✔
1983
            b.escape_and_append_with_quotes(kv.first);
16✔
1984
            b.append_colon();
16✔
1985
            b.escape_and_append_with_quotes(kv.second);
16✔
1986
        }
16✔
1987
    }
2✔
1988
    b.end_object();
2✔
1989
    b.append_comma();
2✔
1990
    b.append_key_value("total_files",
4✔
1991
                       static_cast<std::int64_t>(s ? s->total_files : 0));
2!
1992
    b.append_comma();
2✔
1993
    b.append_key_value("io_files",
4✔
1994
                       static_cast<std::int64_t>(s ? s->io_files : 0));
2!
1995
    b.end_object();
2✔
1996
    co_return HttpResponse::ok(std::string(b));
2!
1997
}
10!
1998

1999
// One scanned event, reduced to what the call-tree needs.
2000
struct FlameEv {
5✔
2001
    std::int64_t pid = 0;
5✔
2002
    std::int64_t tid = 0;
5✔
2003
    double ts = 0;
5✔
2004
    double dur = 0;
5✔
2005
    std::string name;
2006
};
2007

2008
// A node in the merged call tree: identical name-paths across all lanes fold
2009
// into one node. `total` is inclusive; `self` is total minus nested children.
2010
struct FlameNode {
189!
2011
    std::string name;
2012
    double total = 0;
189✔
2013
    double self = 0;
189✔
2014
    std::uint64_t count = 0;
189✔
2015
    dftracer::utils::StringViewMap<std::uint32_t> kids;
2016
    std::vector<std::uint32_t> children;
2017
};
2018

2019
static bool parse_flame_ev(std::string_view event, FlameEv& out) {
380✔
2020
    thread_local simdjson::dom::parser parser;
380✔
2021
    thread_local std::string buf;
380✔
2022
    buf.assign(event);
380!
2023
    auto res = parser.parse(buf);
380✔
2024
    if (res.error()) return false;
380!
2025
    auto root = res.value_unsafe();
380✔
2026
    if (!root.is_object()) return false;
380✔
2027
    auto dr = root["dur"];
380✔
2028
    if (dr.error()) return false;  // no duration: nothing to place in the tree
380!
2029
    out.dur = json_number(dr.value_unsafe());
380✔
2030
    auto tr = root["ts"];
380✔
2031
    if (tr.error()) return false;
380!
2032
    out.ts = json_number(tr.value_unsafe());
380✔
2033
    auto pr = root["pid"];
380✔
2034
    out.pid = pr.error()
380!
2035
                  ? 0
380!
2036
                  : static_cast<std::int64_t>(json_number(pr.value_unsafe()));
380✔
2037
    auto tir = root["tid"];
380✔
2038
    out.tid = tir.error()
380!
2039
                  ? 0
380!
2040
                  : static_cast<std::int64_t>(json_number(tir.value_unsafe()));
380✔
2041
    auto nr = root["name"];
380✔
2042
    if (!nr.error() && nr.is_string())
380!
2043
        out.name.assign(nr.get_string().value_unsafe());
380!
2044
    else
2045
        out.name.clear();
×
2046
    return true;
380✔
2047
}
190✔
2048

2049
static void serialize_flame_node(simdjson::builder::string_builder& sb,
198✔
2050
                                 std::vector<FlameNode>& arena,
2051
                                 std::uint32_t idx) {
2052
    FlameNode& n = arena[idx];
198✔
2053
    sb.start_object();
198✔
2054
    sb.append_key_value("name", n.name);
198✔
2055
    sb.append_comma();
198✔
2056
    sb.append_key_value("total", n.total);
198✔
2057
    sb.append_comma();
198✔
2058
    sb.append_key_value("self", n.self < 0 ? 0.0 : n.self);
198!
2059
    sb.append_comma();
198✔
2060
    sb.append_key_value("count", n.count);
198✔
2061
    std::sort(n.children.begin(), n.children.end(),
297✔
2062
              [&arena](std::uint32_t a, std::uint32_t b) {
912✔
2063
                  return arena[a].total > arena[b].total;
541✔
2064
              });
2065
    sb.append_comma();
198✔
2066
    sb.escape_and_append_with_quotes("children");
198✔
2067
    sb.append_colon();
198✔
2068
    sb.start_array();
198✔
2069
    for (std::size_t i = 0; i < n.children.size(); ++i) {
390✔
2070
        if (i > 0) sb.append_comma();
192✔
2071
        serialize_flame_node(sb, arena, n.children[i]);
192✔
2072
    }
96✔
2073
    sb.end_array();
198✔
2074
    sb.end_object();
198✔
2075
}
198✔
2076

2077
// Fold one event under `base` in `arena` via the open-stack containment walk.
2078
// kids are keyed by owned name copies (StringViewMap), so the source events may
2079
// be discarded afterwards.
2080
static void fold_flame_event(
380✔
2081
    std::vector<FlameNode>& arena,
2082
    std::vector<std::pair<double, std::uint32_t>>& open, std::uint32_t base,
2083
    const FlameEv& ev) {
2084
    double e_end = ev.ts + (ev.dur > 0 ? ev.dur : 0);
380!
2085
    while (!open.empty() && open.back().first <= ev.ts) open.pop_back();
380!
2086
    std::uint32_t parent = open.empty() ? base : open.back().second;
380!
2087
    std::uint32_t mi;
2088
    auto it = arena[parent].kids.find(ev.name);
380!
2089
    if (it == arena[parent].kids.end()) {
380✔
2090
        mi = static_cast<std::uint32_t>(arena.size());
188✔
2091
        arena.emplace_back();
188!
2092
        arena[mi].name = ev.name;
188!
2093
        arena[parent].kids.emplace(ev.name, mi);
188!
2094
        arena[parent].children.push_back(mi);
188!
2095
    } else {
94✔
2096
        mi = it->second;
192✔
2097
    }
2098
    arena[mi].total += ev.dur;
380✔
2099
    arena[mi].self += ev.dur;
380✔
2100
    arena[mi].count += 1;
380✔
2101
    if (parent != 0) arena[parent].self -= ev.dur;
380✔
2102
    open.push_back({e_end, mi});
380!
2103
}
380✔
2104

2105
// Sort one file's events by (pid,tid,ts,dur) and fold each lane into `arena`.
2106
// Lanes never cross files (one pid per rank file), so this is a complete,
2107
// self-contained partial tree for the file.
2108
static void fold_file_events(
10✔
2109
    std::vector<FlameNode>& arena,
2110
    ankerl::unordered_dense::map<std::int64_t, std::uint32_t>& proc_of,
2111
    std::vector<std::pair<double, std::uint32_t>>& open,
2112
    std::vector<FlameEv>& evs, bool by_process) {
2113
    std::sort(evs.begin(), evs.end(), [](const FlameEv& a, const FlameEv& b) {
395✔
2114
        if (a.pid != b.pid) return a.pid < b.pid;
1,205!
NEW
2115
        if (a.tid != b.tid) return a.tid < b.tid;
×
NEW
2116
        if (a.ts != b.ts) return a.ts < b.ts;
×
NEW
2117
        return a.dur > b.dur;
×
2118
    });
385✔
2119
    std::size_t i = 0;
10✔
2120
    while (i < evs.size()) {
390✔
2121
        std::int64_t pid = evs[i].pid, tid = evs[i].tid;
380✔
2122
        std::uint32_t base = 0;
380✔
2123
        if (by_process) {
380✔
2124
            auto pit = proc_of.find(pid);
140!
2125
            if (pit == proc_of.end()) {
140!
2126
                base = static_cast<std::uint32_t>(arena.size());
140✔
2127
                arena.emplace_back();
140!
2128
                arena[base].name = "P" + std::to_string(pid);
140!
2129
                proc_of.emplace(pid, base);
140!
2130
                arena[0].children.push_back(base);
140!
2131
                arena[0].kids.emplace(arena[base].name, base);
140!
2132
            } else {
70✔
NEW
2133
                base = pit->second;
×
2134
            }
2135
        }
70✔
2136
        open.clear();
380✔
2137
        for (; i < evs.size() && evs[i].pid == pid && evs[i].tid == tid; ++i)
760✔
2138
            fold_flame_event(arena, open, base, evs[i]);
380!
2139
    }
2140
}
10✔
2141

2142
// Merge partial tree `src` (subtree si) into `dst` (node di), summing stats per
2143
// name-path. src node names stay alive for the whole merge.
2144
static void merge_flame_arena(std::vector<FlameNode>& dst, std::uint32_t di,
160✔
2145
                              const std::vector<FlameNode>& src,
2146
                              std::uint32_t si) {
2147
    dst[di].total += src[si].total;
160✔
2148
    dst[di].self += src[si].self;
160✔
2149
    dst[di].count += src[si].count;
160✔
2150
    for (std::uint32_t sc : src[si].children) {
316✔
2151
        std::uint32_t dc;
2152
        auto it = dst[di].kids.find(src[sc].name);
156!
2153
        if (it == dst[di].kids.end()) {
156✔
2154
            dc = static_cast<std::uint32_t>(dst.size());
20✔
2155
            dst.emplace_back();
20!
2156
            dst[dc].name = src[sc].name;
20!
2157
            dst[di].kids.emplace(src[sc].name, dc);
20!
2158
            dst[di].children.push_back(dc);
20!
2159
        } else {
20✔
2160
            dc = it->second;
136✔
2161
        }
2162
        merge_flame_arena(dst, dc, src, sc);
156!
2163
    }
2164
}
160✔
2165

2166
// Streaming call-tree worker: claims whole files (work-stealing via
2167
// `next_file`) and folds each file's events into `out` as a partial tree,
2168
// discarding events per file so peak memory is one file's events, not the whole
2169
// trace. Heavy locals are heap-allocated to keep the coroutine frame small.
2170
static coro::CoroTask<void> calltree_stream_worker(
70!
2171
    const std::vector<const TraceIndex::FileInfo*>* files,
2172
    std::atomic<std::size_t>* next_file, TraceIndex* index,
2173
    const ViewDefinition* view, double begin, double end, bool scan_all_chunks,
2174
    bool by_process, std::int64_t cap, std::atomic<std::int64_t>* produced,
2175
    CancelToken cancel, std::vector<FlameNode>* out) {
5!
2176
    auto& arena = *out;
5✔
2177
    arena.emplace_back();  // partial root (index 0)
5!
2178
    arena[0].name = "all";
5!
2179
    auto file_buf = std::make_unique<std::vector<FlameEv>>();
5!
2180
    auto open =
5✔
2181
        std::make_unique<std::vector<std::pair<double, std::uint32_t>>>();
5!
2182
    auto proc_of = std::make_unique<
5!
2183
        ankerl::unordered_dense::map<std::int64_t, std::uint32_t>>();
2184

2185
    while (true) {
10✔
2186
        if (cancel.cancelled()) co_return;
15!
2187
        if (produced->load(std::memory_order_relaxed) >= cap) co_return;
10!
2188
        std::size_t fi = next_file->fetch_add(1, std::memory_order_relaxed);
10✔
2189
        if (fi >= files->size()) co_return;
10✔
2190
        auto* file_info = (*files)[fi];
5✔
2191
        if (file_info->uncompressed_size == 0 &&
5!
2192
            file_info->num_checkpoints == 0)
2193
            continue;
2194

2195
        ViewBuilderInput builder_input;
5✔
2196
        builder_input.with_view(*view)
10!
2197
            .with_file_path(file_info->path)
5!
2198
            .with_index_path(file_info->has_bloom_data ? file_info->index_path
5!
2199
                                                       : "")
×
2200
            .with_uncompressed_size(file_info->uncompressed_size)
5!
2201
            .with_num_checkpoints(file_info->num_checkpoints)
5!
2202
            .with_bloom_cache(&index->bloom_cache())
5!
2203
            .with_time_range(begin, end)
5!
2204
            .with_scan_all_chunks(scan_all_chunks);
5!
2205
        ViewBuilderUtility builder;
5!
2206
        auto build_output = co_await builder.process(builder_input);
10!
2207
        if (!build_output || !build_output->file_may_match) continue;
5!
2208

2209
        file_buf->clear();
5✔
2210
        for (const auto& c : build_output->candidates) {
30!
2211
            if (cancel.cancelled()) co_return;
5!
2212
            ViewReaderInput reader_input;
5✔
2213
            reader_input.with_file_path(file_info->path)
5!
2214
                .with_index_path(file_info->index_path)
5!
2215
                .with_byte_range(c.start_byte, c.end_byte)
5!
2216
                .with_checkpoint_idx(c.checkpoint_idx)
5!
2217
                .with_view(*view);
5!
2218
            ViewReaderUtility reader;
5!
2219
            auto gen = reader.process(reader_input);
5!
2220
            while (auto batch = co_await gen.next()) {
40!
2221
                FlameEv ev;
5✔
2222
                for (auto e : batch->events)
195✔
2223
                    if (parse_flame_ev(e, ev)) file_buf->push_back(ev);
190!
2224
            }
10✔
2225
        }
25✔
2226

2227
        fold_file_events(arena, *proc_of, *open, *file_buf, by_process);
5!
2228
        produced->fetch_add(static_cast<std::int64_t>(file_buf->size()),
5✔
2229
                            std::memory_order_relaxed);
2230
        file_buf->clear();
5✔
2231
    }
30!
2232
}
84!
2233

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

2243
    double begin = params.get_double("begin", 0);
3!
2244
    double end = params.get_double("end", 0);
3!
2245

2246
    auto query = params.get("query");
3!
2247
    if (!query.empty() &&
3!
2248
        !utilities::common::query::try_parse(query).has_value())
×
2249
        co_return HttpResponse::bad_request("Invalid query: " +
×
2250
                                            std::string(query));
×
2251

2252
    auto ts_norm_param = params.get("ts_normalize");
3!
2253
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
2254
    std::uint64_t global_min = 0;
3✔
2255
    if (normalize) {
3!
2256
        global_min = index.global_min_timestamp_us();
3✔
2257
        if (global_min == std::numeric_limits<std::uint64_t>::max())
3!
2258
            global_min = 0;
2259
    }
3✔
2260
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
3!
2261
        begin = static_cast<double>(
2262
            index.us_to_native(static_cast<std::uint64_t>(begin)));
2263
        end = static_cast<double>(
2264
            index.us_to_native(static_cast<std::uint64_t>(end)));
2265
    }
2266
    if (normalize && global_min > 0) {
3!
2267
        begin += static_cast<double>(global_min);
3✔
2268
        end += static_cast<double>(global_min);
3✔
2269
    }
3✔
2270

2271
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
2272
    int limit = params.get_int("limit", 0);
3!
2273
    if (limit < 0) limit = 0;
3!
2274

2275
    std::vector<const TraceIndex::FileInfo*> target_files =
3✔
2276
        select_viz_target_files(index, params, begin, end);
3!
2277

2278
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
2279
    bool by_process = params.get("group") == "pid";
3!
2280
    bool scan_all_chunks = !params.get("file").empty();
3!
2281
    const std::int64_t cap =
6✔
2282
        limit > 0 ? limit : std::numeric_limits<std::int64_t>::max();
3!
2283

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

2287
    // Stream the tree: each worker claims whole files and folds them into its
2288
    // own partial tree, discarding events per file (bounded memory), then the
2289
    // partials merge. Lanes never cross files, so each partial is complete.
2290
    std::size_t nworkers = std::max<std::size_t>(
6!
2291
        1, std::min(slots, target_files.empty() ? std::size_t{1}
3!
2292
                                                : target_files.size()));
3✔
2293
    std::vector<std::vector<FlameNode>> arenas(nworkers);
3!
2294
    std::atomic<std::size_t> next_file{0};
3✔
2295
    std::atomic<std::int64_t> produced{0};
3✔
2296
    CancelToken cancel = req.cancel_token;
3✔
2297

2298
    {
2299
        CoroScope scope(Executor::current());
3!
2300
        auto* files_ptr = &target_files;
3✔
2301
        auto* index_ptr = &index;
3✔
2302
        auto* view_ptr = &view;
3✔
2303
        auto* next_ptr = &next_file;
3✔
2304
        auto* produced_ptr = &produced;
3✔
2305
        for (std::size_t w = 0; w < nworkers; ++w) {
8✔
2306
            auto* out = &arenas[w];
5✔
2307
            scope.spawn([files_ptr, next_ptr, index_ptr, view_ptr, begin, end,
75!
2308
                         scan_all_chunks, by_process, cap, produced_ptr, cancel,
25✔
2309
                         out](CoroScope&) -> coro::CoroTask<void> {
10!
2310
                co_await calltree_stream_worker(files_ptr, next_ptr, index_ptr,
40!
2311
                                                view_ptr, begin, end,
15✔
2312
                                                scan_all_chunks, by_process,
15✔
2313
                                                cap, produced_ptr, cancel, out);
15✔
2314
            });
20!
2315
        }
5✔
2316
        co_await scope.join();
6!
2317
    }
3!
2318

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

2322
    for (std::size_t t = 1; t < arenas.size(); ++t)
5✔
2323
        merge_flame_arena(arenas[0], 0, arenas[t], 0);
2!
2324
    std::vector<FlameNode> arena = std::move(arenas[0]);
3✔
2325

2326
    // Process frames are synthetic containers: total/count roll up from their
2327
    // children, self is 0.
2328
    if (by_process) {
3✔
2329
        for (std::uint32_t pnode : arena[0].children) {
41✔
2330
            double t = 0;
40✔
2331
            std::uint64_t c = 0;
40✔
2332
            for (std::uint32_t ch : arena[pnode].children) {
80✔
2333
                t += arena[ch].total;
40✔
2334
                c += arena[ch].count;
40✔
2335
            }
40✔
2336
            arena[pnode].total = t;
40✔
2337
            arena[pnode].count = c;
40✔
2338
            arena[pnode].self = 0;
40✔
2339
        }
40✔
2340
    }
1✔
2341

2342
    double root_total = 0;
3✔
2343
    std::uint64_t root_count = 0;
3✔
2344
    for (std::uint32_t c : arena[0].children) {
59✔
2345
        root_total += arena[c].total;
56✔
2346
        root_count += arena[c].count;
56✔
2347
    }
56✔
2348
    arena[0].total = root_total;
3✔
2349
    arena[0].count = root_count;
3✔
2350
    arena[0].self = 0;
3✔
2351

2352
    // Node total/self are summed native durations; scale to us for display.
2353
    const double dur_us =
3✔
2354
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
3!
2355
            index.time_metric());
3✔
2356
    if (dur_us != 1.0) {
3!
2357
        for (auto& n : arena) {
×
2358
            n.total *= dur_us;
2359
            if (n.self > 0) n.self *= dur_us;
×
2360
        }
2361
    }
2362

2363
    auto& b = scratch_json_builder();
3!
2364
    b.start_object();
3✔
2365
    b.append_key_value("truncated", truncated);
3✔
2366
    b.append_comma();
3✔
2367
    b.escape_and_append_with_quotes("tree");
3✔
2368
    b.append_colon();
3✔
2369
    serialize_flame_node(b, arena, 0);
3!
2370
    b.end_object();
3✔
2371
    co_return HttpResponse::ok(std::string(b));
3!
2372
}
15!
2373

2374
// One log-spaced duration bucket of the histogram response.
2375
struct HistBucket {
2376
    double lo;
2377
    double hi;
2378
    std::uint64_t count;
2379
};
2380

2381
template <typename builder_type>
2382
void tag_invoke(simdjson::serialize_tag, builder_type& b, const HistBucket& h) {
80✔
2383
    b.start_object();
80✔
2384
    b.append_key_value("lo", h.lo);
80✔
2385
    b.append_comma();
80✔
2386
    b.append_key_value("hi", h.hi);
80✔
2387
    b.append_comma();
80✔
2388
    b.append_key_value("count", h.count);
80✔
2389
    b.end_object();
80✔
2390
}
80✔
2391

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

2402
    double begin = params.get_double("begin", 0);
3✔
2403
    double end = params.get_double("end", 0);
3✔
2404

2405
    auto query = params.get("query");
3✔
2406
    if (!query.empty() &&
3!
2407
        !utilities::common::query::try_parse(query).has_value())
×
2408
        co_return HttpResponse::bad_request("Invalid query: " +
×
2409
                                            std::string(query));
×
2410

2411
    auto ts_norm_param = params.get("ts_normalize");
3✔
2412
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
2413
    std::uint64_t global_min = 0;
3✔
2414
    if (normalize) {
3✔
2415
        global_min = index.global_min_timestamp_us();
1✔
2416
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
2417
            global_min = 0;
2418
    }
1✔
2419
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
3!
2420
        begin = static_cast<double>(
2421
            index.us_to_native(static_cast<std::uint64_t>(begin)));
2422
        end = static_cast<double>(
2423
            index.us_to_native(static_cast<std::uint64_t>(end)));
2424
    }
2425
    if (normalize && global_min > 0) {
3!
2426
        begin += static_cast<double>(global_min);
1✔
2427
        end += static_cast<double>(global_min);
1✔
2428
    }
1✔
2429

2430
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
2431
    int limit = params.get_int("limit", 0);
3✔
2432
    if (limit < 0) limit = 0;
3!
2433
    int nbuckets = params.get_int("buckets", 40);
3✔
2434
    nbuckets = std::clamp(nbuckets, 4, 200);
3!
2435

2436
    std::vector<const TraceIndex::FileInfo*> target_files =
3✔
2437
        select_viz_target_files(index, params, begin, end);
3!
2438

2439
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
2440
    std::vector<std::vector<double>> partials(slots);
3!
2441
    auto collect = [&partials](std::size_t w,
5✔
2442
                               const std::vector<std::string_view>& events) {
1✔
2443
        thread_local simdjson::dom::parser parser;
2✔
2444
        thread_local std::string buf;
2✔
2445
        auto& out = partials[w];  // worker-owned slot, no lock
2✔
2446
        for (auto e : events) {
102✔
2447
            buf.assign(e);
100!
2448
            auto res = parser.parse(buf);
100✔
2449
            if (res.error()) continue;
100!
2450
            auto root = res.value_unsafe();
100✔
2451
            if (!root.is_object()) continue;
100✔
2452
            auto dr = root["dur"];
100✔
2453
            if (dr.error()) continue;
100!
2454
            out.push_back(json_number(dr.value_unsafe()));
100!
2455
        }
2456
    };
2✔
2457

2458
    bool truncated = co_await scan_view_events(
5!
2459
        index, target_files, view, begin, end, limit, slots, collect,
3!
2460
        req.cancel_token, !params.get("file").empty());
3!
2461

2462
    std::vector<double> all;
1✔
2463
    std::size_t total_n = 0;
1✔
2464
    for (auto& p : partials) total_n += p.size();
3✔
2465
    all.reserve(total_n);
1!
2466
    for (auto& p : partials)
3✔
2467
        for (double d : p) all.push_back(d);
52!
2468
    std::sort(all.begin(), all.end());
1!
2469

2470
    // Durations are in the trace's native unit; scale to us for display.
2471
    const double dur_us =
1✔
2472
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
1!
2473
            index.time_metric());
1✔
2474
    if (dur_us != 1.0)
1!
2475
        for (double& d : all) d *= dur_us;
×
2476

2477
    auto& sb = scratch_json_builder();
1!
2478
    if (all.empty()) {
1!
2479
        sb.start_object();
2480
        sb.append_key_value("count", 0);
2481
        sb.append_comma();
2482
        sb.escape_and_append_with_quotes("buckets");
2483
        sb.append_colon();
2484
        sb.start_array();
2485
        sb.end_array();
2486
        sb.append_comma();
2487
        sb.append_key_value("truncated", truncated);
2488
        sb.end_object();
2489
        co_return HttpResponse::ok(std::string(sb));
×
2490
    }
2491

2492
    std::size_t n = all.size();
1✔
2493
    double vmin = all.front();
1✔
2494
    double vmax = all.back();
1✔
2495
    double sum = 0;
1✔
2496
    for (double d : all) sum += d;
51✔
2497
    double mean = sum / static_cast<double>(n);
1✔
2498
    auto pct = [&all, n](double p) {
9✔
2499
        std::size_t idx =
8✔
2500
            static_cast<std::size_t>(p * static_cast<double>(n - 1));
8✔
2501
        return all[idx];
8✔
2502
    };
2503

2504
    // Log-spaced buckets over [max(vmin,1), vmax]; sub-microsecond durs land in
2505
    // the first bucket.
2506
    double lo = std::max(vmin, 1.0);
1!
2507
    double hi = std::max(vmax, lo * 1.0000001);
1!
2508
    double lr = std::log(hi / lo);
1✔
2509
    std::vector<std::uint64_t> counts(static_cast<std::size_t>(nbuckets), 0);
1!
2510
    for (double d : all) {
51✔
2511
        double v = d < lo ? lo : d;
50!
2512
        std::size_t bi =
100✔
2513
            lr > 0 ? static_cast<std::size_t>(static_cast<double>(nbuckets) *
50!
2514
                                              std::log(v / lo) / lr)
100✔
2515
                   : 0;
2516
        if (bi >= static_cast<std::size_t>(nbuckets)) bi = nbuckets - 1;
50✔
2517
        counts[bi]++;
50✔
2518
    }
50✔
2519

2520
    sb.start_object();
1✔
2521
    sb.append_key_value("count", n);
1✔
2522
    sb.append_comma();
1✔
2523
    sb.append_key_value("min", vmin);
1✔
2524
    sb.append_comma();
1✔
2525
    sb.append_key_value("max", vmax);
1✔
2526
    sb.append_comma();
1✔
2527
    sb.append_key_value("mean", mean);
1✔
2528
    sb.append_comma();
1✔
2529
    sb.append_key_value("p50", pct(0.50));
1!
2530
    sb.append_comma();
1✔
2531
    sb.append_key_value("p90", pct(0.90));
1!
2532
    sb.append_comma();
1✔
2533
    sb.append_key_value("p95", pct(0.95));
1!
2534
    sb.append_comma();
1✔
2535
    sb.append_key_value("p99", pct(0.99));
1!
2536
    sb.append_comma();
1✔
2537
    sb.append_key_value("truncated", truncated);
1✔
2538
    sb.append_comma();
1✔
2539
    std::vector<HistBucket> buckets;
1✔
2540
    buckets.reserve(static_cast<std::size_t>(nbuckets));
1!
2541
    for (int i = 0; i < nbuckets; ++i) {
41✔
2542
        buckets.push_back(
40!
2543
            {lo * std::exp(lr * static_cast<double>(i) / nbuckets),
120✔
2544
             lo * std::exp(lr * static_cast<double>(i + 1) / nbuckets),
40✔
2545
             counts[static_cast<std::size_t>(i)]});
40✔
2546
    }
40✔
2547
    sb.append_key_value("buckets", buckets);
1!
2548
    sb.end_object();
1✔
2549
    co_return HttpResponse::ok(std::string(sb));
1!
2550
}
37!
2551

2552
// A density block as it appears in the response: the map key/aggregate pair
2553
// flattened with `ts` resolved from the column index. `name` borrows the
2554
// aggregate's storage.
2555
struct DensityBlock {
2556
    std::string_view name;
2557
    std::int64_t pid;
2558
    std::int64_t tid;
2559
    double ts;
2560
    double dur;
2561
    std::uint32_t count;
2562
    double total;
2563
    std::uint32_t depth;
2564
    std::string_view group;  // group_by value; omitted from JSON when empty
2565
};
2566

2567
template <typename builder_type>
2568
void tag_invoke(simdjson::serialize_tag, builder_type& b,
260✔
2569
                const DensityBlock& d) {
2570
    b.start_object();
260✔
2571
    if (!d.group.empty()) {
260✔
2572
        b.append_key_value("group", d.group);
80✔
2573
        b.append_comma();
80✔
2574
    }
40✔
2575
    b.append_key_value("name", d.name);
260✔
2576
    b.append_comma();
260✔
2577
    b.append_key_value("pid", d.pid);
260✔
2578
    b.append_comma();
260✔
2579
    b.append_key_value("tid", d.tid);
260✔
2580
    b.append_comma();
260✔
2581
    b.append_key_value("ts", d.ts);
260✔
2582
    b.append_comma();
260✔
2583
    b.append_key_value("dur", d.dur);
260✔
2584
    b.append_comma();
260✔
2585
    b.append_key_value("count", d.count);
260✔
2586
    b.append_comma();
260✔
2587
    b.append_key_value("total", d.total);
260✔
2588
    b.append_comma();
260✔
2589
    b.append_key_value("depth", d.depth);
260✔
2590
    b.end_object();
260✔
2591
}
260✔
2592

2593
// Serialize collected density blocks (+ optional individual events) into the
2594
// /viz/density response body. Shared by the live-scan and summary paths.
2595
static std::string serialize_density_body(
8✔
2596
    const std::vector<std::string>& big, const DensityMap& dens,
2597
    double original_begin, double original_end, double threshold, int limit,
2598
    bool truncated, bool ts_normalized, std::uint64_t display_global_min,
2599
    double max_dur, TraceIndex::TimeMetric metric,
2600
    const std::vector<std::uint32_t>* big_depth = nullptr,
2601
    const ankerl::unordered_dense::map<std::string, std::string>* group_names =
2602
        nullptr) {
2603
    // Blocks are positioned/sized in native threshold units; scale the visible
2604
    // time fields (block ts/dur, duration sums, max_dur) to microseconds.
2605
    const double us =
4✔
2606
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
8✔
2607
            metric);
4✔
2608
    const double threshold_us = threshold * us;
8✔
2609
    max_dur *= us;
8✔
2610
    auto& b = scratch_json_builder();
8✔
2611
    b.start_object();
8✔
2612
    b.escape_and_append_with_quotes("events");
8✔
2613
    b.append_colon();
8✔
2614
    b.start_array();
8✔
2615
    for (std::size_t i = 0; i < big.size(); ++i) {
108✔
2616
        if (i > 0) b.append_comma();
100✔
2617
        // Inject the server-computed depth as a sibling field (events end in
2618
        // }).
2619
        if (big_depth && i < big_depth->size() && !big[i].empty() &&
100!
2620
            big[i].back() == '}') {
×
2621
            b.append_raw(std::string_view(big[i]).substr(0, big[i].size() - 1));
×
2622
            b.append_raw(",\"depth\":");
×
2623
            b.append(static_cast<std::uint64_t>((*big_depth)[i]));
×
2624
            b.append_raw("}");
×
2625
        } else {
2626
            b.append_raw(big[i]);
100✔
2627
        }
2628
    }
50✔
2629
    b.end_array();
8✔
2630
    b.append_comma();
8✔
2631
    std::vector<DensityBlock> blocks;
8✔
2632
    blocks.reserve(dens.size());
8!
2633
    for (const auto& [k, a] : dens) {
268✔
2634
        blocks.push_back(
390!
2635
            {a.name, k.pid, k.tid,
910✔
2636
             original_begin + static_cast<double>(k.col) * threshold_us,
260✔
2637
             threshold_us, a.count, a.total * us, a.depth, k.group});
780✔
2638
    }
2639
    b.append_key_value("density", blocks);
8!
2640
    b.append_comma();
8✔
2641
    b.escape_and_append_with_quotes("metadata");
8✔
2642
    b.append_colon();
8✔
2643
    b.start_object();
8✔
2644
    b.append_key_value("begin", original_begin);
8✔
2645
    b.append_comma();
8✔
2646
    b.append_key_value("end", original_end);
8✔
2647
    b.append_comma();
8✔
2648
    b.append_key_value("count", big.size());
8✔
2649
    b.append_comma();
8✔
2650
    b.append_key_value("limit", limit);
8✔
2651
    b.append_comma();
8✔
2652
    b.append_key_value("density_count", dens.size());
8✔
2653
    b.append_comma();
8✔
2654
    b.append_key_value("truncated", truncated);
8✔
2655
    b.append_comma();
8✔
2656
    b.append_key_value("ts_normalized", ts_normalized);
8✔
2657
    b.append_comma();
8✔
2658
    b.append_key_value("global_min_timestamp_us", display_global_min);
8✔
2659
    b.append_comma();
8✔
2660
    b.append_key_value("max_dur", max_dur);
8✔
2661
    if (group_names && !group_names->empty()) {
8!
NEW
2662
        b.append_comma();
×
NEW
2663
        b.escape_and_append_with_quotes("group_names");
×
NEW
2664
        b.append_colon();
×
NEW
2665
        b.start_object();
×
NEW
2666
        bool first = true;
×
NEW
2667
        for (const auto& [hash, name] : *group_names) {
×
NEW
2668
            if (!first) b.append_comma();
×
NEW
2669
            first = false;
×
NEW
2670
            b.escape_and_append_with_quotes(hash);
×
NEW
2671
            b.append_colon();
×
NEW
2672
            b.escape_and_append_with_quotes(name);
×
2673
        }
NEW
2674
        b.end_object();
×
2675
    }
2676
    b.end_object();
8✔
2677
    b.end_object();
8✔
2678
    return std::string(b);
12!
2679
}
8✔
2680

2681
// The summary is unfiltered, so any server-side predicate forces a live scan.
2682
// pid/tid are exempt: they select whole lanes, which the summary can still do.
2683
static bool viz_summary_eligible(const QueryParams& params) {
28✔
2684
    return params.get("query").empty() && params.get("cat").empty() &&
70!
2685
           params.get("lanes").empty() && params.get("filters").empty() &&
52!
2686
           params.get("file").empty() && params.get("group_by").empty();
50!
2687
}
2688

2689
static coro::CoroTask<void> append_app_spans(std::vector<std::string>& out,
70!
2690
                                             TraceIndex& index, double begin,
2691
                                             double end,
2692
                                             const QueryParams& params) {
10!
2693
    if (!viz_summary_eligible(params)) co_return;
30!
2694
    const VizSummary* s = co_await ensure_viz_summary(index);
20!
2695
    if (!s) co_return;
5!
2696
    auto pid_s = params.get("pid");
5!
2697
    auto tid_s = params.get("tid");
5!
2698
    bool has_pid = !pid_s.empty();
5✔
2699
    bool has_tid = !tid_s.empty();
5✔
2700
    std::int64_t want_pid =
10✔
2701
        has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
5!
2702
    std::int64_t want_tid =
10✔
2703
        has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
5!
2704
    for (const auto& sp : s->app_spans) {
5!
2705
        if (has_pid && sp.pid != want_pid) continue;
×
2706
        if (has_tid && sp.tid != want_tid) continue;
×
2707
        if (static_cast<double>(sp.end) > begin &&
×
2708
            static_cast<double>(sp.begin) < end)
2709
            out.push_back(sp.json);
×
2710
    }
×
2711
}
35!
2712

2713
// Re-aggregate the summary's finest per-lane buckets over [begin_abs, end_abs]
2714
// into pixel-column density blocks. `begin_abs`/`end_abs` are absolute us.
2715
static std::string serve_density_from_summary(
2✔
2716
    const VizSummary& s, const QueryParams& params, double begin_abs,
2717
    double end_abs, double original_begin, double original_end,
2718
    double threshold, bool ts_normalized, std::uint64_t display_global_min,
2719
    TraceIndex::TimeMetric metric) {
2720
    auto pid_s = params.get("pid");
2!
2721
    auto tid_s = params.get("tid");
2!
2722
    bool has_pid = !pid_s.empty();
2✔
2723
    bool has_tid = !tid_s.empty();
2✔
2724
    std::int64_t want_pid =
1✔
2725
        has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
2!
2726
    std::int64_t want_tid =
1✔
2727
        has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
2!
2728

2729
    std::int64_t b0 = s.bucket_of(begin_abs);
2✔
2730
    std::int64_t b1 = s.bucket_of(end_abs);
2✔
2731
    if (b0 < 0) b0 = 0;
2✔
2732
    if (b1 < 0) b1 = static_cast<std::int64_t>(s.nbuckets) - 1;
2✔
2733

2734
    DensityMap dens;
2!
2735
    for (const auto& lane : s.lanes) {
2!
UNCOV
2736
        if (has_pid && lane.pid != want_pid) continue;
×
UNCOV
2737
        if (has_tid && lane.tid != want_tid) continue;
×
UNCOV
2738
        for (std::int64_t bk = b0; bk <= b1; ++bk) {
×
UNCOV
2739
            const auto& cell = lane.cells[static_cast<std::size_t>(bk)];
×
UNCOV
2740
            if (cell.count == 0) continue;
×
UNCOV
2741
            double center = static_cast<double>(s.t_begin) +
×
UNCOV
2742
                            (static_cast<double>(bk) + 0.5) * s.bucket_us;
×
UNCOV
2743
            std::int64_t col =
×
UNCOV
2744
                static_cast<std::int64_t>((center - begin_abs) / threshold);
×
NEW
2745
            DensityKey key{lane.pid, lane.tid, col, {}};
×
UNCOV
2746
            auto& a = dens[key];
×
UNCOV
2747
            a.count += cell.count;
×
UNCOV
2748
            a.total += static_cast<double>(cell.total);
×
UNCOV
2749
            if (static_cast<double>(cell.max_dur) > a.max_dur) {
×
UNCOV
2750
                a.max_dur = static_cast<double>(cell.max_dur);
×
UNCOV
2751
                if (cell.name_id < s.names.size())
×
UNCOV
2752
                    a.name = s.names[cell.name_id];
×
2753
            }
UNCOV
2754
        }
×
2755
    }
2756

2757
    std::vector<std::string> spans;
2✔
2758
    ankerl::unordered_dense::set<std::int64_t> span_lanes;
2!
2759
    for (const auto& sp : s.app_spans) {
2!
2760
        if (has_pid && sp.pid != want_pid) continue;
×
2761
        if (has_tid && sp.tid != want_tid) continue;
×
2762
        if (static_cast<double>(sp.end) <= begin_abs ||
×
2763
            static_cast<double>(sp.begin) >= end_abs)
×
2764
            continue;
×
2765
        span_lanes.insert((sp.pid << 20) ^ sp.tid);
×
NEW
2766
        spans.push_back(
×
NEW
2767
            ts_normalized && display_global_min > 0
×
NEW
2768
                ? normalize_event_ts(sp.json, display_global_min, metric)
×
NEW
2769
                : sp.json);
×
2770
    }
2771
    // App spans get an injected depth 0; long events after them do not, so the
2772
    // client stacks overlapping wide events instead of piling them on one row.
2773
    const std::size_t napp_spans = spans.size();
2✔
2774
    for (const auto& sp : s.long_events) {
102✔
2775
        if (has_pid && sp.pid != want_pid) continue;
100!
2776
        if (has_tid && sp.tid != want_tid) continue;
100!
2777
        if (static_cast<double>(sp.end) <= begin_abs ||
100!
2778
            static_cast<double>(sp.begin) >= end_abs)
100!
NEW
2779
            continue;
×
2780
        span_lanes.insert((sp.pid << 20) ^ sp.tid);
100!
2781
        spans.push_back(
100✔
2782
            ts_normalized && display_global_min > 0
150!
2783
                ? normalize_event_ts(sp.json, display_global_min, metric)
200!
NEW
2784
                : sp.json);
×
2785
    }
2786
    // Folded child activity sits one row below its app span, matching the
2787
    // containment nesting the live path computes.
2788
    if (!span_lanes.empty())
2✔
2789
        for (auto& [k, a] : dens)
2✔
2790
            if (span_lanes.count((k.pid << 20) ^ k.tid)) a.depth = 1;
1!
2791

2792
    std::vector<std::uint32_t> span_depth(napp_spans, 0);
2!
2793
    return serialize_density_body(
1!
2794
        spans, dens, original_begin, original_end, threshold, 0, false,
1✔
2795
        ts_normalized, display_global_min, static_cast<double>(s.max_dur),
2✔
2796
        metric, &span_depth);
3!
2797
}
2✔
2798

2799
// GET /api/v1/viz/density: like /viz/events, but instead of dropping sub-pixel
2800
// events it buckets them per (pid, tid, pixel-column) into density blocks so
2801
// zoomed-out views still show where activity is. Returns full-size events (with
2802
// args, for the detail panel) plus the aggregated density blocks.
2803
static coro::CoroTask<HttpResponse> handle_viz_density(
54!
2804
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
5!
2805
    if (!params.has("begin") || !params.has("end")) {
5!
2806
        co_return HttpResponse::bad_request(
5!
2807
            "Missing required parameters: begin, end");
×
2808
    }
2809

2810
    double begin = params.get_double("begin", 0);
5!
2811
    double end = params.get_double("end", 0);
5!
2812
    int summary = params.get_int("summary", 2);
5!
2813
    if (summary < 1) summary = 1;
5!
2814

2815
    auto query = params.get("query");
5!
2816
    if (!query.empty() &&
5!
2817
        !utilities::common::query::try_parse(query).has_value()) {
×
2818
        co_return HttpResponse::bad_request("Invalid query: " +
×
2819
                                            std::string(query));
×
2820
    }
2821

2822
    // Optional density grouping column. A well-formed name that matches no
2823
    // event field is fine (all blocks land in the client's "(none)" group);
2824
    // only malformed names are rejected.
2825
    std::string group_col(params.get("group_by"));
5!
2826
    if (group_col.size() > 64)
5!
2827
        co_return HttpResponse::bad_request("group_by too long");
×
2828
    for (char c : group_col) {
26✔
2829
        if (!std::isalnum(static_cast<unsigned char>(c)) && c != '_' &&
21!
2830
            c != '.' && c != '-')
1!
2831
            co_return HttpResponse::bad_request("Invalid group_by column");
1!
2832
    }
21✔
2833

2834
    // Hash columns auto-resolve for display: group values stay raw hashes, and
2835
    // a group_names map (hash -> resolved name) rides along in the metadata.
2836
    // resolved.*/r.* aliases from the query DSL map to their hash columns.
2837
    using HashType = utilities::indexer::IndexDatabase::HashType;
2838
    std::optional<HashType> resolve_type;
12✔
2839
    {
2840
        // "args.fhash" behaves like "fhash": bare names fall back into args
2841
        // during extraction, and the resolve mapping matches either spelling.
2842
        if (group_col.rfind("args.", 0) == 0 &&
12!
2843
            group_col.find('.', 5) == std::string::npos)
2844
            group_col = group_col.substr(5);
×
2845
        auto is = [&](std::string_view a) { return group_col == a; };
108✔
2846
        if (is("resolved.fpath") || is("r.fpath"))
12!
2847
            group_col = "fhash";
2✔
2848
        else if (is("resolved.cwd") || is("r.cwd"))
4!
2849
            group_col = "cwd";
×
2850
        else if (is("resolved.hostname") || is("r.hostname") ||
4!
2851
                 is("resolved.host") || is("r.host"))
4!
2852
            group_col = "hhash";
×
2853
        else if (is("resolved.exec") || is("r.exec"))
4!
2854
            group_col = "exec_hash";
×
2855
        else if (is("resolved.cmd") || is("r.cmd"))
4!
2856
            group_col = "cmd_hash";
×
2857
        if (group_col == "fhash" || group_col == "cwd")
12!
2858
            resolve_type = HashType::FILE;
8!
2859
        else if (group_col == "hhash")
4!
2860
            resolve_type = HashType::HOST;
×
2861
        else if (group_col == "shash" || group_col == "exec_hash" ||
4!
2862
                 group_col == "cmd_hash")
4✔
2863
            resolve_type = HashType::STRING;
×
2864
    }
12✔
2865

2866
    auto ts_norm_param = params.get("ts_normalize");
12✔
2867
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
12!
2868
    std::uint64_t global_min = 0;
12✔
2869
    if (normalize) {
12✔
2870
        global_min = index.global_min_timestamp_us();
4✔
2871
        if (global_min == std::numeric_limits<std::uint64_t>::max())
4!
2872
            global_min = 0;
2873
    }
4✔
2874
    double original_begin = begin;
12✔
2875
    double original_end = end;
12✔
2876
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
12!
2877
        begin = static_cast<double>(
2878
            index.us_to_native(static_cast<std::uint64_t>(begin)));
2879
        end = static_cast<double>(
2880
            index.us_to_native(static_cast<std::uint64_t>(end)));
2881
    }
2882
    if (normalize && global_min > 0) {
12!
2883
        begin += static_cast<double>(global_min);
4✔
2884
        end += static_cast<double>(global_min);
4✔
2885
    }
4✔
2886

2887
    // Bucket width (== the ~1px cutoff), in the client's actual pixels.
2888
    int width = params.get_int("width", DEFAULT_VIEWPORT_WIDTH);
12!
2889
    width = std::clamp(width, MIN_VIEWPORT_WIDTH, MAX_VIEWPORT_WIDTH);
4✔
2890
    double threshold =
24✔
2891
        duration_threshold(begin, end, static_cast<unsigned>(summary),
24✔
2892
                           static_cast<unsigned>(width));
12✔
2893

2894
    // Zoomed-out, unfiltered views come from the prebuilt summary (no event
2895
    // cap), built lazily here; concurrent requests fall through to a live scan.
2896
    if (threshold > 0 && viz_summary_eligible(params)) {
12!
2897
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
2898
        if (s && s->bucket_us > 0 && s->t_end > s->t_begin &&
1!
2899
            threshold >= s->bucket_us) {
1✔
2900
            co_return HttpResponse::ok(serve_density_from_summary(
1!
2901
                *s, params, begin, end, original_begin, original_end, threshold,
1✔
2902
                global_min > 0,
1✔
2903
                index.native_to_us(index.global_min_timestamp_us()),
1!
2904
                index.time_metric()));
1✔
2905
        }
2906
    }
1!
2907

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

2913
    // Enclosing events are fetched by a separate pass (below) so a large
2914
    // `lookback` can never starve the in-window scan's budget.
2915
    double lookback = params.get_double("lookback", 0);
9✔
2916
    if (lookback < 0) lookback = 0;
9!
2917
    double scan_begin = begin - lookback;
9✔
2918
    if (scan_begin < 0) scan_begin = 0;
9!
2919

2920
    int limit = params.get_int("limit", 0);
9✔
2921
    if (limit < 0) limit = 0;
9!
2922

2923
    struct Acc {
6✔
2924
        std::vector<std::string> big;
2925
        std::vector<double> big_dur;  // parallel to `big`
2926
        DensityMap dens;
2927
        double max_dur = 0;
6✔
2928
    };
2929
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
9!
2930
    std::vector<Acc> accs(slots);
9!
2931

2932
    // Pass 1 (in-window): scan [begin, end] with the full budget so earlier
2933
    // events never consume it. Small events fold into density blocks.
2934
    auto on_batch = [&accs, &group_col, threshold, begin](
19✔
2935
                        std::size_t w,
2936
                        const std::vector<std::string_view>& events) {
385✔
2937
        Acc& acc = accs[w];  // worker-owned slot, no lock
10✔
2938
        for (auto ev : events) {
390✔
2939
            double dur = 0;
380✔
2940
            if (!fold_density(ev, threshold, begin, acc.dens, &dur,
570!
2941
                              group_col)) {
190✔
2942
                acc.big.emplace_back(ev);
×
2943
                acc.big_dur.push_back(dur);
×
2944
            }
2945
            if (dur > acc.max_dur) acc.max_dur = dur;
380✔
2946
        }
2947
    };
10✔
2948
    ViewDefinition win_view = build_viz_view(params, begin, end, 0);
9!
2949
    std::vector<const TraceIndex::FileInfo*> win_files =
9✔
2950
        select_viz_target_files(index, params, begin, end);
9!
2951
    bool single_file = !params.get("file").empty();
9✔
2952
    bool truncated = co_await scan_view_events(
12!
2953
        index, win_files, win_view, begin, end, limit, slots, on_batch,
9!
2954
        req.cancel_token, single_file);
9✔
2955

2956
    // Pass 2 (enclosers): keep only events still open at `begin`
2957
    // (ts < begin <= ts + dur); these ancestor bars set containment depth.
2958
    if (scan_begin < begin) {
9!
2959
        auto on_batch_enc = [&accs, begin](
×
2960
                                std::size_t w,
2961
                                const std::vector<std::string_view>& events) {
2962
            Acc& acc = accs[w];
×
2963
            for (auto ev : events) {
×
2964
                double ts = 0, dur = 0;
×
2965
                if (!parse_ts_dur(ev, ts, dur)) continue;
×
2966
                if (dur > acc.max_dur) acc.max_dur = dur;
×
2967
                if (ts < begin && ts + dur > begin) {
×
2968
                    acc.big.emplace_back(ev);
×
2969
                    acc.big_dur.push_back(dur);
×
2970
                }
2971
            }
2972
        };
×
2973
        ViewDefinition enc_view = build_viz_view(params, scan_begin, begin, 0);
×
2974
        std::vector<const TraceIndex::FileInfo*> enc_files =
2975
            select_viz_target_files(index, params, scan_begin, begin);
×
2976
        bool enc_trunc = co_await scan_view_events(
×
2977
            index, enc_files, enc_view, scan_begin, begin, limit, slots,
2978
            on_batch_enc, req.cancel_token, single_file);
×
2979
        truncated = truncated || enc_trunc;
×
2980
    }
×
2981

2982
    std::vector<std::string> big;
9✔
2983
    std::vector<double> big_dur;
9✔
2984
    DensityMap dens;
9!
2985
    // Longest event scanned (folded ones included); the client feeds it back as
2986
    // `lookback` so deep zooms still catch long enclosing events.
2987
    double max_dur = 0;
9✔
2988
    for (auto& acc : accs) {
15✔
2989
        if (acc.max_dur > max_dur) max_dur = acc.max_dur;
6✔
2990
        for (auto& d : acc.big_dur) big_dur.push_back(d);
6!
2991
        for (auto& s : acc.big) big.emplace_back(std::move(s));
6!
2992
        for (auto& kv : acc.dens) {
196✔
2993
            auto it = dens.find(kv.first);
190!
2994
            if (it == dens.end())
190✔
2995
                dens.emplace(kv.first, std::move(kv.second));
130!
2996
            else
2997
                it->second.merge_from(kv.second);
60!
2998
        }
190✔
2999
    }
6✔
3000
    // scan_view_events overshoots its cap (checked per batch), so clamp here.
3001
    // Keep the longest events: those are the slices wide enough to see.
3002
    if (limit > 0 && big.size() > static_cast<std::size_t>(limit)) {
9!
3003
        const auto keep = static_cast<std::size_t>(limit);
3004
        std::vector<std::size_t> idx(big.size());
×
3005
        std::iota(idx.begin(), idx.end(), std::size_t{0});
×
3006
        std::nth_element(idx.begin(), idx.begin() + static_cast<long>(keep),
×
3007
                         idx.end(), [&big_dur](std::size_t a, std::size_t b) {
×
3008
                             return big_dur[a] > big_dur[b];
×
3009
                         });
3010
        idx.resize(keep);
×
3011
        std::sort(idx.begin(), idx.end());
×
3012
        std::vector<std::string> kept;
3013
        kept.reserve(keep);
×
3014
        for (std::size_t i : idx) kept.emplace_back(std::move(big[i]));
×
3015
        big.swap(kept);
3016
        truncated = true;
3017
    }
3018

3019
    co_await append_app_spans(big, index, begin, end, params);
12!
3020

3021
    // Resolve hash group values (fhash -> path, hhash -> host, ...) for the
3022
    // groups actually present; unresolvable hashes fall back to the raw value
3023
    // on the client.
3024
    ankerl::unordered_dense::map<std::string, std::string> group_names;
3!
3025
    if (resolve_type && !group_col.empty()) {
3!
3026
        ankerl::unordered_dense::set<std::string> raw_groups;
×
3027
        for (const auto& [k, a] : dens)
×
3028
            if (!k.group.empty()) raw_groups.insert(k.group);
×
3029
        for (const auto& e : big) {
×
3030
            auto g = extract_group_from_line(e, group_col);
×
3031
            if (!g.empty()) raw_groups.insert(std::move(g));
×
3032
        }
3033
        if (!raw_groups.empty()) {
×
3034
            try {
3035
                ankerl::unordered_dense::set<std::string> roots;
×
3036
                for (const auto* f : win_files)
×
3037
                    if (!f->index_path.empty()) roots.insert(f->index_path);
×
3038
                for (const auto& r : roots) {
×
3039
                    utilities::indexer::IndexDatabase db(
×
3040
                        r, rocksdb::RocksDatabase::OpenMode::ReadOnly);
3041
                    auto table = db.query_hash_table(*resolve_type);
×
3042
                    for (const auto& g : raw_groups) {
×
3043
                        auto it = table.find(g);
×
3044
                        if (it != table.end())
×
3045
                            group_names.emplace(g, it->second);
×
3046
                    }
3047
                }
3048
            } catch (...) {
3049
                // No usable hash table: raw hashes still group correctly.
3050
            }
×
3051
        }
3052
    }
3053

3054
    // Stable per-event/-block depth (computed in absolute space, before ts
3055
    // normalization rewrites the big strings; order is preserved in place).
3056
    std::vector<std::uint32_t> big_depth =
3✔
3057
        assign_view_depths(big, dens, begin, threshold);
3!
3058
    if (global_min > 0 || index.time_metric() != TraceIndex::TimeMetric::US) {
3!
3059
        for (auto& e : big)
3!
3060
            e = normalize_event_ts(e, global_min, index.time_metric());
×
3061
    }
3✔
3062

3063
    co_return HttpResponse::ok(serialize_density_body(
6!
3064
        big, dens, original_begin, original_end, threshold, limit, truncated,
3✔
3065
        global_min > 0, index.native_to_us(index.global_min_timestamp_us()),
3!
3066
        max_dur, index.time_metric(), &big_depth,
3✔
3067
        group_names.empty() ? nullptr : &group_names));
3!
3068
}
125!
3069

3070
static std::string serialize_counters_body(const std::vector<double>& read,
2✔
3071
                                           const std::vector<double>& write,
3072
                                           const std::vector<double>& ops,
3073
                                           double original_begin,
3074
                                           double original_end, int buckets,
3075
                                           double bucket_us, bool truncated) {
3076
    auto& b = scratch_json_builder();
2✔
3077
    b.start_object();
2✔
3078
    b.append_key_value("begin", original_begin);
2✔
3079
    b.append_comma();
2✔
3080
    b.append_key_value("end", original_end);
2✔
3081
    b.append_comma();
2✔
3082
    b.append_key_value("buckets", static_cast<std::int64_t>(buckets));
2✔
3083
    b.append_comma();
2✔
3084
    b.append_key_value("bucket_us", bucket_us);
2✔
3085
    b.append_comma();
2✔
3086
    b.append_key_value("truncated", truncated);
2✔
3087
    b.append_comma();
2✔
3088
    b.append_key_value("read_bytes", read);
2✔
3089
    b.append_comma();
2✔
3090
    b.append_key_value("write_bytes", write);
2✔
3091
    b.append_comma();
2✔
3092
    b.append_key_value("ops", ops);
2✔
3093
    b.end_object();
2✔
3094
    return std::string(b);
2!
3095
}
3096

3097
// Re-aggregate the summary's finest counter buckets into `buckets` output
3098
// buckets over [begin_abs, end_abs] (absolute us).
3099
static std::string serve_counters_from_summary(const VizSummary& s,
2✔
3100
                                               double begin_abs, double end_abs,
3101
                                               double original_begin,
3102
                                               double original_end, int buckets,
3103
                                               double bucket_us_out,
3104
                                               TraceIndex::TimeMetric metric) {
3105
    std::vector<double> read(buckets, 0.0), write(buckets, 0.0),
2!
3106
        ops(buckets, 0.0);
2!
3107
    std::int64_t fb0 = s.bucket_of(begin_abs);
2✔
3108
    std::int64_t fb1 = s.bucket_of(end_abs);
2✔
3109
    if (fb0 < 0) fb0 = 0;
2✔
3110
    if (fb1 < 0) fb1 = static_cast<std::int64_t>(s.nbuckets) - 1;
2✔
3111
    for (std::int64_t fb = fb0; fb <= fb1; ++fb) {
131,074✔
3112
        double center = static_cast<double>(s.t_begin) +
196,608✔
3113
                        (static_cast<double>(fb) + 0.5) * s.bucket_us;
131,072✔
3114
        long oi = static_cast<long>((center - begin_abs) / bucket_us_out);
131,072✔
3115
        if (oi < 0 || oi >= buckets) continue;
131,072!
3116
        auto f = static_cast<std::size_t>(fb);
131,072✔
3117
        read[oi] += s.read_bytes[f];
131,072✔
3118
        write[oi] += s.write_bytes[f];
131,072✔
3119
        ops[oi] += s.ops[f];
131,072✔
3120
    }
65,536✔
3121
    return serialize_counters_body(
1!
3122
        read, write, ops, original_begin, original_end, buckets,
1✔
3123
        bucket_us_out *
1✔
3124
            dftracer::utils::utilities::composites::dft::time_metric_us_scale(
2!
3125
                metric),
1✔
3126
        false);
2!
3127
}
2✔
3128

3129
// GET /api/v1/viz/counters: per-bucket read/write bytes and I/O op counts over
3130
// a time range, for bandwidth/IOPS counter tracks. Aggregated server-side in
3131
// parallel (per-worker arrays merged after join).
3132
static coro::CoroTask<HttpResponse> handle_viz_counters(
8!
3133
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
1!
3134
    if (!params.has("begin") || !params.has("end")) {
3!
3135
        co_return HttpResponse::bad_request(
5!
3136
            "Missing required parameters: begin, end");
4!
3137
    }
3138

3139
    double begin = params.get_double("begin", 0);
3✔
3140
    double end = params.get_double("end", 0);
3✔
3141
    int buckets = params.get_int("buckets", 800);
3✔
3142
    if (buckets < 16) buckets = 16;
3!
3143
    if (buckets > 4000) buckets = 4000;
3!
3144

3145
    auto query = params.get("query");
3✔
3146
    if (!query.empty() &&
3!
3147
        !utilities::common::query::try_parse(query).has_value()) {
×
3148
        co_return HttpResponse::bad_request("Invalid query: " +
×
3149
                                            std::string(query));
×
3150
    }
3151

3152
    auto ts_norm_param = params.get("ts_normalize");
3✔
3153
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
3154
    std::uint64_t global_min = 0;
3✔
3155
    if (normalize) {
3✔
3156
        global_min = index.global_min_timestamp_us();
1✔
3157
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
3158
            global_min = 0;
3159
    }
1✔
3160
    double original_begin = begin;
3✔
3161
    double original_end = end;
3✔
3162
    if (index.time_metric() != TraceIndex::TimeMetric::US) {
3!
3163
        begin = static_cast<double>(
3164
            index.us_to_native(static_cast<std::uint64_t>(begin)));
×
3165
        end = static_cast<double>(
3166
            index.us_to_native(static_cast<std::uint64_t>(end)));
×
3167
    }
3168
    if (normalize && global_min > 0) {
3!
3169
        begin += static_cast<double>(global_min);
1✔
3170
        end += static_cast<double>(global_min);
1✔
3171
    }
1✔
3172

3173
    double bucket_us = (end - begin) / static_cast<double>(buckets);
3✔
3174
    if (bucket_us <= 0) bucket_us = 1;
3!
3175

3176
    // Zoomed-out, unfiltered counter tracks come from the activity summary.
3177
    if (viz_summary_eligible(params)) {
3!
3178
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
3179
        if (s && s->bucket_us > 0 && s->t_end > s->t_begin &&
1!
3180
            bucket_us >= s->bucket_us) {
1✔
3181
            co_return HttpResponse::ok(serve_counters_from_summary(
2!
3182
                *s, begin, end, original_begin, original_end, buckets,
1✔
3183
                bucket_us, index.time_metric()));
1✔
3184
        }
3185
    }
1!
3186

3187
    ViewDefinition view = build_viz_view(params, begin, end, 0);
×
3188
    // No cap: only zoomed-in or filtered counter queries reach the live path.
3189
    int limit = params.get_int("limit", 0);
×
3190
    if (limit < 0) limit = 0;
×
3191

3192
    std::vector<const TraceIndex::FileInfo*> target_files =
3193
        select_viz_target_files(index, params, begin, end);
×
3194

3195
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
3196
    std::vector<CounterAcc> accs(slots);
×
3197
    for (auto& a : accs) a.init(static_cast<std::size_t>(buckets));
×
3198
    auto on_batch = [&accs, begin, bucket_us, buckets](
×
3199
                        std::size_t w,
3200
                        const std::vector<std::string_view>& events) {
3201
        CounterAcc& acc = accs[w];  // worker-owned, no lock
×
3202
        for (auto ev : events)
×
3203
            fold_counter(ev, begin, bucket_us,
×
3204
                         static_cast<std::size_t>(buckets), acc);
3205
    };
×
3206

3207
    bool truncated = co_await scan_view_events(
×
3208
        index, target_files, view, begin, end, limit, slots, on_batch,
×
3209
        req.cancel_token, !params.get("file").empty());
×
3210

3211
    CounterAcc total;
3212
    total.init(static_cast<std::size_t>(buckets));
×
3213
    for (auto& a : accs) total.merge_from(a);
×
3214

3215
    co_return HttpResponse::ok(serialize_counters_body(
×
3216
        total.read_bytes, total.write_bytes, total.ops, original_begin,
3217
        original_end, buckets,
3218
        bucket_us *
3219
            dftracer::utils::utilities::composites::dft::time_metric_us_scale(
×
3220
                index.time_metric()),
3221
        truncated));
3222
}
29!
3223

3224
// Process-spawning calls: dftracer POSIX (exact "fork"/"clone"/...) and kernel
3225
// syscalls ("__arm64_sys_clone"). Excludes library helpers like ibv_*fork* and
3226
// register_tm_clones, which contain "fork"/"clone" but don't spawn.
3227
static bool is_fork_syscall(std::string_view name) {
100✔
3228
    return name == "fork" || name == "vfork" || name == "clone" ||
250!
3229
           name == "clone3" || name == "posix_spawn" ||
200!
3230
           name == "posix_spawnp" ||
150!
3231
           name.find("sys_clone") != std::string_view::npos ||
150!
3232
           name.find("sys_fork") != std::string_view::npos ||
200!
3233
           name.find("sys_vfork") != std::string_view::npos;
150✔
3234
}
3235

3236
// One process in the proctree response. `host` borrows the hostname table;
3237
// `rank` is null when the trace carries no "PR" metadata for the pid, and the
3238
// key is then omitted.
3239
struct ProcNode {
3240
    std::int64_t pid;
3241
    std::int64_t parent;
3242
    std::uint64_t spawn_ts;
3243
    std::uint64_t first_ts;
3244
    std::string_view host;
3245
    std::uint64_t bytes;
3246
    std::uint64_t io_ops;
3247
    double io_busy;
3248
    const std::string* rank;
3249
};
3250

3251
template <typename builder_type>
3252
void tag_invoke(simdjson::serialize_tag, builder_type& b, const ProcNode& n) {
100✔
3253
    b.start_object();
100✔
3254
    b.append_key_value("pid", n.pid);
100✔
3255
    b.append_comma();
100✔
3256
    b.append_key_value("parent", n.parent);
100✔
3257
    b.append_comma();
100✔
3258
    b.append_key_value("spawn_ts", n.spawn_ts);
100✔
3259
    b.append_comma();
100✔
3260
    b.append_key_value("first_ts", n.first_ts);
100✔
3261
    b.append_comma();
100✔
3262
    b.append_key_value("host", n.host);
100✔
3263
    b.append_comma();
100✔
3264
    b.append_key_value("bytes", n.bytes);
100✔
3265
    b.append_comma();
100✔
3266
    b.append_key_value("io_ops", n.io_ops);
100✔
3267
    b.append_comma();
100✔
3268
    b.append_key_value("io_busy", n.io_busy);
100✔
3269
    if (n.rank) {
100✔
3270
        b.append_comma();
×
3271
        b.append_key_value("rank", *n.rank);
×
3272
    }
3273
    b.end_object();
100✔
3274
}
100✔
3275

3276
// GET /api/v1/viz/proctree: infer the process fork hierarchy. The traces record
3277
// the fork/clone in the parent but not the child pid, so link each process to
3278
// the nearest preceding clone in another process (child start follows the clone
3279
// by microseconds). Respects ?file= for per-node trees on multi-node traces.
3280
static coro::CoroTask<HttpResponse> handle_viz_proctree(
6!
3281
    const HttpRequest& req, const QueryParams& params, TraceIndex& index) {
1!
3282
    std::uint64_t gmin = index.global_min_timestamp_us();
1!
3283
    std::uint64_t gmax = index.global_max_timestamp_us();
1!
3284
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
1!
3285
        co_return HttpResponse::ok("{\"nodes\":[]}");
1!
3286

3287
    auto ts_norm_param = params.get("ts_normalize");
1!
3288
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
1!
3289
    std::uint64_t base = normalize ? gmin : 0;
1!
3290

3291
    struct Acc {
2!
3292
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
3293
        // Explicit parent from the process metadata's args.ppid.
3294
        ankerl::unordered_dense::map<std::int64_t, std::int64_t> ppid;
3295
        // (ts, parent_pid, child_pid): child_pid is args.ret when the fork
3296
        // event records it (dftracer POSIX), else -1 to fall back to inference.
3297
        std::vector<std::tuple<std::uint64_t, std::int64_t, std::int64_t>>
3298
            forks;
3299
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
3300
        // I/O operation count and busy time (us) per process, over I/O-category
3301
        // (POSIX/STDIO/IO) events only.
3302
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
3303
        ankerl::unordered_dense::map<std::int64_t, double> io_busy;
3304
        ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
3305
        // pid -> rank, from "PR" metadata (args.name == "rank").
3306
        ankerl::unordered_dense::map<std::int64_t, std::string> rank;
3307
        dftracer::utils::StringViewMap<std::string> hh;
3308
    };
3309
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
1!
3310
    std::vector<Acc> accs(slots);
1!
3311
    auto on_batch = [&accs](std::size_t w,
3✔
3312
                            const std::vector<std::string_view>& events) {
1✔
3313
        thread_local simdjson::dom::parser parser;
2✔
3314
        thread_local std::string buf;
2✔
3315
        Acc& acc = accs[w];
2✔
3316
        for (auto ev : events) {
102✔
3317
            buf.assign(ev);
100!
3318
            auto res = parser.parse(buf);
100✔
3319
            if (res.error()) continue;
100!
3320
            auto root = res.value_unsafe();
100✔
3321
            if (!root.is_object()) continue;
100✔
3322
            // HH metadata (hhash -> hostname) carries no ts; handle it first.
3323
            {
3324
                auto nm = root["name"];
100✔
3325
                if (!nm.error() && nm.is_string() &&
200!
3326
                    nm.get_string().value_unsafe() == "HH") {
150!
3327
                    auto a = root["args"];
×
3328
                    if (!a.error() && a.is_object()) {
×
3329
                        auto v = a["value"];
×
3330
                        auto n = a["name"];
×
3331
                        if (!v.error() && v.is_string() && !n.error() &&
×
3332
                            n.is_string())
×
3333
                            acc.hh.emplace(
×
3334
                                std::string(v.get_string().value_unsafe()),
×
3335
                                std::string(n.get_string().value_unsafe()));
×
3336
                    }
3337
                    continue;
×
3338
                }
3339
            }
3340
            // PR metadata (pid -> rank) also carries no ts.
3341
            {
3342
                auto nm = root["name"];
100✔
3343
                if (!nm.error() && nm.is_string() &&
200!
3344
                    nm.get_string().value_unsafe() == "PR") {
150!
3345
                    auto a = root["args"];
×
3346
                    auto pp = root["pid"];
×
3347
                    if (!a.error() && a.is_object() && !pp.error()) {
×
3348
                        auto an = a["name"];
×
3349
                        auto av = a["value"];
×
3350
                        if (!an.error() && an.is_string() &&
×
3351
                            an.get_string().value_unsafe() == "rank" &&
×
3352
                            !av.error() && av.is_string())
×
3353
                            acc.rank.emplace(
×
3354
                                static_cast<std::int64_t>(
×
3355
                                    json_number(pp.value_unsafe())),
×
3356
                                std::string(av.get_string().value_unsafe()));
×
3357
                    }
3358
                    continue;
×
3359
                }
3360
            }
3361
            auto pr = root["pid"];
100✔
3362
            auto tr = root["ts"];
100✔
3363
            if (pr.error() || tr.error()) continue;
100!
3364
            auto pid =
50✔
3365
                static_cast<std::int64_t>(json_number(pr.value_unsafe()));
100✔
3366
            auto ts =
50✔
3367
                static_cast<std::uint64_t>(json_number(tr.value_unsafe()));
100✔
3368
            auto it = acc.first_ts.find(pid);
100!
3369
            if (it == acc.first_ts.end())
100!
3370
                acc.first_ts.emplace(pid, ts);
100!
3371
            else if (ts < it->second)
×
3372
                it->second = ts;
×
3373

3374
            auto nr = root["name"];
100✔
3375
            std::string_view name;
100✔
3376
            if (!nr.error() && nr.is_string())
100!
3377
                name = nr.get_string().value_unsafe();
100✔
3378

3379
            std::int64_t child = -1;
100✔
3380
            double ret = 0;
100✔
3381
            auto args = root["args"];
100✔
3382
            if (!args.error() && args.is_object()) {
100!
3383
                auto ppr = args["ppid"];
100✔
3384
                if (!ppr.error()) {
100✔
3385
                    auto pp = static_cast<std::int64_t>(
3386
                        json_number(ppr.value_unsafe()));
×
3387
                    if (pp > 0 && pp != pid) acc.ppid[pid] = pp;
×
3388
                }
3389
                auto rr = args["ret"];
100✔
3390
                if (!rr.error()) {
100✔
3391
                    ret = json_number(rr.value_unsafe());
100✔
3392
                    child = static_cast<std::int64_t>(ret);
100✔
3393
                }
50✔
3394
                auto hr = args["hhash"];
100✔
3395
                if (!hr.error() && hr.is_string() &&
200!
3396
                    acc.pid_hhash.find(pid) == acc.pid_hhash.end())
150!
3397
                    acc.pid_hhash.emplace(
150!
3398
                        pid, std::string(hr.get_string().value_unsafe()));
150!
3399
            }
50✔
3400
            // I/O bytes per process: args.ret on read/write ops.
3401
            if (ret > 0 && (name.find("read") != std::string_view::npos ||
131!
3402
                            name.find("write") != std::string_view::npos))
62✔
3403
                acc.bytes[pid] += static_cast<std::uint64_t>(ret);
76!
3404

3405
            auto cr = root["cat"];
100✔
3406
            std::string_view cat;
100✔
3407
            if (!cr.error() && cr.is_string())
100!
3408
                cat = cr.get_string().value_unsafe();
100✔
3409
            if (cat == "POSIX" || cat == "STDIO" || cat == "IO") {
100!
3410
                acc.io_ops[pid] += 1;
100!
3411
                auto dr = root["dur"];
100✔
3412
                if (!dr.error())
100✔
3413
                    acc.io_busy[pid] += json_number(dr.value_unsafe());
100!
3414
            }
50✔
3415

3416
            if (!name.empty() && is_fork_syscall(name))
100!
3417
                acc.forks.emplace_back(ts, pid, child > 0 ? child : -1);
×
3418
        }
3419
    };
2✔
3420

3421
    ViewDefinition view;
1✔
3422
    view.name = "viz_proctree";
1!
3423
    view.with_query("ts >= " + std::to_string(gmin) +
2!
3424
                    " and ts <= " + std::to_string(gmax));
1!
3425
    auto files = select_viz_target_files(
2!
3426
        index, params, static_cast<double>(gmin), static_cast<double>(gmax));
1✔
3427
    bool single_file = !params.get("file").empty();
1!
3428
    co_await scan_view_events(index, files, view, static_cast<double>(gmin),
2!
3429
                              static_cast<double>(gmax), 0, slots, on_batch,
1!
3430
                              req.cancel_token, single_file);
1✔
3431

3432
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
1!
3433
    ankerl::unordered_dense::map<std::int64_t, std::int64_t> parent_of;
1!
3434
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> spawn_of;
1!
3435
    std::vector<std::pair<std::uint64_t, std::int64_t>> inf_forks;  // (ts, pid)
1✔
3436
    for (auto& a : accs) {
3✔
3437
        for (auto& kv : a.first_ts) {
52✔
3438
            auto it = first_ts.find(kv.first);
50!
3439
            if (it == first_ts.end())
50!
3440
                first_ts.emplace(kv.first, kv.second);
50!
3441
            else if (kv.second < it->second)
×
3442
                it->second = kv.second;
3443
        }
50✔
3444
        // Explicit edges from the fork event's args.ret (child pid + spawn ts).
3445
        for (auto& [ts, ppid, child] : a.forks) {
2!
3446
            if (child > 0) {
×
3447
                parent_of[child] = ppid;
×
3448
                spawn_of[child] = ts;
×
3449
            } else {
3450
                inf_forks.emplace_back(ts, ppid);
×
3451
            }
3452
        }
3453
    }
2✔
3454
    // args.ppid metadata fills in any process not linked by a fork event.
3455
    for (auto& a : accs)
3✔
3456
        for (auto& kv : a.ppid)
2!
3457
            if (parent_of.find(kv.first) == parent_of.end())
×
3458
                parent_of.emplace(kv.first, kv.second);
2!
3459
    std::sort(inf_forks.begin(), inf_forks.end());
1!
3460

3461
    // Merge host (hhash -> hostname resolved) and I/O bytes per process.
3462
    dftracer::utils::StringViewMap<std::string> hh;
1!
3463
    ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
1!
3464
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
1!
3465
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
1!
3466
    ankerl::unordered_dense::map<std::int64_t, double> io_busy;
1!
3467
    ankerl::unordered_dense::map<std::int64_t, std::string> rank;
1!
3468
    for (auto& a : accs) {
3✔
3469
        for (auto& kv : a.hh) hh.emplace(kv.first, kv.second);
2!
3470
        for (auto& kv : a.pid_hhash) pid_hhash.emplace(kv.first, kv.second);
52!
3471
        for (auto& kv : a.bytes) bytes[kv.first] += kv.second;
40!
3472
        for (auto& kv : a.io_ops) io_ops[kv.first] += kv.second;
52!
3473
        for (auto& kv : a.io_busy) io_busy[kv.first] += kv.second;
52!
3474
        for (auto& kv : a.rank) rank.emplace(kv.first, kv.second);
2!
3475
    }
2✔
3476

3477
    std::vector<std::pair<std::uint64_t, std::int64_t>>
1✔
3478
        procs;  // (first_ts, pid)
1✔
3479
    procs.reserve(first_ts.size());
1!
3480
    for (auto& kv : first_ts) procs.emplace_back(kv.second, kv.first);
51!
3481
    std::sort(procs.begin(), procs.end());
1!
3482

3483
    // Time-inference fallback (traces without args.ret/ppid): link a process to
3484
    // the nearest preceding clone in another process.
3485
    std::vector<bool> used(inf_forks.size(), false);
1!
3486
    std::vector<ProcNode> nodes;
1✔
3487
    nodes.reserve(procs.size());
1!
3488
    const double proc_dur_us =
2✔
3489
        dftracer::utils::utilities::composites::dft::time_metric_us_scale(
1!
3490
            index.time_metric());
1!
3491
    for (auto& [fts, pid] : procs) {
51✔
3492
        std::int64_t parent = -1;
50✔
3493
        std::uint64_t spawn_ts = 0;
50✔
3494
        auto pit = parent_of.find(pid);
50!
3495
        auto sit = spawn_of.find(pid);
50!
3496
        // A fork cannot spawn a child that already existed before it: reject
3497
        // such edges (pid reuse across runs) and fall back to time inference.
3498
        bool valid = pit != parent_of.end() &&
50!
3499
                     (sit == spawn_of.end() || sit->second <= fts);
×
3500
        if (valid) {
50!
3501
            parent = pit->second;
3502
            if (sit != spawn_of.end()) spawn_ts = sit->second - base;
×
3503
        } else {
3504
            auto hi = std::upper_bound(
100!
3505
                inf_forks.begin(), inf_forks.end(),
100✔
3506
                std::make_pair(fts, std::numeric_limits<std::int64_t>::max()));
50!
3507
            for (auto it = hi; it != inf_forks.begin();) {
50!
3508
                --it;
3509
                auto idx = static_cast<std::size_t>(it - inf_forks.begin());
3510
                if (!used[idx] && it->second != pid) {
×
3511
                    used[idx] = true;
×
3512
                    parent = it->second;
3513
                    spawn_ts = it->first - base;
3514
                    break;
3515
                }
3516
            }
×
3517
        }
50✔
3518
        std::string_view host;
50✔
3519
        auto hp = pid_hhash.find(pid);
50!
3520
        if (hp != pid_hhash.end()) {
50!
3521
            auto hn = hh.find(hp->second);
50!
3522
            if (hn != hh.end()) host = hn->second;
50!
3523
        }
50✔
3524
        auto bp = bytes.find(pid);
50!
3525
        auto op = io_ops.find(pid);
50!
3526
        auto ib = io_busy.find(pid);
50!
3527
        auto rk = rank.find(pid);
50!
3528
        nodes.push_back({pid, parent, index.native_to_us(spawn_ts),
250!
3529
                         index.native_to_us(fts - base), host,
50!
3530
                         bp != bytes.end() ? bp->second : 0,
50✔
3531
                         op != io_ops.end() ? op->second : 0,
50!
3532
                         (ib != io_busy.end() ? ib->second : 0.0) * proc_dur_us,
50!
3533
                         rk != rank.end() ? &rk->second : nullptr});
50!
3534
    }
50✔
3535

3536
    auto& sb = scratch_json_builder();
1!
3537
    sb.start_object();
1✔
3538
    sb.append_key_value("nodes", nodes);
1!
3539
    sb.end_object();
1✔
3540
    co_return HttpResponse::ok(std::string(sb));
1!
3541
}
5!
3542

3543
// GET /api/v1/viz/columns: the complete set of groupable columns in the trace
3544
// (top-level scalar fields + args keys), harvested during the summary scan.
3545
static coro::CoroTask<HttpResponse> handle_viz_columns(
6!
3546
    const HttpRequest& /*req*/, const QueryParams& /*params*/,
3547
    TraceIndex& index) {
1!
3548
    // Prefer the durable set stored at index build (available immediately, no
3549
    // scan). Fall back to the summary harvest for indexes built before column
3550
    // discovery existed.
3551
    std::vector<std::string> columns;
1✔
3552
    bool ready = true;
1✔
3553
    {
3554
        ankerl::unordered_dense::set<std::string> roots;
1!
3555
        for (const auto& f : index.files())
3✔
3556
            if (!f.index_path.empty()) roots.insert(f.index_path);
2!
3557
        ankerl::unordered_dense::set<std::string> merged;
1!
3558
        for (const auto& r : roots) {
2✔
3559
            try {
3560
                utilities::indexer::IndexDatabase db(
2!
3561
                    r, rocksdb::RocksDatabase::OpenMode::ReadOnly);
1✔
3562
                for (auto& c : db.query_all_columns())
5!
3563
                    merged.emplace(std::move(c));
4!
3564
            } catch (...) {
1✔
3565
            }
×
3566
        }
1✔
3567
        columns.assign(merged.begin(), merged.end());
1!
3568
        std::sort(columns.begin(), columns.end());
1!
3569
    }
1✔
3570
    if (columns.empty()) {
1!
3571
        const VizSummary* s = co_await ensure_viz_summary(index);
1!
3572
        if (s) columns = s->columns;
×
3573
        ready = s != nullptr;  // false => client retries after summary builds
3574
    }
×
3575

3576
    auto& b = scratch_json_builder();
1!
3577
    b.start_object();
1✔
3578
    b.escape_and_append_with_quotes("columns");
1✔
3579
    b.append_colon();
1✔
3580
    b.start_array();
1✔
3581
    bool first = true;
1✔
3582
    for (const auto& c : columns) {
5✔
3583
        if (!first) b.append_comma();
4✔
3584
        first = false;
4✔
3585
        b.escape_and_append_with_quotes(c);
4✔
3586
    }
4✔
3587
    b.end_array();
1✔
3588
    b.append_comma();
1✔
3589
    b.append_key_value("ready", ready);
1✔
3590
    b.end_object();
1✔
3591
    co_return HttpResponse::ok(std::string(b));
1!
3592
}
3!
3593

3594
static coro::CoroTask<HttpResponse> handle_viz_breaks(
32!
3595
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
4!
3596
    auto ts_norm_param = params.get("ts_normalize");
12✔
3597
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
12!
3598
    std::uint64_t global_min = 0;
12✔
3599
    if (normalize) {
12✔
3600
        global_min = index.global_min_timestamp_us();
4✔
3601
        if (global_min == std::numeric_limits<std::uint64_t>::max())
4!
3602
            global_min = 0;
3603
    }
4✔
3604
    const VizSummary* s = co_await ensure_viz_summary(index);
20!
3605
    auto& b = scratch_json_builder();
4!
3606
    b.start_object();
4✔
3607
    b.escape_and_append_with_quotes("gaps");
4✔
3608
    b.append_colon();
4✔
3609
    b.start_array();
4✔
3610
    if (s) {
4!
3611
        bool first = true;
4✔
3612
        for (const auto& g : s->idle_gaps) {
9✔
3613
            if (!first) b.append_comma();
5✔
3614
            first = false;
5✔
3615
            b.start_object();
5✔
3616
            b.append_key_value("begin",
5✔
3617
                               index.native_to_us(g.first - global_min));
5!
3618
            b.append_comma();
5✔
3619
            b.append_key_value("end",
5✔
3620
                               index.native_to_us(g.second - global_min));
5!
3621
            b.end_object();
5✔
3622
        }
5✔
3623
    }
4✔
3624
    b.end_array();
4✔
3625
    b.append_comma();
4✔
3626
    b.append_key_value("multi_run", s && !s->idle_gaps.empty());
4!
3627
    b.end_object();
4✔
3628
    co_return HttpResponse::ok(std::string(b));
4!
3629
}
36!
3630

3631
void register_viz_api(Router& router, TraceIndex& index) {
16✔
3632
    auto* index_ptr = &index;
16✔
3633
    const RouteParam BEGIN{"begin", "Window start (us)", true, "0"};
16!
3634
    const RouteParam END{"end", "Window end (us)", true, "999999999"};
16!
3635
    const RouteParam SUMMARY{"summary", "LOD level (1=full detail)", true, "1"};
16!
3636

3637
    router.get(
24!
3638
        "/api/v1/viz/proctree",
8!
3639
        [index_ptr](const HttpRequest& req,
16!
3640
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3641
            co_return co_await handle_viz_proctree(req, params, *index_ptr);
5!
3642
        },
4!
3643
        RouteDoc{
48!
3644
            "Inferred process/fork hierarchy with host, rank, and I/O.",
8!
3645
            "Visualization",
8!
3646
            {{"file", "Limit to one trace file", false, ""}},
8!
3647
            R"({"nodes":[{"pid":100,"parent":-1,"host":"node01","rank":"0",)"
8!
3648
            R"("bytes":16384,"io_ops":4,"io_busy":600.0}]})"});
16✔
3649

3650
    router.get(
24!
3651
        "/api/v1/viz/counters",
8!
3652
        [index_ptr](const HttpRequest& req,
16!
3653
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3654
            co_return co_await handle_viz_counters(req, params, *index_ptr);
5!
3655
        },
4!
3656
        RouteDoc{"Read/write bytes and I/O op counts per time bucket.",
64!
3657
                 "Visualization",
8!
3658
                 {BEGIN, END, SUMMARY},
8!
3659
                 R"({"buckets":[{"ts":0,"read_bytes":4096,"write_bytes":0,)"
8!
3660
                 R"("read_ops":1,"write_ops":0}]})"});
32✔
3661

3662
    router.get(
24!
3663
        "/api/v1/viz/breaks",
8!
3664
        [index_ptr](const HttpRequest& req,
40!
3665
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4!
3666
            co_return co_await handle_viz_breaks(req, params, *index_ptr);
20!
3667
        },
16!
3668
        RouteDoc{
48!
3669
            "Globally-idle time gaps and multi-run detection.",
8!
3670
            "Visualization",
8!
3671
            {{"ts_normalize", "Normalize to global min (default on)", false,
8!
3672
              "1"}},
8!
3673
            R"({"gaps":[{"begin":50000,"end":900000}],"multi_run":true})"});
24!
3674

3675
    router.get(
24!
3676
        "/api/v1/viz/columns",
8!
3677
        [index_ptr](const HttpRequest& req,
16!
3678
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3679
            co_return co_await handle_viz_columns(req, params, *index_ptr);
5!
3680
        },
4!
3681
        RouteDoc{"Groupable columns present in the trace.",
24!
3682
                 "Visualization",
8!
3683
                 {},
8✔
3684
                 R"({"columns":["cat","name","mhost","fhash"],"ready":true})"});
8!
3685

3686
    router.get(
24!
3687
        "/api/v1/viz/events",
8!
3688
        [index_ptr](const HttpRequest& req,
72!
3689
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
8!
3690
            co_return co_await handle_viz_events(req, params, *index_ptr);
40!
3691
        },
32!
3692
        RouteDoc{"Events for rendering: time-windowed, LOD-aggregated.",
88!
3693
                 "Visualization",
8!
3694
                 {BEGIN,
32!
3695
                  END,
8!
3696
                  SUMMARY,
8!
3697
                  {"pid", "Filter by process id", false, ""},
8!
3698
                  {"cat", "Filter by category", false, ""},
8!
3699
                  {"query", "DSL predicate, e.g. dur >= 1000", false, ""}},
8!
3700
                 R"({"events":[],"metadata":{"begin":0,"end":1000000,)"
8!
3701
                 R"("count":42,"truncated":false,"ts_normalized":true}})"});
56✔
3702

3703
    router.get(
24!
3704
        "/api/v1/viz/density",
8!
3705
        [index_ptr](const HttpRequest& req,
48!
3706
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
5!
3707
            co_return co_await handle_viz_density(req, params, *index_ptr);
25!
3708
        },
20!
3709
        RouteDoc{"Sub-pixel events bucketed into density blocks.",
72!
3710
                 "Visualization",
8!
3711
                 {BEGIN,
24!
3712
                  END,
8!
3713
                  {"summary", "LOD level", true, "2"},
8!
3714
                  {"width", "Canvas width in px (sets the 1px fold cutoff)",
8!
3715
                   false, "1920"}},
8!
3716
                 R"({"events":[],"density":[{"pid":100,"tid":100,"ts":0,)"
8!
3717
                 R"("dur":24457,"count":910,"total":22044,"depth":0}]})"});
40✔
3718

3719
    router.get(
24!
3720
        "/api/v1/viz/stats",
8!
3721
        [index_ptr](const HttpRequest& req,
24!
3722
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
2!
3723
            co_return co_await handle_viz_stats(req, params, *index_ptr);
10!
3724
        },
8!
3725
        RouteDoc{"Per-name aggregation over a time range (Analyze).",
64!
3726
                 "Visualization",
8!
3727
                 {BEGIN, END, SUMMARY},
8!
3728
                 R"({"count":100,"total_dur":5000,"names":[{"name":"read",)"
8!
3729
                 R"("count":50,"total":2500,"avg":50,"min":10,"max":90}]})"});
32✔
3730

3731
    router.get(
24!
3732
        "/api/v1/viz/calltree",
8!
3733
        [index_ptr](const HttpRequest& req,
32!
3734
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
3!
3735
            co_return co_await handle_viz_calltree(req, params, *index_ptr);
15!
3736
        },
12!
3737
        RouteDoc{
72!
3738
            "Merged flamegraph tree from ts/dur containment.",
8!
3739
            "Visualization",
8!
3740
            {BEGIN,
16!
3741
             END,
8!
3742
             SUMMARY,
8!
3743
             {"group", "Set to 'pid' to keep processes separate", false, ""}},
8!
3744
            R"({"name":"root","total":5000,"self":0,"count":0,)"
8!
3745
            R"("children":[{"name":"read","total":2500,"self":2500,)"
3746
            R"("count":50}]})"});
40✔
3747

3748
    router.get(
24!
3749
        "/api/v1/viz/histogram",
8!
3750
        [index_ptr](const HttpRequest& req,
16!
3751
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3752
            co_return co_await handle_viz_histogram(req, params, *index_ptr);
5!
3753
        },
4!
3754
        RouteDoc{"Duration distribution: percentiles + log-spaced buckets.",
72!
3755
                 "Visualization",
8!
3756
                 {BEGIN,
16!
3757
                  END,
8!
3758
                  SUMMARY,
8!
3759
                  {"query", "DSL predicate to narrow to one op", false, ""}},
8!
3760
                 R"({"min":10,"max":900,"p50":150,"p99":880,"buckets":[]})"});
48!
3761

3762
    router.get(
24!
3763
        "/api/v1/viz/layers",
8!
3764
        [index_ptr](const HttpRequest& req,
24!
3765
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
2!
3766
            co_return co_await handle_viz_layers(req, params, *index_ptr);
10!
3767
        },
8!
3768
        RouteDoc{"Operation-name to category map; declared vs I/O files.",
24!
3769
                 "Visualization",
8!
3770
                 {},
8✔
3771
                 R"({"layers":{"read":"POSIX","write":"POSIX"},)"
8!
3772
                 R"("total_files":2,"io_files":2})"});
3773
}
16✔
3774

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