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

llnl / dftracer-utils / 29145320966

11 Jul 2026 07:56AM UTC coverage: 52.68% (-0.008%) from 52.688%
29145320966

push

github

hariharan-devarajan
feat(viz): timelapse axis for multi-run traces

Fold the summary's per-lane activity plus app-span coverage into
globally-idle spans (no lane active, no process alive), so only
between-run dead time is flagged; expose them and a multi_run flag via
GET /api/v1/viz/breaks.

Compress those gaps in the timeline to a fixed break width via a
piecewise-linear real<->display transform: viewport math runs in the
compressed space while events keep real ts, mapped through xOf; fetches,
selection and readouts convert back at the boundary. Auto-enable on
multi-run traces with a Timelapse toggle and a "skip <dur>" marker.

Reject a fork edge whose spawn timestamp is after the child's first
event, so pid reuse across runs no longer links processes between them.

39581 of 97387 branches covered (40.64%)

Branch coverage included in aggregate %.

86 of 86 new or added lines in 1 file covered. (100.0%)

208 existing lines in 8 files now uncovered.

35117 of 44409 relevant lines covered (79.08%)

21360.21 hits per line

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

56.62
/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",
266!
46
                                                                    "SH"};
266!
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,
300✔
56
                                      std::uint64_t offset) {
57
    thread_local simdjson::dom::parser tl_parser;
300✔
58
    auto result = tl_parser.parse(event_json);
300✔
59
    if (result.error()) return event_json;
300!
60

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

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

67
    std::uint64_t old_ts = 0;
300✔
68
    if (ts_result.is_uint64()) {
300!
69
        old_ts = ts_result.get_uint64().value_unsafe();
300✔
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;
×
73
    } else {
74
        return event_json;
×
75
    }
76

77
    std::uint64_t new_ts = old_ts >= offset ? old_ts - offset : 0;
300!
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;
300!
82
    auto pos = modified.find("\"ts\":");
300✔
83
    if (pos == std::string::npos) return event_json;
300!
84

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

88
    auto end_pos = pos;
300✔
89
    while (end_pos < modified.size() &&
5,100✔
90
           (std::isdigit(modified[end_pos]) || modified[end_pos] == '-')) {
3,300!
91
        ++end_pos;
3,000✔
92
    }
93

94
    modified.replace(pos, end_pos - pos, std::to_string(new_ts));
300!
95
    return modified;
300✔
96
}
300✔
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,
16✔
101
                                 unsigned viewport_width = 1920) {
102
    if (level <= 1) return 0.0;
16✔
103
    double range = end - begin;
2✔
104
    return range /
2✔
105
           (static_cast<double>(viewport_width) * static_cast<double>(level));
2✔
106
}
8✔
107

108
static std::string extract_json_value(simdjson::dom::element val) {
6✔
109
    if (val.is_string()) {
6✔
110
        return std::string(val.get_string().value_unsafe());
3!
111
    }
112
    if (val.is_int64()) {
4!
113
        return std::to_string(val.get_int64().value_unsafe());
6!
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,
2✔
122
                               const std::string& val) {
123
    if (!dsl.empty()) dsl += " and ";
2✔
124
    bool numeric =
1✔
125
        !val.empty() && std::all_of(val.begin(), val.end(),
2!
126
                                    [](char c) { return std::isdigit(c); });
2✔
127
    if (numeric) {
2!
128
        dsl += std::string(field) + " == " + val;
2!
129
    } else {
1✔
130
        dsl += std::string(field) + " == \"" + val + "\"";
×
131
    }
132
}
2✔
133

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

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

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

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

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

154
            if (!field_result.value_unsafe().is_string()) continue;
2✔
155
            const char* field =
1✔
156
                field_result.value_unsafe().get_c_str().value_unsafe();
2✔
157
            auto val = extract_json_value(value_result.value_unsafe());
2!
158
            if (!val.empty()) append_lane_clause(dsl, field, val);
2✔
159
        }
2✔
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()) {
×
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
            }
×
174
        }
175
    }
176
}
9✔
177

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

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

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

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

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

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

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

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

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

216
        std::string query_op;
4✔
217
        if (op_str == "=")
4✔
218
            query_op = "==";
2!
219
        else if (op_str == ">=")
2!
220
            query_op = ">=";
2!
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 ";
4!
231
        bool numeric = !val.empty() && (std::isdigit(val[0]) || val[0] == '-');
6!
232
        if (numeric || query_op != "==") {
4!
233
            dsl += field_str + " " + query_op + " " + val;
4!
234
        } else {
2✔
235
            dsl += field_str + " " + query_op + " \"" + val + "\"";
×
236
        }
237
    }
4!
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,
18✔
244
                                     double end, double min_dur) {
245
    ViewDefinition view;
18✔
246
    view.name = "viz_query";
18!
247
    view.description = "Visualization query";
18!
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;
18✔
254
    dsl.reserve(128);
18!
255
    char numbuf[20];  // max digits of a uint64_t
256
    auto append_u64 = [&](std::uint64_t v) {
45✔
257
        dsl.append(numbuf, to_chars_u64(numbuf, numbuf + sizeof(numbuf), v));
36✔
258
    };
45✔
259

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

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

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

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

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

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

299
    view.with_query(dsl);
18!
300
    return view;
27✔
301
}
18!
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(
20✔
306
    TraceIndex& index, const QueryParams& params, double begin, double end) {
307
    auto target_files = collect_candidate_files(index, params);
20✔
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;
20!
312

313
    if (begin > 0 || end > 0) {
20!
314
        std::vector<const TraceIndex::FileInfo*> filtered;
20✔
315
        filtered.reserve(target_files.size());
20!
316
        for (auto* fi : target_files) {
40✔
317
            if (fi->min_timestamp_us == 0 && fi->max_timestamp_us == 0) {
20!
318
                filtered.push_back(fi);
×
319
                continue;
×
320
            }
321
            double fi_min = static_cast<double>(fi->min_timestamp_us);
20✔
322
            double fi_max = static_cast<double>(fi->max_timestamp_us);
20✔
323
            if (fi_max < begin || fi_min > end) continue;
20!
324
            filtered.push_back(fi);
18!
325
        }
326
        target_files = std::move(filtered);
20✔
327
    }
20✔
328
    return target_files;
20✔
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,
12✔
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) {
12✔
341
        for (auto& event : events) {
310✔
342
            event = normalize_event_ts(event, global_min);
300!
343
        }
344
    }
5✔
345

346
    auto& b = scratch_json_builder();
12✔
347
    b.start_object();
12✔
348
    b.escape_and_append_with_quotes("events");
12✔
349
    b.append_colon();
12✔
350
    b.start_array();
12✔
351
    for (std::size_t i = 0; i < events.size(); ++i) {
312✔
352
        if (i > 0) b.append_comma();
300✔
353
        b.append_raw(events[i]);  // Already JSON
300✔
354
    }
150✔
355
    b.end_array();
12✔
356
    b.append_comma();
12✔
357
    b.escape_and_append_with_quotes("metadata");
12✔
358
    b.append_colon();
12✔
359
    b.start_object();
12✔
360
    b.append_key_value("begin", meta_begin);
12✔
361
    b.append_comma();
12✔
362
    b.append_key_value("end", meta_end);
12✔
363
    b.append_comma();
12✔
364
    b.append_key_value("count", events.size());
12✔
365
    b.append_comma();
12✔
366
    b.append_key_value("limit", limit);
12✔
367
    b.append_comma();
12✔
368
    b.append_key_value("truncated", truncated);
12✔
369
    b.append_comma();
12✔
370
    b.append_key_value("ts_normalized", global_min > 0);
12✔
371
    b.append_comma();
12✔
372
    b.append_key_value("global_min_timestamp_us", display_global_min);
12✔
373
    b.end_object();
12✔
374
    b.end_object();
12✔
375
    return std::string(b);
12!
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(
66!
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) {
11!
400
    const std::int64_t cap =
22✔
401
        limit > 0 ? limit : std::numeric_limits<std::int64_t>::max();
11✔
402
    std::atomic<std::int64_t> produced{0};
11✔
403

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

414
    CoroScope scope(executor);
11!
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,
109!
420
                 produced_ptr, cap, begin, end,
44✔
421
                 scan_all_chunks](CoroScope&) mutable -> coro::CoroTask<void> {
22!
422
        auto guard = ch.guard();
11!
423
        for (auto* file_info : *target_files_ptr) {
37✔
424
            if (produced_ptr->load(std::memory_order_relaxed) >= cap) co_return;
21!
425
            if (file_info->uncompressed_size == 0 &&
10!
426
                file_info->num_checkpoints == 0)
427
                continue;
428

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

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

444
            for (const auto& c : build_output->candidates) {
48!
445
                if (produced_ptr->load(std::memory_order_relaxed) >= cap)
24!
446
                    co_return;
447
                if (!co_await ch.send(ScanWorkItem{
128!
448
                        file_info, c.start_byte, c.end_byte, c.checkpoint_idx}))
96✔
449
                    co_return;
450
            }
8✔
451
        }
26✔
452
        co_return;
11✔
453
    });
101!
454

455
    for (std::size_t w = 0; w < num_workers; ++w) {
33✔
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,
224!
459
                     cap](CoroScope&) -> coro::CoroTask<void> {
44!
460
            while (auto item = co_await chan->receive()) {
136!
461
                if (produced_ptr->load(std::memory_order_relaxed) >= cap)
8!
462
                    co_return;
463

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

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

487
    co_await scope.join();
33!
488
    co_return produced.load(std::memory_order_relaxed) >= cap;
11!
489
}
55!
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(
76!
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!
512
        !utilities::common::query::try_parse(query).has_value()) {
×
513
        co_return HttpResponse::bad_request("Invalid query: " +
×
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!
525
            global_min = 0;  // No valid bounds, skip normalization
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,
24✔
564
                               const std::vector<std::string_view>& events) {
3✔
565
        auto& out = partials[w];
6✔
566
        out.reserve(out.size() + events.size());
6✔
567
        for (auto ev : events) out.emplace_back(ev);
306✔
568
    };
6✔
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!
579
        collected_events.resize(static_cast<std::size_t>(limit));
×
580
        truncated = true;
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
}
268!
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;
UNCOV
598
    void merge_from(const NameStat& o) {
×
599
        if (count == 0) {
×
600
            min = o.min;
×
601
            max = o.max;
×
UNCOV
602
        } else if (o.count > 0) {
×
UNCOV
603
            min = std::min(min, o.min);
×
UNCOV
604
            max = std::max(max, o.max);
×
605
        }
UNCOV
606
        count += o.count;
×
UNCOV
607
        total += o.total;
×
UNCOV
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) {
2✔
618
    if (g == "cat") return GroupBy::Cat;
2!
619
    if (g == "pid") return GroupBy::Pid;
2!
620
    if (g == "fhash" || g == "file") return GroupBy::Fhash;
2!
621
    return GroupBy::Name;
2✔
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;
×
UNCOV
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)
×
UNCOV
637
    double dur = 0;
×
638
    if (dur_r.is_uint64())
×
UNCOV
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"];
×
UNCOV
653
        if (!r.error() && r.is_string()) name = r.get_string().value_unsafe();
×
UNCOV
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;
×
660
        }
661
    } else {  // Fhash
UNCOV
662
        auto args = root["args"];
×
UNCOV
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();
×
667
        }
668
        if (name.empty()) return;  // only I/O events have a file hash
×
669
    }
670

671
    auto it = local.find(name);
×
UNCOV
672
    if (it == local.end()) {
×
673
        it = local.emplace(std::string(name), NameStat{}).first;
×
674
        it->second.min = dur;
×
UNCOV
675
        it->second.max = dur;
×
676
    } else {
UNCOV
677
        it->second.min = std::min(it->second.min, dur);
×
UNCOV
678
        it->second.max = std::max(it->second.max, dur);
×
679
    }
UNCOV
680
    it->second.count += 1;
×
UNCOV
681
    it->second.total += dur;
×
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 {
2✔
693
        return pid == o.pid && tid == o.tid && col == o.col;
2!
694
    }
695
};
696

697
struct DensityKeyHash {
698
    std::uint64_t operator()(const DensityKey& k) const noexcept {
788✔
699
        std::uint64_t h = 1469598103934665603ULL;
788✔
700
        auto mix = [&](std::uint64_t v) {
2,758✔
701
            h ^= v;
2,364✔
702
            h *= 1099511628211ULL;
2,364✔
703
        };
2,758✔
704
        mix(static_cast<std::uint64_t>(k.pid));
788!
705
        mix(static_cast<std::uint64_t>(k.tid));
788!
706
        mix(static_cast<std::uint64_t>(k.col));
788!
707
        return h;
788✔
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) {
×
UNCOV
719
        count += o.count;
×
UNCOV
720
        total += o.total;
×
UNCOV
721
        if (o.max_dur > max_dur) {
×
UNCOV
722
            max_dur = o.max_dur;
×
UNCOV
723
            name = o.name;
×
724
        }
UNCOV
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,800✔
732
    if (el.is_uint64())
1,800!
733
        return static_cast<double>(el.get_uint64().value_unsafe());
1,800✔
UNCOV
734
    if (el.is_int64())
×
UNCOV
735
        return static_cast<double>(el.get_int64().value_unsafe());
×
UNCOV
736
    if (el.is_double()) return el.get_double().value_unsafe();
×
UNCOV
737
    return 0;
×
738
}
900✔
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,
100✔
743
                         DensityMap& dens, double* out_dur = nullptr) {
744
    thread_local simdjson::dom::parser parser;
100✔
745
    thread_local std::string buf;
100✔
746
    buf.assign(event);
100!
747
    auto res = parser.parse(buf);
100✔
748
    if (res.error()) return false;
100!
749
    auto root = res.value_unsafe();
100✔
750
    if (!root.is_object()) return false;
100✔
751

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

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

773
    std::int64_t col = static_cast<std::int64_t>((ts - begin) / threshold);
100✔
774
    DensityKey k{pid, tid, col};
100✔
775
    auto it = dens.find(k);
100!
776
    if (it == dens.end()) it = dens.emplace(k, DensityAgg{}).first;
100!
777
    auto& a = it->second;
100✔
778
    a.count += 1;
100✔
779
    a.total += dur;
100✔
780
    if (dur > a.max_dur) {
100✔
781
        a.max_dur = dur;
100✔
782
        a.name.assign(name);
100!
783
    }
50✔
784
    return true;
100✔
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();
×
UNCOV
796
    if (!root.is_object()) return false;
×
UNCOV
797
    auto dr = root["dur"];
×
UNCOV
798
    auto tr = root["ts"];
×
UNCOV
799
    if (dr.error() || tr.error()) return false;
×
800
    dur = json_number(dr.value_unsafe());
×
UNCOV
801
    ts = json_number(tr.value_unsafe());
×
802
    return true;
×
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;
×
UNCOV
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());
×
UNCOV
820
    ts = json_number(tr.value_unsafe());
×
821
    auto pr = root["pid"];
×
822
    pid = pr.error()
×
823
              ? 0
×
UNCOV
824
              : static_cast<std::int64_t>(json_number(pr.value_unsafe()));
×
UNCOV
825
    auto tir = root["tid"];
×
UNCOV
826
    tid = tir.error()
×
827
              ? 0
×
UNCOV
828
              : static_cast<std::int64_t>(json_number(tir.value_unsafe()));
×
UNCOV
829
    return true;
×
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(
2✔
838
    const std::vector<std::string>& big, DensityMap& dens, double begin,
839
    double threshold) {
840
    std::vector<std::uint32_t> depth(big.size(), 0);
2!
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;
UNCOV
850
        bool operator==(const LaneKey& o) const {
×
UNCOV
851
            return pid == o.pid && tid == o.tid;
×
852
        }
853
    };
854
    struct LaneHash {
855
        std::uint64_t operator()(const LaneKey& k) const noexcept {
200✔
856
            std::uint64_t h = 1469598103934665603ULL;
200✔
857
            h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
200✔
858
            h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
200✔
859
            return h;
200✔
860
        }
861
    };
862
    ankerl::unordered_dense::map<LaneKey, std::vector<Item>, LaneHash> lanes;
2!
863

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

880
    for (auto& kv : lanes) {
102✔
881
        auto& items = kv.second;
100✔
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) {
100!
UNCOV
885
            if (a.ts != b.ts) return a.ts < b.ts;
×
886
            return (a.block == nullptr) && (b.block != nullptr);
×
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;
100✔
891
        for (auto& it : items) {
200✔
892
            while (!open.empty() && *open.begin() <= it.ts)
100!
893
                open.erase(open.begin());
×
894
            if (it.block) {
100!
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>(
100✔
898
                    std::distance(open.lower_bound(it.end), open.end()));
100!
899
            } else {
50✔
UNCOV
900
                depth[static_cast<std::size_t>(it.big_idx)] =
×
UNCOV
901
                    static_cast<std::uint32_t>(open.size());
×
UNCOV
902
                open.insert(it.end);
×
903
            }
904
        }
905
    }
100✔
906
    return depth;
3✔
907
}
2!
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);
×
UNCOV
919
        ops.assign(n, 0.0);
×
920
    }
×
UNCOV
921
    void merge_from(const CounterAcc& o) {
×
UNCOV
922
        for (std::size_t i = 0; i < ops.size(); ++i) {
×
923
            read_bytes[i] += o.read_bytes[i];
×
UNCOV
924
            write_bytes[i] += o.write_bytes[i];
×
925
            ops[i] += o.ops[i];
×
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) {
UNCOV
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;
×
UNCOV
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();
×
UNCOV
943
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
×
944

UNCOV
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())
×
UNCOV
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"];
×
UNCOV
962
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
×
963
    }
UNCOV
964
    if (bytes <= 0) return;
×
UNCOV
965
    if (name.find("write") != std::string_view::npos)
×
UNCOV
966
        acc.write_bytes[col] += bytes;
×
UNCOV
967
    else if (name.find("read") != std::string_view::npos)
×
UNCOV
968
        acc.read_bytes[col] += bytes;
×
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;
UNCOV
983
    bool operator==(const PidTid& o) const {
×
UNCOV
984
        return pid == o.pid && tid == o.tid;
×
985
    }
986
};
987

988
struct PidTidHash {
989
    std::uint64_t operator()(const PidTid& k) const noexcept {
580✔
990
        std::uint64_t h = 1469598103934665603ULL;
580✔
991
        h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
580✔
992
        h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
580✔
993
        return h;
580✔
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)
138✔
1005
        : pid(p), tid(t), count(nb), total(nb), maxd(nb), nameid(nb) {
138!
1006
        for (auto& x : nameid)
6,029,404✔
1007
            x.store(std::numeric_limits<std::uint32_t>::max(),
6,029,312✔
1008
                    std::memory_order_relaxed);
1009
    }
138✔
1010
};
1011

1012
struct SumBuild {
1!
1013
    std::size_t nb = 0;
1✔
1014
    double bucket_us = 1;
1✔
1015
    std::uint64_t t0 = 0;
1✔
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};
1✔
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) {
100✔
1057
        PidTid key{pid, tid};
100✔
1058
        auto& cache = lane_cache[w];
100✔
1059
        auto ci = cache.find(key);
100!
1060
        if (ci != cache.end()) return ci->second;
100!
1061
        std::lock_guard<std::mutex> lk(lane_mtx);
100!
1062
        auto it = lane_of.find(key);
100!
1063
        SumLane* lane;
1064
        if (it != lane_of.end()) {
100!
UNCOV
1065
            lane = &lanes[it->second];
×
1066
        } else {
1067
            if (lanes.size() * nb >= VizSummary::MAX_CELLS)
100✔
1068
                return nullptr;  // budget reached; drop further lanes
8✔
1069
            lane_of.emplace(key, lanes.size());
92!
1070
            lanes.emplace_back(nb, pid, tid);
92!
1071
            lane = &lanes.back();
92✔
1072
        }
1073
        cache.emplace(key, lane);
92!
1074
        return lane;
92✔
1075
    }
100✔
1076

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

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

1104
static void atomic_add_double(std::atomic<double>& a, double v) {
176✔
1105
    double cur = a.load(std::memory_order_relaxed);
176✔
1106
    while (!a.compare_exchange_weak(cur, cur + v, std::memory_order_relaxed)) {
176!
1107
    }
1108
}
176✔
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) {
300✔
1112
    auto it = m.find(key);
300!
1113
    if (it == m.end()) {
300✔
1114
        it = m.emplace(std::string(key), NameStat{}).first;
120!
1115
        it->second.min = dur;
120✔
1116
        it->second.max = dur;
120✔
1117
    } else {
60✔
1118
        it->second.min = std::min(it->second.min, dur);
180✔
1119
        it->second.max = std::max(it->second.max, dur);
180✔
1120
    }
1121
    it->second.count += 1;
300✔
1122
    it->second.total += dur;
300✔
1123
}
300✔
1124

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

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

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

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

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

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

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

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

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

1233
    // Counters: read/write bytes and op counts for POSIX/STDIO/IO events.
1234
    auto cat_r = root["cat"];
100✔
1235
    if (cat_r.error() || !cat_r.is_string()) return;
100!
1236
    std::string_view cat = cat_r.get_string().value_unsafe();
100✔
1237
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
100!
1238
    atomic_add_double(b.cops[bi], 1.0);
100✔
1239
    double bytes = 0;
100✔
1240
    auto args = root["args"];
100✔
1241
    if (!args.error() && args.is_object()) {
100!
1242
        auto ret = args["ret"];
100✔
1243
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
100✔
1244
    }
50✔
1245
    if (bytes <= 0) return;
100!
1246
    std::string_view name;
100✔
1247
    auto nr = root["name"];
100✔
1248
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
100!
1249
    bool is_write = name.find("write") != std::string_view::npos;
100✔
1250
    bool is_read = name.find("read") != std::string_view::npos;
100✔
1251
    if (is_write)
100✔
1252
        atomic_add_double(b.cwrite[bi], bytes);
38✔
1253
    else if (is_read)
62✔
1254
        atomic_add_double(b.cread[bi], bytes);
38✔
1255
    if ((is_read || is_write) && !args.error() && args.is_object()) {
100!
1256
        auto fr = args["fhash"];
76✔
1257
        if (!fr.error() && fr.is_string())
76!
UNCOV
1258
            b.io_fh[w].emplace(std::string(fr.get_string().value_unsafe()));
×
1259
    }
38✔
1260
}
50✔
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) {
8!
1267
    auto summary = std::make_unique<VizSummary>();
3!
1268
    std::uint64_t gmin = index.global_min_timestamp_us();
3✔
1269
    std::uint64_t gmax = index.global_max_timestamp_us();
3✔
1270
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin) {
3✔
1271
        index.set_viz_summary(std::move(summary));
4!
1272
        co_return;
1✔
1273
    }
1274

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1494
    if (nb > 0 && summary->bucket_us > 0) {
1!
1495
        std::vector<bool> active(nb, false);
1!
1496
        for (const auto& lane : summary->lanes)
47✔
1497
            for (std::size_t i = 0; i < nb; ++i)
3,014,702✔
1498
                if (lane.cells[i].count) active[i] = true;
3,014,702✔
1499
        // A live process counts as active for its whole span, so within-run
1500
        // compute gaps are not compressed, only between-run dead time.
1501
        for (const auto& sp : summary->app_spans) {
1!
1502
            std::int64_t a = summary->bucket_of(static_cast<double>(sp.begin));
×
1503
            std::int64_t z = summary->bucket_of(static_cast<double>(sp.end));
×
1504
            if (a < 0) a = 0;
×
1505
            if (z < 0) continue;
×
1506
            for (std::int64_t i = a; i <= z; ++i)
×
1507
                active[static_cast<std::size_t>(i)] = true;
1508
        }
×
1509
        std::size_t first = 0, last = 0;
1✔
1510
        bool any = false;
1✔
1511
        for (std::size_t i = 0; i < nb; ++i)
65,537✔
1512
            if (active[i]) {
65,582✔
1513
                if (!any) {
46✔
1514
                    first = i;
1✔
1515
                    any = true;
1✔
1516
                }
1✔
1517
                last = i;
46✔
1518
            }
46✔
1519
        if (any) {
1!
1520
            const double span_us = static_cast<double>(nb) * summary->bucket_us;
1✔
1521
            const double min_gap_us =
1✔
1522
                std::max(3.0 * summary->bucket_us, 0.02 * span_us);
1!
1523
            for (std::size_t i = first; i <= last;) {
92✔
1524
                if (active[i]) {
91✔
1525
                    ++i;
46✔
1526
                    continue;
46✔
1527
                }
1528
                std::size_t j = i;
45✔
1529
                while (j <= last && !active[j]) ++j;
60,178!
1530
                if (static_cast<double>(j - i) * summary->bucket_us >=
90!
1531
                    min_gap_us) {
45✔
1532
                    auto g0 = summary->t_begin +
90✔
1533
                              static_cast<std::uint64_t>(
1534
                                  static_cast<double>(i) * summary->bucket_us);
45✔
1535
                    auto g1 = summary->t_begin +
90✔
1536
                              static_cast<std::uint64_t>(
1537
                                  static_cast<double>(j) * summary->bucket_us);
45✔
1538
                    summary->idle_gaps.emplace_back(g0, g1);
45!
1539
                }
45✔
1540
                i = j;
45✔
1541
            }
45✔
1542
        }
1✔
1543
    }
1✔
1544

1545
    DFTRACER_UTILS_LOG_INFO(
1!
1546
        "viz: built activity summary (%zu lanes, %zu buckets/lane, %zu idle "
1547
        "gaps)",
1548
        summary->lanes.size(), nb, summary->idle_gaps.size());
1549
    index.set_viz_summary(std::move(summary));
1!
1550
}
21!
1551

1552
// The summary, building it on first use. Null when another request is already
1553
// building it, in which case the caller falls back to a live scan.
1554
static coro::CoroTask<const VizSummary*> ensure_viz_summary(TraceIndex& index) {
62!
1555
    const VizSummary* s = index.viz_summary();
12✔
1556
    if (!s && index.try_begin_summary_build()) {
10!
1557
        co_await build_viz_summary(index);
14!
1558
        s = index.viz_summary();
1!
1559
    }
1✔
1560
    co_return s;
12✔
1561
}
36!
1562

1563
// One aggregate row, uniform over the live-scan and summary paths.
1564
struct StatRow {
1565
    const std::string* key;
1566
    std::uint64_t count;
1567
    double total;
1568
    double min;
1569
    double max;
1570
};
1571

1572
template <typename builder_type>
1573
void tag_invoke(simdjson::serialize_tag, builder_type& b, const StatRow& r) {
16✔
1574
    b.start_object();
16✔
1575
    b.append_key_value("name", *r.key);
16✔
1576
    b.append_comma();
16✔
1577
    b.append_key_value("count", r.count);
16✔
1578
    b.append_comma();
16✔
1579
    b.append_key_value("total", r.total);
16✔
1580
    b.append_comma();
16✔
1581
    b.append_key_value("avg",
24✔
1582
                       r.count ? r.total / static_cast<double>(r.count) : 0.0);
16!
1583
    b.append_comma();
16✔
1584
    b.append_key_value("min", r.min);
16✔
1585
    b.append_comma();
16✔
1586
    b.append_key_value("max", r.max);
16✔
1587
    b.end_object();
16✔
1588
}
16✔
1589

1590
static std::string serialize_stats_body(std::uint64_t total_count,
2✔
1591
                                        double total_dur, double wall,
1592
                                        bool truncated,
1593
                                        const std::vector<StatRow>& rows) {
1594
    auto& b = scratch_json_builder();
2✔
1595
    b.start_object();
2✔
1596
    b.append_key_value("count", total_count);
2✔
1597
    b.append_comma();
2✔
1598
    b.append_key_value("total_dur", total_dur);
2✔
1599
    b.append_comma();
2✔
1600
    b.append_key_value("wall", wall);
2✔
1601
    b.append_comma();
2✔
1602
    b.append_key_value("truncated", truncated);
2✔
1603
    b.append_comma();
2✔
1604
    b.append_key_value("names", rows);
2✔
1605
    b.end_object();
2✔
1606
    return std::string(b);
2!
1607
}
1608

1609
static const std::vector<VizSummary::GroupRow>& summary_group_rows(
2✔
1610
    const VizSummary& s, GroupBy g) {
1611
    switch (g) {
2!
1612
        case GroupBy::Cat:
UNCOV
1613
            return s.by_cat;
×
1614
        case GroupBy::Pid:
UNCOV
1615
            return s.by_pid;
×
1616
        case GroupBy::Fhash:
UNCOV
1617
            return s.by_fhash;
×
1618
        case GroupBy::Name:
2✔
1619
        default:
1620
            return s.by_name;
2✔
1621
    }
1622
}
1✔
1623

1624
// Whole-trace, unfiltered Analyze answers come from the prebuilt summary, so no
1625
// live scan is needed. Any predicate or sub-range forces the live path.
1626
static bool viz_stats_summary_eligible(const QueryParams& p, double begin_abs,
2✔
1627
                                       double end_abs, TraceIndex& index) {
1628
    if (!p.get("query").empty() || !p.get("cat").empty() ||
4!
1629
        !p.get("lanes").empty() || !p.get("filters").empty() ||
2!
1630
        !p.get("file").empty() || !p.get("pid").empty() ||
4!
1631
        !p.get("tid").empty())
3!
UNCOV
1632
        return false;
×
1633
    std::uint64_t gmin = index.global_min_timestamp_us();
2✔
1634
    std::uint64_t gmax = index.global_max_timestamp_us();
2✔
1635
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
2!
UNCOV
1636
        return false;
×
1637
    return begin_abs <= static_cast<double>(gmin) + 1.0 &&
4✔
1638
           end_abs >= static_cast<double>(gmax) - 1.0;
3!
1639
}
1✔
1640

1641
// GET /api/v1/viz/stats: server-side per-name aggregation over a time range.
1642
// Scans in parallel worker coroutines, each folding into its own map, then
1643
// merges single-threaded (no lock). Returns only the small aggregate table.
1644
static coro::CoroTask<HttpResponse> handle_viz_stats(const HttpRequest& /*req*/,
8!
1645
                                                     const QueryParams& params,
1646
                                                     TraceIndex& index) {
1!
1647
    if (!params.has("begin") || !params.has("end")) {
3!
1648
        co_return HttpResponse::bad_request(
5!
1649
            "Missing required parameters: begin, end");
4!
1650
    }
1651

1652
    double begin = params.get_double("begin", 0);
3✔
1653
    double end = params.get_double("end", 0);
3✔
1654

1655
    auto query = params.get("query");
3✔
1656
    if (!query.empty() &&
3!
1657
        !utilities::common::query::try_parse(query).has_value()) {
×
1658
        co_return HttpResponse::bad_request("Invalid query: " +
×
1659
                                            std::string(query));
×
1660
    }
1661

1662
    auto ts_norm_param = params.get("ts_normalize");
3✔
1663
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
1664
    std::uint64_t global_min = 0;
3✔
1665
    if (normalize) {
3✔
1666
        global_min = index.global_min_timestamp_us();
1✔
1667
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
1668
            global_min = 0;
1669
    }
1✔
1670
    double original_begin = begin;
3✔
1671
    double original_end = end;
3✔
1672
    if (normalize && global_min > 0) {
3!
1673
        begin += static_cast<double>(global_min);
1✔
1674
        end += static_cast<double>(global_min);
1✔
1675
    }
1✔
1676

1677
    GroupBy group = parse_group_by(params.get("group"));
3!
1678

1679
    // Whole-trace, unfiltered Analyze: answer from the prebuilt summary (built
1680
    // lazily here). Concurrent builds fall through to the live scan below.
1681
    if (viz_stats_summary_eligible(params, begin, end, index)) {
1!
1682
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
1683
        if (s) {
1!
1684
            const auto& gr = summary_group_rows(*s, group);
1!
1685
            std::vector<StatRow> rows;
1✔
1686
            rows.reserve(gr.size());
1!
1687
            std::uint64_t total_count = 0;
1✔
1688
            double total_dur = 0;
1✔
1689
            for (const auto& r : gr) {
9✔
1690
                rows.push_back({&r.key, r.count, r.total, r.min, r.max});
8!
1691
                total_count += r.count;
8✔
1692
                total_dur += r.total;
8✔
1693
            }
8✔
1694
            co_return HttpResponse::ok(serialize_stats_body(
2!
1695
                total_count, total_dur, original_end - original_begin, false,
1✔
1696
                rows));
1697
        }
1✔
1698
    }
1!
1699

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

1703
    int limit = params.get_int("limit", 0);
×
1704
    if (limit < 0) limit = 0;
×
1705

1706
    std::vector<const TraceIndex::FileInfo*> target_files =
1707
        select_viz_target_files(index, params, begin, end);
×
1708

1709
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
1710
    std::vector<NameMap> partials(slots);
×
UNCOV
1711
    auto aggregate = [&partials, group](
×
1712
                         std::size_t w,
1713
                         const std::vector<std::string_view>& events) {
UNCOV
1714
        NameMap& local = partials[w];  // owned by worker w only, no lock
×
UNCOV
1715
        for (auto ev : events) fold_event(ev, group, local);
×
UNCOV
1716
    };
×
1717

1718
    bool truncated = co_await scan_view_events(index, target_files, view, begin,
×
1719
                                               end, limit, slots, aggregate,
×
1720
                                               !params.get("file").empty());
×
1721

1722
    // Single-threaded reduce of the disjoint per-worker maps.
1723
    NameMap merged;
×
1724
    for (auto& p : partials) {
×
1725
        for (auto& kv : p) {
×
1726
            auto it = merged.find(kv.first);
×
1727
            if (it == merged.end())
×
1728
                merged.emplace(kv.first, kv.second);
×
1729
            else
1730
                it->second.merge_from(kv.second);
×
1731
        }
1732
    }
1733

1734
    std::vector<StatRow> rows;
1735
    rows.reserve(merged.size());
×
1736
    std::uint64_t total_count = 0;
1737
    double total_dur = 0;
1738
    for (const auto& kv : merged) {
×
1739
        rows.push_back({&kv.first, kv.second.count, kv.second.total,
×
1740
                        kv.second.min, kv.second.max});
1741
        total_count += kv.second.count;
1742
        total_dur += kv.second.total;
1743
    }
UNCOV
1744
    std::sort(rows.begin(), rows.end(), [](const StatRow& a, const StatRow& b) {
×
UNCOV
1745
        return a.total > b.total;
×
1746
    });
1747

1748
    co_return HttpResponse::ok(
×
1749
        serialize_stats_body(total_count, total_dur,
×
1750
                             original_end - original_begin, truncated, rows));
1751
}
29!
1752

1753
// GET /api/v1/viz/layers: whole-trace reference data - the operation-name ->
1754
// category map (a property of the name, not the view, so fetched once) plus the
1755
// FH file counts: total declared vs. those an I/O event actually touched.
1756
static coro::CoroTask<HttpResponse> handle_viz_layers(
16!
1757
    const HttpRequest& /*req*/, const QueryParams& /*p*/, TraceIndex& index) {
2!
1758
    const VizSummary* s = co_await ensure_viz_summary(index);
10!
1759
    auto& b = scratch_json_builder();
2!
1760
    b.start_object();
2✔
1761
    b.escape_and_append_with_quotes("layers");
2✔
1762
    b.append_colon();
2✔
1763
    b.start_object();
2✔
1764
    if (s) {
2!
1765
        bool first = true;
2✔
1766
        for (const auto& kv : s->name_cats) {
18✔
1767
            if (!first) b.append_comma();
16✔
1768
            first = false;
16✔
1769
            b.escape_and_append_with_quotes(kv.first);
16✔
1770
            b.append_colon();
16✔
1771
            b.escape_and_append_with_quotes(kv.second);
16✔
1772
        }
16✔
1773
    }
2✔
1774
    b.end_object();
2✔
1775
    b.append_comma();
2✔
1776
    b.append_key_value("total_files",
4✔
1777
                       static_cast<std::int64_t>(s ? s->total_files : 0));
2!
1778
    b.append_comma();
2✔
1779
    b.append_key_value("io_files",
4✔
1780
                       static_cast<std::int64_t>(s ? s->io_files : 0));
2!
1781
    b.end_object();
2✔
1782
    co_return HttpResponse::ok(std::string(b));
2!
1783
}
10!
1784

1785
// One scanned event, reduced to what the call-tree needs.
1786
struct FlameEv {
1✔
1787
    std::int64_t pid = 0;
1✔
1788
    std::int64_t tid = 0;
1✔
1789
    double ts = 0;
1✔
1790
    double dur = 0;
1✔
1791
    std::string name;
1792
};
1793

1794
// A node in the merged call tree: identical name-paths across all lanes fold
1795
// into one node. `total` is inclusive; `self` is total minus nested children.
1796
struct FlameNode {
9!
1797
    std::string name;
1798
    double total = 0;
9✔
1799
    double self = 0;
9✔
1800
    std::uint64_t count = 0;
9✔
1801
    dftracer::utils::StringViewMap<std::uint32_t> kids;
1802
    std::vector<std::uint32_t> children;
1803
};
1804

1805
static bool parse_flame_ev(std::string_view event, FlameEv& out) {
100✔
1806
    thread_local simdjson::dom::parser parser;
100✔
1807
    thread_local std::string buf;
100✔
1808
    buf.assign(event);
100!
1809
    auto res = parser.parse(buf);
100✔
1810
    if (res.error()) return false;
100!
1811
    auto root = res.value_unsafe();
100✔
1812
    if (!root.is_object()) return false;
100✔
1813
    auto dr = root["dur"];
100✔
1814
    if (dr.error()) return false;  // no duration: nothing to place in the tree
100!
1815
    out.dur = json_number(dr.value_unsafe());
100✔
1816
    auto tr = root["ts"];
100✔
1817
    if (tr.error()) return false;
100!
1818
    out.ts = json_number(tr.value_unsafe());
100✔
1819
    auto pr = root["pid"];
100✔
1820
    out.pid = pr.error()
100!
1821
                  ? 0
100!
1822
                  : static_cast<std::int64_t>(json_number(pr.value_unsafe()));
100✔
1823
    auto tir = root["tid"];
100✔
1824
    out.tid = tir.error()
100!
1825
                  ? 0
100!
1826
                  : static_cast<std::int64_t>(json_number(tir.value_unsafe()));
100✔
1827
    auto nr = root["name"];
100✔
1828
    if (!nr.error() && nr.is_string())
100!
1829
        out.name.assign(nr.get_string().value_unsafe());
100!
1830
    else
UNCOV
1831
        out.name.clear();
×
1832
    return true;
100✔
1833
}
50✔
1834

1835
static void serialize_flame_node(simdjson::builder::string_builder& sb,
18✔
1836
                                 std::vector<FlameNode>& arena,
1837
                                 std::uint32_t idx) {
1838
    FlameNode& n = arena[idx];
18✔
1839
    sb.start_object();
18✔
1840
    sb.append_key_value("name", n.name);
18✔
1841
    sb.append_comma();
18✔
1842
    sb.append_key_value("total", n.total);
18✔
1843
    sb.append_comma();
18✔
1844
    sb.append_key_value("self", n.self < 0 ? 0.0 : n.self);
18!
1845
    sb.append_comma();
18✔
1846
    sb.append_key_value("count", n.count);
18✔
1847
    std::sort(n.children.begin(), n.children.end(),
27✔
1848
              [&arena](std::uint32_t a, std::uint32_t b) {
87✔
1849
                  return arena[a].total > arena[b].total;
50✔
1850
              });
1851
    sb.append_comma();
18✔
1852
    sb.escape_and_append_with_quotes("children");
18✔
1853
    sb.append_colon();
18✔
1854
    sb.start_array();
18✔
1855
    for (std::size_t i = 0; i < n.children.size(); ++i) {
34✔
1856
        if (i > 0) sb.append_comma();
16✔
1857
        serialize_flame_node(sb, arena, n.children[i]);
16✔
1858
    }
8✔
1859
    sb.end_array();
18✔
1860
    sb.end_object();
18✔
1861
}
18✔
1862

1863
// GET /api/v1/viz/calltree: merge events into a flamegraph tree. The hierarchy
1864
// per pid/tid lane comes from ts/dur containment (same nesting the timeline
1865
// draws); identical name-paths fold together across the whole trace.
1866
static coro::CoroTask<HttpResponse> handle_viz_calltree(
8!
1867
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
1868
    if (!params.has("begin") || !params.has("end"))
3!
1869
        co_return HttpResponse::bad_request(
5!
1870
            "Missing required parameters: begin, end");
4!
1871

1872
    double begin = params.get_double("begin", 0);
3✔
1873
    double end = params.get_double("end", 0);
3✔
1874

1875
    auto query = params.get("query");
3✔
1876
    if (!query.empty() &&
3!
1877
        !utilities::common::query::try_parse(query).has_value())
×
1878
        co_return HttpResponse::bad_request("Invalid query: " +
×
1879
                                            std::string(query));
×
1880

1881
    auto ts_norm_param = params.get("ts_normalize");
3✔
1882
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
1883
    std::uint64_t global_min = 0;
3✔
1884
    if (normalize) {
3✔
1885
        global_min = index.global_min_timestamp_us();
1✔
1886
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
1887
            global_min = 0;
1888
    }
1✔
1889
    if (normalize && global_min > 0) {
3!
1890
        begin += static_cast<double>(global_min);
1✔
1891
        end += static_cast<double>(global_min);
1✔
1892
    }
1✔
1893

1894
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
1895
    int limit = params.get_int("limit", 0);
3✔
1896
    if (limit < 0) limit = 0;
3!
1897

1898
    std::vector<const TraceIndex::FileInfo*> target_files =
3✔
1899
        select_viz_target_files(index, params, begin, end);
3!
1900

1901
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
1902
    std::vector<std::vector<FlameEv>> partials(slots);
3!
1903
    auto collect = [&partials](std::size_t w,
5✔
1904
                               const std::vector<std::string_view>& events) {
1✔
1905
        auto& out = partials[w];  // worker-owned slot, no lock
2✔
1906
        FlameEv ev;
2✔
1907
        for (auto e : events)
102✔
1908
            if (parse_flame_ev(e, ev)) out.push_back(ev);
100!
1909
    };
2✔
1910

1911
    bool truncated =
4✔
1912
        co_await scan_view_events(index, target_files, view, begin, end, limit,
5!
1913
                                  slots, collect, !params.get("file").empty());
3!
1914

1915
    std::vector<FlameEv> all;
1✔
1916
    std::size_t total_n = 0;
1✔
1917
    for (auto& p : partials) total_n += p.size();
3✔
1918
    all.reserve(total_n);
1!
1919
    for (auto& p : partials)
3✔
1920
        for (auto& e : p) all.push_back(std::move(e));
52!
1921

1922
    // Lanes are the contiguous (pid, tid) runs after this sort; within a lane
1923
    // events are ordered for the containment walk (outer/longer first on ties).
1924
    std::sort(all.begin(), all.end(), [](const FlameEv& a, const FlameEv& b) {
348!
1925
        if (a.pid != b.pid) return a.pid < b.pid;
347!
UNCOV
1926
        if (a.tid != b.tid) return a.tid < b.tid;
×
UNCOV
1927
        if (a.ts != b.ts) return a.ts < b.ts;
×
UNCOV
1928
        return a.dur > b.dur;
×
1929
    });
101✔
1930

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

1936
    std::vector<FlameNode> arena;
1✔
1937
    arena.reserve(256);
1!
1938
    arena.emplace_back();  // root (index 0)
1!
1939
    arena[0].name = "all";
1!
1940
    ankerl::unordered_dense::map<std::int64_t, std::uint32_t> proc_of;
1!
1941

1942
    std::vector<std::pair<double, std::uint32_t>> open;  // (end_ts, node idx)
1✔
1943
    std::size_t i = 0;
1✔
1944
    while (i < all.size()) {
51✔
1945
        std::int64_t pid = all[i].pid, tid = all[i].tid;
50✔
1946
        std::uint32_t base = 0;  // top-level events attach here
50✔
1947
        if (by_process) {
50!
1948
            auto pit = proc_of.find(pid);
×
1949
            if (pit == proc_of.end()) {
×
1950
                base = static_cast<std::uint32_t>(arena.size());
1951
                arena.emplace_back();
×
1952
                arena[base].name = "P" + std::to_string(pid);
×
1953
                proc_of.emplace(pid, base);
×
1954
                arena[0].children.push_back(base);
×
1955
            } else {
1956
                base = pit->second;
1957
            }
1958
        }
1959
        open.clear();
50✔
1960
        for (; i < all.size() && all[i].pid == pid && all[i].tid == tid; ++i) {
100✔
1961
            const FlameEv& ev = all[i];
50✔
1962
            double e_end = ev.ts + (ev.dur > 0 ? ev.dur : 0);
50!
1963
            while (!open.empty() && open.back().first <= ev.ts) open.pop_back();
50!
1964
            std::uint32_t parent = open.empty() ? base : open.back().second;
50!
1965

1966
            std::uint32_t mi;
50✔
1967
            auto it = arena[parent].kids.find(ev.name);
50!
1968
            if (it == arena[parent].kids.end()) {
50✔
1969
                mi = static_cast<std::uint32_t>(arena.size());
8✔
1970
                arena.emplace_back();  // may reallocate; index access below
8!
1971
                arena[mi].name = ev.name;
8!
1972
                arena[parent].kids.emplace(ev.name, mi);
8!
1973
                arena[parent].children.push_back(mi);
8!
1974
            } else {
8✔
1975
                mi = it->second;
42✔
1976
            }
1977
            arena[mi].total += ev.dur;
50✔
1978
            arena[mi].self += ev.dur;
50✔
1979
            arena[mi].count += 1;
50✔
1980
            if (parent != 0) arena[parent].self -= ev.dur;
50!
1981
            open.push_back({e_end, mi});
50!
1982
        }
50✔
1983
    }
50✔
1984

1985
    // Process frames are synthetic containers: total/count roll up from their
1986
    // children, self is 0.
1987
    if (by_process) {
1!
1988
        for (std::uint32_t pnode : arena[0].children) {
×
1989
            double t = 0;
1990
            std::uint64_t c = 0;
1991
            for (std::uint32_t ch : arena[pnode].children) {
×
1992
                t += arena[ch].total;
1993
                c += arena[ch].count;
1994
            }
1995
            arena[pnode].total = t;
1996
            arena[pnode].count = c;
1997
            arena[pnode].self = 0;
1998
        }
1999
    }
2000

2001
    double root_total = 0;
1✔
2002
    std::uint64_t root_count = 0;
1✔
2003
    for (std::uint32_t c : arena[0].children) {
9✔
2004
        root_total += arena[c].total;
8✔
2005
        root_count += arena[c].count;
8✔
2006
    }
8✔
2007
    arena[0].total = root_total;
1✔
2008
    arena[0].count = root_count;
1✔
2009
    arena[0].self = 0;
1✔
2010

2011
    auto& b = scratch_json_builder();
1!
2012
    b.start_object();
1✔
2013
    b.append_key_value("truncated", truncated);
1✔
2014
    b.append_comma();
1✔
2015
    b.escape_and_append_with_quotes("tree");
1✔
2016
    b.append_colon();
1✔
2017
    serialize_flame_node(b, arena, 0);
1!
2018
    b.end_object();
1✔
2019
    co_return HttpResponse::ok(std::string(b));
1!
2020
}
33!
2021

2022
// One log-spaced duration bucket of the histogram response.
2023
struct HistBucket {
2024
    double lo;
2025
    double hi;
2026
    std::uint64_t count;
2027
};
2028

2029
template <typename builder_type>
2030
void tag_invoke(simdjson::serialize_tag, builder_type& b, const HistBucket& h) {
80✔
2031
    b.start_object();
80✔
2032
    b.append_key_value("lo", h.lo);
80✔
2033
    b.append_comma();
80✔
2034
    b.append_key_value("hi", h.hi);
80✔
2035
    b.append_comma();
80✔
2036
    b.append_key_value("count", h.count);
80✔
2037
    b.end_object();
80✔
2038
}
80✔
2039

2040
// GET /api/v1/viz/histogram: the distribution of event durations matching the
2041
// query in [begin, end]. Collects each matching dur, then reports exact
2042
// percentiles and a log-spaced histogram of the shape. The caller narrows to
2043
// one operation by folding its predicate (name == "...") into the query.
2044
static coro::CoroTask<HttpResponse> handle_viz_histogram(
8!
2045
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
2046
    if (!params.has("begin") || !params.has("end"))
3!
2047
        co_return HttpResponse::bad_request(
5!
2048
            "Missing required parameters: begin, end");
4!
2049

2050
    double begin = params.get_double("begin", 0);
3✔
2051
    double end = params.get_double("end", 0);
3✔
2052

2053
    auto query = params.get("query");
3✔
2054
    if (!query.empty() &&
3!
2055
        !utilities::common::query::try_parse(query).has_value())
×
2056
        co_return HttpResponse::bad_request("Invalid query: " +
×
2057
                                            std::string(query));
×
2058

2059
    auto ts_norm_param = params.get("ts_normalize");
3✔
2060
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
2061
    std::uint64_t global_min = 0;
3✔
2062
    if (normalize) {
3✔
2063
        global_min = index.global_min_timestamp_us();
1✔
2064
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
2065
            global_min = 0;
2066
    }
1✔
2067
    if (normalize && global_min > 0) {
3!
2068
        begin += static_cast<double>(global_min);
1✔
2069
        end += static_cast<double>(global_min);
1✔
2070
    }
1✔
2071

2072
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
2073
    int limit = params.get_int("limit", 0);
3✔
2074
    if (limit < 0) limit = 0;
3!
2075
    int nbuckets = params.get_int("buckets", 40);
3✔
2076
    nbuckets = std::clamp(nbuckets, 4, 200);
3!
2077

2078
    std::vector<const TraceIndex::FileInfo*> target_files =
3✔
2079
        select_viz_target_files(index, params, begin, end);
3!
2080

2081
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
2082
    std::vector<std::vector<double>> partials(slots);
3!
2083
    auto collect = [&partials](std::size_t w,
5✔
2084
                               const std::vector<std::string_view>& events) {
1✔
2085
        thread_local simdjson::dom::parser parser;
2✔
2086
        thread_local std::string buf;
2✔
2087
        auto& out = partials[w];  // worker-owned slot, no lock
2✔
2088
        for (auto e : events) {
102✔
2089
            buf.assign(e);
100!
2090
            auto res = parser.parse(buf);
100✔
2091
            if (res.error()) continue;
100!
2092
            auto root = res.value_unsafe();
100✔
2093
            if (!root.is_object()) continue;
100✔
2094
            auto dr = root["dur"];
100✔
2095
            if (dr.error()) continue;
100!
2096
            out.push_back(json_number(dr.value_unsafe()));
100!
2097
        }
2098
    };
2✔
2099

2100
    bool truncated =
4✔
2101
        co_await scan_view_events(index, target_files, view, begin, end, limit,
5!
2102
                                  slots, collect, !params.get("file").empty());
3!
2103

2104
    std::vector<double> all;
1✔
2105
    std::size_t total_n = 0;
1✔
2106
    for (auto& p : partials) total_n += p.size();
3✔
2107
    all.reserve(total_n);
1!
2108
    for (auto& p : partials)
3✔
2109
        for (double d : p) all.push_back(d);
52!
2110
    std::sort(all.begin(), all.end());
1!
2111

2112
    auto& sb = scratch_json_builder();
1!
2113
    if (all.empty()) {
1!
2114
        sb.start_object();
2115
        sb.append_key_value("count", 0);
2116
        sb.append_comma();
2117
        sb.escape_and_append_with_quotes("buckets");
2118
        sb.append_colon();
2119
        sb.start_array();
2120
        sb.end_array();
2121
        sb.append_comma();
2122
        sb.append_key_value("truncated", truncated);
2123
        sb.end_object();
2124
        co_return HttpResponse::ok(std::string(sb));
×
2125
    }
2126

2127
    std::size_t n = all.size();
1✔
2128
    double vmin = all.front();
1✔
2129
    double vmax = all.back();
1✔
2130
    double sum = 0;
1✔
2131
    for (double d : all) sum += d;
51✔
2132
    double mean = sum / static_cast<double>(n);
1✔
2133
    auto pct = [&all, n](double p) {
9✔
2134
        std::size_t idx =
8✔
2135
            static_cast<std::size_t>(p * static_cast<double>(n - 1));
8✔
2136
        return all[idx];
8✔
2137
    };
2138

2139
    // Log-spaced buckets over [max(vmin,1), vmax]; sub-microsecond durs land in
2140
    // the first bucket.
2141
    double lo = std::max(vmin, 1.0);
1!
2142
    double hi = std::max(vmax, lo * 1.0000001);
1!
2143
    double lr = std::log(hi / lo);
1✔
2144
    std::vector<std::uint64_t> counts(static_cast<std::size_t>(nbuckets), 0);
1!
2145
    for (double d : all) {
51✔
2146
        double v = d < lo ? lo : d;
50!
2147
        std::size_t bi =
100✔
2148
            lr > 0 ? static_cast<std::size_t>(static_cast<double>(nbuckets) *
50!
2149
                                              std::log(v / lo) / lr)
100✔
2150
                   : 0;
2151
        if (bi >= static_cast<std::size_t>(nbuckets)) bi = nbuckets - 1;
50✔
2152
        counts[bi]++;
50✔
2153
    }
50✔
2154

2155
    sb.start_object();
1✔
2156
    sb.append_key_value("count", n);
1✔
2157
    sb.append_comma();
1✔
2158
    sb.append_key_value("min", vmin);
1✔
2159
    sb.append_comma();
1✔
2160
    sb.append_key_value("max", vmax);
1✔
2161
    sb.append_comma();
1✔
2162
    sb.append_key_value("mean", mean);
1✔
2163
    sb.append_comma();
1✔
2164
    sb.append_key_value("p50", pct(0.50));
1!
2165
    sb.append_comma();
1✔
2166
    sb.append_key_value("p90", pct(0.90));
1!
2167
    sb.append_comma();
1✔
2168
    sb.append_key_value("p95", pct(0.95));
1!
2169
    sb.append_comma();
1✔
2170
    sb.append_key_value("p99", pct(0.99));
1!
2171
    sb.append_comma();
1✔
2172
    sb.append_key_value("truncated", truncated);
1✔
2173
    sb.append_comma();
1✔
2174
    std::vector<HistBucket> buckets;
1✔
2175
    buckets.reserve(static_cast<std::size_t>(nbuckets));
1!
2176
    for (int i = 0; i < nbuckets; ++i) {
41✔
2177
        buckets.push_back(
40!
2178
            {lo * std::exp(lr * static_cast<double>(i) / nbuckets),
120✔
2179
             lo * std::exp(lr * static_cast<double>(i + 1) / nbuckets),
40✔
2180
             counts[static_cast<std::size_t>(i)]});
40✔
2181
    }
40✔
2182
    sb.append_key_value("buckets", buckets);
1!
2183
    sb.end_object();
1✔
2184
    co_return HttpResponse::ok(std::string(sb));
1!
2185
}
37!
2186

2187
// A density block as it appears in the response: the map key/aggregate pair
2188
// flattened with `ts` resolved from the column index. `name` borrows the
2189
// aggregate's storage.
2190
struct DensityBlock {
2191
    std::string_view name;
2192
    std::int64_t pid;
2193
    std::int64_t tid;
2194
    double ts;
2195
    double dur;
2196
    std::uint32_t count;
2197
    double total;
2198
    std::uint32_t depth;
2199
};
2200

2201
template <typename builder_type>
2202
void tag_invoke(simdjson::serialize_tag, builder_type& b,
192✔
2203
                const DensityBlock& d) {
2204
    b.start_object();
192✔
2205
    b.append_key_value("name", d.name);
192✔
2206
    b.append_comma();
192✔
2207
    b.append_key_value("pid", d.pid);
192✔
2208
    b.append_comma();
192✔
2209
    b.append_key_value("tid", d.tid);
192✔
2210
    b.append_comma();
192✔
2211
    b.append_key_value("ts", d.ts);
192✔
2212
    b.append_comma();
192✔
2213
    b.append_key_value("dur", d.dur);
192✔
2214
    b.append_comma();
192✔
2215
    b.append_key_value("count", d.count);
192✔
2216
    b.append_comma();
192✔
2217
    b.append_key_value("total", d.total);
192✔
2218
    b.append_comma();
192✔
2219
    b.append_key_value("depth", d.depth);
192✔
2220
    b.end_object();
192✔
2221
}
192✔
2222

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

2286
// The summary is unfiltered, so any server-side predicate forces a live scan.
2287
// pid/tid are exempt: they select whole lanes, which the summary can still do.
2288
static bool viz_summary_eligible(const QueryParams& params) {
18✔
2289
    return params.get("query").empty() && params.get("cat").empty() &&
45!
2290
           params.get("lanes").empty() && params.get("filters").empty() &&
42!
2291
           params.get("file").empty();
21!
2292
}
2293

2294
static coro::CoroTask<void> append_app_spans(std::vector<std::string>& out,
50!
2295
                                             TraceIndex& index, double begin,
2296
                                             double end,
2297
                                             const QueryParams& params) {
7!
2298
    if (!viz_summary_eligible(params)) co_return;
22!
2299
    const VizSummary* s = co_await ensure_viz_summary(index);
16!
2300
    if (!s) co_return;
4!
2301
    auto pid_s = params.get("pid");
4!
2302
    auto tid_s = params.get("tid");
4!
2303
    bool has_pid = !pid_s.empty();
4✔
2304
    bool has_tid = !tid_s.empty();
4✔
2305
    std::int64_t want_pid =
8✔
2306
        has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
4!
2307
    std::int64_t want_tid =
8✔
2308
        has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
4!
2309
    for (const auto& sp : s->app_spans) {
4!
2310
        if (has_pid && sp.pid != want_pid) continue;
×
2311
        if (has_tid && sp.tid != want_tid) continue;
×
2312
        if (static_cast<double>(sp.end) > begin &&
×
2313
            static_cast<double>(sp.begin) < end)
2314
            out.push_back(sp.json);
×
2315
    }
×
2316
}
26!
2317

2318
// Re-aggregate the summary's finest per-lane buckets over [begin_abs, end_abs]
2319
// into pixel-column density blocks. `begin_abs`/`end_abs` are absolute us.
2320
static std::string serve_density_from_summary(
2✔
2321
    const VizSummary& s, const QueryParams& params, double begin_abs,
2322
    double end_abs, double original_begin, double original_end,
2323
    double threshold, bool ts_normalized, std::uint64_t display_global_min) {
2324
    auto pid_s = params.get("pid");
2!
2325
    auto tid_s = params.get("tid");
2!
2326
    bool has_pid = !pid_s.empty();
2✔
2327
    bool has_tid = !tid_s.empty();
2✔
2328
    std::int64_t want_pid =
1✔
2329
        has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
2!
2330
    std::int64_t want_tid =
1✔
2331
        has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
2!
2332

2333
    std::int64_t b0 = s.bucket_of(begin_abs);
2✔
2334
    std::int64_t b1 = s.bucket_of(end_abs);
2✔
2335
    if (b0 < 0) b0 = 0;
2✔
2336
    if (b1 < 0) b1 = static_cast<std::int64_t>(s.nbuckets) - 1;
2✔
2337

2338
    DensityMap dens;
2!
2339
    for (const auto& lane : s.lanes) {
94✔
2340
        if (has_pid && lane.pid != want_pid) continue;
92!
2341
        if (has_tid && lane.tid != want_tid) continue;
92!
2342
        for (std::int64_t bk = b0; bk <= b1; ++bk) {
6,029,404✔
2343
            const auto& cell = lane.cells[static_cast<std::size_t>(bk)];
6,029,312✔
2344
            if (cell.count == 0) continue;
6,029,312✔
2345
            double center = static_cast<double>(s.t_begin) +
138✔
2346
                            (static_cast<double>(bk) + 0.5) * s.bucket_us;
92✔
2347
            std::int64_t col =
92✔
2348
                static_cast<std::int64_t>((center - begin_abs) / threshold);
92✔
2349
            DensityKey key{lane.pid, lane.tid, col};
92✔
2350
            auto& a = dens[key];
92!
2351
            a.count += cell.count;
92✔
2352
            a.total += static_cast<double>(cell.total);
92✔
2353
            if (static_cast<double>(cell.max_dur) > a.max_dur) {
92✔
2354
                a.max_dur = static_cast<double>(cell.max_dur);
92✔
2355
                if (cell.name_id < s.names.size())
92!
2356
                    a.name = s.names[cell.name_id];
92!
2357
            }
46✔
2358
        }
46✔
2359
    }
2360

2361
    std::vector<std::string> spans;
2✔
2362
    ankerl::unordered_dense::set<std::int64_t> span_lanes;
2!
2363
    for (const auto& sp : s.app_spans) {
2✔
UNCOV
2364
        if (has_pid && sp.pid != want_pid) continue;
×
UNCOV
2365
        if (has_tid && sp.tid != want_tid) continue;
×
UNCOV
2366
        if (static_cast<double>(sp.end) <= begin_abs ||
×
UNCOV
2367
            static_cast<double>(sp.begin) >= end_abs)
×
UNCOV
2368
            continue;
×
UNCOV
2369
        span_lanes.insert((sp.pid << 20) ^ sp.tid);
×
UNCOV
2370
        spans.push_back(ts_normalized && display_global_min > 0
×
UNCOV
2371
                            ? normalize_event_ts(sp.json, display_global_min)
×
UNCOV
2372
                            : sp.json);
×
2373
    }
2374
    // Folded child activity sits one row below its app span, matching the
2375
    // containment nesting the live path computes.
2376
    if (!span_lanes.empty())
2✔
UNCOV
2377
        for (auto& [k, a] : dens)
×
UNCOV
2378
            if (span_lanes.count((k.pid << 20) ^ k.tid)) a.depth = 1;
×
2379

2380
    std::vector<std::uint32_t> span_depth(spans.size(), 0);
2!
2381
    return serialize_density_body(spans, dens, original_begin, original_end,
2!
2382
                                  threshold, 0, false, ts_normalized,
1✔
2383
                                  display_global_min,
1✔
2384
                                  static_cast<double>(s.max_dur), &span_depth);
3!
2385
}
2✔
2386

2387
// GET /api/v1/viz/density: like /viz/events, but instead of dropping sub-pixel
2388
// events it buckets them per (pid, tid, pixel-column) into density blocks so
2389
// zoomed-out views still show where activity is. Returns full-size events (with
2390
// args, for the detail panel) plus the aggregated density blocks.
2391
static coro::CoroTask<HttpResponse> handle_viz_density(
16!
2392
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
2!
2393
    if (!params.has("begin") || !params.has("end")) {
2!
2394
        co_return HttpResponse::bad_request(
2!
2395
            "Missing required parameters: begin, end");
×
2396
    }
2397

2398
    double begin = params.get_double("begin", 0);
2!
2399
    double end = params.get_double("end", 0);
2!
2400
    int summary = params.get_int("summary", 2);
2!
2401
    if (summary < 1) summary = 1;
2!
2402

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

2410
    auto ts_norm_param = params.get("ts_normalize");
2!
2411
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
2!
2412
    std::uint64_t global_min = 0;
2✔
2413
    if (normalize) {
2!
2414
        global_min = index.global_min_timestamp_us();
2✔
2415
        if (global_min == std::numeric_limits<std::uint64_t>::max())
2!
2416
            global_min = 0;
2417
    }
2✔
2418
    double original_begin = begin;
2✔
2419
    double original_end = end;
2✔
2420
    if (normalize && global_min > 0) {
2!
2421
        begin += static_cast<double>(global_min);
2✔
2422
        end += static_cast<double>(global_min);
2✔
2423
    }
2✔
2424

2425
    // Bucket width (== the ~1px cutoff), in the client's actual pixels.
2426
    int width = params.get_int("width", DEFAULT_VIEWPORT_WIDTH);
2!
2427
    width = std::clamp(width, MIN_VIEWPORT_WIDTH, MAX_VIEWPORT_WIDTH);
2!
2428
    double threshold =
4✔
2429
        duration_threshold(begin, end, static_cast<unsigned>(summary),
4✔
2430
                           static_cast<unsigned>(width));
2✔
2431

2432
    // Zoomed-out, unfiltered views come from the prebuilt summary (no event
2433
    // cap), built lazily here; concurrent requests fall through to a live scan.
2434
    if (threshold > 0 && viz_summary_eligible(params)) {
2!
2435
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
2436
        if (s && s->bucket_us > 0 && s->t_end > s->t_begin &&
1!
2437
            threshold >= s->bucket_us) {
1✔
2438
            co_return HttpResponse::ok(serve_density_from_summary(
2!
2439
                *s, params, begin, end, original_begin, original_end, threshold,
1✔
2440
                global_min > 0, index.global_min_timestamp_us()));
1✔
2441
        }
2442
    }
1!
2443

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

2449
    // Enclosing events are fetched by a separate pass (below) so a large
2450
    // `lookback` can never starve the in-window scan's budget.
2451
    double lookback = params.get_double("lookback", 0);
3✔
2452
    if (lookback < 0) lookback = 0;
3!
2453
    double scan_begin = begin - lookback;
3✔
2454
    if (scan_begin < 0) scan_begin = 0;
3!
2455

2456
    int limit = params.get_int("limit", 0);
3✔
2457
    if (limit < 0) limit = 0;
3!
2458

2459
    struct Acc {
2✔
2460
        std::vector<std::string> big;
2461
        std::vector<double> big_dur;  // parallel to `big`
2462
        DensityMap dens;
2463
        double max_dur = 0;
2✔
2464
    };
2465
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
2466
    std::vector<Acc> accs(slots);
3!
2467

2468
    // Pass 1 (in-window): scan [begin, end] with the full budget so earlier
2469
    // events never consume it. Small events fold into density blocks.
2470
    auto on_batch = [&accs, threshold, begin](
5✔
2471
                        std::size_t w,
2472
                        const std::vector<std::string_view>& events) {
51✔
2473
        Acc& acc = accs[w];  // worker-owned slot, no lock
2✔
2474
        for (auto ev : events) {
102✔
2475
            double dur = 0;
100✔
2476
            if (!fold_density(ev, threshold, begin, acc.dens, &dur)) {
100!
UNCOV
2477
                acc.big.emplace_back(ev);
×
UNCOV
2478
                acc.big_dur.push_back(dur);
×
2479
            }
2480
            if (dur > acc.max_dur) acc.max_dur = dur;
100✔
2481
        }
2482
    };
2✔
2483
    ViewDefinition win_view = build_viz_view(params, begin, end, 0);
3!
2484
    std::vector<const TraceIndex::FileInfo*> win_files =
3✔
2485
        select_viz_target_files(index, params, begin, end);
3!
2486
    bool single_file = !params.get("file").empty();
3✔
2487
    bool truncated =
6✔
2488
        co_await scan_view_events(index, win_files, win_view, begin, end, limit,
4!
2489
                                  slots, on_batch, single_file);
3!
2490

2491
    // Pass 2 (enclosers): keep only events still open at `begin`
2492
    // (ts < begin <= ts + dur); these ancestor bars set containment depth.
2493
    if (scan_begin < begin) {
3!
UNCOV
2494
        auto on_batch_enc = [&accs, begin](
×
2495
                                std::size_t w,
2496
                                const std::vector<std::string_view>& events) {
UNCOV
2497
            Acc& acc = accs[w];
×
UNCOV
2498
            for (auto ev : events) {
×
UNCOV
2499
                double ts = 0, dur = 0;
×
UNCOV
2500
                if (!parse_ts_dur(ev, ts, dur)) continue;
×
UNCOV
2501
                if (dur > acc.max_dur) acc.max_dur = dur;
×
UNCOV
2502
                if (ts < begin && ts + dur > begin) {
×
UNCOV
2503
                    acc.big.emplace_back(ev);
×
UNCOV
2504
                    acc.big_dur.push_back(dur);
×
2505
                }
2506
            }
UNCOV
2507
        };
×
2508
        ViewDefinition enc_view = build_viz_view(params, scan_begin, begin, 0);
×
2509
        std::vector<const TraceIndex::FileInfo*> enc_files =
2510
            select_viz_target_files(index, params, scan_begin, begin);
×
2511
        bool enc_trunc = co_await scan_view_events(
×
2512
            index, enc_files, enc_view, scan_begin, begin, limit, slots,
2513
            on_batch_enc, single_file);
×
2514
        truncated = truncated || enc_trunc;
×
2515
    }
×
2516

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

2554
    co_await append_app_spans(big, index, begin, end, params);
4!
2555

2556
    // Stable per-event/-block depth (computed in absolute space, before ts
2557
    // normalization rewrites the big strings; order is preserved in place).
2558
    std::vector<std::uint32_t> big_depth =
1✔
2559
        assign_view_depths(big, dens, begin, threshold);
1!
2560
    if (global_min > 0) {
1!
2561
        for (auto& e : big) e = normalize_event_ts(e, global_min);
1!
2562
    }
1✔
2563

2564
    co_return HttpResponse::ok(serialize_density_body(
2!
2565
        big, dens, original_begin, original_end, threshold, limit, truncated,
1✔
2566
        global_min > 0, index.global_min_timestamp_us(), max_dur, &big_depth));
1✔
2567
}
26!
2568

2569
static std::string serialize_counters_body(const std::vector<double>& read,
2✔
2570
                                           const std::vector<double>& write,
2571
                                           const std::vector<double>& ops,
2572
                                           double original_begin,
2573
                                           double original_end, int buckets,
2574
                                           double bucket_us, bool truncated) {
2575
    auto& b = scratch_json_builder();
2✔
2576
    b.start_object();
2✔
2577
    b.append_key_value("begin", original_begin);
2✔
2578
    b.append_comma();
2✔
2579
    b.append_key_value("end", original_end);
2✔
2580
    b.append_comma();
2✔
2581
    b.append_key_value("buckets", static_cast<std::int64_t>(buckets));
2✔
2582
    b.append_comma();
2✔
2583
    b.append_key_value("bucket_us", bucket_us);
2✔
2584
    b.append_comma();
2✔
2585
    b.append_key_value("truncated", truncated);
2✔
2586
    b.append_comma();
2✔
2587
    b.append_key_value("read_bytes", read);
2✔
2588
    b.append_comma();
2✔
2589
    b.append_key_value("write_bytes", write);
2✔
2590
    b.append_comma();
2✔
2591
    b.append_key_value("ops", ops);
2✔
2592
    b.end_object();
2✔
2593
    return std::string(b);
2!
2594
}
2595

2596
// Re-aggregate the summary's finest counter buckets into `buckets` output
2597
// buckets over [begin_abs, end_abs] (absolute us).
2598
static std::string serve_counters_from_summary(const VizSummary& s,
2✔
2599
                                               double begin_abs, double end_abs,
2600
                                               double original_begin,
2601
                                               double original_end, int buckets,
2602
                                               double bucket_us_out) {
2603
    std::vector<double> read(buckets, 0.0), write(buckets, 0.0),
2!
2604
        ops(buckets, 0.0);
2!
2605
    std::int64_t fb0 = s.bucket_of(begin_abs);
2✔
2606
    std::int64_t fb1 = s.bucket_of(end_abs);
2✔
2607
    if (fb0 < 0) fb0 = 0;
2✔
2608
    if (fb1 < 0) fb1 = static_cast<std::int64_t>(s.nbuckets) - 1;
2✔
2609
    for (std::int64_t fb = fb0; fb <= fb1; ++fb) {
131,074✔
2610
        double center = static_cast<double>(s.t_begin) +
196,608✔
2611
                        (static_cast<double>(fb) + 0.5) * s.bucket_us;
131,072✔
2612
        long oi = static_cast<long>((center - begin_abs) / bucket_us_out);
131,072✔
2613
        if (oi < 0 || oi >= buckets) continue;
131,072!
2614
        auto f = static_cast<std::size_t>(fb);
131,072✔
2615
        read[oi] += s.read_bytes[f];
131,072✔
2616
        write[oi] += s.write_bytes[f];
131,072✔
2617
        ops[oi] += s.ops[f];
131,072✔
2618
    }
65,536✔
2619
    return serialize_counters_body(read, write, ops, original_begin,
2!
2620
                                   original_end, buckets, bucket_us_out, false);
3!
2621
}
2✔
2622

2623
// GET /api/v1/viz/counters: per-bucket read/write bytes and I/O op counts over
2624
// a time range, for bandwidth/IOPS counter tracks. Aggregated server-side in
2625
// parallel (per-worker arrays merged after join).
2626
static coro::CoroTask<HttpResponse> handle_viz_counters(
8!
2627
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
2628
    if (!params.has("begin") || !params.has("end")) {
3!
2629
        co_return HttpResponse::bad_request(
5!
2630
            "Missing required parameters: begin, end");
4!
2631
    }
2632

2633
    double begin = params.get_double("begin", 0);
3✔
2634
    double end = params.get_double("end", 0);
3✔
2635
    int buckets = params.get_int("buckets", 800);
3✔
2636
    if (buckets < 16) buckets = 16;
3!
2637
    if (buckets > 4000) buckets = 4000;
3!
2638

2639
    auto query = params.get("query");
3✔
2640
    if (!query.empty() &&
3!
2641
        !utilities::common::query::try_parse(query).has_value()) {
×
2642
        co_return HttpResponse::bad_request("Invalid query: " +
×
2643
                                            std::string(query));
×
2644
    }
2645

2646
    auto ts_norm_param = params.get("ts_normalize");
3✔
2647
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
2648
    std::uint64_t global_min = 0;
3✔
2649
    if (normalize) {
3✔
2650
        global_min = index.global_min_timestamp_us();
1✔
2651
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
2652
            global_min = 0;
2653
    }
1✔
2654
    double original_begin = begin;
3✔
2655
    double original_end = end;
3✔
2656
    if (normalize && global_min > 0) {
3!
2657
        begin += static_cast<double>(global_min);
1✔
2658
        end += static_cast<double>(global_min);
1✔
2659
    }
1✔
2660

2661
    double bucket_us = (end - begin) / static_cast<double>(buckets);
3✔
2662
    if (bucket_us <= 0) bucket_us = 1;
3!
2663

2664
    // Zoomed-out, unfiltered counter tracks come from the activity summary.
2665
    if (viz_summary_eligible(params)) {
3!
2666
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
2667
        if (s && s->bucket_us > 0 && s->t_end > s->t_begin &&
1!
2668
            bucket_us >= s->bucket_us) {
1✔
2669
            co_return HttpResponse::ok(
1!
2670
                serve_counters_from_summary(*s, begin, end, original_begin,
2!
2671
                                            original_end, buckets, bucket_us));
1✔
2672
        }
2673
    }
1!
2674

2675
    ViewDefinition view = build_viz_view(params, begin, end, 0);
×
2676
    // No cap: only zoomed-in or filtered counter queries reach the live path.
2677
    int limit = params.get_int("limit", 0);
×
2678
    if (limit < 0) limit = 0;
×
2679

2680
    std::vector<const TraceIndex::FileInfo*> target_files =
2681
        select_viz_target_files(index, params, begin, end);
×
2682

2683
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
2684
    std::vector<CounterAcc> accs(slots);
×
2685
    for (auto& a : accs) a.init(static_cast<std::size_t>(buckets));
×
UNCOV
2686
    auto on_batch = [&accs, begin, bucket_us, buckets](
×
2687
                        std::size_t w,
2688
                        const std::vector<std::string_view>& events) {
UNCOV
2689
        CounterAcc& acc = accs[w];  // worker-owned, no lock
×
UNCOV
2690
        for (auto ev : events)
×
UNCOV
2691
            fold_counter(ev, begin, bucket_us,
×
2692
                         static_cast<std::size_t>(buckets), acc);
UNCOV
2693
    };
×
2694

2695
    bool truncated =
2696
        co_await scan_view_events(index, target_files, view, begin, end, limit,
×
2697
                                  slots, on_batch, !params.get("file").empty());
×
2698

2699
    CounterAcc total;
2700
    total.init(static_cast<std::size_t>(buckets));
×
2701
    for (auto& a : accs) total.merge_from(a);
×
2702

2703
    co_return HttpResponse::ok(serialize_counters_body(
×
2704
        total.read_bytes, total.write_bytes, total.ops, original_begin,
2705
        original_end, buckets, bucket_us, truncated));
2706
}
29!
2707

2708
// Process-spawning calls: dftracer POSIX (exact "fork"/"clone"/...) and kernel
2709
// syscalls ("__arm64_sys_clone"). Excludes library helpers like ibv_*fork* and
2710
// register_tm_clones, which contain "fork"/"clone" but don't spawn.
2711
static bool is_fork_syscall(std::string_view name) {
100✔
2712
    return name == "fork" || name == "vfork" || name == "clone" ||
250!
2713
           name == "clone3" || name == "posix_spawn" ||
200!
2714
           name == "posix_spawnp" ||
150!
2715
           name.find("sys_clone") != std::string_view::npos ||
150!
2716
           name.find("sys_fork") != std::string_view::npos ||
200!
2717
           name.find("sys_vfork") != std::string_view::npos;
150✔
2718
}
2719

2720
// One process in the proctree response. `host` borrows the hostname table;
2721
// `rank` is null when the trace carries no "PR" metadata for the pid, and the
2722
// key is then omitted.
2723
struct ProcNode {
2724
    std::int64_t pid;
2725
    std::int64_t parent;
2726
    std::uint64_t spawn_ts;
2727
    std::uint64_t first_ts;
2728
    std::string_view host;
2729
    std::uint64_t bytes;
2730
    std::uint64_t io_ops;
2731
    double io_busy;
2732
    const std::string* rank;
2733
};
2734

2735
template <typename builder_type>
2736
void tag_invoke(simdjson::serialize_tag, builder_type& b, const ProcNode& n) {
100✔
2737
    b.start_object();
100✔
2738
    b.append_key_value("pid", n.pid);
100✔
2739
    b.append_comma();
100✔
2740
    b.append_key_value("parent", n.parent);
100✔
2741
    b.append_comma();
100✔
2742
    b.append_key_value("spawn_ts", n.spawn_ts);
100✔
2743
    b.append_comma();
100✔
2744
    b.append_key_value("first_ts", n.first_ts);
100✔
2745
    b.append_comma();
100✔
2746
    b.append_key_value("host", n.host);
100✔
2747
    b.append_comma();
100✔
2748
    b.append_key_value("bytes", n.bytes);
100✔
2749
    b.append_comma();
100✔
2750
    b.append_key_value("io_ops", n.io_ops);
100✔
2751
    b.append_comma();
100✔
2752
    b.append_key_value("io_busy", n.io_busy);
100✔
2753
    if (n.rank) {
100✔
UNCOV
2754
        b.append_comma();
×
UNCOV
2755
        b.append_key_value("rank", *n.rank);
×
2756
    }
2757
    b.end_object();
100✔
2758
}
100✔
2759

2760
// GET /api/v1/viz/proctree: infer the process fork hierarchy. The traces record
2761
// the fork/clone in the parent but not the child pid, so link each process to
2762
// the nearest preceding clone in another process (child start follows the clone
2763
// by microseconds). Respects ?file= for per-node trees on multi-node traces.
2764
static coro::CoroTask<HttpResponse> handle_viz_proctree(
6!
2765
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
2766
    std::uint64_t gmin = index.global_min_timestamp_us();
1!
2767
    std::uint64_t gmax = index.global_max_timestamp_us();
1!
2768
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
1!
2769
        co_return HttpResponse::ok("{\"nodes\":[]}");
1!
2770

2771
    auto ts_norm_param = params.get("ts_normalize");
1!
2772
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
1!
2773
    std::uint64_t base = normalize ? gmin : 0;
1!
2774

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

2858
            auto nr = root["name"];
100✔
2859
            std::string_view name;
100✔
2860
            if (!nr.error() && nr.is_string())
100!
2861
                name = nr.get_string().value_unsafe();
100✔
2862

2863
            std::int64_t child = -1;
100✔
2864
            double ret = 0;
100✔
2865
            auto args = root["args"];
100✔
2866
            if (!args.error() && args.is_object()) {
100!
2867
                auto ppr = args["ppid"];
100✔
2868
                if (!ppr.error()) {
100✔
2869
                    auto pp = static_cast<std::int64_t>(
UNCOV
2870
                        json_number(ppr.value_unsafe()));
×
UNCOV
2871
                    if (pp > 0 && pp != pid) acc.ppid[pid] = pp;
×
2872
                }
2873
                auto rr = args["ret"];
100✔
2874
                if (!rr.error()) {
100✔
2875
                    ret = json_number(rr.value_unsafe());
100✔
2876
                    child = static_cast<std::int64_t>(ret);
100✔
2877
                }
50✔
2878
                auto hr = args["hhash"];
100✔
2879
                if (!hr.error() && hr.is_string() &&
200!
2880
                    acc.pid_hhash.find(pid) == acc.pid_hhash.end())
150!
2881
                    acc.pid_hhash.emplace(
150!
2882
                        pid, std::string(hr.get_string().value_unsafe()));
150!
2883
            }
50✔
2884
            // I/O bytes per process: args.ret on read/write ops.
2885
            if (ret > 0 && (name.find("read") != std::string_view::npos ||
131!
2886
                            name.find("write") != std::string_view::npos))
62✔
2887
                acc.bytes[pid] += static_cast<std::uint64_t>(ret);
76!
2888

2889
            auto cr = root["cat"];
100✔
2890
            std::string_view cat;
100✔
2891
            if (!cr.error() && cr.is_string())
100!
2892
                cat = cr.get_string().value_unsafe();
100✔
2893
            if (cat == "POSIX" || cat == "STDIO" || cat == "IO") {
100!
2894
                acc.io_ops[pid] += 1;
100!
2895
                auto dr = root["dur"];
100✔
2896
                if (!dr.error())
100✔
2897
                    acc.io_busy[pid] += json_number(dr.value_unsafe());
100!
2898
            }
50✔
2899

2900
            if (!name.empty() && is_fork_syscall(name))
100!
UNCOV
2901
                acc.forks.emplace_back(ts, pid, child > 0 ? child : -1);
×
2902
        }
2903
    };
2✔
2904

2905
    ViewDefinition view;
1✔
2906
    view.name = "viz_proctree";
1!
2907
    view.with_query("ts >= " + std::to_string(gmin) +
2!
2908
                    " and ts <= " + std::to_string(gmax));
1!
2909
    auto files = select_viz_target_files(
2!
2910
        index, params, static_cast<double>(gmin), static_cast<double>(gmax));
1✔
2911
    bool single_file = !params.get("file").empty();
1!
2912
    co_await scan_view_events(index, files, view, static_cast<double>(gmin),
2!
2913
                              static_cast<double>(gmax), 0, slots, on_batch,
1!
2914
                              single_file);
1✔
2915

2916
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
1!
2917
    ankerl::unordered_dense::map<std::int64_t, std::int64_t> parent_of;
1!
2918
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> spawn_of;
1!
2919
    std::vector<std::pair<std::uint64_t, std::int64_t>> inf_forks;  // (ts, pid)
1✔
2920
    for (auto& a : accs) {
3✔
2921
        for (auto& kv : a.first_ts) {
52✔
2922
            auto it = first_ts.find(kv.first);
50!
2923
            if (it == first_ts.end())
50!
2924
                first_ts.emplace(kv.first, kv.second);
50!
2925
            else if (kv.second < it->second)
×
2926
                it->second = kv.second;
2927
        }
50✔
2928
        // Explicit edges from the fork event's args.ret (child pid + spawn ts).
2929
        for (auto& [ts, ppid, child] : a.forks) {
2!
2930
            if (child > 0) {
×
2931
                parent_of[child] = ppid;
×
2932
                spawn_of[child] = ts;
×
2933
            } else {
2934
                inf_forks.emplace_back(ts, ppid);
×
2935
            }
2936
        }
2937
    }
2✔
2938
    // args.ppid metadata fills in any process not linked by a fork event.
2939
    for (auto& a : accs)
3✔
2940
        for (auto& kv : a.ppid)
2!
2941
            if (parent_of.find(kv.first) == parent_of.end())
×
2942
                parent_of.emplace(kv.first, kv.second);
2!
2943
    std::sort(inf_forks.begin(), inf_forks.end());
1!
2944

2945
    // Merge host (hhash -> hostname resolved) and I/O bytes per process.
2946
    dftracer::utils::StringViewMap<std::string> hh;
1!
2947
    ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
1!
2948
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
1!
2949
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
1!
2950
    ankerl::unordered_dense::map<std::int64_t, double> io_busy;
1!
2951
    ankerl::unordered_dense::map<std::int64_t, std::string> rank;
1!
2952
    for (auto& a : accs) {
3✔
2953
        for (auto& kv : a.hh) hh.emplace(kv.first, kv.second);
2!
2954
        for (auto& kv : a.pid_hhash) pid_hhash.emplace(kv.first, kv.second);
52!
2955
        for (auto& kv : a.bytes) bytes[kv.first] += kv.second;
40!
2956
        for (auto& kv : a.io_ops) io_ops[kv.first] += kv.second;
52!
2957
        for (auto& kv : a.io_busy) io_busy[kv.first] += kv.second;
52!
2958
        for (auto& kv : a.rank) rank.emplace(kv.first, kv.second);
2!
2959
    }
2✔
2960

2961
    std::vector<std::pair<std::uint64_t, std::int64_t>>
1✔
2962
        procs;  // (first_ts, pid)
1✔
2963
    procs.reserve(first_ts.size());
1!
2964
    for (auto& kv : first_ts) procs.emplace_back(kv.second, kv.first);
51!
2965
    std::sort(procs.begin(), procs.end());
1!
2966

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

3016
    auto& sb = scratch_json_builder();
1!
3017
    sb.start_object();
1✔
3018
    sb.append_key_value("nodes", nodes);
1!
3019
    sb.end_object();
1✔
3020
    co_return HttpResponse::ok(std::string(sb));
1!
3021
}
5!
3022

3023
static coro::CoroTask<HttpResponse> handle_viz_breaks(
8!
3024
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
3025
    auto ts_norm_param = params.get("ts_normalize");
3✔
3026
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
3027
    std::uint64_t global_min = 0;
3✔
3028
    if (normalize) {
3✔
3029
        global_min = index.global_min_timestamp_us();
1✔
3030
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
3031
            global_min = 0;
3032
    }
1✔
3033
    const VizSummary* s = co_await ensure_viz_summary(index);
5!
3034
    auto& b = scratch_json_builder();
1!
3035
    b.start_object();
1✔
3036
    b.escape_and_append_with_quotes("gaps");
1✔
3037
    b.append_colon();
1✔
3038
    b.start_array();
1✔
3039
    if (s) {
1!
3040
        bool first = true;
1✔
3041
        for (const auto& g : s->idle_gaps) {
46✔
3042
            if (!first) b.append_comma();
45✔
3043
            first = false;
45✔
3044
            b.start_object();
45✔
3045
            b.append_key_value("begin", g.first - global_min);
45✔
3046
            b.append_comma();
45✔
3047
            b.append_key_value("end", g.second - global_min);
45✔
3048
            b.end_object();
45✔
3049
        }
45✔
3050
    }
1✔
3051
    b.end_array();
1✔
3052
    b.append_comma();
1✔
3053
    b.append_key_value("multi_run", s && !s->idle_gaps.empty());
1!
3054
    b.end_object();
1✔
3055
    co_return HttpResponse::ok(std::string(b));
1!
3056
}
9!
3057

3058
void register_viz_api(Router& router, TraceIndex& index) {
6✔
3059
    auto* index_ptr = &index;
6✔
3060
    const RouteParam BEGIN{"begin", "Window start (us)", true, "0"};
6!
3061
    const RouteParam END{"end", "Window end (us)", true, "999999999"};
6!
3062
    const RouteParam SUMMARY{"summary", "LOD level (1=full detail)", true, "1"};
6!
3063

3064
    router.get(
9!
3065
        "/api/v1/viz/proctree",
3!
3066
        [index_ptr](const HttpRequest& req,
11!
3067
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3068
            co_return co_await handle_viz_proctree(req, params, *index_ptr);
5!
3069
        },
4!
3070
        RouteDoc{
18!
3071
            "Inferred process/fork hierarchy with host, rank, and I/O.",
3!
3072
            "Visualization",
3!
3073
            {{"file", "Limit to one trace file", false, ""}},
3!
3074
            R"({"nodes":[{"pid":100,"parent":-1,"host":"node01","rank":"0",)"
3!
3075
            R"("bytes":16384,"io_ops":4,"io_busy":600.0}]})"});
6✔
3076

3077
    router.get(
9!
3078
        "/api/v1/viz/counters",
3!
3079
        [index_ptr](const HttpRequest& req,
11!
3080
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3081
            co_return co_await handle_viz_counters(req, params, *index_ptr);
5!
3082
        },
4!
3083
        RouteDoc{"Read/write bytes and I/O op counts per time bucket.",
24!
3084
                 "Visualization",
3!
3085
                 {BEGIN, END, SUMMARY},
3!
3086
                 R"({"buckets":[{"ts":0,"read_bytes":4096,"write_bytes":0,)"
3!
3087
                 R"("read_ops":1,"write_ops":0}]})"});
12✔
3088

3089
    router.get(
9!
3090
        "/api/v1/viz/breaks",
3!
3091
        [index_ptr](const HttpRequest& req,
11!
3092
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3093
            co_return co_await handle_viz_breaks(req, params, *index_ptr);
5!
3094
        },
4!
3095
        RouteDoc{
18!
3096
            "Globally-idle time gaps and multi-run detection.",
3!
3097
            "Visualization",
3!
3098
            {{"ts_normalize", "Normalize to global min (default on)", false,
3!
3099
              "1"}},
3!
3100
            R"({"gaps":[{"begin":50000,"end":900000}],"multi_run":true})"});
9!
3101

3102
    router.get(
9!
3103
        "/api/v1/viz/events",
3!
3104
        [index_ptr](const HttpRequest& req,
59!
3105
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
7!
3106
            co_return co_await handle_viz_events(req, params, *index_ptr);
35!
3107
        },
28!
3108
        RouteDoc{"Events for rendering: time-windowed, LOD-aggregated.",
33!
3109
                 "Visualization",
3!
3110
                 {BEGIN,
12!
3111
                  END,
3!
3112
                  SUMMARY,
3!
3113
                  {"pid", "Filter by process id", false, ""},
3!
3114
                  {"cat", "Filter by category", false, ""},
3!
3115
                  {"query", "DSL predicate, e.g. dur >= 1000", false, ""}},
3!
3116
                 R"({"events":[],"metadata":{"begin":0,"end":1000000,)"
3!
3117
                 R"("count":42,"truncated":false,"ts_normalized":true}})"});
21✔
3118

3119
    router.get(
9!
3120
        "/api/v1/viz/density",
3!
3121
        [index_ptr](const HttpRequest& req,
19!
3122
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
2!
3123
            co_return co_await handle_viz_density(req, params, *index_ptr);
10!
3124
        },
8!
3125
        RouteDoc{"Sub-pixel events bucketed into density blocks.",
27!
3126
                 "Visualization",
3!
3127
                 {BEGIN,
9!
3128
                  END,
3!
3129
                  {"summary", "LOD level", true, "2"},
3!
3130
                  {"width", "Canvas width in px (sets the 1px fold cutoff)",
3!
3131
                   false, "1920"}},
3!
3132
                 R"({"events":[],"density":[{"pid":100,"tid":100,"ts":0,)"
3!
3133
                 R"("dur":24457,"count":910,"total":22044,"depth":0}]})"});
15✔
3134

3135
    router.get(
9!
3136
        "/api/v1/viz/stats",
3!
3137
        [index_ptr](const HttpRequest& req,
11!
3138
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3139
            co_return co_await handle_viz_stats(req, params, *index_ptr);
5!
3140
        },
4!
3141
        RouteDoc{"Per-name aggregation over a time range (Analyze).",
24!
3142
                 "Visualization",
3!
3143
                 {BEGIN, END, SUMMARY},
3!
3144
                 R"({"count":100,"total_dur":5000,"names":[{"name":"read",)"
3!
3145
                 R"("count":50,"total":2500,"avg":50,"min":10,"max":90}]})"});
12✔
3146

3147
    router.get(
9!
3148
        "/api/v1/viz/calltree",
3!
3149
        [index_ptr](const HttpRequest& req,
11!
3150
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3151
            co_return co_await handle_viz_calltree(req, params, *index_ptr);
5!
3152
        },
4!
3153
        RouteDoc{
27!
3154
            "Merged flamegraph tree from ts/dur containment.",
3!
3155
            "Visualization",
3!
3156
            {BEGIN,
6!
3157
             END,
3!
3158
             SUMMARY,
3!
3159
             {"group", "Set to 'pid' to keep processes separate", false, ""}},
3!
3160
            R"({"name":"root","total":5000,"self":0,"count":0,)"
3!
3161
            R"("children":[{"name":"read","total":2500,"self":2500,)"
3162
            R"("count":50}]})"});
15✔
3163

3164
    router.get(
9!
3165
        "/api/v1/viz/histogram",
3!
3166
        [index_ptr](const HttpRequest& req,
11!
3167
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
3168
            co_return co_await handle_viz_histogram(req, params, *index_ptr);
5!
3169
        },
4!
3170
        RouteDoc{"Duration distribution: percentiles + log-spaced buckets.",
27!
3171
                 "Visualization",
3!
3172
                 {BEGIN,
6!
3173
                  END,
3!
3174
                  SUMMARY,
3!
3175
                  {"query", "DSL predicate to narrow to one op", false, ""}},
3!
3176
                 R"({"min":10,"max":900,"p50":150,"p99":880,"buckets":[]})"});
18!
3177

3178
    router.get(
9!
3179
        "/api/v1/viz/layers",
3!
3180
        [index_ptr](const HttpRequest& req,
19!
3181
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
2!
3182
            co_return co_await handle_viz_layers(req, params, *index_ptr);
10!
3183
        },
8!
3184
        RouteDoc{"Operation-name to category map; declared vs I/O files.",
9!
3185
                 "Visualization",
3!
3186
                 {},
3✔
3187
                 R"({"layers":{"read":"POSIX","write":"POSIX"},)"
3!
3188
                 R"("total_files":2,"io_files":2})"});
3189
}
6✔
3190

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