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

llnl / dftracer-utils / 29118766082

10 Jul 2026 07:39PM UTC coverage: 52.688% (+0.2%) from 52.475%
29118766082

push

github

hariharan-devarajan
fix(io): finish scatter-gather writes past IOV_MAX

writev(2) accepts at most IOV_MAX entries and may write fewer bytes than asked.
The streaming HTTP path builds two iovec entries per event, so a chunk over ~511
events overflowed the limit, writev failed, and the connection still sent the
terminating chunk: clients saw 200 OK with an empty body. Bulk export of any
sizeable trace returned nothing.

Add io::writev_all(), which splits runs longer than IOV_MAX and resumes after
short writes, and use it for streamed responses. The limit is read via
sysconf(_SC_IOV_MAX), since glibc gates IOV_MAX behind feature macros and
spells it UIO_MAXIOV; io_uring's IORING_OP_WRITEV is bounded the same way.

39269 of 96697 branches covered (40.61%)

Branch coverage included in aggregate %.

26 of 27 new or added lines in 2 files covered. (96.3%)

428 existing lines in 11 files now uncovered.

35017 of 44296 relevant lines covered (79.05%)

21293.65 hits per line

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

57.89
/src/dftracer/utils/server/viz_api.cpp
1
#include <dftracer/utils/core/common/logging.h>
2
#include <dftracer/utils/core/common/to_chars.h>
3
#include <dftracer/utils/core/common/transparent_string_hash.h>
4
#include <dftracer/utils/core/coro/channel.h>
5
#include <dftracer/utils/core/pipeline/executor.h>
6
#include <dftracer/utils/core/tasks/coro_scope.h>
7
#include <dftracer/utils/server/http_request.h>
8
#include <dftracer/utils/server/http_response.h>
9
#include <dftracer/utils/server/json_builder.h>
10
#include <dftracer/utils/server/router.h>
11
#include <dftracer/utils/server/trace_index.h>
12
#include <dftracer/utils/server/viz_api.h>
13
#include <dftracer/utils/utilities/common/json/json_doc_guard.h>
14
#include <dftracer/utils/utilities/common/json/json_value.h>
15
#include <dftracer/utils/utilities/common/query/query.h>
16
#include <dftracer/utils/utilities/composites/dft/views/view_builder_utility.h>
17
#include <dftracer/utils/utilities/composites/dft/views/view_definition.h>
18
#include <dftracer/utils/utilities/composites/dft/views/view_reader_utility.h>
19
#include <dftracer/utils/utilities/fileio/lines/sources/async_streaming_gz_line_generator.h>
20
#include <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!
UNCOV
71
        auto val = ts_result.get_int64().value_unsafe();
×
UNCOV
72
        old_ts = val >= 0 ? static_cast<std::uint64_t>(val) : 0;
×
73
    } else {
UNCOV
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
    }
UNCOV
115
    if (val.is_uint64()) {
×
116
        return std::to_string(val.get_uint64().value_unsafe());
×
117
    }
UNCOV
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✔
UNCOV
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!
UNCOV
161
        auto obj = root.get_object().value_unsafe();
×
162

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

UNCOV
167
        if (!field_result.error() && !value_result.error()) {
×
UNCOV
168
            if (field_result.value_unsafe().is_string()) {
×
169
                const char* field =
UNCOV
170
                    field_result.value_unsafe().get_c_str().value_unsafe();
×
UNCOV
171
                auto val = extract_json_value(value_result.value_unsafe());
×
UNCOV
172
                if (!val.empty()) append_lane_clause(dsl, field, val);
×
UNCOV
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!
UNCOV
197
            continue;
×
198

199
        if (!field_result.value_unsafe().is_string() ||
6!
200
            !op_result.value_unsafe().is_string())
4!
UNCOV
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 == "<=")
×
UNCOV
222
            query_op = "<=";
×
UNCOV
223
        else if (op_str == ">")
×
UNCOV
224
            query_op = ">";
×
UNCOV
225
        else if (op_str == "<")
×
UNCOV
226
            query_op = "<";
×
227
        else
UNCOV
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✔
UNCOV
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!
UNCOV
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 == ";
×
UNCOV
275
        dsl += pid;
×
276
    }
277

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

284
    auto cat = params.get("cat");
18!
285
    if (!cat.empty()) {
18!
UNCOV
286
        dsl += " and cat == \"";
×
UNCOV
287
        dsl += cat;
×
UNCOV
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!
UNCOV
294
        dsl += " and (";
×
UNCOV
295
        dsl += query;
×
UNCOV
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!
UNCOV
318
                filtered.push_back(fi);
×
UNCOV
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<HttpResponse> handle_viz_events(
52!
492
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
7!
493
    // Required: begin, end, summary
494
    if (!params.has("begin") || !params.has("end") || !params.has("summary")) {
17!
495
        co_return HttpResponse::bad_request(
30!
496
            "Missing required parameters: begin, end, summary");
23!
497
    }
498

499
    double begin = params.get_double("begin", 0);
18✔
500
    double end = params.get_double("end", 0);
18✔
501
    int summary = params.get_int("summary", 1);
18✔
502
    if (summary < 1) summary = 1;
18!
503

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

512
    // Timestamp normalization: default ON, opt-out with ?ts_normalize=0
513
    auto ts_norm_param = params.get("ts_normalize");
18✔
514
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
18!
515

516
    std::uint64_t global_min = 0;
18✔
517
    if (normalize) {
18✔
518
        global_min = index.global_min_timestamp_us();
5✔
519
        if (global_min == std::numeric_limits<std::uint64_t>::max()) {
5!
520
            global_min = 0;  // No valid bounds, skip normalization
521
        }
522
    }
5✔
523

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

534
    double min_dur =
18✔
535
        duration_threshold(begin, end, static_cast<unsigned>(summary));
18!
536

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

545
    ViewDefinition view = build_viz_view(params, scan_begin, end, min_dur);
18!
546

547
    // Optional limit: 0 (default) means no limit.
548
    int limit = params.get_int("limit", 0);
18✔
549
    if (limit < 0) limit = 0;
18!
550

551
    std::vector<const TraceIndex::FileInfo*> target_files =
18✔
552
        select_viz_target_files(index, params, scan_begin, end);
18!
553

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

565
    bool truncated = co_await scan_view_events(
30!
566
        index, target_files, view, scan_begin, end, limit, slots, collect,
18!
567
        !params.get("file").empty());
18!
568

569
    std::vector<std::string> collected_events;
6✔
570
    for (auto& p : partials) {
18✔
571
        for (auto& s : p) collected_events.emplace_back(std::move(s));
162!
572
    }
12✔
573
    if (limit > 0 && static_cast<int>(collected_events.size()) > limit) {
6!
574
        collected_events.resize(static_cast<std::size_t>(limit));
×
575
        truncated = true;
576
    }
577

578
    std::string body = build_viz_events_body(
12!
579
        collected_events, global_min, original_begin, original_end, limit,
6✔
580
        truncated, index.global_min_timestamp_us());
6✔
581
    co_return HttpResponse::ok(body);
6!
582
}
244!
583

584
namespace {
585

586
struct NameStat {
587
    std::uint64_t count = 0;
588
    double total = 0;
589
    double min = 0;
590
    double max = 0;
UNCOV
591
    void merge_from(const NameStat& o) {
×
UNCOV
592
        if (count == 0) {
×
UNCOV
593
            min = o.min;
×
UNCOV
594
            max = o.max;
×
UNCOV
595
        } else if (o.count > 0) {
×
UNCOV
596
            min = std::min(min, o.min);
×
UNCOV
597
            max = std::max(max, o.max);
×
598
        }
UNCOV
599
        count += o.count;
×
UNCOV
600
        total += o.total;
×
UNCOV
601
    }
×
602
};
603

604
using NameMap = dftracer::utils::StringViewMap<NameStat>;
605

606
static double json_number(simdjson::dom::element el);
607

608
enum class GroupBy { Name, Cat, Pid, Fhash };
609

610
static GroupBy parse_group_by(std::string_view g) {
2✔
611
    if (g == "cat") return GroupBy::Cat;
2!
612
    if (g == "pid") return GroupBy::Pid;
2!
613
    if (g == "fhash" || g == "file") return GroupBy::Fhash;
2!
614
    return GroupBy::Name;
2✔
615
}
1✔
616

617
// Parse one event's dur and grouping key, folding it into a worker-local map.
UNCOV
618
static void fold_event(std::string_view event, GroupBy group, NameMap& local) {
×
UNCOV
619
    thread_local simdjson::dom::parser parser;
×
UNCOV
620
    thread_local std::string buf;
×
UNCOV
621
    thread_local std::string keybuf;
×
UNCOV
622
    buf.assign(event);
×
UNCOV
623
    auto res = parser.parse(buf);
×
UNCOV
624
    if (res.error()) return;
×
UNCOV
625
    auto root = res.value_unsafe();
×
UNCOV
626
    if (!root.is_object()) return;
×
627

UNCOV
628
    auto dur_r = root["dur"];
×
UNCOV
629
    if (dur_r.error()) return;  // skip instant/metadata events (no duration)
×
UNCOV
630
    double dur = 0;
×
UNCOV
631
    if (dur_r.is_uint64())
×
UNCOV
632
        dur = static_cast<double>(dur_r.get_uint64().value_unsafe());
×
UNCOV
633
    else if (dur_r.is_int64())
×
UNCOV
634
        dur = static_cast<double>(dur_r.get_int64().value_unsafe());
×
UNCOV
635
    else if (dur_r.is_double())
×
UNCOV
636
        dur = dur_r.get_double().value_unsafe();
×
637
    else
UNCOV
638
        return;
×
639

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

UNCOV
664
    auto it = local.find(name);
×
UNCOV
665
    if (it == local.end()) {
×
UNCOV
666
        it = local.emplace(std::string(name), NameStat{}).first;
×
UNCOV
667
        it->second.min = dur;
×
UNCOV
668
        it->second.max = dur;
×
669
    } else {
UNCOV
670
        it->second.min = std::min(it->second.min, dur);
×
UNCOV
671
        it->second.max = std::max(it->second.max, dur);
×
672
    }
UNCOV
673
    it->second.count += 1;
×
UNCOV
674
    it->second.total += dur;
×
675
}
676

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

690
struct DensityKeyHash {
691
    std::uint64_t operator()(const DensityKey& k) const noexcept {
788✔
692
        std::uint64_t h = 1469598103934665603ULL;
788✔
693
        auto mix = [&](std::uint64_t v) {
2,758✔
694
            h ^= v;
2,364✔
695
            h *= 1099511628211ULL;
2,364✔
696
        };
2,758✔
697
        mix(static_cast<std::uint64_t>(k.pid));
788!
698
        mix(static_cast<std::uint64_t>(k.tid));
788!
699
        mix(static_cast<std::uint64_t>(k.col));
788!
700
        return h;
788✔
701
    }
702
};
703

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

721
using DensityMap =
722
    ankerl::unordered_dense::map<DensityKey, DensityAgg, DensityKeyHash>;
723

724
static double json_number(simdjson::dom::element el) {
1,800✔
725
    if (el.is_uint64())
1,800!
726
        return static_cast<double>(el.get_uint64().value_unsafe());
1,800✔
UNCOV
727
    if (el.is_int64())
×
UNCOV
728
        return static_cast<double>(el.get_int64().value_unsafe());
×
UNCOV
729
    if (el.is_double()) return el.get_double().value_unsafe();
×
UNCOV
730
    return 0;
×
731
}
900✔
732

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

745
    auto dr = root["dur"];
100✔
746
    if (dr.error()) return false;
100!
747
    double dur = json_number(dr.value_unsafe());
100✔
748
    if (out_dur) *out_dur = dur;
100✔
749
    if (threshold <= 0 || dur >= threshold) return false;
100!
750

751
    double ts = 0;
100✔
752
    auto tr = root["ts"];
100✔
753
    if (!tr.error()) ts = json_number(tr.value_unsafe());
100✔
754
    std::int64_t pid = 0;
100✔
755
    auto pr = root["pid"];
100✔
756
    if (!pr.error())
100✔
757
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
100✔
758
    std::int64_t tid = 0;
100✔
759
    auto tir = root["tid"];
100✔
760
    if (!tir.error())
100✔
761
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
100✔
762
    std::string_view name;
100✔
763
    auto nr = root["name"];
100✔
764
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
100!
765

766
    std::int64_t col = static_cast<std::int64_t>((ts - begin) / threshold);
100✔
767
    DensityKey k{pid, tid, col};
100✔
768
    auto it = dens.find(k);
100!
769
    if (it == dens.end()) it = dens.emplace(k, DensityAgg{}).first;
100!
770
    auto& a = it->second;
100✔
771
    a.count += 1;
100✔
772
    a.total += dur;
100✔
773
    if (dur > a.max_dur) {
100✔
774
        a.max_dur = dur;
100✔
775
        a.name.assign(name);
100!
776
    }
50✔
777
    return true;
100✔
778
}
50✔
779

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

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

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

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

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

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

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

UNCOV
923
static void fold_counter(std::string_view event, double begin, double bucket_us,
×
924
                         std::size_t buckets, CounterAcc& acc) {
UNCOV
925
    thread_local simdjson::dom::parser parser;
×
UNCOV
926
    thread_local std::string buf;
×
UNCOV
927
    buf.assign(event);
×
UNCOV
928
    auto res = parser.parse(buf);
×
UNCOV
929
    if (res.error()) return;
×
UNCOV
930
    auto root = res.value_unsafe();
×
UNCOV
931
    if (!root.is_object()) return;
×
932

UNCOV
933
    auto cat_r = root["cat"];
×
UNCOV
934
    if (cat_r.error() || !cat_r.is_string()) return;
×
UNCOV
935
    std::string_view cat = cat_r.get_string().value_unsafe();
×
UNCOV
936
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
×
937

UNCOV
938
    auto ts_r = root["ts"];
×
UNCOV
939
    if (ts_r.error()) return;
×
UNCOV
940
    double ts = json_number(ts_r.value_unsafe());
×
UNCOV
941
    long col = static_cast<long>((ts - begin) / bucket_us);
×
UNCOV
942
    if (col < 0 || col >= static_cast<long>(buckets)) return;
×
943

UNCOV
944
    acc.ops[col] += 1.0;
×
945

UNCOV
946
    std::string_view name;
×
UNCOV
947
    auto name_r = root["name"];
×
UNCOV
948
    if (!name_r.error() && name_r.is_string())
×
UNCOV
949
        name = name_r.get_string().value_unsafe();
×
950

UNCOV
951
    double bytes = 0;
×
UNCOV
952
    auto args = root["args"];
×
UNCOV
953
    if (!args.error() && args.is_object()) {
×
UNCOV
954
        auto ret = args["ret"];
×
UNCOV
955
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
×
956
    }
UNCOV
957
    if (bytes <= 0) return;
×
UNCOV
958
    if (name.find("write") != std::string_view::npos)
×
UNCOV
959
        acc.write_bytes[col] += bytes;
×
UNCOV
960
    else if (name.find("read") != std::string_view::npos)
×
UNCOV
961
        acc.read_bytes[col] += bytes;
×
962
}
963

964
}  // namespace
965

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

971
namespace {
972

973
struct PidTid {
974
    std::int64_t pid;
975
    std::int64_t tid;
UNCOV
976
    bool operator==(const PidTid& o) const {
×
UNCOV
977
        return pid == o.pid && tid == o.tid;
×
978
    }
979
};
980

981
struct PidTidHash {
982
    std::uint64_t operator()(const PidTid& k) const noexcept {
580✔
983
        std::uint64_t h = 1469598103934665603ULL;
580✔
984
        h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
580✔
985
        h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
580✔
986
        return h;
580✔
987
    }
988
};
989

990
struct SumLane {
991
    std::int64_t pid;
992
    std::int64_t tid;
993
    std::vector<std::atomic<std::uint32_t>> count;
994
    std::vector<std::atomic<std::uint64_t>> total;
995
    std::vector<std::atomic<std::uint32_t>> maxd;
996
    std::vector<std::atomic<std::uint32_t>> nameid;
997
    SumLane(std::size_t nb, std::int64_t p, std::int64_t t)
138✔
998
        : pid(p), tid(t), count(nb), total(nb), maxd(nb), nameid(nb) {
138!
999
        for (auto& x : nameid)
6,029,404✔
1000
            x.store(std::numeric_limits<std::uint32_t>::max(),
6,029,312✔
1001
                    std::memory_order_relaxed);
1002
    }
138✔
1003
};
1004

1005
struct SumBuild {
1!
1006
    std::size_t nb = 0;
1✔
1007
    double bucket_us = 1;
1✔
1008
    std::uint64_t t0 = 0;
1✔
1009

1010
    std::mutex lane_mtx;
1011
    ankerl::unordered_dense::map<PidTid, std::size_t, PidTidHash> lane_of;
1012
    std::deque<SumLane> lanes;
1013

1014
    std::vector<std::atomic<double>> cread;
1015
    std::vector<std::atomic<double>> cwrite;
1016
    std::vector<std::atomic<double>> cops;
1017
    std::atomic<std::uint64_t> gmax_dur{0};
1✔
1018

1019
    std::mutex name_mtx;
1020
    dftracer::utils::StringViewMap<std::uint32_t> name_of;
1021
    std::vector<std::string> names;
1022

1023
    // Per-worker caches so the hot path never locks.
1024
    std::vector<ankerl::unordered_dense::map<PidTid, SumLane*, PidTidHash>>
1025
        lane_cache;
1026
    std::vector<dftracer::utils::StringViewMap<std::uint32_t>> name_cache;
1027

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

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

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

1041
    // Per-worker file-hashes seen on a read/write op (files with real data
1042
    // I/O).
1043
    std::vector<dftracer::utils::StringViewSet> io_fh;
1044

1045
    SumLane* get_lane(std::size_t w, std::int64_t pid, std::int64_t tid) {
100✔
1046
        PidTid key{pid, tid};
100✔
1047
        auto& cache = lane_cache[w];
100✔
1048
        auto ci = cache.find(key);
100!
1049
        if (ci != cache.end()) return ci->second;
100!
1050
        std::lock_guard<std::mutex> lk(lane_mtx);
100!
1051
        auto it = lane_of.find(key);
100!
1052
        SumLane* lane;
1053
        if (it != lane_of.end()) {
100!
UNCOV
1054
            lane = &lanes[it->second];
×
1055
        } else {
1056
            if (lanes.size() * nb >= VizSummary::MAX_CELLS)
100✔
1057
                return nullptr;  // budget reached; drop further lanes
8✔
1058
            lane_of.emplace(key, lanes.size());
92!
1059
            lanes.emplace_back(nb, pid, tid);
92!
1060
            lane = &lanes.back();
92✔
1061
        }
1062
        cache.emplace(key, lane);
92!
1063
        return lane;
92✔
1064
    }
100✔
1065

1066
    std::uint32_t intern(std::size_t w, std::string_view name) {
92✔
1067
        auto& cache = name_cache[w];
92✔
1068
        auto ci = cache.find(name);
92!
1069
        if (ci != cache.end()) return ci->second;
92✔
1070
        std::lock_guard<std::mutex> lk(name_mtx);
16!
1071
        auto it = name_of.find(name);
16!
1072
        std::uint32_t id;
1073
        if (it != name_of.end()) {
16✔
UNCOV
1074
            id = it->second;
×
1075
        } else {
1076
            id = static_cast<std::uint32_t>(names.size());
16✔
1077
            names.emplace_back(name);
16!
1078
            name_of.emplace(std::string(name), id);
16!
1079
        }
1080
        cache.emplace(std::string(name), id);
16!
1081
        return id;
16✔
1082
    }
54✔
1083
};
1084

1085
template <class T>
1086
static void atomic_max(std::atomic<T>& a, T v) {
192✔
1087
    T cur = a.load(std::memory_order_relaxed);
192✔
1088
    while (v > cur &&
384!
1089
           !a.compare_exchange_weak(cur, v, std::memory_order_relaxed)) {
288!
1090
    }
1091
}
192✔
1092

1093
static void atomic_add_double(std::atomic<double>& a, double v) {
176✔
1094
    double cur = a.load(std::memory_order_relaxed);
176✔
1095
    while (!a.compare_exchange_weak(cur, cur + v, std::memory_order_relaxed)) {
176!
1096
    }
1097
}
176✔
1098

1099
// Fold one (key, dur) sample into a group aggregate, mirroring fold_event.
1100
static void fold_group(NameMap& m, std::string_view key, double dur) {
300✔
1101
    auto it = m.find(key);
300!
1102
    if (it == m.end()) {
300✔
1103
        it = m.emplace(std::string(key), NameStat{}).first;
120!
1104
        it->second.min = dur;
120✔
1105
        it->second.max = dur;
120✔
1106
    } else {
60✔
1107
        it->second.min = std::min(it->second.min, dur);
180✔
1108
        it->second.max = std::max(it->second.max, dur);
180✔
1109
    }
1110
    it->second.count += 1;
300✔
1111
    it->second.total += dur;
300✔
1112
}
300✔
1113

1114
static void fold_summary(std::size_t w, std::string_view event, SumBuild& b) {
100✔
1115
    thread_local simdjson::dom::parser parser;
100✔
1116
    thread_local std::string buf;
100✔
1117
    buf.assign(event);
100!
1118
    auto res = parser.parse(buf);
100✔
1119
    if (res.error()) return;
100!
1120
    auto root = res.value_unsafe();
100✔
1121
    if (!root.is_object()) return;
100✔
1122

1123
    auto dr = root["dur"];
100✔
1124
    if (dr.error()) {
100!
1125
        // FH metadata record: file-hash -> path. Kept for "By file" resolution.
UNCOV
1126
        auto nm = root["name"];
×
UNCOV
1127
        if (!nm.error() && nm.is_string() &&
×
UNCOV
1128
            nm.get_string().value_unsafe() == "FH") {
×
UNCOV
1129
            auto args = root["args"];
×
UNCOV
1130
            if (!args.error() && args.is_object()) {
×
UNCOV
1131
                auto v = args["value"];
×
UNCOV
1132
                auto n = args["name"];
×
UNCOV
1133
                if (!v.error() && v.is_string() && !n.error() && n.is_string())
×
UNCOV
1134
                    b.fh_parts[w].emplace(
×
UNCOV
1135
                        std::string(v.get_string().value_unsafe()),
×
UNCOV
1136
                        std::string(n.get_string().value_unsafe()));
×
1137
            }
1138
        }
UNCOV
1139
        return;  // metadata/instant events carry no duration
×
1140
    }
1141
    double dur = json_number(dr.value_unsafe());
100✔
1142

1143
    std::int64_t pid = 0, tid = 0;
100✔
1144
    auto pr = root["pid"];
100✔
1145
    if (!pr.error())
100✔
1146
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
100✔
1147

1148
    // Analyze aggregates: whole-trace, ts-independent so they match a live
1149
    // scan.
1150
    {
1151
        auto nr = root["name"];
100✔
1152
        auto cr = root["cat"];
100✔
1153
        if (!nr.error() && nr.is_string()) {
100!
1154
            std::string_view nm = nr.get_string().value_unsafe();
100✔
1155
            fold_group(b.g_name[w], nm, dur);
100!
1156
            if (!cr.error() && cr.is_string() &&
200!
1157
                b.name_cat[w].find(nm) == b.name_cat[w].end())
150!
1158
                b.name_cat[w].emplace(
24!
1159
                    std::string(nm),
24!
1160
                    std::string(cr.get_string().value_unsafe()));
24!
1161
        }
50✔
1162
        if (!cr.error() && cr.is_string())
100!
1163
            fold_group(b.g_cat[w], cr.get_string().value_unsafe(), dur);
100!
1164
        thread_local std::string pidkey;
100✔
1165
        pidkey.assign(std::to_string(pid));
100!
1166
        fold_group(b.g_pid[w], pidkey, dur);
100!
1167
        auto ar = root["args"];
100✔
1168
        if (!ar.error() && ar.is_object()) {
100!
1169
            auto fr = ar["fhash"];
100✔
1170
            if (!fr.error() && fr.is_string())
100!
UNCOV
1171
                fold_group(b.g_fhash[w], fr.get_string().value_unsafe(), dur);
×
1172
        }
50✔
1173
    }
1174

1175
    auto tr = root["ts"];
100✔
1176
    if (tr.error()) return;  // bucketing/counters below need a timestamp
100!
1177
    double ts = json_number(tr.value_unsafe());
100✔
1178
    std::int64_t bucket = static_cast<std::int64_t>(
100✔
1179
        (ts - static_cast<double>(b.t0)) / b.bucket_us);
100✔
1180
    if (bucket < 0) return;
100✔
1181
    if (bucket >= static_cast<std::int64_t>(b.nb)) bucket = b.nb - 1;
100✔
1182
    auto bi = static_cast<std::size_t>(bucket);
100✔
1183

1184
    auto tir = root["tid"];
100✔
1185
    if (!tir.error())
100✔
1186
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
100✔
1187

1188
    if (SumLane* lane = b.get_lane(w, pid, tid)) {
100✔
1189
        lane->count[bi].fetch_add(1, std::memory_order_relaxed);
92✔
1190
        lane->total[bi].fetch_add(static_cast<std::uint64_t>(dur < 0 ? 0 : dur),
92!
1191
                                  std::memory_order_relaxed);
1192
        std::uint32_t d = dur >= 4294967295.0
138!
1193
                              ? 4294967295u
92!
1194
                              : static_cast<std::uint32_t>(dur < 0 ? 0 : dur);
92!
1195
        if (d > lane->maxd[bi].load(std::memory_order_relaxed)) {
138✔
1196
            atomic_max(lane->maxd[bi], d);
92✔
1197
            std::string_view name;
92✔
1198
            auto nr = root["name"];
92✔
1199
            if (!nr.error() && nr.is_string())
92!
1200
                name = nr.get_string().value_unsafe();
92✔
1201
            lane->nameid[bi].store(b.intern(w, name),
92!
1202
                                   std::memory_order_relaxed);
1203
        }
46✔
1204
    }
46✔
1205
    atomic_max(b.gmax_dur, static_cast<std::uint64_t>(dur < 0 ? 0 : dur));
100!
1206

1207
    // Counters: read/write bytes and op counts for POSIX/STDIO/IO events.
1208
    auto cat_r = root["cat"];
100✔
1209
    if (cat_r.error() || !cat_r.is_string()) return;
100!
1210
    std::string_view cat = cat_r.get_string().value_unsafe();
100✔
1211
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
100!
1212
    atomic_add_double(b.cops[bi], 1.0);
100✔
1213
    double bytes = 0;
100✔
1214
    auto args = root["args"];
100✔
1215
    if (!args.error() && args.is_object()) {
100!
1216
        auto ret = args["ret"];
100✔
1217
        if (!ret.error()) bytes = json_number(ret.value_unsafe());
100✔
1218
    }
50✔
1219
    if (bytes <= 0) return;
100!
1220
    std::string_view name;
100✔
1221
    auto nr = root["name"];
100✔
1222
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
100!
1223
    bool is_write = name.find("write") != std::string_view::npos;
100✔
1224
    bool is_read = name.find("read") != std::string_view::npos;
100✔
1225
    if (is_write)
100✔
1226
        atomic_add_double(b.cwrite[bi], bytes);
38✔
1227
    else if (is_read)
62✔
1228
        atomic_add_double(b.cread[bi], bytes);
38✔
1229
    if ((is_read || is_write) && !args.error() && args.is_object()) {
100!
1230
        auto fr = args["fhash"];
76✔
1231
        if (!fr.error() && fr.is_string())
76!
UNCOV
1232
            b.io_fh[w].emplace(std::string(fr.get_string().value_unsafe()));
×
1233
    }
38✔
1234
}
50✔
1235

1236
}  // namespace
1237

1238
// Build the activity summary by scanning every event once. Blocks the caller
1239
// (the first overview request) for the scan; cached for the server's lifetime.
1240
static coro::CoroTask<void> build_viz_summary(TraceIndex& index) {
8!
1241
    auto summary = std::make_unique<VizSummary>();
3!
1242
    std::uint64_t gmin = index.global_min_timestamp_us();
3✔
1243
    std::uint64_t gmax = index.global_max_timestamp_us();
3✔
1244
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin) {
3✔
1245
        index.set_viz_summary(std::move(summary));
4!
1246
        co_return;
1✔
1247
    }
1248

1249
    std::size_t est_lanes = std::max<std::size_t>(1, index.file_count()) * 8;
3✔
1250
    std::size_t nb = VizSummary::MAX_CELLS / est_lanes;
3✔
1251
    nb = std::clamp(nb, VizSummary::MIN_BUCKETS_PER_LANE,
3!
1252
                    VizSummary::MAX_BUCKETS_PER_LANE);
1253

1254
    SumBuild b;
3!
1255
    b.nb = nb;
3✔
1256
    b.t0 = gmin;
3✔
1257
    b.bucket_us = static_cast<double>(gmax - gmin) / static_cast<double>(nb);
3✔
1258
    b.cread = std::vector<std::atomic<double>>(nb);
3!
1259
    b.cwrite = std::vector<std::atomic<double>>(nb);
3!
1260
    b.cops = std::vector<std::atomic<double>>(nb);
3!
1261
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
1262
    b.lane_cache.resize(slots);
3✔
1263
    b.name_cache.resize(slots);
1!
1264
    b.g_name.resize(slots);
1!
1265
    b.g_cat.resize(slots);
1!
1266
    b.g_pid.resize(slots);
1!
1267
    b.g_fhash.resize(slots);
1!
1268
    b.fh_parts.resize(slots);
1!
1269
    b.name_cat.resize(slots);
1!
1270
    b.io_fh.resize(slots);
1✔
1271

1272
    ViewDefinition view;
3✔
1273
    view.name = "viz_summary";
3✔
1274
    view.description = "Activity summary build";
1✔
1275
    view.with_query("ts >= " + std::to_string(gmin) +
6!
1276
                    " and ts <= " + std::to_string(gmax));
3!
1277

1278
    std::vector<const TraceIndex::FileInfo*> files;
3✔
1279
    files.reserve(index.files().size());
3✔
1280
    for (const auto& f : index.files()) files.push_back(&f);
4!
1281

1282
    auto on_batch = [&b](std::size_t w,
5✔
1283
                         const std::vector<std::string_view>& events) {
50✔
1284
        for (auto ev : events) fold_summary(w, ev, b);
102✔
1285
    };
2✔
1286
    co_await scan_view_events(index, files, view, static_cast<double>(gmin),
4!
1287
                              static_cast<double>(gmax), 0, slots, on_batch);
3!
1288

1289
    summary->t_begin = gmin;
1✔
1290
    summary->t_end = gmax;
1✔
1291
    summary->nbuckets = nb;
1✔
1292
    summary->bucket_us = b.bucket_us;
1✔
1293
    summary->max_dur = b.gmax_dur.load(std::memory_order_relaxed);
1✔
1294
    summary->names = std::move(b.names);
1✔
1295
    summary->read_bytes.assign(nb, 0.0);
1!
1296
    summary->write_bytes.assign(nb, 0.0);
1!
1297
    summary->ops.assign(nb, 0.0);
1!
1298
    for (std::size_t i = 0; i < nb; ++i) {
65,537✔
1299
        summary->read_bytes[i] = b.cread[i].load(std::memory_order_relaxed);
65,536✔
1300
        summary->write_bytes[i] = b.cwrite[i].load(std::memory_order_relaxed);
65,536✔
1301
        summary->ops[i] = b.cops[i].load(std::memory_order_relaxed);
65,536✔
1302
    }
65,536✔
1303
    summary->lanes.reserve(b.lanes.size());
1!
1304
    for (auto& sl : b.lanes) {
47!
1305
        VizSummary::Lane lane;
46✔
1306
        lane.pid = sl.pid;
46✔
1307
        lane.tid = sl.tid;
46✔
1308
        lane.cells.resize(nb);
46!
1309
        for (std::size_t i = 0; i < nb; ++i) {
3,014,702✔
1310
            lane.cells[i].count = sl.count[i].load(std::memory_order_relaxed);
3,014,656✔
1311
            lane.cells[i].total = sl.total[i].load(std::memory_order_relaxed);
3,014,656✔
1312
            lane.cells[i].max_dur = sl.maxd[i].load(std::memory_order_relaxed);
3,014,656✔
1313
            lane.cells[i].name_id =
3,014,656✔
1314
                sl.nameid[i].load(std::memory_order_relaxed);
3,014,656✔
1315
        }
3,014,656✔
1316
        summary->lanes.push_back(std::move(lane));
46!
1317
    }
46✔
1318

1319
    // Merge the per-worker Analyze aggregates and sort each by total desc.
1320
    auto finalize_group = [](std::vector<NameMap>& parts) {
9✔
1321
        NameMap merged;
8!
1322
        for (auto& p : parts) {
24✔
1323
            for (auto& kv : p) {
136✔
1324
                auto it = merged.find(kv.first);
120!
1325
                if (it == merged.end())
120!
1326
                    merged.emplace(kv.first, kv.second);
120!
1327
                else
UNCOV
1328
                    it->second.merge_from(kv.second);
×
1329
            }
1330
        }
1331
        std::vector<VizSummary::GroupRow> rows;
8✔
1332
        rows.reserve(merged.size());
8!
1333
        for (auto& kv : merged)
128✔
1334
            rows.push_back({kv.first, kv.second.count, kv.second.total,
180!
1335
                            kv.second.min, kv.second.max});
180!
1336
        std::sort(rows.begin(), rows.end(),
8!
1337
                  [](const VizSummary::GroupRow& lhs,
426✔
1338
                     const VizSummary::GroupRow& rhs) {
1339
                      return lhs.total > rhs.total;
426✔
1340
                  });
1341
        return rows;
12✔
1342
    };
8!
1343
    summary->by_name = finalize_group(b.g_name);
1!
1344
    summary->by_cat = finalize_group(b.g_cat);
1!
1345
    summary->by_pid = finalize_group(b.g_pid);
1!
1346
    summary->by_fhash = finalize_group(b.g_fhash);
1!
1347

1348
    // Resolve file-hash keys to real paths where a metadata record was seen.
1349
    dftracer::utils::StringViewMap<std::string> fh;
1!
1350
    for (auto& part : b.fh_parts)
3✔
1351
        for (auto& kv : part) fh.emplace(kv.first, kv.second);
2!
1352
    for (auto& r : summary->by_fhash) {
1!
1353
        auto it = fh.find(r.key);
×
1354
        if (it != fh.end()) r.key = it->second;
×
1355
    }
1356
    summary->total_files = fh.size();
1✔
1357

1358
    dftracer::utils::StringViewSet io_fh;
1!
1359
    for (auto& part : b.io_fh)
3✔
1360
        for (auto& k : part) io_fh.emplace(k);
2!
1361
    summary->io_files = io_fh.size();
1✔
1362

1363
    // Merge the per-worker name -> category maps (first writer wins).
1364
    dftracer::utils::StringViewMap<std::string> nc;
1!
1365
    for (auto& part : b.name_cat)
3✔
1366
        for (auto& kv : part) nc.emplace(kv.first, kv.second);
10!
1367
    summary->name_cats.reserve(nc.size());
1!
1368
    for (auto& kv : nc) summary->name_cats.emplace_back(kv.first, kv.second);
9!
1369

1370
    DFTRACER_UTILS_LOG_INFO(
1!
1371
        "viz: built activity summary (%zu lanes, %zu buckets/lane)",
1372
        summary->lanes.size(), nb);
1373
    index.set_viz_summary(std::move(summary));
1!
1374
}
21!
1375

1376
// The summary, building it on first use. Null when another request is already
1377
// building it, in which case the caller falls back to a live scan.
1378
static coro::CoroTask<const VizSummary*> ensure_viz_summary(TraceIndex& index) {
32!
1379
    const VizSummary* s = index.viz_summary();
7✔
1380
    if (!s && index.try_begin_summary_build()) {
5!
1381
        co_await build_viz_summary(index);
9!
1382
        s = index.viz_summary();
1!
1383
    }
1✔
1384
    co_return s;
7✔
1385
}
21!
1386

1387
// One aggregate row, uniform over the live-scan and summary paths.
1388
struct StatRow {
1389
    const std::string* key;
1390
    std::uint64_t count;
1391
    double total;
1392
    double min;
1393
    double max;
1394
};
1395

1396
template <typename builder_type>
1397
void tag_invoke(simdjson::serialize_tag, builder_type& b, const StatRow& r) {
16✔
1398
    b.start_object();
16✔
1399
    b.append_key_value("name", *r.key);
16✔
1400
    b.append_comma();
16✔
1401
    b.append_key_value("count", r.count);
16✔
1402
    b.append_comma();
16✔
1403
    b.append_key_value("total", r.total);
16✔
1404
    b.append_comma();
16✔
1405
    b.append_key_value("avg",
24✔
1406
                       r.count ? r.total / static_cast<double>(r.count) : 0.0);
16!
1407
    b.append_comma();
16✔
1408
    b.append_key_value("min", r.min);
16✔
1409
    b.append_comma();
16✔
1410
    b.append_key_value("max", r.max);
16✔
1411
    b.end_object();
16✔
1412
}
16✔
1413

1414
static std::string serialize_stats_body(std::uint64_t total_count,
2✔
1415
                                        double total_dur, double wall,
1416
                                        bool truncated,
1417
                                        const std::vector<StatRow>& rows) {
1418
    auto& b = scratch_json_builder();
2✔
1419
    b.start_object();
2✔
1420
    b.append_key_value("count", total_count);
2✔
1421
    b.append_comma();
2✔
1422
    b.append_key_value("total_dur", total_dur);
2✔
1423
    b.append_comma();
2✔
1424
    b.append_key_value("wall", wall);
2✔
1425
    b.append_comma();
2✔
1426
    b.append_key_value("truncated", truncated);
2✔
1427
    b.append_comma();
2✔
1428
    b.append_key_value("names", rows);
2✔
1429
    b.end_object();
2✔
1430
    return std::string(b);
2!
1431
}
1432

1433
static const std::vector<VizSummary::GroupRow>& summary_group_rows(
2✔
1434
    const VizSummary& s, GroupBy g) {
1435
    switch (g) {
2!
1436
        case GroupBy::Cat:
UNCOV
1437
            return s.by_cat;
×
1438
        case GroupBy::Pid:
UNCOV
1439
            return s.by_pid;
×
1440
        case GroupBy::Fhash:
UNCOV
1441
            return s.by_fhash;
×
1442
        case GroupBy::Name:
2✔
1443
        default:
1444
            return s.by_name;
2✔
1445
    }
1446
}
1✔
1447

1448
// Whole-trace, unfiltered Analyze answers come from the prebuilt summary, so no
1449
// live scan is needed. Any predicate or sub-range forces the live path.
1450
static bool viz_stats_summary_eligible(const QueryParams& p, double begin_abs,
2✔
1451
                                       double end_abs, TraceIndex& index) {
1452
    if (!p.get("query").empty() || !p.get("cat").empty() ||
4!
1453
        !p.get("lanes").empty() || !p.get("filters").empty() ||
2!
1454
        !p.get("file").empty() || !p.get("pid").empty() ||
4!
1455
        !p.get("tid").empty())
3!
UNCOV
1456
        return false;
×
1457
    std::uint64_t gmin = index.global_min_timestamp_us();
2✔
1458
    std::uint64_t gmax = index.global_max_timestamp_us();
2✔
1459
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
2!
UNCOV
1460
        return false;
×
1461
    return begin_abs <= static_cast<double>(gmin) + 1.0 &&
4✔
1462
           end_abs >= static_cast<double>(gmax) - 1.0;
3!
1463
}
1✔
1464

1465
// GET /api/v1/viz/stats: server-side per-name aggregation over a time range.
1466
// Scans in parallel worker coroutines, each folding into its own map, then
1467
// merges single-threaded (no lock). Returns only the small aggregate table.
1468
static coro::CoroTask<HttpResponse> handle_viz_stats(const HttpRequest& /*req*/,
8!
1469
                                                     const QueryParams& params,
1470
                                                     TraceIndex& index) {
1!
1471
    if (!params.has("begin") || !params.has("end")) {
3!
1472
        co_return HttpResponse::bad_request(
5!
1473
            "Missing required parameters: begin, end");
4!
1474
    }
1475

1476
    double begin = params.get_double("begin", 0);
3✔
1477
    double end = params.get_double("end", 0);
3✔
1478

1479
    auto query = params.get("query");
3✔
1480
    if (!query.empty() &&
3!
1481
        !utilities::common::query::try_parse(query).has_value()) {
×
1482
        co_return HttpResponse::bad_request("Invalid query: " +
×
1483
                                            std::string(query));
×
1484
    }
1485

1486
    auto ts_norm_param = params.get("ts_normalize");
3✔
1487
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
1488
    std::uint64_t global_min = 0;
3✔
1489
    if (normalize) {
3✔
1490
        global_min = index.global_min_timestamp_us();
1✔
1491
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
1492
            global_min = 0;
1493
    }
1✔
1494
    double original_begin = begin;
3✔
1495
    double original_end = end;
3✔
1496
    if (normalize && global_min > 0) {
3!
1497
        begin += static_cast<double>(global_min);
1✔
1498
        end += static_cast<double>(global_min);
1✔
1499
    }
1✔
1500

1501
    GroupBy group = parse_group_by(params.get("group"));
3!
1502

1503
    // Whole-trace, unfiltered Analyze: answer from the prebuilt summary (built
1504
    // lazily here). Concurrent builds fall through to the live scan below.
1505
    if (viz_stats_summary_eligible(params, begin, end, index)) {
1!
1506
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
1507
        if (s) {
1!
1508
            const auto& gr = summary_group_rows(*s, group);
1!
1509
            std::vector<StatRow> rows;
1✔
1510
            rows.reserve(gr.size());
1!
1511
            std::uint64_t total_count = 0;
1✔
1512
            double total_dur = 0;
1✔
1513
            for (const auto& r : gr) {
9✔
1514
                rows.push_back({&r.key, r.count, r.total, r.min, r.max});
8!
1515
                total_count += r.count;
8✔
1516
                total_dur += r.total;
8✔
1517
            }
8✔
1518
            co_return HttpResponse::ok(serialize_stats_body(
2!
1519
                total_count, total_dur, original_end - original_begin, false,
1✔
1520
                rows));
1521
        }
1✔
1522
    }
1!
1523

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

1527
    int limit = params.get_int("limit", 0);
×
1528
    if (limit < 0) limit = 0;
×
1529

1530
    std::vector<const TraceIndex::FileInfo*> target_files =
1531
        select_viz_target_files(index, params, begin, end);
×
1532

1533
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
1534
    std::vector<NameMap> partials(slots);
×
UNCOV
1535
    auto aggregate = [&partials, group](
×
1536
                         std::size_t w,
1537
                         const std::vector<std::string_view>& events) {
UNCOV
1538
        NameMap& local = partials[w];  // owned by worker w only, no lock
×
UNCOV
1539
        for (auto ev : events) fold_event(ev, group, local);
×
UNCOV
1540
    };
×
1541

1542
    bool truncated = co_await scan_view_events(index, target_files, view, begin,
×
1543
                                               end, limit, slots, aggregate,
×
1544
                                               !params.get("file").empty());
×
1545

1546
    // Single-threaded reduce of the disjoint per-worker maps.
1547
    NameMap merged;
×
1548
    for (auto& p : partials) {
×
1549
        for (auto& kv : p) {
×
1550
            auto it = merged.find(kv.first);
×
1551
            if (it == merged.end())
×
1552
                merged.emplace(kv.first, kv.second);
×
1553
            else
1554
                it->second.merge_from(kv.second);
×
1555
        }
1556
    }
1557

1558
    std::vector<StatRow> rows;
1559
    rows.reserve(merged.size());
×
1560
    std::uint64_t total_count = 0;
1561
    double total_dur = 0;
1562
    for (const auto& kv : merged) {
×
1563
        rows.push_back({&kv.first, kv.second.count, kv.second.total,
×
1564
                        kv.second.min, kv.second.max});
1565
        total_count += kv.second.count;
1566
        total_dur += kv.second.total;
1567
    }
UNCOV
1568
    std::sort(rows.begin(), rows.end(), [](const StatRow& a, const StatRow& b) {
×
UNCOV
1569
        return a.total > b.total;
×
1570
    });
1571

1572
    co_return HttpResponse::ok(
×
1573
        serialize_stats_body(total_count, total_dur,
×
1574
                             original_end - original_begin, truncated, rows));
1575
}
29!
1576

1577
// GET /api/v1/viz/layers: whole-trace reference data - the operation-name ->
1578
// category map (a property of the name, not the view, so fetched once) plus the
1579
// FH file counts: total declared vs. those an I/O event actually touched.
1580
static coro::CoroTask<HttpResponse> handle_viz_layers(
16!
1581
    const HttpRequest& /*req*/, const QueryParams& /*p*/, TraceIndex& index) {
2!
1582
    const VizSummary* s = co_await ensure_viz_summary(index);
10!
1583
    auto& b = scratch_json_builder();
2!
1584
    b.start_object();
2✔
1585
    b.escape_and_append_with_quotes("layers");
2✔
1586
    b.append_colon();
2✔
1587
    b.start_object();
2✔
1588
    if (s) {
2!
1589
        bool first = true;
2✔
1590
        for (const auto& kv : s->name_cats) {
18✔
1591
            if (!first) b.append_comma();
16✔
1592
            first = false;
16✔
1593
            b.escape_and_append_with_quotes(kv.first);
16✔
1594
            b.append_colon();
16✔
1595
            b.escape_and_append_with_quotes(kv.second);
16✔
1596
        }
16✔
1597
    }
2✔
1598
    b.end_object();
2✔
1599
    b.append_comma();
2✔
1600
    b.append_key_value("total_files",
4✔
1601
                       static_cast<std::int64_t>(s ? s->total_files : 0));
2!
1602
    b.append_comma();
2✔
1603
    b.append_key_value("io_files",
4✔
1604
                       static_cast<std::int64_t>(s ? s->io_files : 0));
2!
1605
    b.end_object();
2✔
1606
    co_return HttpResponse::ok(std::string(b));
2!
1607
}
10!
1608

1609
// One scanned event, reduced to what the call-tree needs.
1610
struct FlameEv {
1✔
1611
    std::int64_t pid = 0;
1✔
1612
    std::int64_t tid = 0;
1✔
1613
    double ts = 0;
1✔
1614
    double dur = 0;
1✔
1615
    std::string name;
1616
};
1617

1618
// A node in the merged call tree: identical name-paths across all lanes fold
1619
// into one node. `total` is inclusive; `self` is total minus nested children.
1620
struct FlameNode {
9!
1621
    std::string name;
1622
    double total = 0;
9✔
1623
    double self = 0;
9✔
1624
    std::uint64_t count = 0;
9✔
1625
    dftracer::utils::StringViewMap<std::uint32_t> kids;
1626
    std::vector<std::uint32_t> children;
1627
};
1628

1629
static bool parse_flame_ev(std::string_view event, FlameEv& out) {
100✔
1630
    thread_local simdjson::dom::parser parser;
100✔
1631
    thread_local std::string buf;
100✔
1632
    buf.assign(event);
100!
1633
    auto res = parser.parse(buf);
100✔
1634
    if (res.error()) return false;
100!
1635
    auto root = res.value_unsafe();
100✔
1636
    if (!root.is_object()) return false;
100✔
1637
    auto dr = root["dur"];
100✔
1638
    if (dr.error()) return false;  // no duration: nothing to place in the tree
100!
1639
    out.dur = json_number(dr.value_unsafe());
100✔
1640
    auto tr = root["ts"];
100✔
1641
    if (tr.error()) return false;
100!
1642
    out.ts = json_number(tr.value_unsafe());
100✔
1643
    auto pr = root["pid"];
100✔
1644
    out.pid = pr.error()
100!
1645
                  ? 0
100!
1646
                  : static_cast<std::int64_t>(json_number(pr.value_unsafe()));
100✔
1647
    auto tir = root["tid"];
100✔
1648
    out.tid = tir.error()
100!
1649
                  ? 0
100!
1650
                  : static_cast<std::int64_t>(json_number(tir.value_unsafe()));
100✔
1651
    auto nr = root["name"];
100✔
1652
    if (!nr.error() && nr.is_string())
100!
1653
        out.name.assign(nr.get_string().value_unsafe());
100!
1654
    else
UNCOV
1655
        out.name.clear();
×
1656
    return true;
100✔
1657
}
50✔
1658

1659
static void serialize_flame_node(simdjson::builder::string_builder& sb,
18✔
1660
                                 std::vector<FlameNode>& arena,
1661
                                 std::uint32_t idx) {
1662
    FlameNode& n = arena[idx];
18✔
1663
    sb.start_object();
18✔
1664
    sb.append_key_value("name", n.name);
18✔
1665
    sb.append_comma();
18✔
1666
    sb.append_key_value("total", n.total);
18✔
1667
    sb.append_comma();
18✔
1668
    sb.append_key_value("self", n.self < 0 ? 0.0 : n.self);
18!
1669
    sb.append_comma();
18✔
1670
    sb.append_key_value("count", n.count);
18✔
1671
    std::sort(n.children.begin(), n.children.end(),
27✔
1672
              [&arena](std::uint32_t a, std::uint32_t b) {
87✔
1673
                  return arena[a].total > arena[b].total;
50✔
1674
              });
1675
    sb.append_comma();
18✔
1676
    sb.escape_and_append_with_quotes("children");
18✔
1677
    sb.append_colon();
18✔
1678
    sb.start_array();
18✔
1679
    for (std::size_t i = 0; i < n.children.size(); ++i) {
34✔
1680
        if (i > 0) sb.append_comma();
16✔
1681
        serialize_flame_node(sb, arena, n.children[i]);
16✔
1682
    }
8✔
1683
    sb.end_array();
18✔
1684
    sb.end_object();
18✔
1685
}
18✔
1686

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

1696
    double begin = params.get_double("begin", 0);
3✔
1697
    double end = params.get_double("end", 0);
3✔
1698

1699
    auto query = params.get("query");
3✔
1700
    if (!query.empty() &&
3!
1701
        !utilities::common::query::try_parse(query).has_value())
×
1702
        co_return HttpResponse::bad_request("Invalid query: " +
×
1703
                                            std::string(query));
×
1704

1705
    auto ts_norm_param = params.get("ts_normalize");
3✔
1706
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
1707
    std::uint64_t global_min = 0;
3✔
1708
    if (normalize) {
3✔
1709
        global_min = index.global_min_timestamp_us();
1✔
1710
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
1711
            global_min = 0;
1712
    }
1✔
1713
    if (normalize && global_min > 0) {
3!
1714
        begin += static_cast<double>(global_min);
1✔
1715
        end += static_cast<double>(global_min);
1✔
1716
    }
1✔
1717

1718
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
1719
    int limit = params.get_int("limit", 0);
3✔
1720
    if (limit < 0) limit = 0;
3!
1721

1722
    std::vector<const TraceIndex::FileInfo*> target_files =
3✔
1723
        select_viz_target_files(index, params, begin, end);
3!
1724

1725
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
1726
    std::vector<std::vector<FlameEv>> partials(slots);
3!
1727
    auto collect = [&partials](std::size_t w,
5✔
1728
                               const std::vector<std::string_view>& events) {
1✔
1729
        auto& out = partials[w];  // worker-owned slot, no lock
2✔
1730
        FlameEv ev;
2✔
1731
        for (auto e : events)
102✔
1732
            if (parse_flame_ev(e, ev)) out.push_back(ev);
100!
1733
    };
2✔
1734

1735
    bool truncated =
4✔
1736
        co_await scan_view_events(index, target_files, view, begin, end, limit,
5!
1737
                                  slots, collect, !params.get("file").empty());
3!
1738

1739
    std::vector<FlameEv> all;
1✔
1740
    std::size_t total_n = 0;
1✔
1741
    for (auto& p : partials) total_n += p.size();
3✔
1742
    all.reserve(total_n);
1!
1743
    for (auto& p : partials)
3✔
1744
        for (auto& e : p) all.push_back(std::move(e));
52!
1745

1746
    // Lanes are the contiguous (pid, tid) runs after this sort; within a lane
1747
    // events are ordered for the containment walk (outer/longer first on ties).
1748
    std::sort(all.begin(), all.end(), [](const FlameEv& a, const FlameEv& b) {
348!
1749
        if (a.pid != b.pid) return a.pid < b.pid;
347!
UNCOV
1750
        if (a.tid != b.tid) return a.tid < b.tid;
×
UNCOV
1751
        if (a.ts != b.ts) return a.ts < b.ts;
×
UNCOV
1752
        return a.dur > b.dur;
×
1753
    });
101✔
1754

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

1760
    std::vector<FlameNode> arena;
1✔
1761
    arena.reserve(256);
1!
1762
    arena.emplace_back();  // root (index 0)
1!
1763
    arena[0].name = "all";
1!
1764
    ankerl::unordered_dense::map<std::int64_t, std::uint32_t> proc_of;
1!
1765

1766
    std::vector<std::pair<double, std::uint32_t>> open;  // (end_ts, node idx)
1✔
1767
    std::size_t i = 0;
1✔
1768
    while (i < all.size()) {
51✔
1769
        std::int64_t pid = all[i].pid, tid = all[i].tid;
50✔
1770
        std::uint32_t base = 0;  // top-level events attach here
50✔
1771
        if (by_process) {
50!
1772
            auto pit = proc_of.find(pid);
×
1773
            if (pit == proc_of.end()) {
×
1774
                base = static_cast<std::uint32_t>(arena.size());
1775
                arena.emplace_back();
×
1776
                arena[base].name = "P" + std::to_string(pid);
×
1777
                proc_of.emplace(pid, base);
×
1778
                arena[0].children.push_back(base);
×
1779
            } else {
1780
                base = pit->second;
1781
            }
1782
        }
1783
        open.clear();
50✔
1784
        for (; i < all.size() && all[i].pid == pid && all[i].tid == tid; ++i) {
100✔
1785
            const FlameEv& ev = all[i];
50✔
1786
            double e_end = ev.ts + (ev.dur > 0 ? ev.dur : 0);
50!
1787
            while (!open.empty() && open.back().first <= ev.ts) open.pop_back();
50!
1788
            std::uint32_t parent = open.empty() ? base : open.back().second;
50!
1789

1790
            std::uint32_t mi;
50✔
1791
            auto it = arena[parent].kids.find(ev.name);
50!
1792
            if (it == arena[parent].kids.end()) {
50✔
1793
                mi = static_cast<std::uint32_t>(arena.size());
8✔
1794
                arena.emplace_back();  // may reallocate; index access below
8!
1795
                arena[mi].name = ev.name;
8!
1796
                arena[parent].kids.emplace(ev.name, mi);
8!
1797
                arena[parent].children.push_back(mi);
8!
1798
            } else {
8✔
1799
                mi = it->second;
42✔
1800
            }
1801
            arena[mi].total += ev.dur;
50✔
1802
            arena[mi].self += ev.dur;
50✔
1803
            arena[mi].count += 1;
50✔
1804
            if (parent != 0) arena[parent].self -= ev.dur;
50!
1805
            open.push_back({e_end, mi});
50!
1806
        }
50✔
1807
    }
50✔
1808

1809
    // Process frames are synthetic containers: total/count roll up from their
1810
    // children, self is 0.
1811
    if (by_process) {
1!
1812
        for (std::uint32_t pnode : arena[0].children) {
×
1813
            double t = 0;
1814
            std::uint64_t c = 0;
1815
            for (std::uint32_t ch : arena[pnode].children) {
×
1816
                t += arena[ch].total;
1817
                c += arena[ch].count;
1818
            }
1819
            arena[pnode].total = t;
1820
            arena[pnode].count = c;
1821
            arena[pnode].self = 0;
1822
        }
1823
    }
1824

1825
    double root_total = 0;
1✔
1826
    std::uint64_t root_count = 0;
1✔
1827
    for (std::uint32_t c : arena[0].children) {
9✔
1828
        root_total += arena[c].total;
8✔
1829
        root_count += arena[c].count;
8✔
1830
    }
8✔
1831
    arena[0].total = root_total;
1✔
1832
    arena[0].count = root_count;
1✔
1833
    arena[0].self = 0;
1✔
1834

1835
    auto& b = scratch_json_builder();
1!
1836
    b.start_object();
1✔
1837
    b.append_key_value("truncated", truncated);
1✔
1838
    b.append_comma();
1✔
1839
    b.escape_and_append_with_quotes("tree");
1✔
1840
    b.append_colon();
1✔
1841
    serialize_flame_node(b, arena, 0);
1!
1842
    b.end_object();
1✔
1843
    co_return HttpResponse::ok(std::string(b));
1!
1844
}
33!
1845

1846
// One log-spaced duration bucket of the histogram response.
1847
struct HistBucket {
1848
    double lo;
1849
    double hi;
1850
    std::uint64_t count;
1851
};
1852

1853
template <typename builder_type>
1854
void tag_invoke(simdjson::serialize_tag, builder_type& b, const HistBucket& h) {
80✔
1855
    b.start_object();
80✔
1856
    b.append_key_value("lo", h.lo);
80✔
1857
    b.append_comma();
80✔
1858
    b.append_key_value("hi", h.hi);
80✔
1859
    b.append_comma();
80✔
1860
    b.append_key_value("count", h.count);
80✔
1861
    b.end_object();
80✔
1862
}
80✔
1863

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

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

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

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

1896
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
1897
    int limit = params.get_int("limit", 0);
3✔
1898
    if (limit < 0) limit = 0;
3!
1899
    int nbuckets = params.get_int("buckets", 40);
3✔
1900
    nbuckets = std::clamp(nbuckets, 4, 200);
3!
1901

1902
    std::vector<const TraceIndex::FileInfo*> target_files =
3✔
1903
        select_viz_target_files(index, params, begin, end);
3!
1904

1905
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
1906
    std::vector<std::vector<double>> partials(slots);
3!
1907
    auto collect = [&partials](std::size_t w,
5✔
1908
                               const std::vector<std::string_view>& events) {
1✔
1909
        thread_local simdjson::dom::parser parser;
2✔
1910
        thread_local std::string buf;
2✔
1911
        auto& out = partials[w];  // worker-owned slot, no lock
2✔
1912
        for (auto e : events) {
102✔
1913
            buf.assign(e);
100!
1914
            auto res = parser.parse(buf);
100✔
1915
            if (res.error()) continue;
100!
1916
            auto root = res.value_unsafe();
100✔
1917
            if (!root.is_object()) continue;
100✔
1918
            auto dr = root["dur"];
100✔
1919
            if (dr.error()) continue;
100!
1920
            out.push_back(json_number(dr.value_unsafe()));
100!
1921
        }
1922
    };
2✔
1923

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

1928
    std::vector<double> all;
1✔
1929
    std::size_t total_n = 0;
1✔
1930
    for (auto& p : partials) total_n += p.size();
3✔
1931
    all.reserve(total_n);
1!
1932
    for (auto& p : partials)
3✔
1933
        for (double d : p) all.push_back(d);
52!
1934
    std::sort(all.begin(), all.end());
1!
1935

1936
    auto& sb = scratch_json_builder();
1!
1937
    if (all.empty()) {
1!
1938
        sb.start_object();
1939
        sb.append_key_value("count", 0);
1940
        sb.append_comma();
1941
        sb.escape_and_append_with_quotes("buckets");
1942
        sb.append_colon();
1943
        sb.start_array();
1944
        sb.end_array();
1945
        sb.append_comma();
1946
        sb.append_key_value("truncated", truncated);
1947
        sb.end_object();
1948
        co_return HttpResponse::ok(std::string(sb));
×
1949
    }
1950

1951
    std::size_t n = all.size();
1✔
1952
    double vmin = all.front();
1✔
1953
    double vmax = all.back();
1✔
1954
    double sum = 0;
1✔
1955
    for (double d : all) sum += d;
51✔
1956
    double mean = sum / static_cast<double>(n);
1✔
1957
    auto pct = [&all, n](double p) {
9✔
1958
        std::size_t idx =
8✔
1959
            static_cast<std::size_t>(p * static_cast<double>(n - 1));
8✔
1960
        return all[idx];
8✔
1961
    };
1962

1963
    // Log-spaced buckets over [max(vmin,1), vmax]; sub-microsecond durs land in
1964
    // the first bucket.
1965
    double lo = std::max(vmin, 1.0);
1!
1966
    double hi = std::max(vmax, lo * 1.0000001);
1!
1967
    double lr = std::log(hi / lo);
1✔
1968
    std::vector<std::uint64_t> counts(static_cast<std::size_t>(nbuckets), 0);
1!
1969
    for (double d : all) {
51✔
1970
        double v = d < lo ? lo : d;
50!
1971
        std::size_t bi =
100✔
1972
            lr > 0 ? static_cast<std::size_t>(static_cast<double>(nbuckets) *
50!
1973
                                              std::log(v / lo) / lr)
100✔
1974
                   : 0;
1975
        if (bi >= static_cast<std::size_t>(nbuckets)) bi = nbuckets - 1;
50✔
1976
        counts[bi]++;
50✔
1977
    }
50✔
1978

1979
    sb.start_object();
1✔
1980
    sb.append_key_value("count", n);
1✔
1981
    sb.append_comma();
1✔
1982
    sb.append_key_value("min", vmin);
1✔
1983
    sb.append_comma();
1✔
1984
    sb.append_key_value("max", vmax);
1✔
1985
    sb.append_comma();
1✔
1986
    sb.append_key_value("mean", mean);
1✔
1987
    sb.append_comma();
1✔
1988
    sb.append_key_value("p50", pct(0.50));
1!
1989
    sb.append_comma();
1✔
1990
    sb.append_key_value("p90", pct(0.90));
1!
1991
    sb.append_comma();
1✔
1992
    sb.append_key_value("p95", pct(0.95));
1!
1993
    sb.append_comma();
1✔
1994
    sb.append_key_value("p99", pct(0.99));
1!
1995
    sb.append_comma();
1✔
1996
    sb.append_key_value("truncated", truncated);
1✔
1997
    sb.append_comma();
1✔
1998
    std::vector<HistBucket> buckets;
1✔
1999
    buckets.reserve(static_cast<std::size_t>(nbuckets));
1!
2000
    for (int i = 0; i < nbuckets; ++i) {
41✔
2001
        buckets.push_back(
40!
2002
            {lo * std::exp(lr * static_cast<double>(i) / nbuckets),
120✔
2003
             lo * std::exp(lr * static_cast<double>(i + 1) / nbuckets),
40✔
2004
             counts[static_cast<std::size_t>(i)]});
40✔
2005
    }
40✔
2006
    sb.append_key_value("buckets", buckets);
1!
2007
    sb.end_object();
1✔
2008
    co_return HttpResponse::ok(std::string(sb));
1!
2009
}
37!
2010

2011
// A density block as it appears in the response: the map key/aggregate pair
2012
// flattened with `ts` resolved from the column index. `name` borrows the
2013
// aggregate's storage.
2014
struct DensityBlock {
2015
    std::string_view name;
2016
    std::int64_t pid;
2017
    std::int64_t tid;
2018
    double ts;
2019
    double dur;
2020
    std::uint32_t count;
2021
    double total;
2022
    std::uint32_t depth;
2023
};
2024

2025
template <typename builder_type>
2026
void tag_invoke(simdjson::serialize_tag, builder_type& b,
192✔
2027
                const DensityBlock& d) {
2028
    b.start_object();
192✔
2029
    b.append_key_value("name", d.name);
192✔
2030
    b.append_comma();
192✔
2031
    b.append_key_value("pid", d.pid);
192✔
2032
    b.append_comma();
192✔
2033
    b.append_key_value("tid", d.tid);
192✔
2034
    b.append_comma();
192✔
2035
    b.append_key_value("ts", d.ts);
192✔
2036
    b.append_comma();
192✔
2037
    b.append_key_value("dur", d.dur);
192✔
2038
    b.append_comma();
192✔
2039
    b.append_key_value("count", d.count);
192✔
2040
    b.append_comma();
192✔
2041
    b.append_key_value("total", d.total);
192✔
2042
    b.append_comma();
192✔
2043
    b.append_key_value("depth", d.depth);
192✔
2044
    b.end_object();
192✔
2045
}
192✔
2046

2047
// Serialize collected density blocks (+ optional individual events) into the
2048
// /viz/density response body. Shared by the live-scan and summary paths.
2049
static std::string serialize_density_body(
4✔
2050
    const std::vector<std::string>& big, const DensityMap& dens,
2051
    double original_begin, double original_end, double threshold, int limit,
2052
    bool truncated, bool ts_normalized, std::uint64_t display_global_min,
2053
    double max_dur, const std::vector<std::uint32_t>* big_depth = nullptr) {
2054
    auto& b = scratch_json_builder();
4✔
2055
    b.start_object();
4✔
2056
    b.escape_and_append_with_quotes("events");
4✔
2057
    b.append_colon();
4✔
2058
    b.start_array();
4✔
2059
    for (std::size_t i = 0; i < big.size(); ++i) {
4!
UNCOV
2060
        if (i > 0) b.append_comma();
×
2061
        // Inject the server-computed depth as a sibling field (events end in
2062
        // }).
UNCOV
2063
        if (big_depth && i < big_depth->size() && !big[i].empty() &&
×
UNCOV
2064
            big[i].back() == '}') {
×
UNCOV
2065
            b.append_raw(std::string_view(big[i]).substr(0, big[i].size() - 1));
×
UNCOV
2066
            b.append_raw(",\"depth\":");
×
UNCOV
2067
            b.append(static_cast<std::uint64_t>((*big_depth)[i]));
×
UNCOV
2068
            b.append_raw("}");
×
2069
        } else {
UNCOV
2070
            b.append_raw(big[i]);
×
2071
        }
2072
    }
2073
    b.end_array();
4✔
2074
    b.append_comma();
4✔
2075
    std::vector<DensityBlock> blocks;
4✔
2076
    blocks.reserve(dens.size());
4!
2077
    for (const auto& [k, a] : dens) {
196✔
2078
        blocks.push_back(
288!
2079
            {a.name, k.pid, k.tid,
672✔
2080
             original_begin + static_cast<double>(k.col) * threshold, threshold,
288✔
2081
             a.count, a.total, a.depth});
384!
2082
    }
2083
    b.append_key_value("density", blocks);
4!
2084
    b.append_comma();
4✔
2085
    b.escape_and_append_with_quotes("metadata");
4✔
2086
    b.append_colon();
4✔
2087
    b.start_object();
4✔
2088
    b.append_key_value("begin", original_begin);
4✔
2089
    b.append_comma();
4✔
2090
    b.append_key_value("end", original_end);
4✔
2091
    b.append_comma();
4✔
2092
    b.append_key_value("count", big.size());
4✔
2093
    b.append_comma();
4✔
2094
    b.append_key_value("limit", limit);
4✔
2095
    b.append_comma();
4✔
2096
    b.append_key_value("density_count", dens.size());
4✔
2097
    b.append_comma();
4✔
2098
    b.append_key_value("truncated", truncated);
4✔
2099
    b.append_comma();
4✔
2100
    b.append_key_value("ts_normalized", ts_normalized);
4✔
2101
    b.append_comma();
4✔
2102
    b.append_key_value("global_min_timestamp_us", display_global_min);
4✔
2103
    b.append_comma();
4✔
2104
    b.append_key_value("max_dur", max_dur);
4✔
2105
    b.end_object();
4✔
2106
    b.end_object();
4✔
2107
    return std::string(b);
6!
2108
}
4✔
2109

2110
// The summary is unfiltered, so any server-side predicate forces a live scan.
2111
// pid/tid are exempt: they select whole lanes, which the summary can still do.
2112
static bool viz_summary_eligible(const QueryParams& params) {
4✔
2113
    return params.get("query").empty() && params.get("cat").empty() &&
10!
2114
           params.get("lanes").empty() && params.get("filters").empty() &&
10!
2115
           params.get("file").empty();
6!
2116
}
2117

2118
// Re-aggregate the summary's finest per-lane buckets over [begin_abs, end_abs]
2119
// into pixel-column density blocks. `begin_abs`/`end_abs` are absolute us.
2120
static std::string serve_density_from_summary(
2✔
2121
    const VizSummary& s, const QueryParams& params, double begin_abs,
2122
    double end_abs, double original_begin, double original_end,
2123
    double threshold, bool ts_normalized, std::uint64_t display_global_min) {
2124
    auto pid_s = params.get("pid");
2!
2125
    auto tid_s = params.get("tid");
2!
2126
    bool has_pid = !pid_s.empty();
2✔
2127
    bool has_tid = !tid_s.empty();
2✔
2128
    std::int64_t want_pid =
1✔
2129
        has_pid ? std::strtoll(pid_s.data(), nullptr, 10) : 0;
2!
2130
    std::int64_t want_tid =
1✔
2131
        has_tid ? std::strtoll(tid_s.data(), nullptr, 10) : 0;
2!
2132

2133
    std::int64_t b0 = s.bucket_of(begin_abs);
2✔
2134
    std::int64_t b1 = s.bucket_of(end_abs);
2✔
2135
    if (b0 < 0) b0 = 0;
2✔
2136
    if (b1 < 0) b1 = static_cast<std::int64_t>(s.nbuckets) - 1;
2✔
2137

2138
    DensityMap dens;
2!
2139
    for (const auto& lane : s.lanes) {
94✔
2140
        if (has_pid && lane.pid != want_pid) continue;
92!
2141
        if (has_tid && lane.tid != want_tid) continue;
92!
2142
        for (std::int64_t bk = b0; bk <= b1; ++bk) {
6,029,404✔
2143
            const auto& cell = lane.cells[static_cast<std::size_t>(bk)];
6,029,312✔
2144
            if (cell.count == 0) continue;
6,029,312✔
2145
            double center = static_cast<double>(s.t_begin) +
138✔
2146
                            (static_cast<double>(bk) + 0.5) * s.bucket_us;
92✔
2147
            std::int64_t col =
92✔
2148
                static_cast<std::int64_t>((center - begin_abs) / threshold);
92✔
2149
            DensityKey key{lane.pid, lane.tid, col};
92✔
2150
            auto& a = dens[key];
92!
2151
            a.count += cell.count;
92✔
2152
            a.total += static_cast<double>(cell.total);
92✔
2153
            if (static_cast<double>(cell.max_dur) > a.max_dur) {
92✔
2154
                a.max_dur = static_cast<double>(cell.max_dur);
92✔
2155
                if (cell.name_id < s.names.size())
92!
2156
                    a.name = s.names[cell.name_id];
92!
2157
            }
46✔
2158
        }
46✔
2159
    }
2160

2161
    return serialize_density_body(
1!
2162
        {}, dens, original_begin, original_end, threshold, 0, false,
1✔
2163
        ts_normalized, display_global_min, static_cast<double>(s.max_dur));
3!
2164
}
2✔
2165

2166
// GET /api/v1/viz/density: like /viz/events, but instead of dropping sub-pixel
2167
// events it buckets them per (pid, tid, pixel-column) into density blocks so
2168
// zoomed-out views still show where activity is. Returns full-size events (with
2169
// args, for the detail panel) plus the aggregated density blocks.
2170
static coro::CoroTask<HttpResponse> handle_viz_density(
12!
2171
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
2!
2172
    if (!params.has("begin") || !params.has("end")) {
2!
2173
        co_return HttpResponse::bad_request(
2!
2174
            "Missing required parameters: begin, end");
×
2175
    }
2176

2177
    double begin = params.get_double("begin", 0);
2!
2178
    double end = params.get_double("end", 0);
2!
2179
    int summary = params.get_int("summary", 2);
2!
2180
    if (summary < 1) summary = 1;
2!
2181

2182
    auto query = params.get("query");
2!
2183
    if (!query.empty() &&
2!
2184
        !utilities::common::query::try_parse(query).has_value()) {
×
2185
        co_return HttpResponse::bad_request("Invalid query: " +
×
2186
                                            std::string(query));
×
2187
    }
2188

2189
    auto ts_norm_param = params.get("ts_normalize");
2!
2190
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
2!
2191
    std::uint64_t global_min = 0;
2✔
2192
    if (normalize) {
2!
2193
        global_min = index.global_min_timestamp_us();
2✔
2194
        if (global_min == std::numeric_limits<std::uint64_t>::max())
2!
2195
            global_min = 0;
2196
    }
2✔
2197
    double original_begin = begin;
2✔
2198
    double original_end = end;
2✔
2199
    if (normalize && global_min > 0) {
2!
2200
        begin += static_cast<double>(global_min);
2✔
2201
        end += static_cast<double>(global_min);
2✔
2202
    }
2✔
2203

2204
    // Bucket width (== the ~1px cutoff), in the client's actual pixels.
2205
    int width = params.get_int("width", DEFAULT_VIEWPORT_WIDTH);
2!
2206
    width = std::clamp(width, MIN_VIEWPORT_WIDTH, MAX_VIEWPORT_WIDTH);
2!
2207
    double threshold =
4✔
2208
        duration_threshold(begin, end, static_cast<unsigned>(summary),
4✔
2209
                           static_cast<unsigned>(width));
2✔
2210

2211
    // Zoomed-out, unfiltered views come from the prebuilt summary (no event
2212
    // cap), built lazily here; concurrent requests fall through to a live scan.
2213
    if (threshold > 0 && viz_summary_eligible(params)) {
2!
2214
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
2215
        if (s && s->bucket_us > 0 && s->t_end > s->t_begin &&
1!
2216
            threshold >= s->bucket_us) {
1✔
2217
            co_return HttpResponse::ok(serve_density_from_summary(
2!
2218
                *s, params, begin, end, original_begin, original_end, threshold,
1✔
2219
                global_min > 0, index.global_min_timestamp_us()));
1✔
2220
        }
2221
    }
1!
2222

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

2228
    // Enclosing events are fetched by a separate pass (below) so a large
2229
    // `lookback` can never starve the in-window scan's budget.
2230
    double lookback = params.get_double("lookback", 0);
3✔
2231
    if (lookback < 0) lookback = 0;
3!
2232
    double scan_begin = begin - lookback;
3✔
2233
    if (scan_begin < 0) scan_begin = 0;
3!
2234

2235
    int limit = params.get_int("limit", 0);
3✔
2236
    if (limit < 0) limit = 0;
3!
2237

2238
    struct Acc {
2✔
2239
        std::vector<std::string> big;
2240
        std::vector<double> big_dur;  // parallel to `big`
2241
        DensityMap dens;
2242
        double max_dur = 0;
2✔
2243
    };
2244
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
3!
2245
    std::vector<Acc> accs(slots);
3!
2246

2247
    // Pass 1 (in-window): scan [begin, end] with the full budget so earlier
2248
    // events never consume it. Small events fold into density blocks.
2249
    auto on_batch = [&accs, threshold, begin](
5✔
2250
                        std::size_t w,
2251
                        const std::vector<std::string_view>& events) {
51✔
2252
        Acc& acc = accs[w];  // worker-owned slot, no lock
2✔
2253
        for (auto ev : events) {
102✔
2254
            double dur = 0;
100✔
2255
            if (!fold_density(ev, threshold, begin, acc.dens, &dur)) {
100!
UNCOV
2256
                acc.big.emplace_back(ev);
×
UNCOV
2257
                acc.big_dur.push_back(dur);
×
2258
            }
2259
            if (dur > acc.max_dur) acc.max_dur = dur;
100✔
2260
        }
2261
    };
2✔
2262
    ViewDefinition win_view = build_viz_view(params, begin, end, 0);
3!
2263
    std::vector<const TraceIndex::FileInfo*> win_files =
3✔
2264
        select_viz_target_files(index, params, begin, end);
3!
2265
    bool single_file = !params.get("file").empty();
3✔
2266
    bool truncated =
4✔
2267
        co_await scan_view_events(index, win_files, win_view, begin, end, limit,
4!
2268
                                  slots, on_batch, single_file);
3!
2269

2270
    // Pass 2 (enclosers): keep only events still open at `begin`
2271
    // (ts < begin <= ts + dur); these ancestor bars set containment depth.
2272
    if (scan_begin < begin) {
1!
UNCOV
2273
        auto on_batch_enc = [&accs, begin](
×
2274
                                std::size_t w,
2275
                                const std::vector<std::string_view>& events) {
UNCOV
2276
            Acc& acc = accs[w];
×
UNCOV
2277
            for (auto ev : events) {
×
UNCOV
2278
                double ts = 0, dur = 0;
×
UNCOV
2279
                if (!parse_ts_dur(ev, ts, dur)) continue;
×
UNCOV
2280
                if (dur > acc.max_dur) acc.max_dur = dur;
×
UNCOV
2281
                if (ts < begin && ts + dur > begin) {
×
UNCOV
2282
                    acc.big.emplace_back(ev);
×
UNCOV
2283
                    acc.big_dur.push_back(dur);
×
2284
                }
2285
            }
UNCOV
2286
        };
×
2287
        ViewDefinition enc_view = build_viz_view(params, scan_begin, begin, 0);
×
2288
        std::vector<const TraceIndex::FileInfo*> enc_files =
2289
            select_viz_target_files(index, params, scan_begin, begin);
×
2290
        bool enc_trunc = co_await scan_view_events(
×
2291
            index, enc_files, enc_view, scan_begin, begin, limit, slots,
2292
            on_batch_enc, single_file);
×
2293
        truncated = truncated || enc_trunc;
×
2294
    }
×
2295

2296
    std::vector<std::string> big;
1✔
2297
    std::vector<double> big_dur;
1✔
2298
    DensityMap dens;
1!
2299
    // Longest event scanned (folded ones included); the client feeds it back as
2300
    // `lookback` so deep zooms still catch long enclosing events.
2301
    double max_dur = 0;
1✔
2302
    for (auto& acc : accs) {
3✔
2303
        if (acc.max_dur > max_dur) max_dur = acc.max_dur;
2✔
2304
        for (auto& d : acc.big_dur) big_dur.push_back(d);
2!
2305
        for (auto& s : acc.big) big.emplace_back(std::move(s));
2!
2306
        for (auto& kv : acc.dens) {
52✔
2307
            auto it = dens.find(kv.first);
50!
2308
            if (it == dens.end())
50!
2309
                dens.emplace(kv.first, std::move(kv.second));
50!
2310
            else
2311
                it->second.merge_from(kv.second);
×
2312
        }
50✔
2313
    }
2✔
2314
    // scan_view_events overshoots its cap (checked per batch), so clamp here.
2315
    // Keep the longest events: those are the slices wide enough to see.
2316
    if (limit > 0 && big.size() > static_cast<std::size_t>(limit)) {
1!
2317
        const auto keep = static_cast<std::size_t>(limit);
2318
        std::vector<std::size_t> idx(big.size());
×
2319
        std::iota(idx.begin(), idx.end(), std::size_t{0});
×
2320
        std::nth_element(idx.begin(), idx.begin() + static_cast<long>(keep),
×
UNCOV
2321
                         idx.end(), [&big_dur](std::size_t a, std::size_t b) {
×
UNCOV
2322
                             return big_dur[a] > big_dur[b];
×
2323
                         });
2324
        idx.resize(keep);
×
2325
        std::sort(idx.begin(), idx.end());
×
2326
        std::vector<std::string> kept;
2327
        kept.reserve(keep);
×
2328
        for (std::size_t i : idx) kept.emplace_back(std::move(big[i]));
×
2329
        big.swap(kept);
2330
        truncated = true;
2331
    }
2332

2333
    // Stable per-event/-block depth (computed in absolute space, before ts
2334
    // normalization rewrites the big strings; order is preserved in place).
2335
    std::vector<std::uint32_t> big_depth =
1✔
2336
        assign_view_depths(big, dens, begin, threshold);
1!
2337
    if (global_min > 0) {
1!
2338
        for (auto& e : big) e = normalize_event_ts(e, global_min);
1!
2339
    }
1✔
2340

2341
    co_return HttpResponse::ok(serialize_density_body(
2!
2342
        big, dens, original_begin, original_end, threshold, limit, truncated,
1✔
2343
        global_min > 0, index.global_min_timestamp_us(), max_dur, &big_depth));
1✔
2344
}
22!
2345

2346
static std::string serialize_counters_body(const std::vector<double>& read,
2✔
2347
                                           const std::vector<double>& write,
2348
                                           const std::vector<double>& ops,
2349
                                           double original_begin,
2350
                                           double original_end, int buckets,
2351
                                           double bucket_us, bool truncated) {
2352
    auto& b = scratch_json_builder();
2✔
2353
    b.start_object();
2✔
2354
    b.append_key_value("begin", original_begin);
2✔
2355
    b.append_comma();
2✔
2356
    b.append_key_value("end", original_end);
2✔
2357
    b.append_comma();
2✔
2358
    b.append_key_value("buckets", static_cast<std::int64_t>(buckets));
2✔
2359
    b.append_comma();
2✔
2360
    b.append_key_value("bucket_us", bucket_us);
2✔
2361
    b.append_comma();
2✔
2362
    b.append_key_value("truncated", truncated);
2✔
2363
    b.append_comma();
2✔
2364
    b.append_key_value("read_bytes", read);
2✔
2365
    b.append_comma();
2✔
2366
    b.append_key_value("write_bytes", write);
2✔
2367
    b.append_comma();
2✔
2368
    b.append_key_value("ops", ops);
2✔
2369
    b.end_object();
2✔
2370
    return std::string(b);
2!
2371
}
2372

2373
// Re-aggregate the summary's finest counter buckets into `buckets` output
2374
// buckets over [begin_abs, end_abs] (absolute us).
2375
static std::string serve_counters_from_summary(const VizSummary& s,
2✔
2376
                                               double begin_abs, double end_abs,
2377
                                               double original_begin,
2378
                                               double original_end, int buckets,
2379
                                               double bucket_us_out) {
2380
    std::vector<double> read(buckets, 0.0), write(buckets, 0.0),
2!
2381
        ops(buckets, 0.0);
2!
2382
    std::int64_t fb0 = s.bucket_of(begin_abs);
2!
2383
    std::int64_t fb1 = s.bucket_of(end_abs);
2!
2384
    if (fb0 < 0) fb0 = 0;
2✔
2385
    if (fb1 < 0) fb1 = static_cast<std::int64_t>(s.nbuckets) - 1;
2✔
2386
    for (std::int64_t fb = fb0; fb <= fb1; ++fb) {
131,074✔
2387
        double center = static_cast<double>(s.t_begin) +
196,608✔
2388
                        (static_cast<double>(fb) + 0.5) * s.bucket_us;
131,072✔
2389
        long oi = static_cast<long>((center - begin_abs) / bucket_us_out);
131,072✔
2390
        if (oi < 0 || oi >= buckets) continue;
131,072!
2391
        auto f = static_cast<std::size_t>(fb);
131,072✔
2392
        read[oi] += s.read_bytes[f];
131,072✔
2393
        write[oi] += s.write_bytes[f];
131,072✔
2394
        ops[oi] += s.ops[f];
131,072✔
2395
    }
65,536✔
2396
    return serialize_counters_body(read, write, ops, original_begin,
2!
2397
                                   original_end, buckets, bucket_us_out, false);
3!
2398
}
2✔
2399

2400
// GET /api/v1/viz/counters: per-bucket read/write bytes and I/O op counts over
2401
// a time range, for bandwidth/IOPS counter tracks. Aggregated server-side in
2402
// parallel (per-worker arrays merged after join).
2403
static coro::CoroTask<HttpResponse> handle_viz_counters(
8!
2404
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
2405
    if (!params.has("begin") || !params.has("end")) {
3!
2406
        co_return HttpResponse::bad_request(
5!
2407
            "Missing required parameters: begin, end");
4!
2408
    }
2409

2410
    double begin = params.get_double("begin", 0);
3✔
2411
    double end = params.get_double("end", 0);
3✔
2412
    int buckets = params.get_int("buckets", 800);
3✔
2413
    if (buckets < 16) buckets = 16;
3!
2414
    if (buckets > 4000) buckets = 4000;
3!
2415

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

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

2438
    double bucket_us = (end - begin) / static_cast<double>(buckets);
3✔
2439
    if (bucket_us <= 0) bucket_us = 1;
3!
2440

2441
    // Zoomed-out, unfiltered counter tracks come from the activity summary.
2442
    if (viz_summary_eligible(params)) {
3!
2443
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
2444
        if (s && s->bucket_us > 0 && s->t_end > s->t_begin &&
1!
2445
            bucket_us >= s->bucket_us) {
1✔
2446
            co_return HttpResponse::ok(
1!
2447
                serve_counters_from_summary(*s, begin, end, original_begin,
2!
2448
                                            original_end, buckets, bucket_us));
1✔
2449
        }
2450
    }
1!
2451

2452
    ViewDefinition view = build_viz_view(params, begin, end, 0);
×
2453
    // No cap: only zoomed-in or filtered counter queries reach the live path.
2454
    int limit = params.get_int("limit", 0);
×
2455
    if (limit < 0) limit = 0;
×
2456

2457
    std::vector<const TraceIndex::FileInfo*> target_files =
2458
        select_viz_target_files(index, params, begin, end);
×
2459

2460
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
2461
    std::vector<CounterAcc> accs(slots);
×
2462
    for (auto& a : accs) a.init(static_cast<std::size_t>(buckets));
×
UNCOV
2463
    auto on_batch = [&accs, begin, bucket_us, buckets](
×
2464
                        std::size_t w,
2465
                        const std::vector<std::string_view>& events) {
UNCOV
2466
        CounterAcc& acc = accs[w];  // worker-owned, no lock
×
UNCOV
2467
        for (auto ev : events)
×
UNCOV
2468
            fold_counter(ev, begin, bucket_us,
×
2469
                         static_cast<std::size_t>(buckets), acc);
UNCOV
2470
    };
×
2471

2472
    bool truncated =
2473
        co_await scan_view_events(index, target_files, view, begin, end, limit,
×
2474
                                  slots, on_batch, !params.get("file").empty());
×
2475

2476
    CounterAcc total;
2477
    total.init(static_cast<std::size_t>(buckets));
×
2478
    for (auto& a : accs) total.merge_from(a);
×
2479

2480
    co_return HttpResponse::ok(serialize_counters_body(
×
2481
        total.read_bytes, total.write_bytes, total.ops, original_begin,
2482
        original_end, buckets, bucket_us, truncated));
2483
}
29!
2484

2485
// Process-spawning calls: dftracer POSIX (exact "fork"/"clone"/...) and kernel
2486
// syscalls ("__arm64_sys_clone"). Excludes library helpers like ibv_*fork* and
2487
// register_tm_clones, which contain "fork"/"clone" but don't spawn.
2488
static bool is_fork_syscall(std::string_view name) {
100✔
2489
    return name == "fork" || name == "vfork" || name == "clone" ||
250!
2490
           name == "clone3" || name == "posix_spawn" ||
200!
2491
           name == "posix_spawnp" ||
150!
2492
           name.find("sys_clone") != std::string_view::npos ||
150!
2493
           name.find("sys_fork") != std::string_view::npos ||
200!
2494
           name.find("sys_vfork") != std::string_view::npos;
150✔
2495
}
2496

2497
// One process in the proctree response. `host` borrows the hostname table;
2498
// `rank` is null when the trace carries no "PR" metadata for the pid, and the
2499
// key is then omitted.
2500
struct ProcNode {
2501
    std::int64_t pid;
2502
    std::int64_t parent;
2503
    std::uint64_t spawn_ts;
2504
    std::uint64_t first_ts;
2505
    std::string_view host;
2506
    std::uint64_t bytes;
2507
    std::uint64_t io_ops;
2508
    double io_busy;
2509
    const std::string* rank;
2510
};
2511

2512
template <typename builder_type>
2513
void tag_invoke(simdjson::serialize_tag, builder_type& b, const ProcNode& n) {
100✔
2514
    b.start_object();
100✔
2515
    b.append_key_value("pid", n.pid);
100✔
2516
    b.append_comma();
100✔
2517
    b.append_key_value("parent", n.parent);
100✔
2518
    b.append_comma();
100✔
2519
    b.append_key_value("spawn_ts", n.spawn_ts);
100✔
2520
    b.append_comma();
100✔
2521
    b.append_key_value("first_ts", n.first_ts);
100✔
2522
    b.append_comma();
100✔
2523
    b.append_key_value("host", n.host);
100✔
2524
    b.append_comma();
100✔
2525
    b.append_key_value("bytes", n.bytes);
100✔
2526
    b.append_comma();
100✔
2527
    b.append_key_value("io_ops", n.io_ops);
100✔
2528
    b.append_comma();
100✔
2529
    b.append_key_value("io_busy", n.io_busy);
100✔
2530
    if (n.rank) {
100✔
UNCOV
2531
        b.append_comma();
×
UNCOV
2532
        b.append_key_value("rank", *n.rank);
×
2533
    }
2534
    b.end_object();
100✔
2535
}
100✔
2536

2537
// GET /api/v1/viz/proctree: infer the process fork hierarchy. The traces record
2538
// the fork/clone in the parent but not the child pid, so link each process to
2539
// the nearest preceding clone in another process (child start follows the clone
2540
// by microseconds). Respects ?file= for per-node trees on multi-node traces.
2541
static coro::CoroTask<HttpResponse> handle_viz_proctree(
6!
2542
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
2543
    std::uint64_t gmin = index.global_min_timestamp_us();
1!
2544
    std::uint64_t gmax = index.global_max_timestamp_us();
1!
2545
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
1!
2546
        co_return HttpResponse::ok("{\"nodes\":[]}");
1!
2547

2548
    auto ts_norm_param = params.get("ts_normalize");
1!
2549
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
1!
2550
    std::uint64_t base = normalize ? gmin : 0;
1!
2551

2552
    struct Acc {
2!
2553
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
2554
        // Explicit parent from the process metadata's args.ppid.
2555
        ankerl::unordered_dense::map<std::int64_t, std::int64_t> ppid;
2556
        // (ts, parent_pid, child_pid): child_pid is args.ret when the fork
2557
        // event records it (dftracer POSIX), else -1 to fall back to inference.
2558
        std::vector<std::tuple<std::uint64_t, std::int64_t, std::int64_t>>
2559
            forks;
2560
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
2561
        // I/O operation count and busy time (us) per process, over I/O-category
2562
        // (POSIX/STDIO/IO) events only.
2563
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
2564
        ankerl::unordered_dense::map<std::int64_t, double> io_busy;
2565
        ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
2566
        // pid -> rank, from "PR" metadata (args.name == "rank").
2567
        ankerl::unordered_dense::map<std::int64_t, std::string> rank;
2568
        dftracer::utils::StringViewMap<std::string> hh;
2569
    };
2570
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
1!
2571
    std::vector<Acc> accs(slots);
1!
2572
    auto on_batch = [&accs](std::size_t w,
3✔
2573
                            const std::vector<std::string_view>& events) {
1✔
2574
        thread_local simdjson::dom::parser parser;
2✔
2575
        thread_local std::string buf;
2✔
2576
        Acc& acc = accs[w];
2✔
2577
        for (auto ev : events) {
102✔
2578
            buf.assign(ev);
100!
2579
            auto res = parser.parse(buf);
100✔
2580
            if (res.error()) continue;
100!
2581
            auto root = res.value_unsafe();
100✔
2582
            if (!root.is_object()) continue;
100✔
2583
            // HH metadata (hhash -> hostname) carries no ts; handle it first.
2584
            {
2585
                auto nm = root["name"];
100✔
2586
                if (!nm.error() && nm.is_string() &&
200!
2587
                    nm.get_string().value_unsafe() == "HH") {
150!
UNCOV
2588
                    auto a = root["args"];
×
UNCOV
2589
                    if (!a.error() && a.is_object()) {
×
UNCOV
2590
                        auto v = a["value"];
×
UNCOV
2591
                        auto n = a["name"];
×
UNCOV
2592
                        if (!v.error() && v.is_string() && !n.error() &&
×
UNCOV
2593
                            n.is_string())
×
UNCOV
2594
                            acc.hh.emplace(
×
UNCOV
2595
                                std::string(v.get_string().value_unsafe()),
×
UNCOV
2596
                                std::string(n.get_string().value_unsafe()));
×
2597
                    }
UNCOV
2598
                    continue;
×
2599
                }
2600
            }
2601
            // PR metadata (pid -> rank) also carries no ts.
2602
            {
2603
                auto nm = root["name"];
100✔
2604
                if (!nm.error() && nm.is_string() &&
200!
2605
                    nm.get_string().value_unsafe() == "PR") {
150!
UNCOV
2606
                    auto a = root["args"];
×
UNCOV
2607
                    auto pp = root["pid"];
×
UNCOV
2608
                    if (!a.error() && a.is_object() && !pp.error()) {
×
UNCOV
2609
                        auto an = a["name"];
×
UNCOV
2610
                        auto av = a["value"];
×
UNCOV
2611
                        if (!an.error() && an.is_string() &&
×
UNCOV
2612
                            an.get_string().value_unsafe() == "rank" &&
×
UNCOV
2613
                            !av.error() && av.is_string())
×
UNCOV
2614
                            acc.rank.emplace(
×
UNCOV
2615
                                static_cast<std::int64_t>(
×
UNCOV
2616
                                    json_number(pp.value_unsafe())),
×
UNCOV
2617
                                std::string(av.get_string().value_unsafe()));
×
2618
                    }
UNCOV
2619
                    continue;
×
2620
                }
2621
            }
2622
            auto pr = root["pid"];
100✔
2623
            auto tr = root["ts"];
100✔
2624
            if (pr.error() || tr.error()) continue;
100!
2625
            auto pid =
50✔
2626
                static_cast<std::int64_t>(json_number(pr.value_unsafe()));
100✔
2627
            auto ts =
50✔
2628
                static_cast<std::uint64_t>(json_number(tr.value_unsafe()));
100✔
2629
            auto it = acc.first_ts.find(pid);
100!
2630
            if (it == acc.first_ts.end())
100!
2631
                acc.first_ts.emplace(pid, ts);
100!
UNCOV
2632
            else if (ts < it->second)
×
UNCOV
2633
                it->second = ts;
×
2634

2635
            auto nr = root["name"];
100✔
2636
            std::string_view name;
100✔
2637
            if (!nr.error() && nr.is_string())
100!
2638
                name = nr.get_string().value_unsafe();
100✔
2639

2640
            std::int64_t child = -1;
100✔
2641
            double ret = 0;
100✔
2642
            auto args = root["args"];
100✔
2643
            if (!args.error() && args.is_object()) {
100!
2644
                auto ppr = args["ppid"];
100✔
2645
                if (!ppr.error()) {
100✔
2646
                    auto pp = static_cast<std::int64_t>(
UNCOV
2647
                        json_number(ppr.value_unsafe()));
×
UNCOV
2648
                    if (pp > 0 && pp != pid) acc.ppid[pid] = pp;
×
2649
                }
2650
                auto rr = args["ret"];
100✔
2651
                if (!rr.error()) {
100✔
2652
                    ret = json_number(rr.value_unsafe());
100✔
2653
                    child = static_cast<std::int64_t>(ret);
100✔
2654
                }
50✔
2655
                auto hr = args["hhash"];
100✔
2656
                if (!hr.error() && hr.is_string() &&
200!
2657
                    acc.pid_hhash.find(pid) == acc.pid_hhash.end())
150!
2658
                    acc.pid_hhash.emplace(
150!
2659
                        pid, std::string(hr.get_string().value_unsafe()));
150!
2660
            }
50✔
2661
            // I/O bytes per process: args.ret on read/write ops.
2662
            if (ret > 0 && (name.find("read") != std::string_view::npos ||
131!
2663
                            name.find("write") != std::string_view::npos))
62✔
2664
                acc.bytes[pid] += static_cast<std::uint64_t>(ret);
76!
2665

2666
            auto cr = root["cat"];
100✔
2667
            std::string_view cat;
100✔
2668
            if (!cr.error() && cr.is_string())
100!
2669
                cat = cr.get_string().value_unsafe();
100✔
2670
            if (cat == "POSIX" || cat == "STDIO" || cat == "IO") {
100!
2671
                acc.io_ops[pid] += 1;
100!
2672
                auto dr = root["dur"];
100✔
2673
                if (!dr.error())
100✔
2674
                    acc.io_busy[pid] += json_number(dr.value_unsafe());
100!
2675
            }
50✔
2676

2677
            if (!name.empty() && is_fork_syscall(name))
100!
UNCOV
2678
                acc.forks.emplace_back(ts, pid, child > 0 ? child : -1);
×
2679
        }
2680
    };
2✔
2681

2682
    ViewDefinition view;
1✔
2683
    view.name = "viz_proctree";
1!
2684
    view.with_query("ts >= " + std::to_string(gmin) +
2!
2685
                    " and ts <= " + std::to_string(gmax));
1!
2686
    auto files = select_viz_target_files(
2!
2687
        index, params, static_cast<double>(gmin), static_cast<double>(gmax));
1✔
2688
    bool single_file = !params.get("file").empty();
1!
2689
    co_await scan_view_events(index, files, view, static_cast<double>(gmin),
2!
2690
                              static_cast<double>(gmax), 0, slots, on_batch,
1!
2691
                              single_file);
1✔
2692

2693
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
1!
2694
    ankerl::unordered_dense::map<std::int64_t, std::int64_t> parent_of;
1!
2695
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> spawn_of;
1!
2696
    std::vector<std::pair<std::uint64_t, std::int64_t>> inf_forks;  // (ts, pid)
1✔
2697
    for (auto& a : accs) {
3✔
2698
        for (auto& kv : a.first_ts) {
52✔
2699
            auto it = first_ts.find(kv.first);
50!
2700
            if (it == first_ts.end())
50!
2701
                first_ts.emplace(kv.first, kv.second);
50!
2702
            else if (kv.second < it->second)
×
2703
                it->second = kv.second;
2704
        }
50✔
2705
        // Explicit edges from the fork event's args.ret (child pid + spawn ts).
2706
        for (auto& [ts, ppid, child] : a.forks) {
2!
2707
            if (child > 0) {
×
2708
                parent_of[child] = ppid;
×
2709
                spawn_of[child] = ts;
×
2710
            } else {
2711
                inf_forks.emplace_back(ts, ppid);
×
2712
            }
2713
        }
2714
    }
2✔
2715
    // args.ppid metadata fills in any process not linked by a fork event.
2716
    for (auto& a : accs)
3✔
2717
        for (auto& kv : a.ppid)
2!
2718
            if (parent_of.find(kv.first) == parent_of.end())
×
2719
                parent_of.emplace(kv.first, kv.second);
2!
2720
    std::sort(inf_forks.begin(), inf_forks.end());
1!
2721

2722
    // Merge host (hhash -> hostname resolved) and I/O bytes per process.
2723
    dftracer::utils::StringViewMap<std::string> hh;
1!
2724
    ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
1!
2725
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
1!
2726
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
1!
2727
    ankerl::unordered_dense::map<std::int64_t, double> io_busy;
1!
2728
    ankerl::unordered_dense::map<std::int64_t, std::string> rank;
1!
2729
    for (auto& a : accs) {
3✔
2730
        for (auto& kv : a.hh) hh.emplace(kv.first, kv.second);
2!
2731
        for (auto& kv : a.pid_hhash) pid_hhash.emplace(kv.first, kv.second);
52!
2732
        for (auto& kv : a.bytes) bytes[kv.first] += kv.second;
40!
2733
        for (auto& kv : a.io_ops) io_ops[kv.first] += kv.second;
52!
2734
        for (auto& kv : a.io_busy) io_busy[kv.first] += kv.second;
52!
2735
        for (auto& kv : a.rank) rank.emplace(kv.first, kv.second);
2!
2736
    }
2✔
2737

2738
    std::vector<std::pair<std::uint64_t, std::int64_t>>
1✔
2739
        procs;  // (first_ts, pid)
1✔
2740
    procs.reserve(first_ts.size());
1!
2741
    for (auto& kv : first_ts) procs.emplace_back(kv.second, kv.first);
51!
2742
    std::sort(procs.begin(), procs.end());
1!
2743

2744
    // Time-inference fallback (traces without args.ret/ppid): link a process to
2745
    // the nearest preceding clone in another process.
2746
    std::vector<bool> used(inf_forks.size(), false);
1!
2747
    std::vector<ProcNode> nodes;
1✔
2748
    nodes.reserve(procs.size());
1!
2749
    for (auto& [fts, pid] : procs) {
51✔
2750
        std::int64_t parent = -1;
50✔
2751
        std::uint64_t spawn_ts = 0;
50✔
2752
        auto pit = parent_of.find(pid);
50!
2753
        if (pit != parent_of.end()) {
50!
2754
            parent = pit->second;
2755
            auto sit = spawn_of.find(pid);
×
2756
            if (sit != spawn_of.end()) spawn_ts = sit->second - base;
×
2757
        } else {
2758
            auto hi = std::upper_bound(
100!
2759
                inf_forks.begin(), inf_forks.end(),
100✔
2760
                std::make_pair(fts, std::numeric_limits<std::int64_t>::max()));
50!
2761
            for (auto it = hi; it != inf_forks.begin();) {
50!
2762
                --it;
2763
                auto idx = static_cast<std::size_t>(it - inf_forks.begin());
2764
                if (!used[idx] && it->second != pid) {
×
2765
                    used[idx] = true;
×
2766
                    parent = it->second;
2767
                    spawn_ts = it->first - base;
2768
                    break;
2769
                }
2770
            }
×
2771
        }
50✔
2772
        std::string_view host;
50✔
2773
        auto hp = pid_hhash.find(pid);
50!
2774
        if (hp != pid_hhash.end()) {
50!
2775
            auto hn = hh.find(hp->second);
50!
2776
            if (hn != hh.end()) host = hn->second;
50!
2777
        }
50✔
2778
        auto bp = bytes.find(pid);
50!
2779
        auto op = io_ops.find(pid);
50!
2780
        auto ib = io_busy.find(pid);
50!
2781
        auto rk = rank.find(pid);
50!
2782
        nodes.push_back({pid, parent, spawn_ts, fts - base, host,
250!
2783
                         bp != bytes.end() ? bp->second : 0,
50✔
2784
                         op != io_ops.end() ? op->second : 0,
50!
2785
                         ib != io_busy.end() ? ib->second : 0.0,
50!
2786
                         rk != rank.end() ? &rk->second : nullptr});
50!
2787
    }
50✔
2788

2789
    auto& sb = scratch_json_builder();
1!
2790
    sb.start_object();
1✔
2791
    sb.append_key_value("nodes", nodes);
1!
2792
    sb.end_object();
1✔
2793
    co_return HttpResponse::ok(std::string(sb));
1!
2794
}
5!
2795

2796
void register_viz_api(Router& router, TraceIndex& index) {
6✔
2797
    auto* index_ptr = &index;
6✔
2798
    const RouteParam BEGIN{"begin", "Window start (us)", true, "0"};
6!
2799
    const RouteParam END{"end", "Window end (us)", true, "999999999"};
6!
2800
    const RouteParam SUMMARY{"summary", "LOD level (1=full detail)", true, "1"};
6!
2801

2802
    router.get(
9!
2803
        "/api/v1/viz/proctree",
3!
2804
        [index_ptr](const HttpRequest& req,
11!
2805
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
2806
            co_return co_await handle_viz_proctree(req, params, *index_ptr);
5!
2807
        },
4!
2808
        RouteDoc{
18!
2809
            "Inferred process/fork hierarchy with host, rank, and I/O.",
3!
2810
            "Visualization",
3!
2811
            {{"file", "Limit to one trace file", false, ""}},
3!
2812
            R"({"nodes":[{"pid":100,"parent":-1,"host":"node01","rank":"0",)"
3!
2813
            R"("bytes":16384,"io_ops":4,"io_busy":600.0}]})"});
6✔
2814

2815
    router.get(
9!
2816
        "/api/v1/viz/counters",
3!
2817
        [index_ptr](const HttpRequest& req,
11!
2818
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
2819
            co_return co_await handle_viz_counters(req, params, *index_ptr);
5!
2820
        },
4!
2821
        RouteDoc{"Read/write bytes and I/O op counts per time bucket.",
24!
2822
                 "Visualization",
3!
2823
                 {BEGIN, END, SUMMARY},
3!
2824
                 R"({"buckets":[{"ts":0,"read_bytes":4096,"write_bytes":0,)"
3!
2825
                 R"("read_ops":1,"write_ops":0}]})"});
12✔
2826

2827
    router.get(
9!
2828
        "/api/v1/viz/events",
3!
2829
        [index_ptr](const HttpRequest& req,
59!
2830
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
7!
2831
            co_return co_await handle_viz_events(req, params, *index_ptr);
35!
2832
        },
28!
2833
        RouteDoc{"Events for rendering: time-windowed, LOD-aggregated.",
33!
2834
                 "Visualization",
3!
2835
                 {BEGIN,
12!
2836
                  END,
3!
2837
                  SUMMARY,
3!
2838
                  {"pid", "Filter by process id", false, ""},
3!
2839
                  {"cat", "Filter by category", false, ""},
3!
2840
                  {"query", "DSL predicate, e.g. dur >= 1000", false, ""}},
3!
2841
                 R"({"events":[],"metadata":{"begin":0,"end":1000000,)"
3!
2842
                 R"("count":42,"truncated":false,"ts_normalized":true}})"});
21✔
2843

2844
    router.get(
9!
2845
        "/api/v1/viz/density",
3!
2846
        [index_ptr](const HttpRequest& req,
19!
2847
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
2!
2848
            co_return co_await handle_viz_density(req, params, *index_ptr);
10!
2849
        },
8!
2850
        RouteDoc{"Sub-pixel events bucketed into density blocks.",
27!
2851
                 "Visualization",
3!
2852
                 {BEGIN,
9!
2853
                  END,
3!
2854
                  {"summary", "LOD level", true, "2"},
3!
2855
                  {"width", "Canvas width in px (sets the 1px fold cutoff)",
3!
2856
                   false, "1920"}},
3!
2857
                 R"({"events":[],"density":[{"pid":100,"tid":100,"ts":0,)"
3!
2858
                 R"("dur":24457,"count":910,"total":22044,"depth":0}]})"});
15✔
2859

2860
    router.get(
9!
2861
        "/api/v1/viz/stats",
3!
2862
        [index_ptr](const HttpRequest& req,
11!
2863
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
2864
            co_return co_await handle_viz_stats(req, params, *index_ptr);
5!
2865
        },
4!
2866
        RouteDoc{"Per-name aggregation over a time range (Analyze).",
24!
2867
                 "Visualization",
3!
2868
                 {BEGIN, END, SUMMARY},
3!
2869
                 R"({"count":100,"total_dur":5000,"names":[{"name":"read",)"
3!
2870
                 R"("count":50,"total":2500,"avg":50,"min":10,"max":90}]})"});
12✔
2871

2872
    router.get(
9!
2873
        "/api/v1/viz/calltree",
3!
2874
        [index_ptr](const HttpRequest& req,
11!
2875
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
2876
            co_return co_await handle_viz_calltree(req, params, *index_ptr);
5!
2877
        },
4!
2878
        RouteDoc{
27!
2879
            "Merged flamegraph tree from ts/dur containment.",
3!
2880
            "Visualization",
3!
2881
            {BEGIN,
6!
2882
             END,
3!
2883
             SUMMARY,
3!
2884
             {"group", "Set to 'pid' to keep processes separate", false, ""}},
3!
2885
            R"({"name":"root","total":5000,"self":0,"count":0,)"
3!
2886
            R"("children":[{"name":"read","total":2500,"self":2500,)"
2887
            R"("count":50}]})"});
15✔
2888

2889
    router.get(
9!
2890
        "/api/v1/viz/histogram",
3!
2891
        [index_ptr](const HttpRequest& req,
11!
2892
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
2893
            co_return co_await handle_viz_histogram(req, params, *index_ptr);
5!
2894
        },
4!
2895
        RouteDoc{"Duration distribution: percentiles + log-spaced buckets.",
27!
2896
                 "Visualization",
3!
2897
                 {BEGIN,
6!
2898
                  END,
3!
2899
                  SUMMARY,
3!
2900
                  {"query", "DSL predicate to narrow to one op", false, ""}},
3!
2901
                 R"({"min":10,"max":900,"p50":150,"p99":880,"buckets":[]})"});
18!
2902

2903
    router.get(
9!
2904
        "/api/v1/viz/layers",
3!
2905
        [index_ptr](const HttpRequest& req,
19!
2906
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
2!
2907
            co_return co_await handle_viz_layers(req, params, *index_ptr);
10!
2908
        },
8!
2909
        RouteDoc{"Operation-name to category map; declared vs I/O files.",
9!
2910
                 "Visualization",
3!
2911
                 {},
3✔
2912
                 R"({"layers":{"read":"POSIX","write":"POSIX"},)"
3!
2913
                 R"("total_files":2,"io_files":2})"});
2914
}
6✔
2915

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