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

llnl / dftracer-utils / 29787659139

20 Jul 2026 11:34PM UTC coverage: 51.578% (-1.1%) from 52.66%
29787659139

Pull #99

github

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

34832 of 86278 branches covered (40.37%)

Branch coverage included in aggregate %.

1034 of 1319 new or added lines in 30 files covered. (78.39%)

5193 existing lines in 197 files now uncovered.

35349 of 49791 relevant lines covered (70.99%)

9765.54 hits per line

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

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

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

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

87
    std::uint64_t old_ts = 0;
400✔
88
    if (ts_result.is_uint64()) {
400!
89
        old_ts = ts_result.get_uint64().value_unsafe();
400✔
90
    } else if (ts_result.is_int64()) {
400!
91
        auto val = ts_result.get_int64().value_unsafe();
×
92
        old_ts = val >= 0 ? static_cast<std::uint64_t>(val) : 0;
×
UNCOV
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);
400!
101

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

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

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

126
static std::string extract_json_value(simdjson::dom::element val) {
3✔
127
    if (val.is_string()) {
3✔
128
        return std::string(val.get_string().value_unsafe());
1✔
129
    }
130
    if (val.is_int64()) {
2!
131
        return std::to_string(val.get_int64().value_unsafe());
2✔
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,
1✔
140
                               const std::string& val) {
141
    if (!dsl.empty()) dsl += " and ";
1!
142
    bool numeric =
1✔
143
        !val.empty() && std::all_of(val.begin(), val.end(),
1!
144
                                    [](char c) { return std::isdigit(c); });
1✔
145
    if (numeric) {
1!
146
        dsl += std::string(field) + " == " + val;
1!
147
    } else {
1✔
148
        dsl += std::string(field) + " == \"" + val + "\"";
×
149
    }
150
}
1✔
151

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

248
        if (!dsl.empty()) dsl += " and ";
2!
249
        bool numeric = !val.empty() && (std::isdigit(val[0]) || val[0] == '-');
4!
250
        if (numeric || query_op != "==") {
2!
251
            dsl += field_str + " " + query_op + " " + val;
2!
252
        } else {
2✔
253
            dsl += field_str + " " + query_op + " \"" + val + "\"";
×
254
        }
255
    }
2!
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,
14✔
262
                                     double end, double min_dur) {
263
    ViewDefinition view;
14✔
264
    view.name = "viz_query";
14!
265
    view.description = "Visualization query";
14!
266

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

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

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

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

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

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

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

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

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

331
    if (begin > 0 || end > 0) {
15!
332
        std::vector<const TraceIndex::FileInfo*> filtered;
15✔
333
        filtered.reserve(target_files.size());
15!
334
        for (auto* fi : target_files) {
34✔
335
            if (fi->min_timestamp_us == 0 && fi->max_timestamp_us == 0) {
19!
336
                filtered.push_back(fi);
×
337
                continue;
×
338
            }
339
            double fi_min = static_cast<double>(fi->min_timestamp_us);
19✔
340
            double fi_max = static_cast<double>(fi->max_timestamp_us);
19✔
341
            if (fi_max < begin || fi_min > end) continue;
19!
342
            filtered.push_back(fi);
18!
343
        }
344
        target_files = std::move(filtered);
15✔
345
    }
15✔
346
    return target_files;
15✔
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,
7✔
354
                                         std::uint64_t global_min,
355
                                         double meta_begin, double meta_end,
356
                                         int limit, bool truncated,
357
                                         std::uint64_t display_global_min,
358
                                         TraceIndex::TimeMetric metric) {
359
    // Even without an offset, a non-US trace still needs ts/dur scaled to us.
360
    if (global_min > 0 || metric != TraceIndex::TimeMetric::US) {
7!
361
        for (auto& event : events) {
357✔
362
            event = normalize_event_ts(event, global_min, metric);
351✔
363
        }
364
    }
6✔
365

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

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

408
// Scan events matching `view` within [begin, end] across `target_files`,
409
// invoking `on_batch(slot, events)` for each batch. `on_batch` must be
410
// thread-safe across slots: it is called concurrently from worker coroutines,
411
// but each worker owns its own slot so per-slot state needs no lock. Returns
412
// true if scanning stopped early because `limit` (0 = unlimited) was reached.
413
static coro::CoroTask<bool> scan_view_events(
85!
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,
166!
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!
UNCOV
447
                file_info->num_checkpoints == 0)
×
UNCOV
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!
UNCOV
468
                    co_return;
×
469
                if (!co_await ch.send(ScanWorkItem{
256!
470
                        file_info, c.start_byte, c.end_byte, c.checkpoint_idx}))
192✔
UNCOV
471
                    co_return;
×
472
            }
16✔
473
        }
50✔
474
        co_return;
17✔
475
    });
149✔
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,
302!
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!
UNCOV
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!
UNCOV
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
        });
320✔
509
    }
34✔
510

511
    co_await scope.join();
51!
512
    co_return produced.load(std::memory_order_relaxed) >= cap;
17!
513
}
51!
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,
80!
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!
UNCOV
537
        !utilities::common::query::try_parse(query).has_value()) {
×
UNCOV
538
        co_return HttpResponse::bad_request("Invalid query: " +
×
UNCOV
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!
UNCOV
550
            global_min = 0;  // No valid bounds, skip normalization
×
UNCOV
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,
25✔
597
                               const std::vector<std::string_view>& events) {
598
        auto& out = partials[w];
4✔
599
        out.reserve(out.size() + events.size());
4✔
600
        for (auto ev : events) out.emplace_back(ev);
355✔
601
    };
4✔
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!
UNCOV
612
        collected_events.resize(static_cast<std::size_t>(limit));
×
UNCOV
613
        truncated = true;
×
UNCOV
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
}
297✔
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);
×
UNCOV
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) {
2✔
652
    if (g == "cat") return GroupBy::Cat;
2!
653
    if (g == "pid") return GroupBy::Pid;
2!
654
    if (g == "fhash" || g == "file") return GroupBy::Fhash;
2!
655
    return GroupBy::Name;
2✔
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;
×
UNCOV
694
        }
×
UNCOV
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();
×
UNCOV
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;
×
UNCOV
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;
×
UNCOV
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 {
67✔
728
        return pid == o.pid && tid == o.tid && col == o.col && group == o.group;
67!
729
    }
730
};
731

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

UNCOV
747
struct DensityAgg {
×
UNCOV
748
    std::uint32_t count = 0;
×
UNCOV
749
    double total = 0;
×
UNCOV
750
    double max_dur = -1;
×
UNCOV
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) {
60✔
755
        count += o.count;
60✔
756
        total += o.total;
60✔
757
        if (o.max_dur > max_dur) {
60!
758
            max_dur = o.max_dur;
×
759
            name = o.name;
×
UNCOV
760
        }
×
761
    }
60✔
762
};
763

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

767
static double json_number(simdjson::dom::element el) {
3,060✔
768
    if (el.is_uint64())
3,060!
769
        return static_cast<double>(el.get_uint64().value_unsafe());
3,060✔
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,
140✔
781
                                       std::string_view col) {
782
    auto scalar = [](simdjson::dom::element el) -> std::string {
210✔
783
        if (el.is_string()) return std::string(el.get_string().value_unsafe());
70!
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,
280✔
794
                    simdjson::dom::element& out) -> bool {
795
        simdjson::dom::element cur = root;
140✔
796
        std::size_t start = 0;
140✔
797
        while (start <= path.size()) {
140!
798
            auto dot = path.find('.', start);
140✔
799
            auto key = path.substr(start, dot == std::string_view::npos
140!
800
                                              ? path.size() - start
140✔
NEW
801
                                              : dot - start);
×
802
            if (!key.empty()) {
140!
803
                auto next = cur[key];
140✔
804
                if (next.error()) return false;
140✔
805
                cur = next.value_unsafe();
70✔
806
            }
70✔
807
            if (dot == std::string_view::npos) break;
70!
NEW
808
            start = dot + 1;
×
809
        }
810
        out = cur;
70✔
811
        return true;
70✔
812
    };
140✔
813
    simdjson::dom::element v;
140✔
814
    if (walk(col, v)) return scalar(v);
140✔
815
    if (col.find('.') == std::string_view::npos) {
70!
816
        auto nested = root["args"][col];
70✔
817
        if (!nested.error()) return scalar(nested.value_unsafe());
70!
818
    }
70✔
819
    return "";
70✔
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);
×
NEW
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,
190✔
837
                         DensityMap& dens, double* out_dur = nullptr,
838
                         std::string_view group_col = {}) {
839
    thread_local simdjson::dom::parser parser;
190✔
840
    thread_local std::string buf;
190✔
841
    buf.assign(event);
190✔
842
    auto res = parser.parse(buf);
190✔
843
    if (res.error()) return false;
190!
844
    auto root = res.value_unsafe();
190✔
845
    if (!root.is_object()) return false;
190!
846

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

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

868
    std::int64_t col = static_cast<std::int64_t>((ts - begin) / threshold);
190✔
869
    std::string group;
190✔
870
    if (!group_col.empty()) group = extract_group_value(root, group_col);
190!
871
    DensityKey k{pid, tid, col, std::move(group)};
190✔
872
    auto it = dens.find(k);
190!
873
    if (it == dens.end()) it = dens.emplace(std::move(k), DensityAgg{}).first;
190!
874
    auto& a = it->second;
190✔
875
    a.count += 1;
190✔
876
    a.total += dur;
190✔
877
    if (dur > a.max_dur) {
190!
878
        a.max_dur = dur;
190✔
879
        a.name.assign(name);
190!
880
    }
190✔
881
    return true;
190✔
882
}
190✔
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;
×
UNCOV
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;
×
UNCOV
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(
3✔
935
    const std::vector<std::string>& big, DensityMap& dens, double begin,
936
    double threshold) {
937
    std::vector<std::uint32_t> depth(big.size(), 0);
3✔
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 {
280✔
953
            std::uint64_t h = 1469598103934665603ULL;
280✔
954
            h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
280✔
955
            h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
280✔
956
            return h;
280✔
957
        }
958
    };
959
    ankerl::unordered_dense::map<LaneKey, std::vector<Item>, LaneHash> lanes;
3!
960

961
    for (std::size_t i = 0; i < big.size(); ++i) {
3!
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});
×
UNCOV
968
    }
×
969
    if (threshold > 0) {
3!
970
        for (auto& kv : dens) {
133✔
971
            double lo = begin + static_cast<double>(kv.first.col) * threshold;
130✔
972
            lanes[LaneKey{kv.first.pid, kv.first.tid}].push_back(
130!
973
                Item{lo, lo + threshold, -1, &kv.second});
130✔
974
        }
975
    }
3✔
976

977
    for (auto& kv : lanes) {
133✔
978
        auto& items = kv.second;
130✔
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) {
130!
982
            if (a.ts != b.ts) return a.ts < b.ts;
×
983
            return (a.block == nullptr) && (b.block != nullptr);
×
UNCOV
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
130✔
989
        for (auto& it : items) {
260✔
990
            if (it.block) {
130!
991
                std::size_t r = 0;
130✔
992
                while (r < row_end.size() && row_end[r] > it.end) ++r;
130!
993
                it.block->depth = static_cast<std::uint32_t>(r);
130✔
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)] =
×
NEW
1002
                    static_cast<std::uint32_t>(r);
×
1003
            }
1004
        }
1005
    }
130✔
1006
    return depth;
3✔
1007
}
3!
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];
×
UNCOV
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());
×
UNCOV
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;
×
UNCOV
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 {
8✔
1084
        return pid == o.pid && tid == o.tid;
8!
1085
    }
1086
};
1087

1088
struct PidTidHash {
1089
    std::uint64_t operator()(const PidTid& k) const noexcept {
34✔
1090
        std::uint64_t h = 1469598103934665603ULL;
34✔
1091
        h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
34✔
1092
        h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
34✔
1093
        return h;
34✔
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)
16✔
1105
        : pid(p), tid(t), count(nb), total(nb), maxd(nb), nameid(nb) {
16!
1106
        for (auto& x : nameid)
524,296✔
1107
            x.store(std::numeric_limits<std::uint32_t>::max(),
524,288✔
1108
                    std::memory_order_relaxed);
1109
    }
16✔
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) {
16✔
1165
        PidTid key{pid, tid};
16✔
1166
        auto& cache = lane_cache[w];
16✔
1167
        auto ci = cache.find(key);
16✔
1168
        if (ci != cache.end()) return ci->second;
16✔
1169
        std::lock_guard<std::mutex> lk(lane_mtx);
8✔
1170
        auto it = lane_of.find(key);
8!
1171
        SumLane* lane;
1172
        if (it != lane_of.end()) {
8!
1173
            lane = &lanes[it->second];
×
UNCOV
1174
        } else {
×
1175
            if (lanes.size() * nb >= VizSummary::MAX_CELLS)
8!
UNCOV
1176
                return nullptr;  // budget reached; drop further lanes
×
1177
            lane_of.emplace(key, lanes.size());
8!
1178
            lanes.emplace_back(nb, pid, tid);
8!
1179
            lane = &lanes.back();
8✔
1180
        }
1181
        cache.emplace(key, lane);
8!
1182
        return lane;
8✔
1183
    }
16✔
1184

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

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

1212
static void atomic_add_double(std::atomic<double>& a, double v) {
136✔
1213
    double cur = a.load(std::memory_order_relaxed);
136✔
1214
    while (!a.compare_exchange_weak(cur, cur + v, std::memory_order_relaxed)) {
136!
1215
    }
1216
}
136✔
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) {
870✔
1220
    auto it = m.find(key);
870✔
1221
    if (it == m.end()) {
870✔
1222
        it = m.emplace(std::string(key), NameStat{}).first;
86!
1223
        it->second.min = dur;
86✔
1224
        it->second.max = dur;
86✔
1225
    } else {
86✔
1226
        it->second.min = std::min(it->second.min, dur);
784✔
1227
        it->second.max = std::max(it->second.max, dur);
784✔
1228
    }
1229
    it->second.count += 1;
870✔
1230
    it->second.total += dur;
870✔
1231
}
870✔
1232

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

1242
    std::string_view name0;
291✔
1243
    {
1244
        auto nr = root["name"];
291✔
1245
        if (!nr.error() && nr.is_string())
291!
1246
            name0 = nr.get_string().value_unsafe();
291✔
1247
    }
1248
    if (name0 == "FH" || name0 == "SH") {
291!
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()));
×
UNCOV
1257
            }
×
UNCOV
1258
        }
×
1259
        return;
×
1260
    }
1261

1262
    auto dr = root["dur"];
291✔
1263
    if (dr.error()) return;
291✔
1264
    double dur = json_number(dr.value_unsafe());
290✔
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()) {
290✔
1269
        b.col_seen[w].emplace(name0);
18✔
1270
        static const ankerl::unordered_dense::set<std::string_view> SKIP = {
18!
1271
            "pid", "tid", "ts", "dur", "ph", "id", "args"};
1272
        auto obj = root.get_object();
18✔
1273
        if (!obj.error())
18!
1274
            for (auto kv : obj.value_unsafe())
179✔
1275
                if (SKIP.find(kv.key) == SKIP.end()) b.cols[w].emplace(kv.key);
179✔
1276
        auto ar = root["args"];
18✔
1277
        if (!ar.error() && ar.is_object())
18!
1278
            for (auto kv : ar.get_object().value_unsafe())
57✔
1279
                b.cols[w].emplace(kv.key);
57✔
1280
    }
18✔
1281

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

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

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

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

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

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

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

1394
}  // namespace
1395

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1887
    GroupBy group = parse_group_by(params.get("group"));
6!
1888

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

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

UNCOV
1914
    int limit = params.get_int("limit", 0);
×
UNCOV
1915
    if (limit < 0) limit = 0;
×
1916

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

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

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

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

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

NEW
1959
    scale_stat_durations(rows, total_dur, index.time_metric());
×
UNCOV
1960
    co_return HttpResponse::ok(
×
UNCOV
1961
        serialize_stats_body(total_count, total_dur,
×
UNCOV
1962
                             original_end - original_begin, truncated, rows));
×
1963
}
54!
1964

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3017
    co_await append_app_spans(big, index, begin, end, params);
12!
3018

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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