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

llnl / dftracer-utils / 29187058583

12 Jul 2026 09:11AM UTC coverage: 51.324% (-1.4%) from 52.754%
29187058583

Pull #96

github

web-flow
Merge 7e90b76dd into 056c79287
Pull Request #96: fix: consolidate stale-index rebuilds across consumers and fix multi-run breaks

33807 of 84432 branches covered (40.04%)

Branch coverage included in aggregate %.

145 of 152 new or added lines in 16 files covered. (95.39%)

5186 existing lines in 197 files now uncovered.

34558 of 48770 relevant lines covered (70.86%)

10797.15 hits per line

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

58.2
/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 <simdjson.h>
21

22
#include <algorithm>
23
#include <atomic>
24
#include <cstddef>
25
#include <cstdint>
26
#include <cstdlib>
27
#include <deque>
28
#include <functional>
29
#include <limits>
30
#include <memory>
31
#include <mutex>
32
#include <numeric>
33
#include <set>
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",
273!
46
                                                                    "SH"};
273!
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
/// Normalize the "ts" field in a Chrome Trace Event JSON string by
53
/// subtracting an offset.  Returns the modified JSON.  Falls back to
54
/// the original string on parse failure.
55
static std::string normalize_event_ts(const std::string& event_json,
150✔
56
                                      std::uint64_t offset) {
57
    thread_local simdjson::dom::parser tl_parser;
150✔
58
    auto result = tl_parser.parse(event_json);
150✔
59
    if (result.error()) return event_json;
150!
60

61
    auto root = result.value_unsafe();
150✔
62
    if (!root.is_object()) return event_json;
150!
63

64
    auto ts_result = root["ts"];
150✔
65
    if (ts_result.error()) return event_json;
150!
66

67
    std::uint64_t old_ts = 0;
150✔
68
    if (ts_result.is_uint64()) {
150!
69
        old_ts = ts_result.get_uint64().value_unsafe();
150✔
70
    } else if (ts_result.is_int64()) {
150!
71
        auto val = ts_result.get_int64().value_unsafe();
×
72
        old_ts = val >= 0 ? static_cast<std::uint64_t>(val) : 0;
×
UNCOV
73
    } else {
×
74
        return event_json;
×
75
    }
76

77
    std::uint64_t new_ts = old_ts >= offset ? old_ts - offset : 0;
150!
78

79
    // simdjson DOM is read-only, so we need to rebuild the JSON with the new ts
80
    // Find "ts": and replace the value
81
    std::string modified = event_json;
150✔
82
    auto pos = modified.find("\"ts\":");
150✔
83
    if (pos == std::string::npos) return event_json;
150!
84

85
    pos += 5;  // Skip past "ts":
150✔
86
    while (pos < modified.size() && std::isspace(modified[pos])) ++pos;
150!
87

88
    auto end_pos = pos;
150✔
89
    while (end_pos < modified.size() &&
1,800!
90
           (std::isdigit(modified[end_pos]) || modified[end_pos] == '-')) {
1,650!
91
        ++end_pos;
1,500✔
92
    }
93

94
    modified.replace(pos, end_pos - pos, std::to_string(new_ts));
150!
95
    return modified;
150✔
96
}
150✔
97

98
/// Compute the minimum event duration threshold for a given summary level.
99
/// Level 1 = full detail, higher levels filter shorter events.
100
static double duration_threshold(double begin, double end, unsigned level,
8✔
101
                                 unsigned viewport_width = 1920) {
102
    if (level <= 1) return 0.0;
8✔
103
    double range = end - begin;
1✔
104
    return range /
2✔
105
           (static_cast<double>(viewport_width) * static_cast<double>(level));
1✔
106
}
8✔
107

108
static std::string extract_json_value(simdjson::dom::element val) {
3✔
109
    if (val.is_string()) {
3✔
110
        return std::string(val.get_string().value_unsafe());
1✔
111
    }
112
    if (val.is_int64()) {
2!
113
        return std::to_string(val.get_int64().value_unsafe());
2✔
114
    }
115
    if (val.is_uint64()) {
×
116
        return std::to_string(val.get_uint64().value_unsafe());
×
117
    }
118
    return {};
×
119
}
3✔
120

121
static void append_lane_clause(std::string& dsl, const char* field,
1✔
122
                               const std::string& val) {
123
    if (!dsl.empty()) dsl += " and ";
1!
124
    bool numeric =
1✔
125
        !val.empty() && std::all_of(val.begin(), val.end(),
1!
126
                                    [](char c) { return std::isdigit(c); });
1✔
127
    if (numeric) {
1!
128
        dsl += std::string(field) + " == " + val;
1!
129
    } else {
1✔
130
        dsl += std::string(field) + " == \"" + val + "\"";
×
131
    }
132
}
1✔
133

134
static void apply_lanes(std::string& dsl, std::string_view lanes_str) {
9✔
135
    if (lanes_str.empty()) return;
9✔
136

137
    thread_local simdjson::dom::parser tl_parser;
1!
138
    auto result = tl_parser.parse(lanes_str.data(), lanes_str.size());
1✔
139
    if (result.error()) return;
1!
140

141
    auto root = result.value_unsafe();
1✔
142

143
    if (root.is_array()) {
1!
144
        auto arr = root.get_array().value_unsafe();
1✔
145
        for (auto item : arr) {
2✔
146
            if (!item.is_object()) continue;
1!
147
            auto obj = item.get_object().value_unsafe();
1✔
148

149
            auto field_result = obj["field"];
1✔
150
            if (field_result.error()) field_result = obj["fields"];
1!
151
            auto value_result = obj["value"];
1✔
152
            if (field_result.error() || value_result.error()) continue;
1!
153

154
            if (!field_result.value_unsafe().is_string()) continue;
1!
155
            const char* field =
1✔
156
                field_result.value_unsafe().get_c_str().value_unsafe();
1✔
157
            auto val = extract_json_value(value_result.value_unsafe());
1✔
158
            if (!val.empty()) append_lane_clause(dsl, field, val);
1!
159
        }
1✔
160
    } else if (root.is_object()) {
1!
161
        auto obj = root.get_object().value_unsafe();
×
162

163
        auto field_result = obj["field"];
×
164
        if (field_result.error()) field_result = obj["fields"];
×
165
        auto value_result = obj["value"];
×
166

167
        if (!field_result.error() && !value_result.error()) {
×
168
            if (field_result.value_unsafe().is_string()) {
×
UNCOV
169
                const char* field =
×
170
                    field_result.value_unsafe().get_c_str().value_unsafe();
×
171
                auto val = extract_json_value(value_result.value_unsafe());
×
172
                if (!val.empty()) append_lane_clause(dsl, field, val);
×
173
            }
×
UNCOV
174
        }
×
UNCOV
175
    }
×
176
}
9✔
177

178
static void apply_filters(std::string& dsl, std::string_view filters_str) {
9✔
179
    if (filters_str.empty()) return;
9✔
180

181
    thread_local simdjson::dom::parser tl_parser;
2✔
182
    auto result = tl_parser.parse(filters_str.data(), filters_str.size());
2✔
183
    if (result.error()) return;
2!
184

185
    auto root = result.value_unsafe();
2✔
186
    if (!root.is_array()) return;
2!
187

188
    auto arr = root.get_array().value_unsafe();
2✔
189
    for (auto item : arr) {
4✔
190
        if (!item.is_object()) continue;
2!
191
        auto obj = item.get_object().value_unsafe();
2✔
192

193
        auto field_result = obj["field"];
2✔
194
        auto op_result = obj["op"];
2✔
195
        auto value_result = obj["value"];
2✔
196
        if (field_result.error() || op_result.error() || value_result.error())
2!
197
            continue;
×
198

199
        if (!field_result.value_unsafe().is_string() ||
2!
200
            !op_result.value_unsafe().is_string())
2✔
201
            continue;
×
202

203
        const char* field =
2✔
204
            field_result.value_unsafe().get_c_str().value_unsafe();
2✔
205
        const char* op = op_result.value_unsafe().get_c_str().value_unsafe();
2✔
206

207
        std::string val = extract_json_value(value_result.value_unsafe());
2✔
208
        if (val.empty()) continue;
2!
209

210
        std::string op_str(op);
2!
211
        std::string field_str(field);
2!
212
        if (field_str == "begin") field_str = "ts";
2!
213
        if (field_str == "end") field_str = "ts";
2!
214
        if (field_str == "duration") field_str = "dur";
2!
215

216
        std::string query_op;
2✔
217
        if (op_str == "=")
2✔
218
            query_op = "==";
1!
219
        else if (op_str == ">=")
1!
220
            query_op = ">=";
1!
221
        else if (op_str == "<=")
×
222
            query_op = "<=";
×
223
        else if (op_str == ">")
×
224
            query_op = ">";
×
225
        else if (op_str == "<")
×
226
            query_op = "<";
×
227
        else
228
            continue;
×
229

230
        if (!dsl.empty()) dsl += " and ";
2!
231
        bool numeric = !val.empty() && (std::isdigit(val[0]) || val[0] == '-');
4!
232
        if (numeric || query_op != "==") {
2!
233
            dsl += field_str + " " + query_op + " " + val;
2!
234
        } else {
2✔
235
            dsl += field_str + " " + query_op + " \"" + val + "\"";
×
236
        }
237
    }
2!
238
}
9✔
239

240
// --- GET /api/v1/viz/events ---
241
// Build the query view (time range + lane/filter/pid/tid/cat predicates) for a
242
// viz request from the parsed parameters.
243
static ViewDefinition build_viz_view(const QueryParams& params, double begin,
9✔
244
                                     double end, double min_dur) {
245
    ViewDefinition view;
9✔
246
    view.name = "viz_query";
9!
247
    view.description = "Visualization query";
9!
248

249
    // Reserve once and append in place: numbers go through to_chars into a
250
    // stack buffer (no per-number heap allocation, unlike std::to_string), and
251
    // literals/string_views are appended directly (no temporary
252
    // concatenations).
253
    std::string dsl;
9✔
254
    dsl.reserve(128);
9!
255
    char numbuf[20];  // max digits of a uint64_t
256
    auto append_u64 = [&](std::uint64_t v) {
27✔
257
        dsl.append(numbuf, to_chars_u64(numbuf, numbuf + sizeof(numbuf), v));
18✔
258
    };
18✔
259

260
    dsl += "ts >= ";
9!
261
    append_u64(static_cast<std::uint64_t>(begin));
9!
262
    dsl += " and ts <= ";
9!
263
    append_u64(static_cast<std::uint64_t>(end));
9!
264
    if (min_dur > 0) {
9!
265
        dsl += " and dur >= ";
×
266
        append_u64(static_cast<std::uint64_t>(min_dur));
×
UNCOV
267
    }
×
268

269
    apply_lanes(dsl, params.get("lanes"));
9!
270
    apply_filters(dsl, params.get("filters"));
9!
271

272
    auto pid = params.get("pid");
9!
273
    if (!pid.empty()) {
9!
274
        dsl += " and pid == ";
×
275
        dsl += pid;
×
UNCOV
276
    }
×
277

278
    auto tid = params.get("tid");
9!
279
    if (!tid.empty()) {
9!
280
        dsl += " and tid == ";
×
281
        dsl += tid;
×
UNCOV
282
    }
×
283

284
    auto cat = params.get("cat");
9!
285
    if (!cat.empty()) {
9!
286
        dsl += " and cat == \"";
×
287
        dsl += cat;
×
288
        dsl += '"';
×
UNCOV
289
    }
×
290

291
    // Raw DSL from the front-end query box, already validated by the caller.
292
    auto query = params.get("query");
9!
293
    if (!query.empty()) {
9!
294
        dsl += " and (";
×
295
        dsl += query;
×
296
        dsl += ')';
×
UNCOV
297
    }
×
298

299
    view.with_query(dsl);
9!
300
    return view;
9✔
301
}
9!
302

303
// Select the files to scan: the explicit ?file= or all indexed files, then drop
304
// files whose cached time bounds don't overlap [begin, end]. Pure/synchronous.
305
static std::vector<const TraceIndex::FileInfo*> select_viz_target_files(
10✔
306
    TraceIndex& index, const QueryParams& params, double begin, double end) {
307
    auto target_files = collect_candidate_files(index, params);
10✔
308

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

313
    if (begin > 0 || end > 0) {
10!
314
        std::vector<const TraceIndex::FileInfo*> filtered;
10✔
315
        filtered.reserve(target_files.size());
10!
316
        for (auto* fi : target_files) {
20✔
317
            if (fi->min_timestamp_us == 0 && fi->max_timestamp_us == 0) {
10!
318
                filtered.push_back(fi);
×
319
                continue;
×
320
            }
321
            double fi_min = static_cast<double>(fi->min_timestamp_us);
10✔
322
            double fi_max = static_cast<double>(fi->max_timestamp_us);
10✔
323
            if (fi_max < begin || fi_min > end) continue;
10!
324
            filtered.push_back(fi);
9!
325
        }
326
        target_files = std::move(filtered);
10✔
327
    }
10✔
328
    return target_files;
10✔
329
}
10!
330

331
// Normalize event timestamps (when global_min > 0) and serialize the collected
332
// events plus metadata into the Chrome Trace Event Format body. `global_min` is
333
// the de-normalization base (already 0 unless normalization is active);
334
// `display_global_min` is the value reported in the metadata. Pure/synchronous.
335
static std::string build_viz_events_body(std::vector<std::string>& events,
6✔
336
                                         std::uint64_t global_min,
337
                                         double meta_begin, double meta_end,
338
                                         int limit, bool truncated,
339
                                         std::uint64_t display_global_min) {
340
    if (global_min > 0) {
6✔
341
        for (auto& event : events) {
155✔
342
            event = normalize_event_ts(event, global_min);
150✔
343
        }
344
    }
5✔
345

346
    auto& b = scratch_json_builder();
6✔
347
    b.start_object();
6✔
348
    b.escape_and_append_with_quotes("events");
6✔
349
    b.append_colon();
6✔
350
    b.start_array();
6✔
351
    for (std::size_t i = 0; i < events.size(); ++i) {
156✔
352
        if (i > 0) b.append_comma();
150✔
353
        b.append_raw(events[i]);  // Already JSON
150✔
354
    }
150✔
355
    b.end_array();
6✔
356
    b.append_comma();
6✔
357
    b.escape_and_append_with_quotes("metadata");
6✔
358
    b.append_colon();
6✔
359
    b.start_object();
6✔
360
    b.append_key_value("begin", meta_begin);
6✔
361
    b.append_comma();
6✔
362
    b.append_key_value("end", meta_end);
6✔
363
    b.append_comma();
6✔
364
    b.append_key_value("count", events.size());
6✔
365
    b.append_comma();
6✔
366
    b.append_key_value("limit", limit);
6✔
367
    b.append_comma();
6✔
368
    b.append_key_value("truncated", truncated);
6✔
369
    b.append_comma();
6✔
370
    b.append_key_value("ts_normalized", global_min > 0);
6✔
371
    b.append_comma();
6✔
372
    b.append_key_value("global_min_timestamp_us", display_global_min);
6✔
373
    b.end_object();
6✔
374
    b.end_object();
6✔
375
    return std::string(b);
6✔
376
}
377

378
// One checkpoint byte-range to read from a specific file. Work is distributed
379
// at this granularity (not per file) so a single large file still fans out
380
// across every worker.
381
struct ScanWorkItem {
382
    const TraceIndex::FileInfo* file;
383
    std::uint64_t start_byte;
384
    std::uint64_t end_byte;
385
    std::size_t checkpoint_idx;
386
};
387

388
// Scan events matching `view` within [begin, end] across `target_files`,
389
// invoking `on_batch(slot, events)` for each batch. `on_batch` must be
390
// thread-safe across slots: it is called concurrently from worker coroutines,
391
// but each worker owns its own slot so per-slot state needs no lock. Returns
392
// true if scanning stopped early because `limit` (0 = unlimited) was reached.
393
static coro::CoroTask<bool> scan_view_events(
70!
394
    TraceIndex& index, std::vector<const TraceIndex::FileInfo*>& target_files,
395
    ViewDefinition& view, double begin, double end, int limit,
396
    std::size_t num_slots,
397
    const std::function<void(std::size_t,
398
                             const std::vector<std::string_view>&)>& on_batch,
399
    bool scan_all_chunks = false) {
14!
400
    const std::int64_t cap =
28✔
401
        limit > 0 ? limit : std::numeric_limits<std::int64_t>::max();
14✔
402
    std::atomic<std::int64_t> produced{0};
14✔
403

404
    std::size_t num_workers =
28✔
405
        std::max<std::size_t>(1, std::min(num_slots, index.max_concurrent()));
14!
406
    auto* executor = Executor::current();
14✔
407
    auto chan = coro::make_channel<ScanWorkItem>(num_workers * 4);
14!
408
    auto* target_files_ptr = &target_files;
14✔
409
    auto* view_ptr = &view;
14✔
410
    auto* index_ptr = &index;
14✔
411
    auto* produced_ptr = &produced;
14✔
412
    auto* on_batch_ptr = &on_batch;
14✔
413

414
    CoroScope scope(executor);
14!
415

416
    // Producer: prune each file to its matching checkpoints (bloom + time) and
417
    // feed those byte-ranges as work items. Building is cheap next to the
418
    // reads.
419
    scope.spawn([ch = chan->producer(), target_files_ptr, view_ptr, index_ptr,
128!
420
                 produced_ptr, cap, begin, end,
56✔
421
                 scan_all_chunks](CoroScope&) mutable -> coro::CoroTask<void> {
28!
422
        auto guard = ch.guard();
14!
423
        for (auto* file_info : *target_files_ptr) {
49✔
424
            if (produced_ptr->load(std::memory_order_relaxed) >= cap) co_return;
27!
425
            if (file_info->uncompressed_size == 0 &&
13!
UNCOV
426
                file_info->num_checkpoints == 0)
×
UNCOV
427
                continue;
×
428

429
            ViewBuilderInput builder_input;
13✔
430
            builder_input.with_view(*view_ptr)
26!
431
                .with_file_path(file_info->path)
13!
432
                .with_index_path(
13!
433
                    file_info->has_bloom_data ? file_info->index_path : "")
13!
434
                .with_uncompressed_size(file_info->uncompressed_size)
13!
435
                .with_num_checkpoints(file_info->num_checkpoints)
13!
436
                .with_bloom_cache(&index_ptr->bloom_cache())
13!
437
                .with_time_range(begin, end)
13!
438
                .with_scan_all_chunks(scan_all_chunks);
13!
439

440
            ViewBuilderUtility builder;
13!
441
            auto build_output = co_await builder.process(builder_input);
26!
442
            if (!build_output || !build_output->file_may_match) continue;
13!
443

444
            for (const auto& c : build_output->candidates) {
66!
445
                if (produced_ptr->load(std::memory_order_relaxed) >= cap)
33!
UNCOV
446
                    co_return;
×
447
                if (!co_await ch.send(ScanWorkItem{
176!
448
                        file_info, c.start_byte, c.end_byte, c.checkpoint_idx}))
132✔
UNCOV
449
                    co_return;
×
450
            }
11✔
451
        }
35✔
452
        co_return;
14✔
453
    });
106✔
454

455
    for (std::size_t w = 0; w < num_workers; ++w) {
42✔
456
        // Worker `w` writes only to slot `w`; a coroutine never runs
457
        // concurrently with itself, so per-slot output needs no lock.
458
        scope.spawn([w, chan, view_ptr, produced_ptr, on_batch_ptr,
256!
459
                     cap](CoroScope&) -> coro::CoroTask<void> {
56!
460
            while (auto item = co_await chan->receive()) {
178!
461
                if (produced_ptr->load(std::memory_order_relaxed) >= cap)
11!
UNCOV
462
                    co_return;
×
463

464
                ViewReaderInput reader_input;
11✔
465
                reader_input.with_file_path(item->file->path)
11!
466
                    .with_index_path(item->file->index_path)
11!
467
                    .with_byte_range(item->start_byte, item->end_byte)
11!
468
                    .with_checkpoint_idx(item->checkpoint_idx)
11!
469
                    .with_view(*view_ptr);
11!
470

471
                ViewReaderUtility reader;
11!
472
                auto gen = reader.process(reader_input);
11!
473
                while (auto batch = co_await gen.next()) {
88!
474
                    if (batch->events.empty()) continue;
11!
475
                    if (produced_ptr->load(std::memory_order_relaxed) >= cap)
11!
UNCOV
476
                        break;
×
477
                    (*on_batch_ptr)(w, batch->events);
11!
478
                    produced_ptr->fetch_add(
22✔
479
                        static_cast<std::int64_t>(batch->events.size()),
11✔
480
                        std::memory_order_relaxed);
481
                }
22✔
482
            }
83!
483
            co_return;
28✔
484
        });
236✔
485
    }
28✔
486

487
    co_await scope.join();
42!
488
    co_return produced.load(std::memory_order_relaxed) >= cap;
14!
489
}
42!
490

491
static coro::CoroTask<void> append_app_spans(std::vector<std::string>& out,
492
                                             TraceIndex& index, double begin,
493
                                             double end,
494
                                             const QueryParams& params);
495

496
static coro::CoroTask<HttpResponse> handle_viz_events(
69!
497
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
7!
498
    // Required: begin, end, summary
499
    if (!params.has("begin") || !params.has("end") || !params.has("summary")) {
17!
500
        co_return HttpResponse::bad_request(
30!
501
            "Missing required parameters: begin, end, summary");
23!
502
    }
503

504
    double begin = params.get_double("begin", 0);
18✔
505
    double end = params.get_double("end", 0);
18✔
506
    int summary = params.get_int("summary", 1);
18✔
507
    if (summary < 1) summary = 1;
18!
508

509
    // Validate the optional raw DSL query before it is spliced into the view.
510
    auto query = params.get("query");
18✔
511
    if (!query.empty() &&
18!
UNCOV
512
        !utilities::common::query::try_parse(query).has_value()) {
×
UNCOV
513
        co_return HttpResponse::bad_request("Invalid query: " +
×
UNCOV
514
                                            std::string(query));
×
515
    }
516

517
    // Timestamp normalization: default ON, opt-out with ?ts_normalize=0
518
    auto ts_norm_param = params.get("ts_normalize");
18✔
519
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
18!
520

521
    std::uint64_t global_min = 0;
18✔
522
    if (normalize) {
18✔
523
        global_min = index.global_min_timestamp_us();
5✔
524
        if (global_min == std::numeric_limits<std::uint64_t>::max()) {
5!
UNCOV
525
            global_min = 0;  // No valid bounds, skip normalization
×
UNCOV
526
        }
×
527
    }
5✔
528

529
    // When normalization is active the user sends normalized
530
    // begin/end values (relative to global_min).  De-normalize them
531
    // so the predicate filters against absolute timestamps.
532
    double original_begin = begin;
18✔
533
    double original_end = end;
18✔
534
    if (normalize && global_min > 0) {
18!
535
        begin += static_cast<double>(global_min);
5✔
536
        end += static_cast<double>(global_min);
5✔
537
    }
5✔
538

539
    double min_dur =
18✔
540
        duration_threshold(begin, end, static_cast<unsigned>(summary));
18!
541

542
    // Overlap, bounded: look back at most `lookback` (the longest event's
543
    // duration, supplied by the client) so events that started before the
544
    // window but extend into it are included, without scanning to time 0.
545
    double lookback = params.get_double("lookback", 0);
18✔
546
    if (lookback < 0) lookback = 0;
18!
547
    double scan_begin = begin - lookback;
18✔
548
    if (scan_begin < 0) scan_begin = 0;
18!
549

550
    ViewDefinition view = build_viz_view(params, scan_begin, end, min_dur);
18!
551

552
    // Optional limit: 0 (default) means no limit.
553
    int limit = params.get_int("limit", 0);
18✔
554
    if (limit < 0) limit = 0;
18!
555

556
    std::vector<const TraceIndex::FileInfo*> target_files =
18✔
557
        select_viz_target_files(index, params, scan_begin, end);
18!
558

559
    // Each worker slot collects into its own vector (no shared state, no lock);
560
    // the slots are concatenated single-threaded after the scan.
561
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
18!
562
    std::vector<std::vector<std::string>> partials(slots);
18!
563
    auto collect = [&partials](std::size_t w,
21✔
564
                               const std::vector<std::string_view>& events) {
565
        auto& out = partials[w];
3✔
566
        out.reserve(out.size() + events.size());
3✔
567
        for (auto ev : events) out.emplace_back(ev);
153✔
568
    };
3✔
569

570
    bool truncated = co_await scan_view_events(
30!
571
        index, target_files, view, scan_begin, end, limit, slots, collect,
18!
572
        !params.get("file").empty());
18!
573

574
    std::vector<std::string> collected_events;
18✔
575
    for (auto& p : partials) {
30✔
576
        for (auto& s : p) collected_events.emplace_back(std::move(s));
162!
577
    }
12✔
578
    if (limit > 0 && static_cast<int>(collected_events.size()) > limit) {
18!
UNCOV
579
        collected_events.resize(static_cast<std::size_t>(limit));
×
UNCOV
580
        truncated = true;
×
UNCOV
581
    }
×
582

583
    co_await append_app_spans(collected_events, index, begin, end, params);
24!
584

585
    std::string body = build_viz_events_body(
12!
586
        collected_events, global_min, original_begin, original_end, limit,
6✔
587
        truncated, index.global_min_timestamp_us());
6✔
588
    co_return HttpResponse::ok(body);
6!
589
}
254✔
590

591
namespace {
592

593
struct NameStat {
594
    std::uint64_t count = 0;
595
    double total = 0;
596
    double min = 0;
597
    double max = 0;
598
    void merge_from(const NameStat& o) {
×
599
        if (count == 0) {
×
600
            min = o.min;
×
601
            max = o.max;
×
602
        } else if (o.count > 0) {
×
603
            min = std::min(min, o.min);
×
604
            max = std::max(max, o.max);
×
UNCOV
605
        }
×
606
        count += o.count;
×
607
        total += o.total;
×
608
    }
×
609
};
610

611
using NameMap = dftracer::utils::StringViewMap<NameStat>;
612

613
static double json_number(simdjson::dom::element el);
614

615
enum class GroupBy { Name, Cat, Pid, Fhash };
616

617
static GroupBy parse_group_by(std::string_view g) {
1✔
618
    if (g == "cat") return GroupBy::Cat;
1!
619
    if (g == "pid") return GroupBy::Pid;
1!
620
    if (g == "fhash" || g == "file") return GroupBy::Fhash;
1!
621
    return GroupBy::Name;
1✔
622
}
1✔
623

624
// Parse one event's dur and grouping key, folding it into a worker-local map.
625
static void fold_event(std::string_view event, GroupBy group, NameMap& local) {
×
626
    thread_local simdjson::dom::parser parser;
×
627
    thread_local std::string buf;
×
628
    thread_local std::string keybuf;
×
629
    buf.assign(event);
×
630
    auto res = parser.parse(buf);
×
631
    if (res.error()) return;
×
632
    auto root = res.value_unsafe();
×
633
    if (!root.is_object()) return;
×
634

635
    auto dur_r = root["dur"];
×
636
    if (dur_r.error()) return;  // skip instant/metadata events (no duration)
×
637
    double dur = 0;
×
638
    if (dur_r.is_uint64())
×
639
        dur = static_cast<double>(dur_r.get_uint64().value_unsafe());
×
640
    else if (dur_r.is_int64())
×
641
        dur = static_cast<double>(dur_r.get_int64().value_unsafe());
×
642
    else if (dur_r.is_double())
×
643
        dur = dur_r.get_double().value_unsafe();
×
644
    else
645
        return;
×
646

647
    std::string_view name;
×
648
    if (group == GroupBy::Name) {
×
649
        auto r = root["name"];
×
650
        if (!r.error() && r.is_string()) name = r.get_string().value_unsafe();
×
651
    } else if (group == GroupBy::Cat) {
×
652
        auto r = root["cat"];
×
653
        if (!r.error() && r.is_string()) name = r.get_string().value_unsafe();
×
654
    } else if (group == GroupBy::Pid) {
×
655
        auto r = root["pid"];
×
656
        if (!r.error()) {
×
657
            keybuf = std::to_string(
×
658
                static_cast<std::int64_t>(json_number(r.value_unsafe())));
×
659
            name = keybuf;
×
UNCOV
660
        }
×
UNCOV
661
    } else {  // Fhash
×
662
        auto args = root["args"];
×
663
        if (!args.error() && args.is_object()) {
×
664
            auto r = args["fhash"];
×
665
            if (!r.error() && r.is_string())
×
666
                name = r.get_string().value_unsafe();
×
UNCOV
667
        }
×
668
        if (name.empty()) return;  // only I/O events have a file hash
×
669
    }
670

671
    auto it = local.find(name);
×
672
    if (it == local.end()) {
×
673
        it = local.emplace(std::string(name), NameStat{}).first;
×
674
        it->second.min = dur;
×
675
        it->second.max = dur;
×
UNCOV
676
    } else {
×
677
        it->second.min = std::min(it->second.min, dur);
×
678
        it->second.max = std::max(it->second.max, dur);
×
679
    }
680
    it->second.count += 1;
×
681
    it->second.total += dur;
×
UNCOV
682
}
×
683

684
// Sub-pixel events are bucketed by (pid, tid, pixel-column) instead of dropped,
685
// so zoomed-out views still show where activity is. The live path also assigns
686
// each bucket a containment depth (see assign_view_depths) so blocks stack
687
// under their enclosing events instead of collapsing to row 0.
688
struct DensityKey {
689
    std::int64_t pid;
690
    std::int64_t tid;
691
    std::int64_t col;
692
    bool operator==(const DensityKey& o) const {
1✔
693
        return pid == o.pid && tid == o.tid && col == o.col;
1!
694
    }
695
};
696

697
struct DensityKeyHash {
698
    std::uint64_t operator()(const DensityKey& k) const noexcept {
394✔
699
        std::uint64_t h = 1469598103934665603ULL;
394✔
700
        auto mix = [&](std::uint64_t v) {
1,576✔
701
            h ^= v;
1,182✔
702
            h *= 1099511628211ULL;
1,182✔
703
        };
1,182✔
704
        mix(static_cast<std::uint64_t>(k.pid));
394!
705
        mix(static_cast<std::uint64_t>(k.tid));
394!
706
        mix(static_cast<std::uint64_t>(k.col));
394!
707
        return h;
394✔
708
    }
709
};
710

711
struct DensityAgg {
46✔
712
    std::uint32_t count = 0;
46✔
713
    double total = 0;
46✔
714
    double max_dur = -1;
46✔
715
    std::uint32_t depth = 0;  // containment depth, set by assign_view_depths
46✔
716
    std::string
717
        name;  // representative: name of the longest event in the bucket
718
    void merge_from(const DensityAgg& o) {
×
719
        count += o.count;
×
720
        total += o.total;
×
721
        if (o.max_dur > max_dur) {
×
722
            max_dur = o.max_dur;
×
723
            name = o.name;
×
UNCOV
724
        }
×
725
    }
×
726
};
727

728
using DensityMap =
729
    ankerl::unordered_dense::map<DensityKey, DensityAgg, DensityKeyHash>;
730

731
static double json_number(simdjson::dom::element el) {
1,140✔
732
    if (el.is_uint64())
1,140!
733
        return static_cast<double>(el.get_uint64().value_unsafe());
1,140✔
734
    if (el.is_int64())
×
735
        return static_cast<double>(el.get_int64().value_unsafe());
×
736
    if (el.is_double()) return el.get_double().value_unsafe();
×
737
    return 0;
×
738
}
1,140✔
739

740
// Fold a small event into the density map. Returns false (keep as an individual
741
// event) when the event has no duration or is at/above the `threshold`.
742
static bool fold_density(std::string_view event, double threshold, double begin,
50✔
743
                         DensityMap& dens, double* out_dur = nullptr) {
744
    thread_local simdjson::dom::parser parser;
50✔
745
    thread_local std::string buf;
50✔
746
    buf.assign(event);
50✔
747
    auto res = parser.parse(buf);
50✔
748
    if (res.error()) return false;
50!
749
    auto root = res.value_unsafe();
50✔
750
    if (!root.is_object()) return false;
50!
751

752
    auto dr = root["dur"];
50✔
753
    if (dr.error()) return false;
50!
754
    double dur = json_number(dr.value_unsafe());
50✔
755
    if (out_dur) *out_dur = dur;
50!
756
    if (threshold <= 0 || dur >= threshold) return false;
50!
757

758
    double ts = 0;
50✔
759
    auto tr = root["ts"];
50✔
760
    if (!tr.error()) ts = json_number(tr.value_unsafe());
50!
761
    std::int64_t pid = 0;
50✔
762
    auto pr = root["pid"];
50✔
763
    if (!pr.error())
50!
764
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
50✔
765
    std::int64_t tid = 0;
50✔
766
    auto tir = root["tid"];
50✔
767
    if (!tir.error())
50!
768
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
50✔
769
    std::string_view name;
50✔
770
    auto nr = root["name"];
50✔
771
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
50!
772

773
    std::int64_t col = static_cast<std::int64_t>((ts - begin) / threshold);
50✔
774
    DensityKey k{pid, tid, col};
50✔
775
    auto it = dens.find(k);
50✔
776
    if (it == dens.end()) it = dens.emplace(k, DensityAgg{}).first;
50!
777
    auto& a = it->second;
50✔
778
    a.count += 1;
50✔
779
    a.total += dur;
50✔
780
    if (dur > a.max_dur) {
50!
781
        a.max_dur = dur;
50✔
782
        a.name.assign(name);
50✔
783
    }
50✔
784
    return true;
50✔
785
}
50✔
786

787
// Parse just the ts and dur of an event. Returns false for metadata/instant
788
// events that lack either field.
789
static bool parse_ts_dur(std::string_view event, double& ts, double& dur) {
×
790
    thread_local simdjson::dom::parser parser;
×
791
    thread_local std::string buf;
×
792
    buf.assign(event);
×
793
    auto res = parser.parse(buf);
×
794
    if (res.error()) return false;
×
795
    auto root = res.value_unsafe();
×
796
    if (!root.is_object()) return false;
×
797
    auto dr = root["dur"];
×
798
    auto tr = root["ts"];
×
799
    if (dr.error() || tr.error()) return false;
×
800
    dur = json_number(dr.value_unsafe());
×
801
    ts = json_number(tr.value_unsafe());
×
802
    return true;
×
UNCOV
803
}
×
804

805
// Parse pid, tid, ts, dur of an event. Returns false for metadata/instant
806
// events that lack ts or dur.
807
static bool parse_lane_ts_dur(std::string_view event, std::int64_t& pid,
×
808
                              std::int64_t& tid, double& ts, double& dur) {
809
    thread_local simdjson::dom::parser parser;
×
810
    thread_local std::string buf;
×
811
    buf.assign(event);
×
812
    auto res = parser.parse(buf);
×
813
    if (res.error()) return false;
×
814
    auto root = res.value_unsafe();
×
815
    if (!root.is_object()) return false;
×
816
    auto dr = root["dur"];
×
817
    auto tr = root["ts"];
×
818
    if (dr.error() || tr.error()) return false;
×
819
    dur = json_number(dr.value_unsafe());
×
820
    ts = json_number(tr.value_unsafe());
×
821
    auto pr = root["pid"];
×
822
    pid = pr.error()
×
823
              ? 0
824
              : static_cast<std::int64_t>(json_number(pr.value_unsafe()));
×
825
    auto tir = root["tid"];
×
826
    tid = tir.error()
×
827
              ? 0
828
              : static_cast<std::int64_t>(json_number(tir.value_unsafe()));
×
829
    return true;
×
UNCOV
830
}
×
831

832
// Containment depth per event/block, stable across zoom because an event's
833
// ancestors are always longer and so survive any threshold that kept it. A
834
// block is nested only under big events that fully cover its bucket interval
835
// [ts, ts+threshold]; a bucket-sized sibling that merely overlaps it is not an
836
// ancestor, so folded events stay on their sibling's row.
837
static std::vector<std::uint32_t> assign_view_depths(
1✔
838
    const std::vector<std::string>& big, DensityMap& dens, double begin,
839
    double threshold) {
840
    std::vector<std::uint32_t> depth(big.size(), 0);
1✔
841

842
    struct Item {
843
        double ts;          // interval start (big) or bucket left edge (block)
844
        double end;         // big end, or bucket right edge for a block query
845
        int big_idx;        // index into `big`, or -1 for a density query
846
        DensityAgg* block;  // set for a density query, null for a big event
847
    };
848
    struct LaneKey {
849
        std::int64_t pid, tid;
850
        bool operator==(const LaneKey& o) const {
×
851
            return pid == o.pid && tid == o.tid;
×
852
        }
853
    };
854
    struct LaneHash {
855
        std::uint64_t operator()(const LaneKey& k) const noexcept {
100✔
856
            std::uint64_t h = 1469598103934665603ULL;
100✔
857
            h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
100✔
858
            h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
100✔
859
            return h;
100✔
860
        }
861
    };
862
    ankerl::unordered_dense::map<LaneKey, std::vector<Item>, LaneHash> lanes;
1!
863

864
    for (std::size_t i = 0; i < big.size(); ++i) {
1!
865
        std::int64_t pid = 0, tid = 0;
×
866
        double ts = 0, dur = 0;
×
867
        if (!parse_lane_ts_dur(big[i], pid, tid, ts, dur)) continue;
×
868
        double end = ts + (dur > 0 ? dur : 0);
×
869
        lanes[LaneKey{pid, tid}].push_back(
×
870
            Item{ts, end, static_cast<int>(i), nullptr});
×
UNCOV
871
    }
×
872
    if (threshold > 0) {
1!
873
        for (auto& kv : dens) {
51✔
874
            double lo = begin + static_cast<double>(kv.first.col) * threshold;
50✔
875
            lanes[LaneKey{kv.first.pid, kv.first.tid}].push_back(
50!
876
                Item{lo, lo + threshold, -1, &kv.second});
50✔
877
        }
878
    }
1✔
879

880
    for (auto& kv : lanes) {
51✔
881
        auto& items = kv.second;
50✔
882
        // Opens sort before queries at equal ts so a block sitting exactly at a
883
        // parent's start counts that parent as an ancestor.
884
        std::sort(items.begin(), items.end(), [](const Item& a, const Item& b) {
50!
885
            if (a.ts != b.ts) return a.ts < b.ts;
×
886
            return (a.block == nullptr) && (b.block != nullptr);
×
UNCOV
887
        });
×
888
        // End times of the currently open big events (all are ancestors of the
889
        // point being processed, since same-lane events nest or are disjoint).
890
        std::multiset<double> open;
50✔
891
        for (auto& it : items) {
100✔
892
            while (!open.empty() && *open.begin() <= it.ts)
50!
893
                open.erase(open.begin());
×
894
            if (it.block) {
50!
895
                // Ancestors are the open events that also cover the bucket's
896
                // right edge; a sibling ending inside the bucket does not.
897
                it.block->depth = static_cast<std::uint32_t>(
50✔
898
                    std::distance(open.lower_bound(it.end), open.end()));
50!
899
            } else {
50✔
900
                depth[static_cast<std::size_t>(it.big_idx)] =
×
901
                    static_cast<std::uint32_t>(open.size());
×
902
                open.insert(it.end);
×
903
            }
904
        }
905
    }
50✔
906
    return depth;
1✔
907
}
1!
908

909
// --- Counters (bandwidth / IOPS over time) ---
910
// Per-bucket read/write bytes and I/O op counts, aggregated from POSIX/STDIO/IO
911
// events (bytes come from args.ret).
912
struct CounterAcc {
913
    std::vector<double> read_bytes;
914
    std::vector<double> write_bytes;
915
    std::vector<double> ops;
916
    void init(std::size_t n) {
×
917
        read_bytes.assign(n, 0.0);
×
918
        write_bytes.assign(n, 0.0);
×
919
        ops.assign(n, 0.0);
×
920
    }
×
921
    void merge_from(const CounterAcc& o) {
×
922
        for (std::size_t i = 0; i < ops.size(); ++i) {
×
923
            read_bytes[i] += o.read_bytes[i];
×
924
            write_bytes[i] += o.write_bytes[i];
×
925
            ops[i] += o.ops[i];
×
UNCOV
926
        }
×
927
    }
×
928
};
929

930
static void fold_counter(std::string_view event, double begin, double bucket_us,
×
931
                         std::size_t buckets, CounterAcc& acc) {
932
    thread_local simdjson::dom::parser parser;
×
933
    thread_local std::string buf;
×
934
    buf.assign(event);
×
935
    auto res = parser.parse(buf);
×
936
    if (res.error()) return;
×
937
    auto root = res.value_unsafe();
×
938
    if (!root.is_object()) return;
×
939

940
    auto cat_r = root["cat"];
×
941
    if (cat_r.error() || !cat_r.is_string()) return;
×
942
    std::string_view cat = cat_r.get_string().value_unsafe();
×
943
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
×
944

945
    auto ts_r = root["ts"];
×
946
    if (ts_r.error()) return;
×
947
    double ts = json_number(ts_r.value_unsafe());
×
948
    long col = static_cast<long>((ts - begin) / bucket_us);
×
949
    if (col < 0 || col >= static_cast<long>(buckets)) return;
×
950

951
    acc.ops[col] += 1.0;
×
952

953
    std::string_view name;
×
954
    auto name_r = root["name"];
×
955
    if (!name_r.error() && name_r.is_string())
×
956
        name = name_r.get_string().value_unsafe();
×
957

958
    double bytes = 0;
×
959
    auto args = root["args"];
×
960
    if (!args.error() && args.is_object()) {
×
961
        auto ret = args["ret"];
×
962
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
×
UNCOV
963
    }
×
964
    if (bytes <= 0) return;
×
965
    if (name.find("write") != std::string_view::npos)
×
966
        acc.write_bytes[col] += bytes;
×
967
    else if (name.find("read") != std::string_view::npos)
×
968
        acc.read_bytes[col] += bytes;
×
UNCOV
969
}
×
970

971
}  // namespace
972

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

978
namespace {
979

980
struct PidTid {
981
    std::int64_t pid;
982
    std::int64_t tid;
983
    bool operator==(const PidTid& o) const {
32✔
984
        return pid == o.pid && tid == o.tid;
32!
985
    }
986
};
987

988
struct PidTidHash {
989
    std::uint64_t operator()(const PidTid& k) const noexcept {
348✔
990
        std::uint64_t h = 1469598103934665603ULL;
348✔
991
        h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
348✔
992
        h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
348✔
993
        return h;
348✔
994
    }
995
};
996

997
struct SumLane {
998
    std::int64_t pid;
999
    std::int64_t tid;
1000
    std::vector<std::atomic<std::uint32_t>> count;
1001
    std::vector<std::atomic<std::uint64_t>> total;
1002
    std::vector<std::atomic<std::uint32_t>> maxd;
1003
    std::vector<std::atomic<std::uint32_t>> nameid;
1004
    SumLane(std::size_t nb, std::int64_t p, std::int64_t t)
108✔
1005
        : pid(p), tid(t), count(nb), total(nb), maxd(nb), nameid(nb) {
108!
1006
        for (auto& x : nameid)
3,538,998✔
1007
            x.store(std::numeric_limits<std::uint32_t>::max(),
3,538,944✔
1008
                    std::memory_order_relaxed);
1009
    }
108✔
1010
};
1011

1012
struct SumBuild {
4!
1013
    std::size_t nb = 0;
4✔
1014
    double bucket_us = 1;
4✔
1015
    std::uint64_t t0 = 0;
4✔
1016

1017
    std::mutex lane_mtx;
1018
    ankerl::unordered_dense::map<PidTid, std::size_t, PidTidHash> lane_of;
1019
    std::deque<SumLane> lanes;
1020

1021
    std::vector<std::atomic<double>> cread;
1022
    std::vector<std::atomic<double>> cwrite;
1023
    std::vector<std::atomic<double>> cops;
1024
    std::atomic<std::uint64_t> gmax_dur{0};
4✔
1025

1026
    std::mutex name_mtx;
1027
    dftracer::utils::StringViewMap<std::uint32_t> name_of;
1028
    std::vector<std::string> names;
1029

1030
    // Per-worker caches so the hot path never locks.
1031
    std::vector<ankerl::unordered_dense::map<PidTid, SumLane*, PidTidHash>>
1032
        lane_cache;
1033
    std::vector<dftracer::utils::StringViewMap<std::uint32_t>> name_cache;
1034

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

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

1044
    std::vector<dftracer::utils::StringViewMap<std::string>> sh_parts;
1045
    std::vector<ankerl::unordered_dense::map<std::int64_t, std::string>>
1046
        app_start, app_end;
1047

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

1052
    // Per-worker file-hashes seen on a read/write op (files with real data
1053
    // I/O).
1054
    std::vector<dftracer::utils::StringViewSet> io_fh;
1055

1056
    SumLane* get_lane(std::size_t w, std::int64_t pid, std::int64_t tid) {
90✔
1057
        PidTid key{pid, tid};
90✔
1058
        auto& cache = lane_cache[w];
90✔
1059
        auto ci = cache.find(key);
90✔
1060
        if (ci != cache.end()) return ci->second;
90✔
1061
        std::lock_guard<std::mutex> lk(lane_mtx);
58✔
1062
        auto it = lane_of.find(key);
58!
1063
        SumLane* lane;
1064
        if (it != lane_of.end()) {
58!
1065
            lane = &lanes[it->second];
×
UNCOV
1066
        } else {
×
1067
            if (lanes.size() * nb >= VizSummary::MAX_CELLS)
58✔
1068
                return nullptr;  // budget reached; drop further lanes
4✔
1069
            lane_of.emplace(key, lanes.size());
54!
1070
            lanes.emplace_back(nb, pid, tid);
54!
1071
            lane = &lanes.back();
54✔
1072
        }
1073
        cache.emplace(key, lane);
54!
1074
        return lane;
54✔
1075
    }
90✔
1076

1077
    std::uint32_t intern(std::size_t w, std::string_view name) {
86✔
1078
        auto& cache = name_cache[w];
86✔
1079
        auto ci = cache.find(name);
86✔
1080
        if (ci != cache.end()) return ci->second;
86✔
1081
        std::lock_guard<std::mutex> lk(name_mtx);
17✔
1082
        auto it = name_of.find(name);
17!
1083
        std::uint32_t id;
1084
        if (it != name_of.end()) {
17!
1085
            id = it->second;
×
UNCOV
1086
        } else {
×
1087
            id = static_cast<std::uint32_t>(names.size());
17✔
1088
            names.emplace_back(name);
17!
1089
            name_of.emplace(std::string(name), id);
17!
1090
        }
1091
        cache.emplace(std::string(name), id);
17!
1092
        return id;
17✔
1093
    }
86✔
1094
};
1095

1096
template <class T>
1097
static void atomic_max(std::atomic<T>& a, T v) {
176✔
1098
    T cur = a.load(std::memory_order_relaxed);
176✔
1099
    while (v > cur &&
318!
1100
           !a.compare_exchange_weak(cur, v, std::memory_order_relaxed)) {
142✔
1101
    }
1102
}
176✔
1103

1104
static void atomic_add_double(std::atomic<double>& a, double v) {
136✔
1105
    double cur = a.load(std::memory_order_relaxed);
136✔
1106
    while (!a.compare_exchange_weak(cur, cur + v, std::memory_order_relaxed)) {
136!
1107
    }
1108
}
136✔
1109

1110
// Fold one (key, dur) sample into a group aggregate, mirroring fold_event.
1111
static void fold_group(NameMap& m, std::string_view key, double dur) {
270✔
1112
    auto it = m.find(key);
270✔
1113
    if (it == m.end()) {
270✔
1114
        it = m.emplace(std::string(key), NameStat{}).first;
83!
1115
        it->second.min = dur;
83✔
1116
        it->second.max = dur;
83✔
1117
    } else {
83✔
1118
        it->second.min = std::min(it->second.min, dur);
187✔
1119
        it->second.max = std::max(it->second.max, dur);
187✔
1120
    }
1121
    it->second.count += 1;
270✔
1122
    it->second.total += dur;
270✔
1123
}
270✔
1124

1125
static void fold_summary(std::size_t w, std::string_view event, SumBuild& b) {
90✔
1126
    thread_local simdjson::dom::parser parser;
90✔
1127
    thread_local std::string buf;
90✔
1128
    buf.assign(event);
90✔
1129
    auto res = parser.parse(buf);
90✔
1130
    if (res.error()) return;
90!
1131
    auto root = res.value_unsafe();
90✔
1132
    if (!root.is_object()) return;
90!
1133

1134
    std::string_view name0;
90✔
1135
    {
1136
        auto nr = root["name"];
90✔
1137
        if (!nr.error() && nr.is_string())
90!
1138
            name0 = nr.get_string().value_unsafe();
90✔
1139
    }
1140
    if (name0 == "FH" || name0 == "SH") {
90!
1141
        auto args = root["args"];
×
1142
        if (!args.error() && args.is_object()) {
×
1143
            auto v = args["value"];
×
1144
            auto n = args["name"];
×
1145
            if (!v.error() && v.is_string() && !n.error() && n.is_string()) {
×
1146
                auto& tbl = name0 == "FH" ? b.fh_parts[w] : b.sh_parts[w];
×
1147
                tbl.emplace(std::string(v.get_string().value_unsafe()),
×
1148
                            std::string(n.get_string().value_unsafe()));
×
UNCOV
1149
            }
×
UNCOV
1150
        }
×
1151
        return;
×
1152
    }
1153

1154
    auto dr = root["dur"];
90✔
1155
    if (dr.error()) return;
90!
1156
    double dur = json_number(dr.value_unsafe());
90✔
1157

1158
    if (name0 == "start" || name0 == "end") {
90✔
1159
        auto cr = root["cat"];
16✔
1160
        auto pp = root["pid"];
16✔
1161
        if (!cr.error() && cr.is_string() &&
32!
1162
            cr.get_string().value_unsafe() == "dftracer" && !pp.error()) {
16!
1163
            auto p = static_cast<std::int64_t>(json_number(pp.value_unsafe()));
16✔
1164
            (name0 == "start" ? b.app_start[w] : b.app_end[w])
16✔
1165
                .emplace(p, std::string(event));
16!
1166
        }
16✔
1167
    }
16✔
1168

1169
    std::int64_t pid = 0, tid = 0;
90✔
1170
    auto pr = root["pid"];
90✔
1171
    if (!pr.error())
90!
1172
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
90✔
1173

1174
    // Analyze aggregates: whole-trace, ts-independent so they match a live
1175
    // scan.
1176
    {
1177
        auto nr = root["name"];
90✔
1178
        auto cr = root["cat"];
90✔
1179
        if (!nr.error() && nr.is_string()) {
90!
1180
            std::string_view nm = nr.get_string().value_unsafe();
90✔
1181
            fold_group(b.g_name[w], nm, dur);
90✔
1182
            if (!cr.error() && cr.is_string() &&
180!
1183
                b.name_cat[w].find(nm) == b.name_cat[w].end())
90✔
1184
                b.name_cat[w].emplace(
17!
1185
                    std::string(nm),
17✔
1186
                    std::string(cr.get_string().value_unsafe()));
17!
1187
        }
90✔
1188
        if (!cr.error() && cr.is_string())
90!
1189
            fold_group(b.g_cat[w], cr.get_string().value_unsafe(), dur);
90✔
1190
        thread_local std::string pidkey;
90✔
1191
        pidkey.assign(std::to_string(pid));
90✔
1192
        fold_group(b.g_pid[w], pidkey, dur);
90✔
1193
        auto ar = root["args"];
90✔
1194
        if (!ar.error() && ar.is_object()) {
90!
1195
            auto fr = ar["fhash"];
90✔
1196
            if (!fr.error() && fr.is_string())
90!
1197
                fold_group(b.g_fhash[w], fr.get_string().value_unsafe(), dur);
×
1198
        }
90✔
1199
    }
1200

1201
    auto tr = root["ts"];
90✔
1202
    if (tr.error()) return;  // bucketing/counters below need a timestamp
90!
1203
    double ts = json_number(tr.value_unsafe());
90✔
1204
    std::int64_t bucket = static_cast<std::int64_t>(
90✔
1205
        (ts - static_cast<double>(b.t0)) / b.bucket_us);
90✔
1206
    if (bucket < 0) return;
90!
1207
    if (bucket >= static_cast<std::int64_t>(b.nb)) bucket = b.nb - 1;
90!
1208
    auto bi = static_cast<std::size_t>(bucket);
90✔
1209

1210
    auto tir = root["tid"];
90✔
1211
    if (!tir.error())
90!
1212
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
90✔
1213

1214
    if (SumLane* lane = b.get_lane(w, pid, tid)) {
90✔
1215
        lane->count[bi].fetch_add(1, std::memory_order_relaxed);
86✔
1216
        lane->total[bi].fetch_add(static_cast<std::uint64_t>(dur < 0 ? 0 : dur),
86!
1217
                                  std::memory_order_relaxed);
1218
        std::uint32_t d = dur >= 4294967295.0
172!
1219
                              ? 4294967295u
1220
                              : static_cast<std::uint32_t>(dur < 0 ? 0 : dur);
86!
1221
        if (d > lane->maxd[bi].load(std::memory_order_relaxed)) {
86!
1222
            atomic_max(lane->maxd[bi], d);
86✔
1223
            std::string_view name;
86✔
1224
            auto nr = root["name"];
86✔
1225
            if (!nr.error() && nr.is_string())
86!
1226
                name = nr.get_string().value_unsafe();
86✔
1227
            lane->nameid[bi].store(b.intern(w, name),
86✔
1228
                                   std::memory_order_relaxed);
1229
        }
86✔
1230
    }
86✔
1231
    atomic_max(b.gmax_dur, static_cast<std::uint64_t>(dur < 0 ? 0 : dur));
90!
1232

1233
    // Counters: read/write bytes and op counts for POSIX/STDIO/IO events.
1234
    auto cat_r = root["cat"];
90✔
1235
    if (cat_r.error() || !cat_r.is_string()) return;
90!
1236
    std::string_view cat = cat_r.get_string().value_unsafe();
90✔
1237
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
90!
1238
    atomic_add_double(b.cops[bi], 1.0);
74✔
1239
    double bytes = 0;
74✔
1240
    auto args = root["args"];
74✔
1241
    if (!args.error() && args.is_object()) {
74!
1242
        auto ret = args["ret"];
74✔
1243
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
74!
1244
    }
74✔
1245
    if (bytes <= 0) return;
74!
1246
    std::string_view name;
74✔
1247
    auto nr = root["name"];
74✔
1248
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
74!
1249
    bool is_write = name.find("write") != std::string_view::npos;
74✔
1250
    bool is_read = name.find("read") != std::string_view::npos;
74✔
1251
    if (is_write)
74✔
1252
        atomic_add_double(b.cwrite[bi], bytes);
19✔
1253
    else if (is_read)
55✔
1254
        atomic_add_double(b.cread[bi], bytes);
43✔
1255
    if ((is_read || is_write) && !args.error() && args.is_object()) {
74!
1256
        auto fr = args["fhash"];
62✔
1257
        if (!fr.error() && fr.is_string())
62!
1258
            b.io_fh[w].emplace(std::string(fr.get_string().value_unsafe()));
×
1259
    }
62✔
1260
}
90✔
1261

1262
}  // namespace
1263

1264
// Build the activity summary by scanning every event once. Blocks the caller
1265
// (the first overview request) for the scan; cached for the server's lifetime.
1266
static coro::CoroTask<void> build_viz_summary(TraceIndex& index) {
28!
1267
    auto summary = std::make_unique<VizSummary>();
12!
1268
    std::uint64_t gmin = index.global_min_timestamp_us();
12✔
1269
    std::uint64_t gmax = index.global_max_timestamp_us();
12✔
1270
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin) {
12✔
1271
        index.set_viz_summary(std::move(summary));
16!
1272
        co_return;
4✔
1273
    }
1274

1275
    std::size_t est_lanes = std::max<std::size_t>(1, index.file_count()) * 8;
12✔
1276
    std::size_t nb = VizSummary::MAX_CELLS / est_lanes;
12✔
1277
    nb = std::clamp(nb, VizSummary::MIN_BUCKETS_PER_LANE,
12!
1278
                    VizSummary::MAX_BUCKETS_PER_LANE);
1279

1280
    SumBuild b;
12!
1281
    b.nb = nb;
12✔
1282
    b.t0 = gmin;
12✔
1283
    b.bucket_us = static_cast<double>(gmax - gmin) / static_cast<double>(nb);
12✔
1284
    b.cread = std::vector<std::atomic<double>>(nb);
12!
1285
    b.cwrite = std::vector<std::atomic<double>>(nb);
12!
1286
    b.cops = std::vector<std::atomic<double>>(nb);
12!
1287
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
12!
1288
    b.lane_cache.resize(slots);
12✔
1289
    b.name_cache.resize(slots);
4!
1290
    b.g_name.resize(slots);
4!
1291
    b.g_cat.resize(slots);
4!
1292
    b.g_pid.resize(slots);
4!
1293
    b.g_fhash.resize(slots);
4!
1294
    b.fh_parts.resize(slots);
4!
1295
    b.sh_parts.resize(slots);
4!
1296
    b.app_start.resize(slots);
4!
1297
    b.app_end.resize(slots);
4!
1298
    b.name_cat.resize(slots);
4!
1299
    b.io_fh.resize(slots);
4✔
1300

1301
    ViewDefinition view;
12✔
1302
    view.name = "viz_summary";
12✔
1303
    view.description = "Activity summary build";
4✔
1304
    std::vector<const TraceIndex::FileInfo*> files;
12✔
1305
    files.reserve(index.files().size());
12✔
1306
    for (const auto& f : index.files()) files.push_back(&f);
16!
1307

1308
    auto on_batch = [&b](std::size_t w,
16✔
1309
                         const std::vector<std::string_view>& events) {
1310
        for (auto ev : events) fold_summary(w, ev, b);
94✔
1311
    };
4✔
1312
    co_await scan_view_events(index, files, view, static_cast<double>(gmin),
16!
1313
                              static_cast<double>(gmax), 0, slots, on_batch);
12!
1314

1315
    summary->t_begin = gmin;
4✔
1316
    summary->t_end = gmax;
4✔
1317
    summary->nbuckets = nb;
4✔
1318
    summary->bucket_us = b.bucket_us;
4✔
1319
    summary->max_dur = b.gmax_dur.load(std::memory_order_relaxed);
4✔
1320
    summary->names = std::move(b.names);
4✔
1321
    summary->read_bytes.assign(nb, 0.0);
4!
1322
    summary->write_bytes.assign(nb, 0.0);
4!
1323
    summary->ops.assign(nb, 0.0);
4!
1324
    for (std::size_t i = 0; i < nb; ++i) {
262,148✔
1325
        summary->read_bytes[i] = b.cread[i].load(std::memory_order_relaxed);
262,144✔
1326
        summary->write_bytes[i] = b.cwrite[i].load(std::memory_order_relaxed);
262,144✔
1327
        summary->ops[i] = b.cops[i].load(std::memory_order_relaxed);
262,144✔
1328
    }
262,144✔
1329
    summary->lanes.reserve(b.lanes.size());
4!
1330
    for (auto& sl : b.lanes) {
58!
1331
        VizSummary::Lane lane;
54✔
1332
        lane.pid = sl.pid;
54✔
1333
        lane.tid = sl.tid;
54✔
1334
        lane.cells.resize(nb);
54!
1335
        for (std::size_t i = 0; i < nb; ++i) {
3,538,998✔
1336
            lane.cells[i].count = sl.count[i].load(std::memory_order_relaxed);
3,538,944✔
1337
            lane.cells[i].total = sl.total[i].load(std::memory_order_relaxed);
3,538,944✔
1338
            lane.cells[i].max_dur = sl.maxd[i].load(std::memory_order_relaxed);
3,538,944✔
1339
            lane.cells[i].name_id =
3,538,944✔
1340
                sl.nameid[i].load(std::memory_order_relaxed);
3,538,944✔
1341
        }
3,538,944✔
1342
        summary->lanes.push_back(std::move(lane));
54!
1343
    }
54✔
1344

1345
    // Merge the per-worker Analyze aggregates and sort each by total desc.
1346
    auto finalize_group = [](std::vector<NameMap>& parts) {
20✔
1347
        NameMap merged;
16✔
1348
        for (auto& p : parts) {
48✔
1349
            for (auto& kv : p) {
115✔
1350
                auto it = merged.find(kv.first);
83!
1351
                if (it == merged.end())
83!
1352
                    merged.emplace(kv.first, kv.second);
83!
1353
                else
1354
                    it->second.merge_from(kv.second);
×
1355
            }
1356
        }
1357
        std::vector<VizSummary::GroupRow> rows;
16✔
1358
        rows.reserve(merged.size());
16!
1359
        for (auto& kv : merged)
99✔
1360
            rows.push_back({kv.first, kv.second.count, kv.second.total,
83!
1361
                            kv.second.min, kv.second.max});
166✔
1362
        std::sort(rows.begin(), rows.end(),
16!
1363
                  [](const VizSummary::GroupRow& lhs,
195✔
1364
                     const VizSummary::GroupRow& rhs) {
1365
                      return lhs.total > rhs.total;
195✔
1366
                  });
1367
        return rows;
16✔
1368
    };
16!
1369
    summary->by_name = finalize_group(b.g_name);
4!
1370
    summary->by_cat = finalize_group(b.g_cat);
4!
1371
    summary->by_pid = finalize_group(b.g_pid);
4!
1372
    summary->by_fhash = finalize_group(b.g_fhash);
4!
1373

1374
    // Resolve file-hash keys to real paths where a metadata record was seen.
1375
    dftracer::utils::StringViewMap<std::string> fh;
4!
1376
    for (auto& part : b.fh_parts)
12✔
1377
        for (auto& kv : part) fh.emplace(kv.first, kv.second);
8!
1378
    for (auto& r : summary->by_fhash) {
4!
UNCOV
1379
        auto it = fh.find(r.key);
×
UNCOV
1380
        if (it != fh.end()) r.key = it->second;
×
UNCOV
1381
    }
×
1382
    summary->total_files = fh.size();
4✔
1383

1384
    {
1385
        dftracer::utils::StringViewMap<std::string> sh;
4!
1386
        for (auto& part : b.sh_parts)
12✔
1387
            for (auto& kv : part) sh.emplace(kv.first, kv.second);
8!
1388
        ankerl::unordered_dense::map<std::int64_t, std::string> starts, ends;
4!
1389
        for (auto& part : b.app_start)
12✔
1390
            for (auto& kv : part) starts.emplace(kv.first, kv.second);
16!
1391
        for (auto& part : b.app_end)
12✔
1392
            for (auto& kv : part) ends.emplace(kv.first, kv.second);
16!
1393

1394
        auto resolve = [](dftracer::utils::StringViewMap<std::string>& tbl,
28✔
1395
                          const std::string& h) -> std::string {
1396
            auto it = tbl.find(h);
24✔
1397
            return it != tbl.end() ? it->second : h;
24!
1398
        };
1399
        simdjson::dom::parser sp, ep;
4✔
1400
        for (auto& [pid, sjson] : starts) {
12✔
1401
            auto eit = ends.find(pid);
8!
1402
            if (eit == ends.end()) continue;
8!
1403
            std::string sbuf(sjson), ebuf(eit->second);
8!
1404
            auto sr = sp.parse(sbuf);
8✔
1405
            auto er = ep.parse(ebuf);
8✔
1406
            if (sr.error() || er.error()) continue;
8!
1407
            auto se = sr.value_unsafe();
8✔
1408
            auto ee = er.value_unsafe();
8✔
1409

1410
            auto num = [](simdjson::dom::element r, const char* k) -> double {
32✔
1411
                auto v = r[k];
24✔
1412
                return v.error() ? 0.0 : json_number(v.value_unsafe());
24!
1413
            };
1414
            auto sarg = [](simdjson::dom::element r,
48✔
1415
                           const char* k) -> std::string {
1416
                auto a = r["args"];
40✔
1417
                if (a.error()) return "";
40!
1418
                auto v = a[k];
40✔
1419
                return (!v.error() && v.is_string())
40!
1420
                           ? std::string(v.get_string().value_unsafe())
16✔
1421
                           : "";
24✔
1422
            };
40✔
1423
            auto narg = [](simdjson::dom::element r, const char* k) -> double {
24✔
1424
                auto a = r["args"];
16✔
1425
                if (a.error()) return 0.0;
16!
1426
                auto v = a[k];
16✔
1427
                return v.error() ? 0.0 : json_number(v.value_unsafe());
16!
1428
            };
16✔
1429

1430
            auto start_ts = static_cast<std::uint64_t>(num(se, "ts"));
8!
1431
            auto end_ts = static_cast<std::uint64_t>(num(ee, "ts"));
8!
1432
            if (end_ts <= start_ts) continue;
8!
1433
            auto tid = static_cast<std::int64_t>(num(se, "tid"));
8!
1434
            std::string app = resolve(sh, sarg(se, "exec_hash"));
8!
1435
            std::string cmd = resolve(sh, sarg(se, "cmd_hash"));
8!
1436
            std::string cwd = resolve(fh, sarg(se, "cwd"));
8!
1437
            std::string version = sarg(se, "version");
8!
1438
            std::string date = sarg(se, "date");
8!
1439
            auto ppid = static_cast<std::int64_t>(narg(se, "ppid"));
8!
1440
            auto num_events = static_cast<std::int64_t>(narg(ee, "num_events"));
8!
1441
            if (app.empty()) app = "app " + std::to_string(pid);
8!
1442

1443
            auto& jb = scratch_json_builder();
8!
1444
            jb.start_object();
8✔
1445
            jb.append_key_value("name", app);
8!
1446
            jb.append_comma();
8✔
1447
            jb.append_key_value("cat", "dftracer");
8✔
1448
            jb.append_comma();
8✔
1449
            jb.append_key_value("pid", pid);
8✔
1450
            jb.append_comma();
8✔
1451
            jb.append_key_value("tid", tid);
8✔
1452
            jb.append_comma();
8✔
1453
            jb.append_key_value("ts", start_ts);
8✔
1454
            jb.append_comma();
8✔
1455
            jb.append_key_value("dur", end_ts - start_ts);
8✔
1456
            jb.append_comma();
8✔
1457
            jb.append_key_value("ph", "X");
8✔
1458
            jb.append_comma();
8✔
1459
            jb.escape_and_append_with_quotes("args");
8✔
1460
            jb.append_colon();
8✔
1461
            jb.start_object();
8✔
1462
            jb.append_key_value("app", app);
8!
1463
            jb.append_comma();
8✔
1464
            jb.append_key_value("cmd", cmd);
8!
1465
            jb.append_comma();
8✔
1466
            jb.append_key_value("cwd", cwd);
8!
1467
            jb.append_comma();
8✔
1468
            jb.append_key_value("ppid", ppid);
8✔
1469
            jb.append_comma();
8✔
1470
            jb.append_key_value("version", version);
8!
1471
            jb.append_comma();
8✔
1472
            jb.append_key_value("date", date);
8!
1473
            jb.append_comma();
8✔
1474
            jb.append_key_value("num_events", num_events);
8✔
1475
            jb.end_object();
8✔
1476
            jb.end_object();
8✔
1477
            summary->app_spans.push_back(
8!
1478
                {start_ts, end_ts, pid, tid, std::string(jb)});
8!
1479
        }
8!
1480
    }
4✔
1481

1482
    dftracer::utils::StringViewSet io_fh;
4!
1483
    for (auto& part : b.io_fh)
12✔
1484
        for (auto& k : part) io_fh.emplace(k);
8!
1485
    summary->io_files = io_fh.size();
4✔
1486

1487
    // Merge the per-worker name -> category maps (first writer wins).
1488
    dftracer::utils::StringViewMap<std::string> nc;
4!
1489
    for (auto& part : b.name_cat)
12✔
1490
        for (auto& kv : part) nc.emplace(kv.first, kv.second);
25!
1491
    summary->name_cats.reserve(nc.size());
4!
1492
    for (auto& kv : nc) summary->name_cats.emplace_back(kv.first, kv.second);
21!
1493

1494
    if (!summary->app_spans.empty()) {
4✔
1495
        // Break = dead time between runs: merge app-spans into run clusters and
1496
        // emit every gap between them, no size threshold.
1497
        std::vector<const VizSummary::AppSpan*> spans;
3✔
1498
        spans.reserve(summary->app_spans.size());
3!
1499
        for (const auto& sp : summary->app_spans) spans.push_back(&sp);
11!
1500
        std::sort(spans.begin(), spans.end(),
3!
1501
                  [](const auto* lhs, const auto* rhs) {
5✔
1502
                      return lhs->begin < rhs->begin;
5✔
1503
                  });
1504
        std::vector<std::pair<std::uint64_t, std::uint64_t>> runs;
3✔
1505
        for (const auto* sp : spans) {
11✔
1506
            if (!runs.empty() && sp->begin <= runs.back().second)
8!
NEW
1507
                runs.back().second = std::max(runs.back().second, sp->end);
×
1508
            else
1509
                runs.emplace_back(sp->begin, sp->end);
8!
1510
        }
8✔
1511
        for (std::size_t i = 1; i < runs.size(); ++i)
8✔
1512
            if (runs[i].first > runs[i - 1].second)
10!
1513
                summary->idle_gaps.emplace_back(runs[i - 1].second,
10!
1514
                                                runs[i].first);
5✔
1515
    } else if (nb > 0 && summary->bucket_us > 0) {
4!
1516
        // No app-spans (malformed trace): fall back to a lane-activity scan,
1517
        // requiring a gap to be a fraction of active (not total) time.
1518
        std::vector<bool> active(nb, false);
1!
1519
        for (const auto& lane : summary->lanes)
47✔
1520
            for (std::size_t i = 0; i < nb; ++i)
3,014,702✔
1521
                if (lane.cells[i].count) active[i] = true;
3,014,702✔
1522
        std::size_t first = 0, last = 0, active_count = 0;
1✔
1523
        bool any = false;
1✔
1524
        for (std::size_t i = 0; i < nb; ++i)
65,537✔
1525
            if (active[i]) {
65,582✔
1526
                if (!any) {
46✔
1527
                    first = i;
1✔
1528
                    any = true;
1✔
1529
                }
1✔
1530
                last = i;
46✔
1531
                ++active_count;
46✔
1532
            }
46✔
1533
        if (any) {
1!
1534
            const double active_us =
2✔
1535
                static_cast<double>(active_count) * summary->bucket_us;
1✔
1536
            const double min_gap_us =
1✔
1537
                std::max(3.0 * summary->bucket_us, 0.05 * active_us);
1!
1538
            for (std::size_t i = first; i <= last;) {
92✔
1539
                if (active[i]) {
91✔
1540
                    ++i;
46✔
1541
                    continue;
46✔
1542
                }
1543
                std::size_t j = i;
45✔
1544
                while (j <= last && !active[j]) ++j;
60,178!
1545
                if (static_cast<double>(j - i) * summary->bucket_us >=
90!
1546
                    min_gap_us) {
45✔
1547
                    auto g0 = summary->t_begin +
90✔
1548
                              static_cast<std::uint64_t>(
1549
                                  static_cast<double>(i) * summary->bucket_us);
45✔
1550
                    auto g1 = summary->t_begin +
90✔
1551
                              static_cast<std::uint64_t>(
1552
                                  static_cast<double>(j) * summary->bucket_us);
45✔
1553
                    summary->idle_gaps.emplace_back(g0, g1);
45!
1554
                }
45✔
1555
                i = j;
45✔
1556
            }
45✔
1557
        }
1✔
1558
    }
1✔
1559

1560
    DFTRACER_UTILS_LOG_INFO(
4!
1561
        "viz: built activity summary (%zu lanes, %zu buckets/lane, %zu idle "
1562
        "gaps)",
1563
        summary->lanes.size(), nb, summary->idle_gaps.size());
1564
    index.set_viz_summary(std::move(summary));
4!
1565
}
76!
1566

1567
// The summary, building it on first use. Null when another request is already
1568
// building it, in which case the caller falls back to a live scan.
1569
static coro::CoroTask<const VizSummary*> ensure_viz_summary(TraceIndex& index) {
73!
1570
    const VizSummary* s = index.viz_summary();
21✔
1571
    if (!s && index.try_begin_summary_build()) {
13!
1572
        co_await build_viz_summary(index);
29!
1573
        s = index.viz_summary();
4!
1574
    }
4✔
1575
    co_return s;
21✔
1576
}
37!
1577

1578
// One aggregate row, uniform over the live-scan and summary paths.
1579
struct StatRow {
1580
    const std::string* key;
1581
    std::uint64_t count;
1582
    double total;
1583
    double min;
1584
    double max;
1585
};
1586

1587
template <typename builder_type>
1588
void tag_invoke(simdjson::serialize_tag, builder_type& b, const StatRow& r) {
8✔
1589
    b.start_object();
8✔
1590
    b.append_key_value("name", *r.key);
8✔
1591
    b.append_comma();
8✔
1592
    b.append_key_value("count", r.count);
8✔
1593
    b.append_comma();
8✔
1594
    b.append_key_value("total", r.total);
8✔
1595
    b.append_comma();
8✔
1596
    b.append_key_value("avg",
16✔
1597
                       r.count ? r.total / static_cast<double>(r.count) : 0.0);
8!
1598
    b.append_comma();
8✔
1599
    b.append_key_value("min", r.min);
8✔
1600
    b.append_comma();
8✔
1601
    b.append_key_value("max", r.max);
8✔
1602
    b.end_object();
8✔
1603
}
8✔
1604

1605
static std::string serialize_stats_body(std::uint64_t total_count,
1✔
1606
                                        double total_dur, double wall,
1607
                                        bool truncated,
1608
                                        const std::vector<StatRow>& rows) {
1609
    auto& b = scratch_json_builder();
1✔
1610
    b.start_object();
1✔
1611
    b.append_key_value("count", total_count);
1✔
1612
    b.append_comma();
1✔
1613
    b.append_key_value("total_dur", total_dur);
1✔
1614
    b.append_comma();
1✔
1615
    b.append_key_value("wall", wall);
1✔
1616
    b.append_comma();
1✔
1617
    b.append_key_value("truncated", truncated);
1✔
1618
    b.append_comma();
1✔
1619
    b.append_key_value("names", rows);
1✔
1620
    b.end_object();
1✔
1621
    return std::string(b);
1✔
1622
}
1623

1624
static const std::vector<VizSummary::GroupRow>& summary_group_rows(
1✔
1625
    const VizSummary& s, GroupBy g) {
1626
    switch (g) {
1!
1627
        case GroupBy::Cat:
1628
            return s.by_cat;
×
1629
        case GroupBy::Pid:
1630
            return s.by_pid;
×
1631
        case GroupBy::Fhash:
1632
            return s.by_fhash;
×
1633
        case GroupBy::Name:
1✔
1634
        default:
1635
            return s.by_name;
1✔
1636
    }
1637
}
1✔
1638

1639
// Whole-trace, unfiltered Analyze answers come from the prebuilt summary, so no
1640
// live scan is needed. Any predicate or sub-range forces the live path.
1641
static bool viz_stats_summary_eligible(const QueryParams& p, double begin_abs,
1✔
1642
                                       double end_abs, TraceIndex& index) {
1643
    if (!p.get("query").empty() || !p.get("cat").empty() ||
2!
1644
        !p.get("lanes").empty() || !p.get("filters").empty() ||
1!
1645
        !p.get("file").empty() || !p.get("pid").empty() ||
1!
1646
        !p.get("tid").empty())
1✔
1647
        return false;
×
1648
    std::uint64_t gmin = index.global_min_timestamp_us();
1✔
1649
    std::uint64_t gmax = index.global_max_timestamp_us();
1✔
1650
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
1!
1651
        return false;
×
1652
    return begin_abs <= static_cast<double>(gmin) + 1.0 &&
2!
1653
           end_abs >= static_cast<double>(gmax) - 1.0;
1✔
1654
}
1✔
1655

1656
// GET /api/v1/viz/stats: server-side per-name aggregation over a time range.
1657
// Scans in parallel worker coroutines, each folding into its own map, then
1658
// merges single-threaded (no lock). Returns only the small aggregate table.
1659
static coro::CoroTask<HttpResponse> handle_viz_stats(const HttpRequest& /*req*/,
7!
1660
                                                     const QueryParams& params,
1661
                                                     TraceIndex& index) {
1!
1662
    if (!params.has("begin") || !params.has("end")) {
3!
1663
        co_return HttpResponse::bad_request(
5!
1664
            "Missing required parameters: begin, end");
4!
1665
    }
1666

1667
    double begin = params.get_double("begin", 0);
3✔
1668
    double end = params.get_double("end", 0);
3✔
1669

1670
    auto query = params.get("query");
3✔
1671
    if (!query.empty() &&
3!
UNCOV
1672
        !utilities::common::query::try_parse(query).has_value()) {
×
UNCOV
1673
        co_return HttpResponse::bad_request("Invalid query: " +
×
UNCOV
1674
                                            std::string(query));
×
1675
    }
1676

1677
    auto ts_norm_param = params.get("ts_normalize");
3✔
1678
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
1679
    std::uint64_t global_min = 0;
3✔
1680
    if (normalize) {
3✔
1681
        global_min = index.global_min_timestamp_us();
1✔
1682
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
UNCOV
1683
            global_min = 0;
×
1684
    }
1✔
1685
    double original_begin = begin;
3✔
1686
    double original_end = end;
3✔
1687
    if (normalize && global_min > 0) {
3!
1688
        begin += static_cast<double>(global_min);
1✔
1689
        end += static_cast<double>(global_min);
1✔
1690
    }
1✔
1691

1692
    GroupBy group = parse_group_by(params.get("group"));
3!
1693

1694
    // Whole-trace, unfiltered Analyze: answer from the prebuilt summary (built
1695
    // lazily here). Concurrent builds fall through to the live scan below.
1696
    if (viz_stats_summary_eligible(params, begin, end, index)) {
1!
1697
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
1698
        if (s) {
1!
1699
            const auto& gr = summary_group_rows(*s, group);
1!
1700
            std::vector<StatRow> rows;
1✔
1701
            rows.reserve(gr.size());
1!
1702
            std::uint64_t total_count = 0;
1✔
1703
            double total_dur = 0;
1✔
1704
            for (const auto& r : gr) {
9✔
1705
                rows.push_back({&r.key, r.count, r.total, r.min, r.max});
8!
1706
                total_count += r.count;
8✔
1707
                total_dur += r.total;
8✔
1708
            }
8✔
1709
            co_return HttpResponse::ok(serialize_stats_body(
2!
1710
                total_count, total_dur, original_end - original_begin, false,
1✔
1711
                rows));
1712
        }
1✔
1713
    }
1!
1714

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

UNCOV
1718
    int limit = params.get_int("limit", 0);
×
UNCOV
1719
    if (limit < 0) limit = 0;
×
1720

UNCOV
1721
    std::vector<const TraceIndex::FileInfo*> target_files =
×
UNCOV
1722
        select_viz_target_files(index, params, begin, end);
×
1723

UNCOV
1724
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
UNCOV
1725
    std::vector<NameMap> partials(slots);
×
1726
    auto aggregate = [&partials, group](
×
1727
                         std::size_t w,
1728
                         const std::vector<std::string_view>& events) {
1729
        NameMap& local = partials[w];  // owned by worker w only, no lock
×
1730
        for (auto ev : events) fold_event(ev, group, local);
×
1731
    };
×
1732

UNCOV
1733
    bool truncated = co_await scan_view_events(index, target_files, view, begin,
×
UNCOV
1734
                                               end, limit, slots, aggregate,
×
UNCOV
1735
                                               !params.get("file").empty());
×
1736

1737
    // Single-threaded reduce of the disjoint per-worker maps.
UNCOV
1738
    NameMap merged;
×
UNCOV
1739
    for (auto& p : partials) {
×
UNCOV
1740
        for (auto& kv : p) {
×
UNCOV
1741
            auto it = merged.find(kv.first);
×
UNCOV
1742
            if (it == merged.end())
×
UNCOV
1743
                merged.emplace(kv.first, kv.second);
×
1744
            else
UNCOV
1745
                it->second.merge_from(kv.second);
×
UNCOV
1746
        }
×
UNCOV
1747
    }
×
1748

UNCOV
1749
    std::vector<StatRow> rows;
×
UNCOV
1750
    rows.reserve(merged.size());
×
UNCOV
1751
    std::uint64_t total_count = 0;
×
UNCOV
1752
    double total_dur = 0;
×
UNCOV
1753
    for (const auto& kv : merged) {
×
UNCOV
1754
        rows.push_back({&kv.first, kv.second.count, kv.second.total,
×
UNCOV
1755
                        kv.second.min, kv.second.max});
×
UNCOV
1756
        total_count += kv.second.count;
×
UNCOV
1757
        total_dur += kv.second.total;
×
UNCOV
1758
    }
×
1759
    std::sort(rows.begin(), rows.end(), [](const StatRow& a, const StatRow& b) {
×
1760
        return a.total > b.total;
×
1761
    });
1762

UNCOV
1763
    co_return HttpResponse::ok(
×
UNCOV
1764
        serialize_stats_body(total_count, total_dur,
×
UNCOV
1765
                             original_end - original_begin, truncated, rows));
×
1766
}
27!
1767

1768
// GET /api/v1/viz/layers: whole-trace reference data - the operation-name ->
1769
// category map (a property of the name, not the view, so fetched once) plus the
1770
// FH file counts: total declared vs. those an I/O event actually touched.
1771
static coro::CoroTask<HttpResponse> handle_viz_layers(
14!
1772
    const HttpRequest& /*req*/, const QueryParams& /*p*/, TraceIndex& index) {
2!
1773
    const VizSummary* s = co_await ensure_viz_summary(index);
10!
1774
    auto& b = scratch_json_builder();
2!
1775
    b.start_object();
2✔
1776
    b.escape_and_append_with_quotes("layers");
2✔
1777
    b.append_colon();
2✔
1778
    b.start_object();
2✔
1779
    if (s) {
2!
1780
        bool first = true;
2✔
1781
        for (const auto& kv : s->name_cats) {
18✔
1782
            if (!first) b.append_comma();
16✔
1783
            first = false;
16✔
1784
            b.escape_and_append_with_quotes(kv.first);
16✔
1785
            b.append_colon();
16✔
1786
            b.escape_and_append_with_quotes(kv.second);
16✔
1787
        }
16✔
1788
    }
2✔
1789
    b.end_object();
2✔
1790
    b.append_comma();
2✔
1791
    b.append_key_value("total_files",
4✔
1792
                       static_cast<std::int64_t>(s ? s->total_files : 0));
2!
1793
    b.append_comma();
2✔
1794
    b.append_key_value("io_files",
4✔
1795
                       static_cast<std::int64_t>(s ? s->io_files : 0));
2!
1796
    b.end_object();
2✔
1797
    co_return HttpResponse::ok(std::string(b));
2!
1798
}
6!
1799

1800
// One scanned event, reduced to what the call-tree needs.
1801
struct FlameEv {
1✔
1802
    std::int64_t pid = 0;
1✔
1803
    std::int64_t tid = 0;
1✔
1804
    double ts = 0;
1✔
1805
    double dur = 0;
1✔
1806
    std::string name;
1807
};
1808

1809
// A node in the merged call tree: identical name-paths across all lanes fold
1810
// into one node. `total` is inclusive; `self` is total minus nested children.
1811
struct FlameNode {
9!
1812
    std::string name;
1813
    double total = 0;
9✔
1814
    double self = 0;
9✔
1815
    std::uint64_t count = 0;
9✔
1816
    dftracer::utils::StringViewMap<std::uint32_t> kids;
1817
    std::vector<std::uint32_t> children;
1818
};
1819

1820
static bool parse_flame_ev(std::string_view event, FlameEv& out) {
50✔
1821
    thread_local simdjson::dom::parser parser;
50✔
1822
    thread_local std::string buf;
50✔
1823
    buf.assign(event);
50✔
1824
    auto res = parser.parse(buf);
50✔
1825
    if (res.error()) return false;
50!
1826
    auto root = res.value_unsafe();
50✔
1827
    if (!root.is_object()) return false;
50!
1828
    auto dr = root["dur"];
50✔
1829
    if (dr.error()) return false;  // no duration: nothing to place in the tree
50!
1830
    out.dur = json_number(dr.value_unsafe());
50✔
1831
    auto tr = root["ts"];
50✔
1832
    if (tr.error()) return false;
50!
1833
    out.ts = json_number(tr.value_unsafe());
50✔
1834
    auto pr = root["pid"];
50✔
1835
    out.pid = pr.error()
50!
1836
                  ? 0
1837
                  : static_cast<std::int64_t>(json_number(pr.value_unsafe()));
50✔
1838
    auto tir = root["tid"];
50✔
1839
    out.tid = tir.error()
50!
1840
                  ? 0
1841
                  : static_cast<std::int64_t>(json_number(tir.value_unsafe()));
50✔
1842
    auto nr = root["name"];
50✔
1843
    if (!nr.error() && nr.is_string())
50!
1844
        out.name.assign(nr.get_string().value_unsafe());
50✔
1845
    else
1846
        out.name.clear();
×
1847
    return true;
50✔
1848
}
50✔
1849

1850
static void serialize_flame_node(simdjson::builder::string_builder& sb,
9✔
1851
                                 std::vector<FlameNode>& arena,
1852
                                 std::uint32_t idx) {
1853
    FlameNode& n = arena[idx];
9✔
1854
    sb.start_object();
9✔
1855
    sb.append_key_value("name", n.name);
9✔
1856
    sb.append_comma();
9✔
1857
    sb.append_key_value("total", n.total);
9✔
1858
    sb.append_comma();
9✔
1859
    sb.append_key_value("self", n.self < 0 ? 0.0 : n.self);
9!
1860
    sb.append_comma();
9✔
1861
    sb.append_key_value("count", n.count);
9✔
1862
    std::sort(n.children.begin(), n.children.end(),
18✔
1863
              [&arena](std::uint32_t a, std::uint32_t b) {
31✔
1864
                  return arena[a].total > arena[b].total;
22✔
1865
              });
1866
    sb.append_comma();
9✔
1867
    sb.escape_and_append_with_quotes("children");
9✔
1868
    sb.append_colon();
9✔
1869
    sb.start_array();
9✔
1870
    for (std::size_t i = 0; i < n.children.size(); ++i) {
17✔
1871
        if (i > 0) sb.append_comma();
8✔
1872
        serialize_flame_node(sb, arena, n.children[i]);
8✔
1873
    }
8✔
1874
    sb.end_array();
9✔
1875
    sb.end_object();
9✔
1876
}
9✔
1877

1878
// GET /api/v1/viz/calltree: merge events into a flamegraph tree. The hierarchy
1879
// per pid/tid lane comes from ts/dur containment (same nesting the timeline
1880
// draws); identical name-paths fold together across the whole trace.
1881
static coro::CoroTask<HttpResponse> handle_viz_calltree(
7!
1882
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
1883
    if (!params.has("begin") || !params.has("end"))
3!
1884
        co_return HttpResponse::bad_request(
5!
1885
            "Missing required parameters: begin, end");
4!
1886

1887
    double begin = params.get_double("begin", 0);
3✔
1888
    double end = params.get_double("end", 0);
3✔
1889

1890
    auto query = params.get("query");
3✔
1891
    if (!query.empty() &&
3!
UNCOV
1892
        !utilities::common::query::try_parse(query).has_value())
×
UNCOV
1893
        co_return HttpResponse::bad_request("Invalid query: " +
×
UNCOV
1894
                                            std::string(query));
×
1895

1896
    auto ts_norm_param = params.get("ts_normalize");
3✔
1897
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
1898
    std::uint64_t global_min = 0;
3✔
1899
    if (normalize) {
3✔
1900
        global_min = index.global_min_timestamp_us();
1✔
1901
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
UNCOV
1902
            global_min = 0;
×
1903
    }
1✔
1904
    if (normalize && global_min > 0) {
3!
1905
        begin += static_cast<double>(global_min);
1✔
1906
        end += static_cast<double>(global_min);
1✔
1907
    }
1✔
1908

1909
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
1910
    int limit = params.get_int("limit", 0);
3✔
1911
    if (limit < 0) limit = 0;
3!
1912

1913
    std::vector<const TraceIndex::FileInfo*> target_files =
3✔
1914
        select_viz_target_files(index, params, begin, end);
3!
1915

1916
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
1917
    std::vector<std::vector<FlameEv>> partials(slots);
3!
1918
    auto collect = [&partials](std::size_t w,
4✔
1919
                               const std::vector<std::string_view>& events) {
1920
        auto& out = partials[w];  // worker-owned slot, no lock
1✔
1921
        FlameEv ev;
1✔
1922
        for (auto e : events)
51✔
1923
            if (parse_flame_ev(e, ev)) out.push_back(ev);
50!
1924
    };
1✔
1925

1926
    bool truncated =
4✔
1927
        co_await scan_view_events(index, target_files, view, begin, end, limit,
5!
1928
                                  slots, collect, !params.get("file").empty());
3!
1929

1930
    std::vector<FlameEv> all;
1✔
1931
    std::size_t total_n = 0;
1✔
1932
    for (auto& p : partials) total_n += p.size();
3✔
1933
    all.reserve(total_n);
1!
1934
    for (auto& p : partials)
3✔
1935
        for (auto& e : p) all.push_back(std::move(e));
52!
1936

1937
    // Lanes are the contiguous (pid, tid) runs after this sort; within a lane
1938
    // events are ordered for the containment walk (outer/longer first on ties).
1939
    std::sort(all.begin(), all.end(), [](const FlameEv& a, const FlameEv& b) {
102!
1940
        if (a.pid != b.pid) return a.pid < b.pid;
101!
1941
        if (a.tid != b.tid) return a.tid < b.tid;
×
1942
        if (a.ts != b.ts) return a.ts < b.ts;
×
1943
        return a.dur > b.dur;
×
1944
    });
101✔
1945

1946
    // With group=pid the root's children are one frame per process, so each
1947
    // process's tree stays separate (surfacing stragglers/imbalance); otherwise
1948
    // every lane merges into one aggregate tree.
1949
    bool by_process = params.get("group") == "pid";
1!
1950

1951
    std::vector<FlameNode> arena;
1✔
1952
    arena.reserve(256);
1!
1953
    arena.emplace_back();  // root (index 0)
1!
1954
    arena[0].name = "all";
1!
1955
    ankerl::unordered_dense::map<std::int64_t, std::uint32_t> proc_of;
1!
1956

1957
    std::vector<std::pair<double, std::uint32_t>> open;  // (end_ts, node idx)
1✔
1958
    std::size_t i = 0;
1✔
1959
    while (i < all.size()) {
51✔
1960
        std::int64_t pid = all[i].pid, tid = all[i].tid;
50✔
1961
        std::uint32_t base = 0;  // top-level events attach here
50✔
1962
        if (by_process) {
50!
UNCOV
1963
            auto pit = proc_of.find(pid);
×
UNCOV
1964
            if (pit == proc_of.end()) {
×
UNCOV
1965
                base = static_cast<std::uint32_t>(arena.size());
×
UNCOV
1966
                arena.emplace_back();
×
UNCOV
1967
                arena[base].name = "P" + std::to_string(pid);
×
UNCOV
1968
                proc_of.emplace(pid, base);
×
UNCOV
1969
                arena[0].children.push_back(base);
×
UNCOV
1970
            } else {
×
UNCOV
1971
                base = pit->second;
×
1972
            }
UNCOV
1973
        }
×
1974
        open.clear();
50✔
1975
        for (; i < all.size() && all[i].pid == pid && all[i].tid == tid; ++i) {
100✔
1976
            const FlameEv& ev = all[i];
50✔
1977
            double e_end = ev.ts + (ev.dur > 0 ? ev.dur : 0);
50!
1978
            while (!open.empty() && open.back().first <= ev.ts) open.pop_back();
50!
1979
            std::uint32_t parent = open.empty() ? base : open.back().second;
50!
1980

1981
            std::uint32_t mi;
50✔
1982
            auto it = arena[parent].kids.find(ev.name);
50!
1983
            if (it == arena[parent].kids.end()) {
50✔
1984
                mi = static_cast<std::uint32_t>(arena.size());
8✔
1985
                arena.emplace_back();  // may reallocate; index access below
8!
1986
                arena[mi].name = ev.name;
8!
1987
                arena[parent].kids.emplace(ev.name, mi);
8!
1988
                arena[parent].children.push_back(mi);
8!
1989
            } else {
8✔
1990
                mi = it->second;
42✔
1991
            }
1992
            arena[mi].total += ev.dur;
50✔
1993
            arena[mi].self += ev.dur;
50✔
1994
            arena[mi].count += 1;
50✔
1995
            if (parent != 0) arena[parent].self -= ev.dur;
50!
1996
            open.push_back({e_end, mi});
50!
1997
        }
50✔
1998
    }
50✔
1999

2000
    // Process frames are synthetic containers: total/count roll up from their
2001
    // children, self is 0.
2002
    if (by_process) {
1!
UNCOV
2003
        for (std::uint32_t pnode : arena[0].children) {
×
UNCOV
2004
            double t = 0;
×
UNCOV
2005
            std::uint64_t c = 0;
×
UNCOV
2006
            for (std::uint32_t ch : arena[pnode].children) {
×
UNCOV
2007
                t += arena[ch].total;
×
UNCOV
2008
                c += arena[ch].count;
×
UNCOV
2009
            }
×
UNCOV
2010
            arena[pnode].total = t;
×
UNCOV
2011
            arena[pnode].count = c;
×
UNCOV
2012
            arena[pnode].self = 0;
×
UNCOV
2013
        }
×
UNCOV
2014
    }
×
2015

2016
    double root_total = 0;
1✔
2017
    std::uint64_t root_count = 0;
1✔
2018
    for (std::uint32_t c : arena[0].children) {
9✔
2019
        root_total += arena[c].total;
8✔
2020
        root_count += arena[c].count;
8✔
2021
    }
8✔
2022
    arena[0].total = root_total;
1✔
2023
    arena[0].count = root_count;
1✔
2024
    arena[0].self = 0;
1✔
2025

2026
    auto& b = scratch_json_builder();
1!
2027
    b.start_object();
1✔
2028
    b.append_key_value("truncated", truncated);
1✔
2029
    b.append_comma();
1✔
2030
    b.escape_and_append_with_quotes("tree");
1✔
2031
    b.append_colon();
1✔
2032
    serialize_flame_node(b, arena, 0);
1!
2033
    b.end_object();
1✔
2034
    co_return HttpResponse::ok(std::string(b));
1!
2035
}
31!
2036

2037
// One log-spaced duration bucket of the histogram response.
2038
struct HistBucket {
2039
    double lo;
2040
    double hi;
2041
    std::uint64_t count;
2042
};
2043

2044
template <typename builder_type>
2045
void tag_invoke(simdjson::serialize_tag, builder_type& b, const HistBucket& h) {
40✔
2046
    b.start_object();
40✔
2047
    b.append_key_value("lo", h.lo);
40✔
2048
    b.append_comma();
40✔
2049
    b.append_key_value("hi", h.hi);
40✔
2050
    b.append_comma();
40✔
2051
    b.append_key_value("count", h.count);
40✔
2052
    b.end_object();
40✔
2053
}
40✔
2054

2055
// GET /api/v1/viz/histogram: the distribution of event durations matching the
2056
// query in [begin, end]. Collects each matching dur, then reports exact
2057
// percentiles and a log-spaced histogram of the shape. The caller narrows to
2058
// one operation by folding its predicate (name == "...") into the query.
2059
static coro::CoroTask<HttpResponse> handle_viz_histogram(
7!
2060
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
2061
    if (!params.has("begin") || !params.has("end"))
3!
2062
        co_return HttpResponse::bad_request(
5!
2063
            "Missing required parameters: begin, end");
4!
2064

2065
    double begin = params.get_double("begin", 0);
3✔
2066
    double end = params.get_double("end", 0);
3✔
2067

2068
    auto query = params.get("query");
3✔
2069
    if (!query.empty() &&
3!
UNCOV
2070
        !utilities::common::query::try_parse(query).has_value())
×
UNCOV
2071
        co_return HttpResponse::bad_request("Invalid query: " +
×
UNCOV
2072
                                            std::string(query));
×
2073

2074
    auto ts_norm_param = params.get("ts_normalize");
3✔
2075
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
2076
    std::uint64_t global_min = 0;
3✔
2077
    if (normalize) {
3✔
2078
        global_min = index.global_min_timestamp_us();
1✔
2079
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
UNCOV
2080
            global_min = 0;
×
2081
    }
1✔
2082
    if (normalize && global_min > 0) {
3!
2083
        begin += static_cast<double>(global_min);
1✔
2084
        end += static_cast<double>(global_min);
1✔
2085
    }
1✔
2086

2087
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
2088
    int limit = params.get_int("limit", 0);
3✔
2089
    if (limit < 0) limit = 0;
3!
2090
    int nbuckets = params.get_int("buckets", 40);
3✔
2091
    nbuckets = std::clamp(nbuckets, 4, 200);
3!
2092

2093
    std::vector<const TraceIndex::FileInfo*> target_files =
3✔
2094
        select_viz_target_files(index, params, begin, end);
3!
2095

2096
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
2097
    std::vector<std::vector<double>> partials(slots);
3!
2098
    auto collect = [&partials](std::size_t w,
4✔
2099
                               const std::vector<std::string_view>& events) {
2100
        thread_local simdjson::dom::parser parser;
1!
2101
        thread_local std::string buf;
1!
2102
        auto& out = partials[w];  // worker-owned slot, no lock
1✔
2103
        for (auto e : events) {
51✔
2104
            buf.assign(e);
50✔
2105
            auto res = parser.parse(buf);
50✔
2106
            if (res.error()) continue;
50!
2107
            auto root = res.value_unsafe();
50✔
2108
            if (!root.is_object()) continue;
50!
2109
            auto dr = root["dur"];
50✔
2110
            if (dr.error()) continue;
50!
2111
            out.push_back(json_number(dr.value_unsafe()));
50✔
2112
        }
2113
    };
1✔
2114

2115
    bool truncated =
4✔
2116
        co_await scan_view_events(index, target_files, view, begin, end, limit,
5!
2117
                                  slots, collect, !params.get("file").empty());
3!
2118

2119
    std::vector<double> all;
1✔
2120
    std::size_t total_n = 0;
1✔
2121
    for (auto& p : partials) total_n += p.size();
3✔
2122
    all.reserve(total_n);
1!
2123
    for (auto& p : partials)
3✔
2124
        for (double d : p) all.push_back(d);
52!
2125
    std::sort(all.begin(), all.end());
1!
2126

2127
    auto& sb = scratch_json_builder();
1!
2128
    if (all.empty()) {
1!
UNCOV
2129
        sb.start_object();
×
UNCOV
2130
        sb.append_key_value("count", 0);
×
UNCOV
2131
        sb.append_comma();
×
UNCOV
2132
        sb.escape_and_append_with_quotes("buckets");
×
UNCOV
2133
        sb.append_colon();
×
UNCOV
2134
        sb.start_array();
×
UNCOV
2135
        sb.end_array();
×
UNCOV
2136
        sb.append_comma();
×
UNCOV
2137
        sb.append_key_value("truncated", truncated);
×
UNCOV
2138
        sb.end_object();
×
UNCOV
2139
        co_return HttpResponse::ok(std::string(sb));
×
2140
    }
2141

2142
    std::size_t n = all.size();
1✔
2143
    double vmin = all.front();
1✔
2144
    double vmax = all.back();
1✔
2145
    double sum = 0;
1✔
2146
    for (double d : all) sum += d;
51✔
2147
    double mean = sum / static_cast<double>(n);
1✔
2148
    auto pct = [&all, n](double p) {
5✔
2149
        std::size_t idx =
4✔
2150
            static_cast<std::size_t>(p * static_cast<double>(n - 1));
4✔
2151
        return all[idx];
4✔
2152
    };
2153

2154
    // Log-spaced buckets over [max(vmin,1), vmax]; sub-microsecond durs land in
2155
    // the first bucket.
2156
    double lo = std::max(vmin, 1.0);
1!
2157
    double hi = std::max(vmax, lo * 1.0000001);
1!
2158
    double lr = std::log(hi / lo);
1✔
2159
    std::vector<std::uint64_t> counts(static_cast<std::size_t>(nbuckets), 0);
1!
2160
    for (double d : all) {
51✔
2161
        double v = d < lo ? lo : d;
50!
2162
        std::size_t bi =
100✔
2163
            lr > 0 ? static_cast<std::size_t>(static_cast<double>(nbuckets) *
50!
2164
                                              std::log(v / lo) / lr)
100✔
2165
                   : 0;
2166
        if (bi >= static_cast<std::size_t>(nbuckets)) bi = nbuckets - 1;
50✔
2167
        counts[bi]++;
50✔
2168
    }
50✔
2169

2170
    sb.start_object();
1✔
2171
    sb.append_key_value("count", n);
1✔
2172
    sb.append_comma();
1✔
2173
    sb.append_key_value("min", vmin);
1✔
2174
    sb.append_comma();
1✔
2175
    sb.append_key_value("max", vmax);
1✔
2176
    sb.append_comma();
1✔
2177
    sb.append_key_value("mean", mean);
1✔
2178
    sb.append_comma();
1✔
2179
    sb.append_key_value("p50", pct(0.50));
1!
2180
    sb.append_comma();
1✔
2181
    sb.append_key_value("p90", pct(0.90));
1!
2182
    sb.append_comma();
1✔
2183
    sb.append_key_value("p95", pct(0.95));
1!
2184
    sb.append_comma();
1✔
2185
    sb.append_key_value("p99", pct(0.99));
1!
2186
    sb.append_comma();
1✔
2187
    sb.append_key_value("truncated", truncated);
1✔
2188
    sb.append_comma();
1✔
2189
    std::vector<HistBucket> buckets;
1✔
2190
    buckets.reserve(static_cast<std::size_t>(nbuckets));
1!
2191
    for (int i = 0; i < nbuckets; ++i) {
41✔
2192
        buckets.push_back(
40!
2193
            {lo * std::exp(lr * static_cast<double>(i) / nbuckets),
120✔
2194
             lo * std::exp(lr * static_cast<double>(i + 1) / nbuckets),
40✔
2195
             counts[static_cast<std::size_t>(i)]});
40✔
2196
    }
40✔
2197
    sb.append_key_value("buckets", buckets);
1!
2198
    sb.end_object();
1✔
2199
    co_return HttpResponse::ok(std::string(sb));
1!
2200
}
35!
2201

2202
// A density block as it appears in the response: the map key/aggregate pair
2203
// flattened with `ts` resolved from the column index. `name` borrows the
2204
// aggregate's storage.
2205
struct DensityBlock {
2206
    std::string_view name;
2207
    std::int64_t pid;
2208
    std::int64_t tid;
2209
    double ts;
2210
    double dur;
2211
    std::uint32_t count;
2212
    double total;
2213
    std::uint32_t depth;
2214
};
2215

2216
template <typename builder_type>
2217
void tag_invoke(simdjson::serialize_tag, builder_type& b,
96✔
2218
                const DensityBlock& d) {
2219
    b.start_object();
96✔
2220
    b.append_key_value("name", d.name);
96✔
2221
    b.append_comma();
96✔
2222
    b.append_key_value("pid", d.pid);
96✔
2223
    b.append_comma();
96✔
2224
    b.append_key_value("tid", d.tid);
96✔
2225
    b.append_comma();
96✔
2226
    b.append_key_value("ts", d.ts);
96✔
2227
    b.append_comma();
96✔
2228
    b.append_key_value("dur", d.dur);
96✔
2229
    b.append_comma();
96✔
2230
    b.append_key_value("count", d.count);
96✔
2231
    b.append_comma();
96✔
2232
    b.append_key_value("total", d.total);
96✔
2233
    b.append_comma();
96✔
2234
    b.append_key_value("depth", d.depth);
96✔
2235
    b.end_object();
96✔
2236
}
96✔
2237

2238
// Serialize collected density blocks (+ optional individual events) into the
2239
// /viz/density response body. Shared by the live-scan and summary paths.
2240
static std::string serialize_density_body(
2✔
2241
    const std::vector<std::string>& big, const DensityMap& dens,
2242
    double original_begin, double original_end, double threshold, int limit,
2243
    bool truncated, bool ts_normalized, std::uint64_t display_global_min,
2244
    double max_dur, const std::vector<std::uint32_t>* big_depth = nullptr) {
2245
    auto& b = scratch_json_builder();
2✔
2246
    b.start_object();
2✔
2247
    b.escape_and_append_with_quotes("events");
2✔
2248
    b.append_colon();
2✔
2249
    b.start_array();
2✔
2250
    for (std::size_t i = 0; i < big.size(); ++i) {
2!
2251
        if (i > 0) b.append_comma();
×
2252
        // Inject the server-computed depth as a sibling field (events end in
2253
        // }).
2254
        if (big_depth && i < big_depth->size() && !big[i].empty() &&
×
2255
            big[i].back() == '}') {
×
2256
            b.append_raw(std::string_view(big[i]).substr(0, big[i].size() - 1));
×
2257
            b.append_raw(",\"depth\":");
×
2258
            b.append(static_cast<std::uint64_t>((*big_depth)[i]));
×
2259
            b.append_raw("}");
×
UNCOV
2260
        } else {
×
2261
            b.append_raw(big[i]);
×
2262
        }
UNCOV
2263
    }
×
2264
    b.end_array();
2✔
2265
    b.append_comma();
2✔
2266
    std::vector<DensityBlock> blocks;
2✔
2267
    blocks.reserve(dens.size());
2!
2268
    for (const auto& [k, a] : dens) {
98✔
2269
        blocks.push_back(
96!
2270
            {a.name, k.pid, k.tid,
576✔
2271
             original_begin + static_cast<double>(k.col) * threshold, threshold,
192✔
2272
             a.count, a.total, a.depth});
288✔
2273
    }
2274
    b.append_key_value("density", blocks);
2!
2275
    b.append_comma();
2✔
2276
    b.escape_and_append_with_quotes("metadata");
2✔
2277
    b.append_colon();
2✔
2278
    b.start_object();
2✔
2279
    b.append_key_value("begin", original_begin);
2✔
2280
    b.append_comma();
2✔
2281
    b.append_key_value("end", original_end);
2✔
2282
    b.append_comma();
2✔
2283
    b.append_key_value("count", big.size());
2✔
2284
    b.append_comma();
2✔
2285
    b.append_key_value("limit", limit);
2✔
2286
    b.append_comma();
2✔
2287
    b.append_key_value("density_count", dens.size());
2✔
2288
    b.append_comma();
2✔
2289
    b.append_key_value("truncated", truncated);
2✔
2290
    b.append_comma();
2✔
2291
    b.append_key_value("ts_normalized", ts_normalized);
2✔
2292
    b.append_comma();
2✔
2293
    b.append_key_value("global_min_timestamp_us", display_global_min);
2✔
2294
    b.append_comma();
2✔
2295
    b.append_key_value("max_dur", max_dur);
2✔
2296
    b.end_object();
2✔
2297
    b.end_object();
2✔
2298
    return std::string(b);
2!
2299
}
2✔
2300

2301
// The summary is unfiltered, so any server-side predicate forces a live scan.
2302
// pid/tid are exempt: they select whole lanes, which the summary can still do.
2303
static bool viz_summary_eligible(const QueryParams& params) {
9✔
2304
    return params.get("query").empty() && params.get("cat").empty() &&
18!
2305
           params.get("lanes").empty() && params.get("filters").empty() &&
9✔
2306
           params.get("file").empty();
6✔
2307
}
2308

2309
static coro::CoroTask<void> append_app_spans(std::vector<std::string>& out,
43!
2310
                                             TraceIndex& index, double begin,
2311
                                             double end,
2312
                                             const QueryParams& params) {
7!
2313
    if (!viz_summary_eligible(params)) co_return;
22!
2314
    const VizSummary* s = co_await ensure_viz_summary(index);
16!
2315
    if (!s) co_return;
4!
2316
    auto pid_s = params.get("pid");
4!
2317
    auto tid_s = params.get("tid");
4!
2318
    bool has_pid = !pid_s.empty();
4✔
2319
    bool has_tid = !tid_s.empty();
4✔
2320
    std::int64_t want_pid =
8✔
2321
        has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
4!
2322
    std::int64_t want_tid =
8✔
2323
        has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
4!
2324
    for (const auto& sp : s->app_spans) {
4!
UNCOV
2325
        if (has_pid && sp.pid != want_pid) continue;
×
UNCOV
2326
        if (has_tid && sp.tid != want_tid) continue;
×
UNCOV
2327
        if (static_cast<double>(sp.end) > begin &&
×
UNCOV
2328
            static_cast<double>(sp.begin) < end)
×
UNCOV
2329
            out.push_back(sp.json);
×
UNCOV
2330
    }
×
2331
}
12!
2332

2333
// Re-aggregate the summary's finest per-lane buckets over [begin_abs, end_abs]
2334
// into pixel-column density blocks. `begin_abs`/`end_abs` are absolute us.
2335
static std::string serve_density_from_summary(
1✔
2336
    const VizSummary& s, const QueryParams& params, double begin_abs,
2337
    double end_abs, double original_begin, double original_end,
2338
    double threshold, bool ts_normalized, std::uint64_t display_global_min) {
2339
    auto pid_s = params.get("pid");
1✔
2340
    auto tid_s = params.get("tid");
1✔
2341
    bool has_pid = !pid_s.empty();
1✔
2342
    bool has_tid = !tid_s.empty();
1✔
2343
    std::int64_t want_pid =
1✔
2344
        has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
1!
2345
    std::int64_t want_tid =
1✔
2346
        has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
1!
2347

2348
    std::int64_t b0 = s.bucket_of(begin_abs);
1✔
2349
    std::int64_t b1 = s.bucket_of(end_abs);
1✔
2350
    if (b0 < 0) b0 = 0;
1!
2351
    if (b1 < 0) b1 = static_cast<std::int64_t>(s.nbuckets) - 1;
1!
2352

2353
    DensityMap dens;
1✔
2354
    for (const auto& lane : s.lanes) {
47✔
2355
        if (has_pid && lane.pid != want_pid) continue;
46!
2356
        if (has_tid && lane.tid != want_tid) continue;
46!
2357
        for (std::int64_t bk = b0; bk <= b1; ++bk) {
3,014,702✔
2358
            const auto& cell = lane.cells[static_cast<std::size_t>(bk)];
3,014,656✔
2359
            if (cell.count == 0) continue;
3,014,656✔
2360
            double center = static_cast<double>(s.t_begin) +
92✔
2361
                            (static_cast<double>(bk) + 0.5) * s.bucket_us;
46✔
2362
            std::int64_t col =
46✔
2363
                static_cast<std::int64_t>((center - begin_abs) / threshold);
46✔
2364
            DensityKey key{lane.pid, lane.tid, col};
46✔
2365
            auto& a = dens[key];
46!
2366
            a.count += cell.count;
46✔
2367
            a.total += static_cast<double>(cell.total);
46✔
2368
            if (static_cast<double>(cell.max_dur) > a.max_dur) {
46!
2369
                a.max_dur = static_cast<double>(cell.max_dur);
46✔
2370
                if (cell.name_id < s.names.size())
46!
2371
                    a.name = s.names[cell.name_id];
46!
2372
            }
46✔
2373
        }
46✔
2374
    }
2375

2376
    std::vector<std::string> spans;
1✔
2377
    ankerl::unordered_dense::set<std::int64_t> span_lanes;
1!
2378
    for (const auto& sp : s.app_spans) {
1!
2379
        if (has_pid && sp.pid != want_pid) continue;
×
2380
        if (has_tid && sp.tid != want_tid) continue;
×
2381
        if (static_cast<double>(sp.end) <= begin_abs ||
×
2382
            static_cast<double>(sp.begin) >= end_abs)
×
2383
            continue;
×
2384
        span_lanes.insert((sp.pid << 20) ^ sp.tid);
×
2385
        spans.push_back(ts_normalized && display_global_min > 0
×
2386
                            ? normalize_event_ts(sp.json, display_global_min)
×
2387
                            : sp.json);
×
2388
    }
2389
    // Folded child activity sits one row below its app span, matching the
2390
    // containment nesting the live path computes.
2391
    if (!span_lanes.empty())
1!
2392
        for (auto& [k, a] : dens)
×
2393
            if (span_lanes.count((k.pid << 20) ^ k.tid)) a.depth = 1;
×
2394

2395
    std::vector<std::uint32_t> span_depth(spans.size(), 0);
1!
2396
    return serialize_density_body(spans, dens, original_begin, original_end,
2!
2397
                                  threshold, 0, false, ts_normalized,
1✔
2398
                                  display_global_min,
1✔
2399
                                  static_cast<double>(s.max_dur), &span_depth);
1✔
2400
}
1✔
2401

2402
// GET /api/v1/viz/density: like /viz/events, but instead of dropping sub-pixel
2403
// events it buckets them per (pid, tid, pixel-column) into density blocks so
2404
// zoomed-out views still show where activity is. Returns full-size events (with
2405
// args, for the detail panel) plus the aggregated density blocks.
2406
static coro::CoroTask<HttpResponse> handle_viz_density(
14!
2407
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
2!
2408
    if (!params.has("begin") || !params.has("end")) {
2!
2409
        co_return HttpResponse::bad_request(
2!
UNCOV
2410
            "Missing required parameters: begin, end");
×
2411
    }
2412

2413
    double begin = params.get_double("begin", 0);
2!
2414
    double end = params.get_double("end", 0);
2!
2415
    int summary = params.get_int("summary", 2);
2!
2416
    if (summary < 1) summary = 1;
2!
2417

2418
    auto query = params.get("query");
2!
2419
    if (!query.empty() &&
2!
UNCOV
2420
        !utilities::common::query::try_parse(query).has_value()) {
×
UNCOV
2421
        co_return HttpResponse::bad_request("Invalid query: " +
×
UNCOV
2422
                                            std::string(query));
×
2423
    }
2424

2425
    auto ts_norm_param = params.get("ts_normalize");
2!
2426
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
2!
2427
    std::uint64_t global_min = 0;
2✔
2428
    if (normalize) {
2!
2429
        global_min = index.global_min_timestamp_us();
2✔
2430
        if (global_min == std::numeric_limits<std::uint64_t>::max())
2!
UNCOV
2431
            global_min = 0;
×
2432
    }
2✔
2433
    double original_begin = begin;
2✔
2434
    double original_end = end;
2✔
2435
    if (normalize && global_min > 0) {
2!
2436
        begin += static_cast<double>(global_min);
2✔
2437
        end += static_cast<double>(global_min);
2✔
2438
    }
2✔
2439

2440
    // Bucket width (== the ~1px cutoff), in the client's actual pixels.
2441
    int width = params.get_int("width", DEFAULT_VIEWPORT_WIDTH);
2!
2442
    width = std::clamp(width, MIN_VIEWPORT_WIDTH, MAX_VIEWPORT_WIDTH);
2!
2443
    double threshold =
4✔
2444
        duration_threshold(begin, end, static_cast<unsigned>(summary),
4✔
2445
                           static_cast<unsigned>(width));
2✔
2446

2447
    // Zoomed-out, unfiltered views come from the prebuilt summary (no event
2448
    // cap), built lazily here; concurrent requests fall through to a live scan.
2449
    if (threshold > 0 && viz_summary_eligible(params)) {
2!
2450
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
2451
        if (s && s->bucket_us > 0 && s->t_end > s->t_begin &&
1!
2452
            threshold >= s->bucket_us) {
1✔
2453
            co_return HttpResponse::ok(serve_density_from_summary(
2!
2454
                *s, params, begin, end, original_begin, original_end, threshold,
1✔
2455
                global_min > 0, index.global_min_timestamp_us()));
1✔
2456
        }
2457
    }
1!
2458

2459
    // summary=1 means "no aggregation", but the viewport is still finite.
2460
    // Fold sub-pixel events into density blocks instead of dropping them.
2461
    if (threshold <= 0 && end > begin)
3!
2462
        threshold = (end - begin) / static_cast<double>(width);
1✔
2463

2464
    // Enclosing events are fetched by a separate pass (below) so a large
2465
    // `lookback` can never starve the in-window scan's budget.
2466
    double lookback = params.get_double("lookback", 0);
3✔
2467
    if (lookback < 0) lookback = 0;
3!
2468
    double scan_begin = begin - lookback;
3✔
2469
    if (scan_begin < 0) scan_begin = 0;
3!
2470

2471
    int limit = params.get_int("limit", 0);
3✔
2472
    if (limit < 0) limit = 0;
3!
2473

2474
    struct Acc {
2✔
2475
        std::vector<std::string> big;
2476
        std::vector<double> big_dur;  // parallel to `big`
2477
        DensityMap dens;
2478
        double max_dur = 0;
2✔
2479
    };
2480
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
2481
    std::vector<Acc> accs(slots);
3!
2482

2483
    // Pass 1 (in-window): scan [begin, end] with the full budget so earlier
2484
    // events never consume it. Small events fold into density blocks.
2485
    auto on_batch = [&accs, threshold, begin](
4✔
2486
                        std::size_t w,
2487
                        const std::vector<std::string_view>& events) {
2488
        Acc& acc = accs[w];  // worker-owned slot, no lock
1✔
2489
        for (auto ev : events) {
51✔
2490
            double dur = 0;
50✔
2491
            if (!fold_density(ev, threshold, begin, acc.dens, &dur)) {
50!
2492
                acc.big.emplace_back(ev);
×
2493
                acc.big_dur.push_back(dur);
×
UNCOV
2494
            }
×
2495
            if (dur > acc.max_dur) acc.max_dur = dur;
50!
2496
        }
2497
    };
1✔
2498
    ViewDefinition win_view = build_viz_view(params, begin, end, 0);
3!
2499
    std::vector<const TraceIndex::FileInfo*> win_files =
3✔
2500
        select_viz_target_files(index, params, begin, end);
3!
2501
    bool single_file = !params.get("file").empty();
3✔
2502
    bool truncated =
6✔
2503
        co_await scan_view_events(index, win_files, win_view, begin, end, limit,
4!
2504
                                  slots, on_batch, single_file);
3!
2505

2506
    // Pass 2 (enclosers): keep only events still open at `begin`
2507
    // (ts < begin <= ts + dur); these ancestor bars set containment depth.
2508
    if (scan_begin < begin) {
3!
2509
        auto on_batch_enc = [&accs, begin](
×
2510
                                std::size_t w,
2511
                                const std::vector<std::string_view>& events) {
2512
            Acc& acc = accs[w];
×
2513
            for (auto ev : events) {
×
2514
                double ts = 0, dur = 0;
×
2515
                if (!parse_ts_dur(ev, ts, dur)) continue;
×
2516
                if (dur > acc.max_dur) acc.max_dur = dur;
×
2517
                if (ts < begin && ts + dur > begin) {
×
2518
                    acc.big.emplace_back(ev);
×
2519
                    acc.big_dur.push_back(dur);
×
UNCOV
2520
                }
×
2521
            }
2522
        };
×
UNCOV
2523
        ViewDefinition enc_view = build_viz_view(params, scan_begin, begin, 0);
×
UNCOV
2524
        std::vector<const TraceIndex::FileInfo*> enc_files =
×
UNCOV
2525
            select_viz_target_files(index, params, scan_begin, begin);
×
UNCOV
2526
        bool enc_trunc = co_await scan_view_events(
×
UNCOV
2527
            index, enc_files, enc_view, scan_begin, begin, limit, slots,
×
UNCOV
2528
            on_batch_enc, single_file);
×
UNCOV
2529
        truncated = truncated || enc_trunc;
×
UNCOV
2530
    }
×
2531

2532
    std::vector<std::string> big;
3✔
2533
    std::vector<double> big_dur;
3✔
2534
    DensityMap dens;
3!
2535
    // Longest event scanned (folded ones included); the client feeds it back as
2536
    // `lookback` so deep zooms still catch long enclosing events.
2537
    double max_dur = 0;
3✔
2538
    for (auto& acc : accs) {
5✔
2539
        if (acc.max_dur > max_dur) max_dur = acc.max_dur;
2✔
2540
        for (auto& d : acc.big_dur) big_dur.push_back(d);
2!
2541
        for (auto& s : acc.big) big.emplace_back(std::move(s));
2!
2542
        for (auto& kv : acc.dens) {
52✔
2543
            auto it = dens.find(kv.first);
50!
2544
            if (it == dens.end())
50!
2545
                dens.emplace(kv.first, std::move(kv.second));
50!
2546
            else
UNCOV
2547
                it->second.merge_from(kv.second);
×
2548
        }
50✔
2549
    }
2✔
2550
    // scan_view_events overshoots its cap (checked per batch), so clamp here.
2551
    // Keep the longest events: those are the slices wide enough to see.
2552
    if (limit > 0 && big.size() > static_cast<std::size_t>(limit)) {
3!
UNCOV
2553
        const auto keep = static_cast<std::size_t>(limit);
×
UNCOV
2554
        std::vector<std::size_t> idx(big.size());
×
UNCOV
2555
        std::iota(idx.begin(), idx.end(), std::size_t{0});
×
UNCOV
2556
        std::nth_element(idx.begin(), idx.begin() + static_cast<long>(keep),
×
2557
                         idx.end(), [&big_dur](std::size_t a, std::size_t b) {
×
2558
                             return big_dur[a] > big_dur[b];
×
2559
                         });
UNCOV
2560
        idx.resize(keep);
×
UNCOV
2561
        std::sort(idx.begin(), idx.end());
×
UNCOV
2562
        std::vector<std::string> kept;
×
UNCOV
2563
        kept.reserve(keep);
×
UNCOV
2564
        for (std::size_t i : idx) kept.emplace_back(std::move(big[i]));
×
UNCOV
2565
        big.swap(kept);
×
UNCOV
2566
        truncated = true;
×
UNCOV
2567
    }
×
2568

2569
    co_await append_app_spans(big, index, begin, end, params);
4!
2570

2571
    // Stable per-event/-block depth (computed in absolute space, before ts
2572
    // normalization rewrites the big strings; order is preserved in place).
2573
    std::vector<std::uint32_t> big_depth =
1✔
2574
        assign_view_depths(big, dens, begin, threshold);
1!
2575
    if (global_min > 0) {
1!
2576
        for (auto& e : big) e = normalize_event_ts(e, global_min);
1!
2577
    }
1✔
2578

2579
    co_return HttpResponse::ok(serialize_density_body(
2!
2580
        big, dens, original_begin, original_end, threshold, limit, truncated,
1✔
2581
        global_min > 0, index.global_min_timestamp_us(), max_dur, &big_depth));
1✔
2582
}
22✔
2583

2584
static std::string serialize_counters_body(const std::vector<double>& read,
1✔
2585
                                           const std::vector<double>& write,
2586
                                           const std::vector<double>& ops,
2587
                                           double original_begin,
2588
                                           double original_end, int buckets,
2589
                                           double bucket_us, bool truncated) {
2590
    auto& b = scratch_json_builder();
1✔
2591
    b.start_object();
1✔
2592
    b.append_key_value("begin", original_begin);
1✔
2593
    b.append_comma();
1✔
2594
    b.append_key_value("end", original_end);
1✔
2595
    b.append_comma();
1✔
2596
    b.append_key_value("buckets", static_cast<std::int64_t>(buckets));
1✔
2597
    b.append_comma();
1✔
2598
    b.append_key_value("bucket_us", bucket_us);
1✔
2599
    b.append_comma();
1✔
2600
    b.append_key_value("truncated", truncated);
1✔
2601
    b.append_comma();
1✔
2602
    b.append_key_value("read_bytes", read);
1✔
2603
    b.append_comma();
1✔
2604
    b.append_key_value("write_bytes", write);
1✔
2605
    b.append_comma();
1✔
2606
    b.append_key_value("ops", ops);
1✔
2607
    b.end_object();
1✔
2608
    return std::string(b);
1✔
2609
}
2610

2611
// Re-aggregate the summary's finest counter buckets into `buckets` output
2612
// buckets over [begin_abs, end_abs] (absolute us).
2613
static std::string serve_counters_from_summary(const VizSummary& s,
1✔
2614
                                               double begin_abs, double end_abs,
2615
                                               double original_begin,
2616
                                               double original_end, int buckets,
2617
                                               double bucket_us_out) {
2618
    std::vector<double> read(buckets, 0.0), write(buckets, 0.0),
1!
2619
        ops(buckets, 0.0);
1!
2620
    std::int64_t fb0 = s.bucket_of(begin_abs);
1!
2621
    std::int64_t fb1 = s.bucket_of(end_abs);
1!
2622
    if (fb0 < 0) fb0 = 0;
1!
2623
    if (fb1 < 0) fb1 = static_cast<std::int64_t>(s.nbuckets) - 1;
1!
2624
    for (std::int64_t fb = fb0; fb <= fb1; ++fb) {
65,537✔
2625
        double center = static_cast<double>(s.t_begin) +
131,072✔
2626
                        (static_cast<double>(fb) + 0.5) * s.bucket_us;
65,536✔
2627
        long oi = static_cast<long>((center - begin_abs) / bucket_us_out);
65,536✔
2628
        if (oi < 0 || oi >= buckets) continue;
65,536!
2629
        auto f = static_cast<std::size_t>(fb);
65,536✔
2630
        read[oi] += s.read_bytes[f];
65,536✔
2631
        write[oi] += s.write_bytes[f];
65,536✔
2632
        ops[oi] += s.ops[f];
65,536✔
2633
    }
65,536✔
2634
    return serialize_counters_body(read, write, ops, original_begin,
2!
2635
                                   original_end, buckets, bucket_us_out, false);
1✔
2636
}
1✔
2637

2638
// GET /api/v1/viz/counters: per-bucket read/write bytes and I/O op counts over
2639
// a time range, for bandwidth/IOPS counter tracks. Aggregated server-side in
2640
// parallel (per-worker arrays merged after join).
2641
static coro::CoroTask<HttpResponse> handle_viz_counters(
7!
2642
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
2643
    if (!params.has("begin") || !params.has("end")) {
3!
2644
        co_return HttpResponse::bad_request(
5!
2645
            "Missing required parameters: begin, end");
4!
2646
    }
2647

2648
    double begin = params.get_double("begin", 0);
3✔
2649
    double end = params.get_double("end", 0);
3✔
2650
    int buckets = params.get_int("buckets", 800);
3✔
2651
    if (buckets < 16) buckets = 16;
3!
2652
    if (buckets > 4000) buckets = 4000;
3!
2653

2654
    auto query = params.get("query");
3✔
2655
    if (!query.empty() &&
3!
UNCOV
2656
        !utilities::common::query::try_parse(query).has_value()) {
×
UNCOV
2657
        co_return HttpResponse::bad_request("Invalid query: " +
×
UNCOV
2658
                                            std::string(query));
×
2659
    }
2660

2661
    auto ts_norm_param = params.get("ts_normalize");
3✔
2662
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
2663
    std::uint64_t global_min = 0;
3✔
2664
    if (normalize) {
3✔
2665
        global_min = index.global_min_timestamp_us();
1✔
2666
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
UNCOV
2667
            global_min = 0;
×
2668
    }
1✔
2669
    double original_begin = begin;
3✔
2670
    double original_end = end;
3✔
2671
    if (normalize && global_min > 0) {
3!
2672
        begin += static_cast<double>(global_min);
1✔
2673
        end += static_cast<double>(global_min);
1✔
2674
    }
1✔
2675

2676
    double bucket_us = (end - begin) / static_cast<double>(buckets);
3✔
2677
    if (bucket_us <= 0) bucket_us = 1;
3!
2678

2679
    // Zoomed-out, unfiltered counter tracks come from the activity summary.
2680
    if (viz_summary_eligible(params)) {
3!
2681
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
2682
        if (s && s->bucket_us > 0 && s->t_end > s->t_begin &&
1!
2683
            bucket_us >= s->bucket_us) {
1✔
2684
            co_return HttpResponse::ok(
1!
2685
                serve_counters_from_summary(*s, begin, end, original_begin,
2!
2686
                                            original_end, buckets, bucket_us));
1✔
2687
        }
2688
    }
1!
2689

UNCOV
2690
    ViewDefinition view = build_viz_view(params, begin, end, 0);
×
2691
    // No cap: only zoomed-in or filtered counter queries reach the live path.
UNCOV
2692
    int limit = params.get_int("limit", 0);
×
UNCOV
2693
    if (limit < 0) limit = 0;
×
2694

UNCOV
2695
    std::vector<const TraceIndex::FileInfo*> target_files =
×
UNCOV
2696
        select_viz_target_files(index, params, begin, end);
×
2697

UNCOV
2698
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
UNCOV
2699
    std::vector<CounterAcc> accs(slots);
×
UNCOV
2700
    for (auto& a : accs) a.init(static_cast<std::size_t>(buckets));
×
2701
    auto on_batch = [&accs, begin, bucket_us, buckets](
×
2702
                        std::size_t w,
2703
                        const std::vector<std::string_view>& events) {
2704
        CounterAcc& acc = accs[w];  // worker-owned, no lock
×
2705
        for (auto ev : events)
×
2706
            fold_counter(ev, begin, bucket_us,
×
UNCOV
2707
                         static_cast<std::size_t>(buckets), acc);
×
2708
    };
×
2709

UNCOV
2710
    bool truncated =
×
UNCOV
2711
        co_await scan_view_events(index, target_files, view, begin, end, limit,
×
UNCOV
2712
                                  slots, on_batch, !params.get("file").empty());
×
2713

UNCOV
2714
    CounterAcc total;
×
UNCOV
2715
    total.init(static_cast<std::size_t>(buckets));
×
UNCOV
2716
    for (auto& a : accs) total.merge_from(a);
×
2717

UNCOV
2718
    co_return HttpResponse::ok(serialize_counters_body(
×
UNCOV
2719
        total.read_bytes, total.write_bytes, total.ops, original_begin,
×
UNCOV
2720
        original_end, buckets, bucket_us, truncated));
×
2721
}
27!
2722

2723
// Process-spawning calls: dftracer POSIX (exact "fork"/"clone"/...) and kernel
2724
// syscalls ("__arm64_sys_clone"). Excludes library helpers like ibv_*fork* and
2725
// register_tm_clones, which contain "fork"/"clone" but don't spawn.
2726
static bool is_fork_syscall(std::string_view name) {
50✔
2727
    return name == "fork" || name == "vfork" || name == "clone" ||
100!
2728
           name == "clone3" || name == "posix_spawn" ||
50!
2729
           name == "posix_spawnp" ||
50!
2730
           name.find("sys_clone") != std::string_view::npos ||
50!
2731
           name.find("sys_fork") != std::string_view::npos ||
50!
2732
           name.find("sys_vfork") != std::string_view::npos;
50✔
2733
}
2734

2735
// One process in the proctree response. `host` borrows the hostname table;
2736
// `rank` is null when the trace carries no "PR" metadata for the pid, and the
2737
// key is then omitted.
2738
struct ProcNode {
2739
    std::int64_t pid;
2740
    std::int64_t parent;
2741
    std::uint64_t spawn_ts;
2742
    std::uint64_t first_ts;
2743
    std::string_view host;
2744
    std::uint64_t bytes;
2745
    std::uint64_t io_ops;
2746
    double io_busy;
2747
    const std::string* rank;
2748
};
2749

2750
template <typename builder_type>
2751
void tag_invoke(simdjson::serialize_tag, builder_type& b, const ProcNode& n) {
50✔
2752
    b.start_object();
50✔
2753
    b.append_key_value("pid", n.pid);
50✔
2754
    b.append_comma();
50✔
2755
    b.append_key_value("parent", n.parent);
50✔
2756
    b.append_comma();
50✔
2757
    b.append_key_value("spawn_ts", n.spawn_ts);
50✔
2758
    b.append_comma();
50✔
2759
    b.append_key_value("first_ts", n.first_ts);
50✔
2760
    b.append_comma();
50✔
2761
    b.append_key_value("host", n.host);
50✔
2762
    b.append_comma();
50✔
2763
    b.append_key_value("bytes", n.bytes);
50✔
2764
    b.append_comma();
50✔
2765
    b.append_key_value("io_ops", n.io_ops);
50✔
2766
    b.append_comma();
50✔
2767
    b.append_key_value("io_busy", n.io_busy);
50✔
2768
    if (n.rank) {
50!
2769
        b.append_comma();
×
2770
        b.append_key_value("rank", *n.rank);
×
UNCOV
2771
    }
×
2772
    b.end_object();
50✔
2773
}
50✔
2774

2775
// GET /api/v1/viz/proctree: infer the process fork hierarchy. The traces record
2776
// the fork/clone in the parent but not the child pid, so link each process to
2777
// the nearest preceding clone in another process (child start follows the clone
2778
// by microseconds). Respects ?file= for per-node trees on multi-node traces.
2779
static coro::CoroTask<HttpResponse> handle_viz_proctree(
5!
2780
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
2781
    std::uint64_t gmin = index.global_min_timestamp_us();
1!
2782
    std::uint64_t gmax = index.global_max_timestamp_us();
1!
2783
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
1!
2784
        co_return HttpResponse::ok("{\"nodes\":[]}");
1!
2785

2786
    auto ts_norm_param = params.get("ts_normalize");
1!
2787
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
1!
2788
    std::uint64_t base = normalize ? gmin : 0;
1!
2789

2790
    struct Acc {
2!
2791
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
2792
        // Explicit parent from the process metadata's args.ppid.
2793
        ankerl::unordered_dense::map<std::int64_t, std::int64_t> ppid;
2794
        // (ts, parent_pid, child_pid): child_pid is args.ret when the fork
2795
        // event records it (dftracer POSIX), else -1 to fall back to inference.
2796
        std::vector<std::tuple<std::uint64_t, std::int64_t, std::int64_t>>
2797
            forks;
2798
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
2799
        // I/O operation count and busy time (us) per process, over I/O-category
2800
        // (POSIX/STDIO/IO) events only.
2801
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
2802
        ankerl::unordered_dense::map<std::int64_t, double> io_busy;
2803
        ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
2804
        // pid -> rank, from "PR" metadata (args.name == "rank").
2805
        ankerl::unordered_dense::map<std::int64_t, std::string> rank;
2806
        dftracer::utils::StringViewMap<std::string> hh;
2807
    };
2808
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
1!
2809
    std::vector<Acc> accs(slots);
1!
2810
    auto on_batch = [&accs](std::size_t w,
2✔
2811
                            const std::vector<std::string_view>& events) {
2812
        thread_local simdjson::dom::parser parser;
1!
2813
        thread_local std::string buf;
1!
2814
        Acc& acc = accs[w];
1✔
2815
        for (auto ev : events) {
51✔
2816
            buf.assign(ev);
50✔
2817
            auto res = parser.parse(buf);
50✔
2818
            if (res.error()) continue;
50!
2819
            auto root = res.value_unsafe();
50✔
2820
            if (!root.is_object()) continue;
50!
2821
            // HH metadata (hhash -> hostname) carries no ts; handle it first.
2822
            {
2823
                auto nm = root["name"];
50✔
2824
                if (!nm.error() && nm.is_string() &&
100!
2825
                    nm.get_string().value_unsafe() == "HH") {
50✔
2826
                    auto a = root["args"];
×
2827
                    if (!a.error() && a.is_object()) {
×
2828
                        auto v = a["value"];
×
2829
                        auto n = a["name"];
×
2830
                        if (!v.error() && v.is_string() && !n.error() &&
×
2831
                            n.is_string())
×
2832
                            acc.hh.emplace(
×
2833
                                std::string(v.get_string().value_unsafe()),
×
2834
                                std::string(n.get_string().value_unsafe()));
×
UNCOV
2835
                    }
×
2836
                    continue;
×
2837
                }
2838
            }
2839
            // PR metadata (pid -> rank) also carries no ts.
2840
            {
2841
                auto nm = root["name"];
50✔
2842
                if (!nm.error() && nm.is_string() &&
100!
2843
                    nm.get_string().value_unsafe() == "PR") {
50✔
2844
                    auto a = root["args"];
×
2845
                    auto pp = root["pid"];
×
2846
                    if (!a.error() && a.is_object() && !pp.error()) {
×
2847
                        auto an = a["name"];
×
2848
                        auto av = a["value"];
×
2849
                        if (!an.error() && an.is_string() &&
×
2850
                            an.get_string().value_unsafe() == "rank" &&
×
2851
                            !av.error() && av.is_string())
×
2852
                            acc.rank.emplace(
×
2853
                                static_cast<std::int64_t>(
×
2854
                                    json_number(pp.value_unsafe())),
×
2855
                                std::string(av.get_string().value_unsafe()));
×
UNCOV
2856
                    }
×
2857
                    continue;
×
2858
                }
2859
            }
2860
            auto pr = root["pid"];
50✔
2861
            auto tr = root["ts"];
50✔
2862
            if (pr.error() || tr.error()) continue;
50!
2863
            auto pid =
50✔
2864
                static_cast<std::int64_t>(json_number(pr.value_unsafe()));
50✔
2865
            auto ts =
50✔
2866
                static_cast<std::uint64_t>(json_number(tr.value_unsafe()));
50✔
2867
            auto it = acc.first_ts.find(pid);
50✔
2868
            if (it == acc.first_ts.end())
50!
2869
                acc.first_ts.emplace(pid, ts);
50✔
2870
            else if (ts < it->second)
×
2871
                it->second = ts;
×
2872

2873
            auto nr = root["name"];
50✔
2874
            std::string_view name;
50✔
2875
            if (!nr.error() && nr.is_string())
50!
2876
                name = nr.get_string().value_unsafe();
50✔
2877

2878
            std::int64_t child = -1;
50✔
2879
            double ret = 0;
50✔
2880
            auto args = root["args"];
50✔
2881
            if (!args.error() && args.is_object()) {
50!
2882
                auto ppr = args["ppid"];
50✔
2883
                if (!ppr.error()) {
50!
UNCOV
2884
                    auto pp = static_cast<std::int64_t>(
×
2885
                        json_number(ppr.value_unsafe()));
×
2886
                    if (pp > 0 && pp != pid) acc.ppid[pid] = pp;
×
UNCOV
2887
                }
×
2888
                auto rr = args["ret"];
50✔
2889
                if (!rr.error()) {
50!
2890
                    ret = json_number(rr.value_unsafe());
50✔
2891
                    child = static_cast<std::int64_t>(ret);
50✔
2892
                }
50✔
2893
                auto hr = args["hhash"];
50✔
2894
                if (!hr.error() && hr.is_string() &&
100!
2895
                    acc.pid_hhash.find(pid) == acc.pid_hhash.end())
50✔
2896
                    acc.pid_hhash.emplace(
100!
2897
                        pid, std::string(hr.get_string().value_unsafe()));
50✔
2898
            }
50✔
2899
            // I/O bytes per process: args.ret on read/write ops.
2900
            if (ret > 0 && (name.find("read") != std::string_view::npos ||
50!
2901
                            name.find("write") != std::string_view::npos))
31✔
2902
                acc.bytes[pid] += static_cast<std::uint64_t>(ret);
38✔
2903

2904
            auto cr = root["cat"];
50✔
2905
            std::string_view cat;
50✔
2906
            if (!cr.error() && cr.is_string())
50!
2907
                cat = cr.get_string().value_unsafe();
50✔
2908
            if (cat == "POSIX" || cat == "STDIO" || cat == "IO") {
50!
2909
                acc.io_ops[pid] += 1;
50✔
2910
                auto dr = root["dur"];
50✔
2911
                if (!dr.error())
50!
2912
                    acc.io_busy[pid] += json_number(dr.value_unsafe());
50✔
2913
            }
50✔
2914

2915
            if (!name.empty() && is_fork_syscall(name))
50!
2916
                acc.forks.emplace_back(ts, pid, child > 0 ? child : -1);
×
2917
        }
2918
    };
1✔
2919

2920
    ViewDefinition view;
1✔
2921
    view.name = "viz_proctree";
1!
2922
    view.with_query("ts >= " + std::to_string(gmin) +
2!
2923
                    " and ts <= " + std::to_string(gmax));
1!
2924
    auto files = select_viz_target_files(
2!
2925
        index, params, static_cast<double>(gmin), static_cast<double>(gmax));
1✔
2926
    bool single_file = !params.get("file").empty();
1!
2927
    co_await scan_view_events(index, files, view, static_cast<double>(gmin),
2!
2928
                              static_cast<double>(gmax), 0, slots, on_batch,
1!
2929
                              single_file);
1✔
2930

2931
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
1!
2932
    ankerl::unordered_dense::map<std::int64_t, std::int64_t> parent_of;
1!
2933
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> spawn_of;
1!
2934
    std::vector<std::pair<std::uint64_t, std::int64_t>> inf_forks;  // (ts, pid)
1✔
2935
    for (auto& a : accs) {
3✔
2936
        for (auto& kv : a.first_ts) {
52✔
2937
            auto it = first_ts.find(kv.first);
50!
2938
            if (it == first_ts.end())
50!
2939
                first_ts.emplace(kv.first, kv.second);
50!
UNCOV
2940
            else if (kv.second < it->second)
×
UNCOV
2941
                it->second = kv.second;
×
2942
        }
50✔
2943
        // Explicit edges from the fork event's args.ret (child pid + spawn ts).
2944
        for (auto& [ts, ppid, child] : a.forks) {
2!
UNCOV
2945
            if (child > 0) {
×
UNCOV
2946
                parent_of[child] = ppid;
×
UNCOV
2947
                spawn_of[child] = ts;
×
UNCOV
2948
            } else {
×
UNCOV
2949
                inf_forks.emplace_back(ts, ppid);
×
2950
            }
UNCOV
2951
        }
×
2952
    }
2✔
2953
    // args.ppid metadata fills in any process not linked by a fork event.
2954
    for (auto& a : accs)
3✔
2955
        for (auto& kv : a.ppid)
2!
UNCOV
2956
            if (parent_of.find(kv.first) == parent_of.end())
×
2957
                parent_of.emplace(kv.first, kv.second);
2!
2958
    std::sort(inf_forks.begin(), inf_forks.end());
1!
2959

2960
    // Merge host (hhash -> hostname resolved) and I/O bytes per process.
2961
    dftracer::utils::StringViewMap<std::string> hh;
1!
2962
    ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
1!
2963
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
1!
2964
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
1!
2965
    ankerl::unordered_dense::map<std::int64_t, double> io_busy;
1!
2966
    ankerl::unordered_dense::map<std::int64_t, std::string> rank;
1!
2967
    for (auto& a : accs) {
3✔
2968
        for (auto& kv : a.hh) hh.emplace(kv.first, kv.second);
2!
2969
        for (auto& kv : a.pid_hhash) pid_hhash.emplace(kv.first, kv.second);
52!
2970
        for (auto& kv : a.bytes) bytes[kv.first] += kv.second;
40!
2971
        for (auto& kv : a.io_ops) io_ops[kv.first] += kv.second;
52!
2972
        for (auto& kv : a.io_busy) io_busy[kv.first] += kv.second;
52!
2973
        for (auto& kv : a.rank) rank.emplace(kv.first, kv.second);
2!
2974
    }
2✔
2975

2976
    std::vector<std::pair<std::uint64_t, std::int64_t>>
1✔
2977
        procs;  // (first_ts, pid)
1✔
2978
    procs.reserve(first_ts.size());
1!
2979
    for (auto& kv : first_ts) procs.emplace_back(kv.second, kv.first);
51!
2980
    std::sort(procs.begin(), procs.end());
1!
2981

2982
    // Time-inference fallback (traces without args.ret/ppid): link a process to
2983
    // the nearest preceding clone in another process.
2984
    std::vector<bool> used(inf_forks.size(), false);
1!
2985
    std::vector<ProcNode> nodes;
1✔
2986
    nodes.reserve(procs.size());
1!
2987
    for (auto& [fts, pid] : procs) {
51✔
2988
        std::int64_t parent = -1;
50✔
2989
        std::uint64_t spawn_ts = 0;
50✔
2990
        auto pit = parent_of.find(pid);
50!
2991
        auto sit = spawn_of.find(pid);
50!
2992
        // A fork cannot spawn a child that already existed before it: reject
2993
        // such edges (pid reuse across runs) and fall back to time inference.
2994
        bool valid = pit != parent_of.end() &&
50!
UNCOV
2995
                     (sit == spawn_of.end() || sit->second <= fts);
×
2996
        if (valid) {
50!
UNCOV
2997
            parent = pit->second;
×
UNCOV
2998
            if (sit != spawn_of.end()) spawn_ts = sit->second - base;
×
UNCOV
2999
        } else {
×
3000
            auto hi = std::upper_bound(
100!
3001
                inf_forks.begin(), inf_forks.end(),
100✔
3002
                std::make_pair(fts, std::numeric_limits<std::int64_t>::max()));
50!
3003
            for (auto it = hi; it != inf_forks.begin();) {
50!
UNCOV
3004
                --it;
×
UNCOV
3005
                auto idx = static_cast<std::size_t>(it - inf_forks.begin());
×
UNCOV
3006
                if (!used[idx] && it->second != pid) {
×
UNCOV
3007
                    used[idx] = true;
×
UNCOV
3008
                    parent = it->second;
×
UNCOV
3009
                    spawn_ts = it->first - base;
×
UNCOV
3010
                    break;
×
3011
                }
UNCOV
3012
            }
×
3013
        }
50✔
3014
        std::string_view host;
50✔
3015
        auto hp = pid_hhash.find(pid);
50!
3016
        if (hp != pid_hhash.end()) {
50!
3017
            auto hn = hh.find(hp->second);
50!
3018
            if (hn != hh.end()) host = hn->second;
50!
3019
        }
50✔
3020
        auto bp = bytes.find(pid);
50!
3021
        auto op = io_ops.find(pid);
50!
3022
        auto ib = io_busy.find(pid);
50!
3023
        auto rk = rank.find(pid);
50!
3024
        nodes.push_back({pid, parent, spawn_ts, fts - base, host,
250!
3025
                         bp != bytes.end() ? bp->second : 0,
50✔
3026
                         op != io_ops.end() ? op->second : 0,
50!
3027
                         ib != io_busy.end() ? ib->second : 0.0,
50!
3028
                         rk != rank.end() ? &rk->second : nullptr});
50!
3029
    }
50✔
3030

3031
    auto& sb = scratch_json_builder();
1!
3032
    sb.start_object();
1✔
3033
    sb.append_key_value("nodes", nodes);
1!
3034
    sb.end_object();
1✔
3035
    co_return HttpResponse::ok(std::string(sb));
1!
3036
}
3!
3037

3038
static coro::CoroTask<HttpResponse> handle_viz_breaks(
28!
3039
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
4!
3040
    auto ts_norm_param = params.get("ts_normalize");
12✔
3041
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
12!
3042
    std::uint64_t global_min = 0;
12✔
3043
    if (normalize) {
12✔
3044
        global_min = index.global_min_timestamp_us();
4✔
3045
        if (global_min == std::numeric_limits<std::uint64_t>::max())
4!
UNCOV
3046
            global_min = 0;
×
3047
    }
4✔
3048
    const VizSummary* s = co_await ensure_viz_summary(index);
20!
3049
    auto& b = scratch_json_builder();
4!
3050
    b.start_object();
4✔
3051
    b.escape_and_append_with_quotes("gaps");
4✔
3052
    b.append_colon();
4✔
3053
    b.start_array();
4✔
3054
    if (s) {
4!
3055
        bool first = true;
4✔
3056
        for (const auto& g : s->idle_gaps) {
54✔
3057
            if (!first) b.append_comma();
50✔
3058
            first = false;
50✔
3059
            b.start_object();
50✔
3060
            b.append_key_value("begin", g.first - global_min);
50✔
3061
            b.append_comma();
50✔
3062
            b.append_key_value("end", g.second - global_min);
50✔
3063
            b.end_object();
50✔
3064
        }
50✔
3065
    }
4✔
3066
    b.end_array();
4✔
3067
    b.append_comma();
4✔
3068
    b.append_key_value("multi_run", s && !s->idle_gaps.empty());
4!
3069
    b.end_object();
4✔
3070
    co_return HttpResponse::ok(std::string(b));
4!
3071
}
28!
3072

3073
void register_viz_api(Router& router, TraceIndex& index) {
6✔
3074
    auto* index_ptr = &index;
6✔
3075
    const RouteParam BEGIN{"begin", "Window start (us)", true, "0"};
6!
3076
    const RouteParam END{"end", "Window end (us)", true, "999999999"};
6!
3077
    const RouteParam SUMMARY{"summary", "LOD level (1=full detail)", true, "1"};
6!
3078

3079
    router.get(
12!
3080
        "/api/v1/viz/proctree",
6!
3081
        [index_ptr](const HttpRequest& req,
13!
3082
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3083
            co_return co_await handle_viz_proctree(req, params, *index_ptr);
5!
3084
        },
2✔
3085
        RouteDoc{
12✔
3086
            "Inferred process/fork hierarchy with host, rank, and I/O.",
6!
3087
            "Visualization",
6!
3088
            {{"file", "Limit to one trace file", false, ""}},
6!
3089
            R"({"nodes":[{"pid":100,"parent":-1,"host":"node01","rank":"0",)"
6!
3090
            R"("bytes":16384,"io_ops":4,"io_busy":600.0}]})"});
3091

3092
    router.get(
12!
3093
        "/api/v1/viz/counters",
6!
3094
        [index_ptr](const HttpRequest& req,
13!
3095
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3096
            co_return co_await handle_viz_counters(req, params, *index_ptr);
5!
3097
        },
2✔
3098
        RouteDoc{"Read/write bytes and I/O op counts per time bucket.",
12!
3099
                 "Visualization",
6!
3100
                 {BEGIN, END, SUMMARY},
6!
3101
                 R"({"buckets":[{"ts":0,"read_bytes":4096,"write_bytes":0,)"
6!
3102
                 R"("read_ops":1,"write_ops":0}]})"});
3103

3104
    router.get(
12!
3105
        "/api/v1/viz/breaks",
6!
3106
        [index_ptr](const HttpRequest& req,
34!
3107
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
4!
3108
            co_return co_await handle_viz_breaks(req, params, *index_ptr);
20!
3109
        },
8✔
3110
        RouteDoc{
12✔
3111
            "Globally-idle time gaps and multi-run detection.",
6!
3112
            "Visualization",
6!
3113
            {{"ts_normalize", "Normalize to global min (default on)", false,
6!
3114
              "1"}},
6!
3115
            R"({"gaps":[{"begin":50000,"end":900000}],"multi_run":true})"});
6!
3116

3117
    router.get(
12!
3118
        "/api/v1/viz/events",
6!
3119
        [index_ptr](const HttpRequest& req,
55!
3120
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
7!
3121
            co_return co_await handle_viz_events(req, params, *index_ptr);
35!
3122
        },
14✔
3123
        RouteDoc{"Events for rendering: time-windowed, LOD-aggregated.",
12!
3124
                 "Visualization",
6!
3125
                 {BEGIN,
24!
3126
                  END,
6!
3127
                  SUMMARY,
6!
3128
                  {"pid", "Filter by process id", false, ""},
6!
3129
                  {"cat", "Filter by category", false, ""},
6!
3130
                  {"query", "DSL predicate, e.g. dur >= 1000", false, ""}},
6!
3131
                 R"({"events":[],"metadata":{"begin":0,"end":1000000,)"
6!
3132
                 R"("count":42,"truncated":false,"ts_normalized":true}})"});
3133

3134
    router.get(
12!
3135
        "/api/v1/viz/density",
6!
3136
        [index_ptr](const HttpRequest& req,
20!
3137
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
2!
3138
            co_return co_await handle_viz_density(req, params, *index_ptr);
10!
3139
        },
4✔
3140
        RouteDoc{"Sub-pixel events bucketed into density blocks.",
12!
3141
                 "Visualization",
6!
3142
                 {BEGIN,
18!
3143
                  END,
6!
3144
                  {"summary", "LOD level", true, "2"},
6!
3145
                  {"width", "Canvas width in px (sets the 1px fold cutoff)",
6!
3146
                   false, "1920"}},
6!
3147
                 R"({"events":[],"density":[{"pid":100,"tid":100,"ts":0,)"
6!
3148
                 R"("dur":24457,"count":910,"total":22044,"depth":0}]})"});
3149

3150
    router.get(
12!
3151
        "/api/v1/viz/stats",
6!
3152
        [index_ptr](const HttpRequest& req,
13!
3153
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3154
            co_return co_await handle_viz_stats(req, params, *index_ptr);
5!
3155
        },
2✔
3156
        RouteDoc{"Per-name aggregation over a time range (Analyze).",
12!
3157
                 "Visualization",
6!
3158
                 {BEGIN, END, SUMMARY},
6!
3159
                 R"({"count":100,"total_dur":5000,"names":[{"name":"read",)"
6!
3160
                 R"("count":50,"total":2500,"avg":50,"min":10,"max":90}]})"});
3161

3162
    router.get(
12!
3163
        "/api/v1/viz/calltree",
6!
3164
        [index_ptr](const HttpRequest& req,
13!
3165
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3166
            co_return co_await handle_viz_calltree(req, params, *index_ptr);
5!
3167
        },
2✔
3168
        RouteDoc{
12✔
3169
            "Merged flamegraph tree from ts/dur containment.",
6!
3170
            "Visualization",
6!
3171
            {BEGIN,
12!
3172
             END,
6!
3173
             SUMMARY,
6!
3174
             {"group", "Set to 'pid' to keep processes separate", false, ""}},
6!
3175
            R"({"name":"root","total":5000,"self":0,"count":0,)"
6!
3176
            R"("children":[{"name":"read","total":2500,"self":2500,)"
3177
            R"("count":50}]})"});
3178

3179
    router.get(
12!
3180
        "/api/v1/viz/histogram",
6!
3181
        [index_ptr](const HttpRequest& req,
13!
3182
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3183
            co_return co_await handle_viz_histogram(req, params, *index_ptr);
5!
3184
        },
2✔
3185
        RouteDoc{"Duration distribution: percentiles + log-spaced buckets.",
12!
3186
                 "Visualization",
6!
3187
                 {BEGIN,
12!
3188
                  END,
6!
3189
                  SUMMARY,
6!
3190
                  {"query", "DSL predicate to narrow to one op", false, ""}},
6!
3191
                 R"({"min":10,"max":900,"p50":150,"p99":880,"buckets":[]})"});
6!
3192

3193
    router.get(
12!
3194
        "/api/v1/viz/layers",
6!
3195
        [index_ptr](const HttpRequest& req,
20!
3196
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
2!
3197
            co_return co_await handle_viz_layers(req, params, *index_ptr);
10!
3198
        },
4✔
3199
        RouteDoc{"Operation-name to category map; declared vs I/O files.",
6!
3200
                 "Visualization",
6!
3201
                 {},
6✔
3202
                 R"({"layers":{"read":"POSIX","write":"POSIX"},)"
6!
3203
                 R"("total_files":2,"io_files":2})"});
3204
}
6✔
3205

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