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

llnl / dftracer-utils / 29087613398

10 Jul 2026 10:51AM UTC coverage: 52.665% (+0.2%) from 52.475%
29087613398

Pull #92

github

web-flow
Merge f2c60f488 into 20c268ff5
Pull Request #92: feat: interactive web trace viewer for dftracer_server

39238 of 96516 branches covered (40.65%)

Branch coverage included in aggregate %.

1569 of 2002 new or added lines in 10 files covered. (78.37%)

3 existing lines in 3 files now uncovered.

34868 of 44197 relevant lines covered (78.89%)

21266.26 hits per line

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

55.33
/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 <set>
33
#include <string>
34
#include <string_view>
35
#include <tuple>
36
#include <unordered_set>
37
#include <vector>
38

39
namespace dftracer::utils::server {
40

41
using namespace dftracer::utils::utilities::composites::dft;
42
using namespace dftracer::utils::utilities::composites::dft::views;
43

44
static const std::unordered_set<std::string> HASH_METADATA_NAMES = {"FH", "HH",
265!
45
                                                                    "SH"};
265!
46

47
/// Normalize the "ts" field in a Chrome Trace Event JSON string by
48
/// subtracting an offset.  Returns the modified JSON.  Falls back to
49
/// the original string on parse failure.
50
static std::string normalize_event_ts(const std::string& event_json,
300✔
51
                                      std::uint64_t offset) {
52
    thread_local simdjson::dom::parser tl_parser;
300✔
53
    auto result = tl_parser.parse(event_json);
300✔
54
    if (result.error()) return event_json;
300!
55

56
    auto root = result.value_unsafe();
300✔
57
    if (!root.is_object()) return event_json;
300!
58

59
    auto ts_result = root["ts"];
300✔
60
    if (ts_result.error()) return event_json;
300!
61

62
    std::uint64_t old_ts = 0;
300✔
63
    if (ts_result.is_uint64()) {
300!
64
        old_ts = ts_result.get_uint64().value_unsafe();
300✔
65
    } else if (ts_result.is_int64()) {
150!
66
        auto val = ts_result.get_int64().value_unsafe();
×
67
        old_ts = val >= 0 ? static_cast<std::uint64_t>(val) : 0;
×
68
    } else {
69
        return event_json;
×
70
    }
71

72
    std::uint64_t new_ts = old_ts >= offset ? old_ts - offset : 0;
300!
73

74
    // simdjson DOM is read-only, so we need to rebuild the JSON with the new ts
75
    // Find "ts": and replace the value
76
    std::string modified = event_json;
300!
77
    auto pos = modified.find("\"ts\":");
300✔
78
    if (pos == std::string::npos) return event_json;
300!
79

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

83
    auto end_pos = pos;
300✔
84
    while (end_pos < modified.size() &&
5,100✔
85
           (std::isdigit(modified[end_pos]) || modified[end_pos] == '-')) {
3,300!
86
        ++end_pos;
3,000✔
87
    }
88

89
    modified.replace(pos, end_pos - pos, std::to_string(new_ts));
300!
90
    return modified;
300✔
91
}
300✔
92

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

103
static std::string extract_json_value(simdjson::dom::element val) {
6✔
104
    if (val.is_string()) {
6✔
105
        return std::string(val.get_string().value_unsafe());
3!
106
    }
107
    if (val.is_int64()) {
4!
108
        return std::to_string(val.get_int64().value_unsafe());
6!
109
    }
110
    if (val.is_uint64()) {
×
111
        return std::to_string(val.get_uint64().value_unsafe());
×
112
    }
113
    return {};
×
114
}
3✔
115

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

129
static void apply_lanes(std::string& dsl, std::string_view lanes_str) {
16✔
130
    if (lanes_str.empty()) return;
16✔
131

132
    thread_local simdjson::dom::parser tl_parser;
2✔
133
    auto result = tl_parser.parse(lanes_str.data(), lanes_str.size());
2✔
134
    if (result.error()) return;
2!
135

136
    auto root = result.value_unsafe();
2✔
137

138
    if (root.is_array()) {
2!
139
        auto arr = root.get_array().value_unsafe();
2✔
140
        for (auto item : arr) {
4✔
141
            if (!item.is_object()) continue;
2✔
142
            auto obj = item.get_object().value_unsafe();
2✔
143

144
            auto field_result = obj["field"];
2✔
145
            if (field_result.error()) field_result = obj["fields"];
2✔
146
            auto value_result = obj["value"];
2✔
147
            if (field_result.error() || value_result.error()) continue;
2!
148

149
            if (!field_result.value_unsafe().is_string()) continue;
2✔
150
            const char* field =
1✔
151
                field_result.value_unsafe().get_c_str().value_unsafe();
2✔
152
            auto val = extract_json_value(value_result.value_unsafe());
2!
153
            if (!val.empty()) append_lane_clause(dsl, field, val);
2✔
154
        }
2✔
155
    } else if (root.is_object()) {
1!
156
        auto obj = root.get_object().value_unsafe();
×
157

158
        auto field_result = obj["field"];
×
159
        if (field_result.error()) field_result = obj["fields"];
×
160
        auto value_result = obj["value"];
×
161

162
        if (!field_result.error() && !value_result.error()) {
×
163
            if (field_result.value_unsafe().is_string()) {
×
164
                const char* field =
165
                    field_result.value_unsafe().get_c_str().value_unsafe();
×
166
                auto val = extract_json_value(value_result.value_unsafe());
×
167
                if (!val.empty()) append_lane_clause(dsl, field, val);
×
168
            }
×
169
        }
170
    }
171
}
8✔
172

173
static void apply_filters(std::string& dsl, std::string_view filters_str) {
16✔
174
    if (filters_str.empty()) return;
16✔
175

176
    thread_local simdjson::dom::parser tl_parser;
4✔
177
    auto result = tl_parser.parse(filters_str.data(), filters_str.size());
4✔
178
    if (result.error()) return;
4!
179

180
    auto root = result.value_unsafe();
4✔
181
    if (!root.is_array()) return;
4✔
182

183
    auto arr = root.get_array().value_unsafe();
4✔
184
    for (auto item : arr) {
8✔
185
        if (!item.is_object()) continue;
4✔
186
        auto obj = item.get_object().value_unsafe();
4✔
187

188
        auto field_result = obj["field"];
4✔
189
        auto op_result = obj["op"];
4✔
190
        auto value_result = obj["value"];
4✔
191
        if (field_result.error() || op_result.error() || value_result.error())
4!
192
            continue;
×
193

194
        if (!field_result.value_unsafe().is_string() ||
6!
195
            !op_result.value_unsafe().is_string())
4!
196
            continue;
×
197

198
        const char* field =
2✔
199
            field_result.value_unsafe().get_c_str().value_unsafe();
4✔
200
        const char* op = op_result.value_unsafe().get_c_str().value_unsafe();
4✔
201

202
        std::string val = extract_json_value(value_result.value_unsafe());
4!
203
        if (val.empty()) continue;
4!
204

205
        std::string op_str(op);
4✔
206
        std::string field_str(field);
4✔
207
        if (field_str == "begin") field_str = "ts";
4!
208
        if (field_str == "end") field_str = "ts";
4!
209
        if (field_str == "duration") field_str = "dur";
4!
210

211
        std::string query_op;
4✔
212
        if (op_str == "=")
4✔
213
            query_op = "==";
2!
214
        else if (op_str == ">=")
2!
215
            query_op = ">=";
2!
216
        else if (op_str == "<=")
×
217
            query_op = "<=";
×
218
        else if (op_str == ">")
×
219
            query_op = ">";
×
220
        else if (op_str == "<")
×
221
            query_op = "<";
×
222
        else
223
            continue;
×
224

225
        if (!dsl.empty()) dsl += " and ";
4!
226
        bool numeric = !val.empty() && (std::isdigit(val[0]) || val[0] == '-');
6!
227
        if (numeric || query_op != "==") {
4!
228
            dsl += field_str + " " + query_op + " " + val;
4!
229
        } else {
2✔
230
            dsl += field_str + " " + query_op + " \"" + val + "\"";
×
231
        }
232
    }
4!
233
}
8✔
234

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

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

255
    dsl += "ts >= ";
16!
256
    append_u64(static_cast<std::uint64_t>(begin));
16!
257
    dsl += " and ts <= ";
16!
258
    append_u64(static_cast<std::uint64_t>(end));
16!
259
    if (min_dur > 0) {
16!
260
        dsl += " and dur >= ";
×
261
        append_u64(static_cast<std::uint64_t>(min_dur));
×
262
    }
263

264
    apply_lanes(dsl, params.get("lanes"));
16!
265
    apply_filters(dsl, params.get("filters"));
16!
266

267
    auto pid = params.get("pid");
16!
268
    if (!pid.empty()) {
16!
269
        dsl += " and pid == ";
×
270
        dsl += pid;
×
271
    }
272

273
    auto tid = params.get("tid");
16!
274
    if (!tid.empty()) {
16!
275
        dsl += " and tid == ";
×
276
        dsl += tid;
×
277
    }
278

279
    auto cat = params.get("cat");
16!
280
    if (!cat.empty()) {
16!
281
        dsl += " and cat == \"";
×
282
        dsl += cat;
×
283
        dsl += '"';
×
284
    }
285

286
    // Raw DSL from the front-end query box, already validated by the caller.
287
    auto query = params.get("query");
16!
288
    if (!query.empty()) {
16!
NEW
289
        dsl += " and (";
×
NEW
290
        dsl += query;
×
NEW
291
        dsl += ')';
×
292
    }
293

294
    view.with_query(dsl);
16!
295
    return view;
24✔
296
}
16!
297

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

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

308
    if (begin > 0 || end > 0) {
18!
309
        std::vector<const TraceIndex::FileInfo*> filtered;
18✔
310
        filtered.reserve(target_files.size());
18!
311
        for (auto* fi : target_files) {
36✔
312
            if (fi->min_timestamp_us == 0 && fi->max_timestamp_us == 0) {
18!
313
                filtered.push_back(fi);
×
314
                continue;
×
315
            }
316
            double fi_min = static_cast<double>(fi->min_timestamp_us);
18✔
317
            double fi_max = static_cast<double>(fi->max_timestamp_us);
18✔
318
            if (fi_max < begin || fi_min > end) continue;
18!
319
            filtered.push_back(fi);
16!
320
        }
321
        target_files = std::move(filtered);
18✔
322
    }
18✔
323
    return target_files;
18✔
324
}
9!
325

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

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

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

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

399
    std::size_t num_workers =
20✔
400
        std::max<std::size_t>(1, std::min(num_slots, index.max_concurrent()));
10!
401
    auto* executor = Executor::current();
10✔
402
    auto chan = coro::make_channel<ScanWorkItem>(num_workers * 4);
10!
403
    auto* target_files_ptr = &target_files;
10✔
404
    auto* view_ptr = &view;
10✔
405
    auto* index_ptr = &index;
10✔
406
    auto* produced_ptr = &produced;
10✔
407
    auto* on_batch_ptr = &on_batch;
10✔
408

409
    CoroScope scope(executor);
10!
410

411
    // Producer: prune each file to its matching checkpoints (bloom + time) and
412
    // feed those byte-ranges as work items. Building is cheap next to the
413
    // reads.
414
    scope.spawn([ch = chan->producer(), target_files_ptr, view_ptr, index_ptr,
98!
415
                 produced_ptr, cap, begin, end,
40✔
416
                 scan_all_chunks](CoroScope&) mutable -> coro::CoroTask<void> {
20!
417
        auto guard = ch.guard();
10!
418
        for (auto* file_info : *target_files_ptr) {
33✔
419
            if (produced_ptr->load(std::memory_order_relaxed) >= cap) co_return;
19!
420
            if (file_info->uncompressed_size == 0 &&
9!
421
                file_info->num_checkpoints == 0)
422
                continue;
423

424
            ViewBuilderInput builder_input;
9✔
425
            builder_input.with_view(*view_ptr)
18!
426
                .with_file_path(file_info->path)
9!
427
                .with_index_path(
9!
428
                    file_info->has_bloom_data ? file_info->index_path : "")
9!
429
                .with_uncompressed_size(file_info->uncompressed_size)
9!
430
                .with_num_checkpoints(file_info->num_checkpoints)
9!
431
                .with_bloom_cache(&index_ptr->bloom_cache())
9!
432
                .with_time_range(begin, end)
9!
433
                .with_scan_all_chunks(scan_all_chunks);
9!
434

435
            ViewBuilderUtility builder;
9!
436
            auto build_output = co_await builder.process(builder_input);
18!
437
            if (!build_output || !build_output->file_may_match) continue;
9!
438

439
            for (const auto& c : build_output->candidates) {
42!
440
                if (produced_ptr->load(std::memory_order_relaxed) >= cap)
21!
441
                    co_return;
442
                if (!co_await ch.send(ScanWorkItem{
112!
443
                        file_info, c.start_byte, c.end_byte, c.checkpoint_idx}))
84✔
444
                    co_return;
445
            }
7✔
446
        }
23✔
447
        co_return;
10✔
448
    });
90!
449

450
    for (std::size_t w = 0; w < num_workers; ++w) {
30✔
451
        // Worker `w` writes only to slot `w`; a coroutine never runs
452
        // concurrently with itself, so per-slot output needs no lock.
453
        scope.spawn([w, chan, view_ptr, produced_ptr, on_batch_ptr,
204!
454
                     cap](CoroScope&) -> coro::CoroTask<void> {
40!
455
            while (auto item = co_await chan->receive()) {
122!
456
                if (produced_ptr->load(std::memory_order_relaxed) >= cap)
7!
457
                    co_return;
458

459
                ViewReaderInput reader_input;
7✔
460
                reader_input.with_file_path(item->file->path)
7!
461
                    .with_index_path(item->file->index_path)
7!
462
                    .with_byte_range(item->start_byte, item->end_byte)
7!
463
                    .with_checkpoint_idx(item->checkpoint_idx)
7!
464
                    .with_view(*view_ptr);
7!
465

466
                ViewReaderUtility reader;
7!
467
                auto gen = reader.process(reader_input);
7!
468
                while (auto batch = co_await gen.next()) {
56!
469
                    if (batch->events.empty()) continue;
7!
470
                    if (produced_ptr->load(std::memory_order_relaxed) >= cap)
7!
471
                        break;
472
                    (*on_batch_ptr)(w, batch->events);
7!
473
                    produced_ptr->fetch_add(
14✔
474
                        static_cast<std::int64_t>(batch->events.size()),
7✔
475
                        std::memory_order_relaxed);
476
                }
14✔
477
            }
55!
478
            co_return;
20✔
479
        });
196!
480
    }
20✔
481

482
    co_await scope.join();
30!
483
    co_return produced.load(std::memory_order_relaxed) >= cap;
10!
484
}
50!
485

486
static coro::CoroTask<HttpResponse> handle_viz_events(
52!
487
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
7!
488
    // Required: begin, end, summary
489
    if (!params.has("begin") || !params.has("end") || !params.has("summary")) {
17!
490
        co_return HttpResponse::bad_request(
30!
491
            "Missing required parameters: begin, end, summary");
23!
492
    }
493

494
    double begin = params.get_double("begin", 0);
18✔
495
    double end = params.get_double("end", 0);
18✔
496
    int summary = params.get_int("summary", 1);
18✔
497
    if (summary < 1) summary = 1;
18!
498

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

507
    // Timestamp normalization: default ON, opt-out with ?ts_normalize=0
508
    auto ts_norm_param = params.get("ts_normalize");
18✔
509
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
18!
510

511
    std::uint64_t global_min = 0;
18✔
512
    if (normalize) {
18✔
513
        global_min = index.global_min_timestamp_us();
5✔
514
        if (global_min == std::numeric_limits<std::uint64_t>::max()) {
5!
515
            global_min = 0;  // No valid bounds, skip normalization
516
        }
517
    }
5✔
518

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

529
    double min_dur =
18✔
530
        duration_threshold(begin, end, static_cast<unsigned>(summary));
18!
531

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

540
    ViewDefinition view = build_viz_view(params, scan_begin, end, min_dur);
18!
541

542
    // Optional limit: 0 (default) means no limit.
543
    int limit = params.get_int("limit", 0);
18✔
544
    if (limit < 0) limit = 0;
18!
545

546
    std::vector<const TraceIndex::FileInfo*> target_files =
18✔
547
        select_viz_target_files(index, params, scan_begin, end);
18!
548

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

560
    bool truncated = co_await scan_view_events(
30!
561
        index, target_files, view, scan_begin, end, limit, slots, collect,
18!
562
        !params.get("file").empty());
18!
563

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

573
    std::string body = build_viz_events_body(
12!
574
        collected_events, global_min, original_begin, original_end, limit,
6✔
575
        truncated, index.global_min_timestamp_us());
6✔
576
    co_return HttpResponse::ok(body);
6!
577
}
244!
578

579
namespace {
580

581
struct NameStat {
582
    std::uint64_t count = 0;
583
    double total = 0;
584
    double min = 0;
585
    double max = 0;
NEW
586
    void merge_from(const NameStat& o) {
×
NEW
587
        if (count == 0) {
×
NEW
588
            min = o.min;
×
NEW
589
            max = o.max;
×
NEW
590
        } else if (o.count > 0) {
×
NEW
591
            min = std::min(min, o.min);
×
NEW
592
            max = std::max(max, o.max);
×
593
        }
NEW
594
        count += o.count;
×
NEW
595
        total += o.total;
×
NEW
596
    }
×
597
};
598

599
using NameMap = dftracer::utils::StringViewMap<NameStat>;
600

601
static double json_number(simdjson::dom::element el);
602

603
enum class GroupBy { Name, Cat, Pid, Fhash };
604

605
static GroupBy parse_group_by(std::string_view g) {
2✔
606
    if (g == "cat") return GroupBy::Cat;
2!
607
    if (g == "pid") return GroupBy::Pid;
2!
608
    if (g == "fhash" || g == "file") return GroupBy::Fhash;
2!
609
    return GroupBy::Name;
2✔
610
}
1✔
611

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

NEW
623
    auto dur_r = root["dur"];
×
NEW
624
    if (dur_r.error()) return;  // skip instant/metadata events (no duration)
×
NEW
625
    double dur = 0;
×
NEW
626
    if (dur_r.is_uint64())
×
NEW
627
        dur = static_cast<double>(dur_r.get_uint64().value_unsafe());
×
NEW
628
    else if (dur_r.is_int64())
×
NEW
629
        dur = static_cast<double>(dur_r.get_int64().value_unsafe());
×
NEW
630
    else if (dur_r.is_double())
×
NEW
631
        dur = dur_r.get_double().value_unsafe();
×
632
    else
NEW
633
        return;
×
634

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

NEW
659
    auto it = local.find(name);
×
NEW
660
    if (it == local.end()) {
×
NEW
661
        it = local.emplace(std::string(name), NameStat{}).first;
×
NEW
662
        it->second.min = dur;
×
NEW
663
        it->second.max = dur;
×
664
    } else {
NEW
665
        it->second.min = std::min(it->second.min, dur);
×
NEW
666
        it->second.max = std::max(it->second.max, dur);
×
667
    }
NEW
668
    it->second.count += 1;
×
NEW
669
    it->second.total += dur;
×
670
}
671

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

685
struct DensityKeyHash {
686
    std::uint64_t operator()(const DensityKey& k) const noexcept {
192✔
687
        std::uint64_t h = 1469598103934665603ULL;
192✔
688
        auto mix = [&](std::uint64_t v) {
672✔
689
            h ^= v;
576✔
690
            h *= 1099511628211ULL;
576✔
691
        };
672✔
692
        mix(static_cast<std::uint64_t>(k.pid));
192!
693
        mix(static_cast<std::uint64_t>(k.tid));
192!
694
        mix(static_cast<std::uint64_t>(k.col));
192!
695
        return h;
192✔
696
    }
697
};
698

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

716
using DensityMap =
717
    ankerl::unordered_dense::map<DensityKey, DensityAgg, DensityKeyHash>;
718

719
static double json_number(simdjson::dom::element el) {
1,400✔
720
    if (el.is_uint64())
1,400!
721
        return static_cast<double>(el.get_uint64().value_unsafe());
1,400✔
NEW
722
    if (el.is_int64())
×
NEW
723
        return static_cast<double>(el.get_int64().value_unsafe());
×
NEW
724
    if (el.is_double()) return el.get_double().value_unsafe();
×
NEW
725
    return 0;
×
726
}
700✔
727

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

NEW
740
    auto dr = root["dur"];
×
NEW
741
    if (dr.error()) return false;
×
NEW
742
    double dur = json_number(dr.value_unsafe());
×
NEW
743
    if (out_dur) *out_dur = dur;
×
NEW
744
    if (threshold <= 0 || dur >= threshold) return false;
×
745

NEW
746
    double ts = 0;
×
NEW
747
    auto tr = root["ts"];
×
NEW
748
    if (!tr.error()) ts = json_number(tr.value_unsafe());
×
NEW
749
    std::int64_t pid = 0;
×
NEW
750
    auto pr = root["pid"];
×
NEW
751
    if (!pr.error())
×
NEW
752
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
×
NEW
753
    std::int64_t tid = 0;
×
NEW
754
    auto tir = root["tid"];
×
NEW
755
    if (!tir.error())
×
NEW
756
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
×
NEW
757
    std::string_view name;
×
NEW
758
    auto nr = root["name"];
×
NEW
759
    if (!nr.error() && nr.is_string()) name = nr.get_string().value_unsafe();
×
760

NEW
761
    std::int64_t col = static_cast<std::int64_t>((ts - begin) / threshold);
×
NEW
762
    DensityKey k{pid, tid, col};
×
NEW
763
    auto it = dens.find(k);
×
NEW
764
    if (it == dens.end()) it = dens.emplace(k, DensityAgg{}).first;
×
NEW
765
    auto& a = it->second;
×
NEW
766
    a.count += 1;
×
NEW
767
    a.total += dur;
×
NEW
768
    if (dur > a.max_dur) {
×
NEW
769
        a.max_dur = dur;
×
NEW
770
        a.name.assign(name);
×
771
    }
NEW
772
    return true;
×
773
}
774

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

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

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

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

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

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

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

NEW
918
static void fold_counter(std::string_view event, double begin, double bucket_us,
×
919
                         std::size_t buckets, CounterAcc& acc) {
NEW
920
    thread_local simdjson::dom::parser parser;
×
NEW
921
    thread_local std::string buf;
×
NEW
922
    buf.assign(event);
×
NEW
923
    auto res = parser.parse(buf);
×
NEW
924
    if (res.error()) return;
×
NEW
925
    auto root = res.value_unsafe();
×
NEW
926
    if (!root.is_object()) return;
×
927

NEW
928
    auto cat_r = root["cat"];
×
NEW
929
    if (cat_r.error() || !cat_r.is_string()) return;
×
NEW
930
    std::string_view cat = cat_r.get_string().value_unsafe();
×
NEW
931
    if (cat != "POSIX" && cat != "STDIO" && cat != "IO") return;
×
932

NEW
933
    auto ts_r = root["ts"];
×
NEW
934
    if (ts_r.error()) return;
×
NEW
935
    double ts = json_number(ts_r.value_unsafe());
×
NEW
936
    long col = static_cast<long>((ts - begin) / bucket_us);
×
NEW
937
    if (col < 0 || col >= static_cast<long>(buckets)) return;
×
938

NEW
939
    acc.ops[col] += 1.0;
×
940

NEW
941
    std::string_view name;
×
NEW
942
    auto name_r = root["name"];
×
NEW
943
    if (!name_r.error() && name_r.is_string())
×
NEW
944
        name = name_r.get_string().value_unsafe();
×
945

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

959
}  // namespace
960

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

966
namespace {
967

968
struct PidTid {
969
    std::int64_t pid;
970
    std::int64_t tid;
NEW
971
    bool operator==(const PidTid& o) const {
×
NEW
972
        return pid == o.pid && tid == o.tid;
×
973
    }
974
};
975

976
struct PidTidHash {
977
    std::uint64_t operator()(const PidTid& k) const noexcept {
580✔
978
        std::uint64_t h = 1469598103934665603ULL;
580✔
979
        h = (h ^ static_cast<std::uint64_t>(k.pid)) * 1099511628211ULL;
580✔
980
        h = (h ^ static_cast<std::uint64_t>(k.tid)) * 1099511628211ULL;
580✔
981
        return h;
580✔
982
    }
983
};
984

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

1000
struct SumBuild {
1!
1001
    std::size_t nb = 0;
1✔
1002
    double bucket_us = 1;
1✔
1003
    std::uint64_t t0 = 0;
1✔
1004

1005
    std::mutex lane_mtx;
1006
    ankerl::unordered_dense::map<PidTid, std::size_t, PidTidHash> lane_of;
1007
    std::deque<SumLane> lanes;
1008

1009
    std::vector<std::atomic<double>> cread;
1010
    std::vector<std::atomic<double>> cwrite;
1011
    std::vector<std::atomic<double>> cops;
1012
    std::atomic<std::uint64_t> gmax_dur{0};
1✔
1013

1014
    std::mutex name_mtx;
1015
    dftracer::utils::StringViewMap<std::uint32_t> name_of;
1016
    std::vector<std::string> names;
1017

1018
    // Per-worker caches so the hot path never locks.
1019
    std::vector<ankerl::unordered_dense::map<PidTid, SumLane*, PidTidHash>>
1020
        lane_cache;
1021
    std::vector<dftracer::utils::StringViewMap<std::uint32_t>> name_cache;
1022

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

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

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

1036
    // Per-worker file-hashes seen on a read/write op (files with real data
1037
    // I/O).
1038
    std::vector<dftracer::utils::StringViewSet> io_fh;
1039

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

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

1080
template <class T>
1081
static void atomic_max(std::atomic<T>& a, T v) {
192✔
1082
    T cur = a.load(std::memory_order_relaxed);
192✔
1083
    while (v > cur &&
384!
1084
           !a.compare_exchange_weak(cur, v, std::memory_order_relaxed)) {
288!
1085
    }
1086
}
192✔
1087

1088
static void atomic_add_double(std::atomic<double>& a, double v) {
176✔
1089
    double cur = a.load(std::memory_order_relaxed);
176✔
1090
    while (!a.compare_exchange_weak(cur, cur + v, std::memory_order_relaxed)) {
176!
1091
    }
1092
}
176✔
1093

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

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

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

1138
    std::int64_t pid = 0, tid = 0;
100✔
1139
    auto pr = root["pid"];
100✔
1140
    if (!pr.error())
100✔
1141
        pid = static_cast<std::int64_t>(json_number(pr.value_unsafe()));
100✔
1142

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

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

1179
    auto tir = root["tid"];
100✔
1180
    if (!tir.error())
100✔
1181
        tid = static_cast<std::int64_t>(json_number(tir.value_unsafe()));
100✔
1182

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

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

1231
}  // namespace
1232

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

1244
    std::size_t est_lanes = std::max<std::size_t>(1, index.file_count()) * 8;
3✔
1245
    std::size_t nb = VizSummary::MAX_CELLS / est_lanes;
3✔
1246
    nb = std::clamp(nb, VizSummary::MIN_BUCKETS_PER_LANE,
3!
1247
                    VizSummary::MAX_BUCKETS_PER_LANE);
1248

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

1267
    ViewDefinition view;
3✔
1268
    view.name = "viz_summary";
3✔
1269
    view.description = "Activity summary build";
1✔
1270
    view.with_query("ts >= " + std::to_string(gmin) +
6!
1271
                    " and ts <= " + std::to_string(gmax));
3!
1272

1273
    std::vector<const TraceIndex::FileInfo*> files;
3✔
1274
    files.reserve(index.files().size());
3✔
1275
    for (const auto& f : index.files()) files.push_back(&f);
4!
1276

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

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

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

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

1353
    dftracer::utils::StringViewSet io_fh;
1!
1354
    for (auto& part : b.io_fh)
3✔
1355
        for (auto& k : part) io_fh.emplace(k);
2!
1356
    summary->io_files = io_fh.size();
1✔
1357

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

1365
    DFTRACER_UTILS_LOG_INFO(
1!
1366
        "viz: built activity summary (%zu lanes, %zu buckets/lane)",
1367
        summary->lanes.size(), nb);
1368
    index.set_viz_summary(std::move(summary));
1!
1369
}
21!
1370

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

1382
// One aggregate row, uniform over the live-scan and summary paths.
1383
struct StatRow {
1384
    const std::string* key;
1385
    std::uint64_t count;
1386
    double total;
1387
    double min;
1388
    double max;
1389
};
1390

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

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

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

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

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

1471
    double begin = params.get_double("begin", 0);
3✔
1472
    double end = params.get_double("end", 0);
3✔
1473

1474
    auto query = params.get("query");
3✔
1475
    if (!query.empty() &&
3!
1476
        !utilities::common::query::try_parse(query).has_value()) {
×
1477
        co_return HttpResponse::bad_request("Invalid query: " +
×
1478
                                            std::string(query));
×
1479
    }
1480

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

1496
    GroupBy group = parse_group_by(params.get("group"));
3!
1497

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

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

1522
    int limit = params.get_int("limit", 0);
×
1523
    if (limit < 0) limit = 0;
×
1524

1525
    std::vector<const TraceIndex::FileInfo*> target_files =
1526
        select_viz_target_files(index, params, begin, end);
×
1527

1528
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
1529
    std::vector<NameMap> partials(slots);
×
NEW
1530
    auto aggregate = [&partials, group](
×
1531
                         std::size_t w,
1532
                         const std::vector<std::string_view>& events) {
NEW
1533
        NameMap& local = partials[w];  // owned by worker w only, no lock
×
NEW
1534
        for (auto ev : events) fold_event(ev, group, local);
×
NEW
1535
    };
×
1536

1537
    bool truncated = co_await scan_view_events(index, target_files, view, begin,
×
1538
                                               end, limit, slots, aggregate,
×
1539
                                               !params.get("file").empty());
×
1540

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

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

1567
    co_return HttpResponse::ok(
×
1568
        serialize_stats_body(total_count, total_dur,
×
1569
                             original_end - original_begin, truncated, rows));
1570
}
29!
1571

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

1604
// One scanned event, reduced to what the call-tree needs.
1605
struct FlameEv {
1✔
1606
    std::int64_t pid = 0;
1✔
1607
    std::int64_t tid = 0;
1✔
1608
    double ts = 0;
1✔
1609
    double dur = 0;
1✔
1610
    std::string name;
1611
};
1612

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

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

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

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

1691
    double begin = params.get_double("begin", 0);
3✔
1692
    double end = params.get_double("end", 0);
3✔
1693

1694
    auto query = params.get("query");
3✔
1695
    if (!query.empty() &&
3!
1696
        !utilities::common::query::try_parse(query).has_value())
×
1697
        co_return HttpResponse::bad_request("Invalid query: " +
×
1698
                                            std::string(query));
×
1699

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

1713
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
1714
    int limit = params.get_int("limit", 0);
3✔
1715
    if (limit < 0) limit = 0;
3!
1716

1717
    std::vector<const TraceIndex::FileInfo*> target_files =
3✔
1718
        select_viz_target_files(index, params, begin, end);
3!
1719

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

1730
    bool truncated =
4✔
1731
        co_await scan_view_events(index, target_files, view, begin, end, limit,
5!
1732
                                  slots, collect, !params.get("file").empty());
3!
1733

1734
    std::vector<FlameEv> all;
1✔
1735
    std::size_t total_n = 0;
1✔
1736
    for (auto& p : partials) total_n += p.size();
3✔
1737
    all.reserve(total_n);
1!
1738
    for (auto& p : partials)
3✔
1739
        for (auto& e : p) all.push_back(std::move(e));
52!
1740

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

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

1755
    std::vector<FlameNode> arena;
1✔
1756
    arena.reserve(256);
1!
1757
    arena.emplace_back();  // root (index 0)
1!
1758
    arena[0].name = "all";
1!
1759
    ankerl::unordered_dense::map<std::int64_t, std::uint32_t> proc_of;
1!
1760

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

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

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

1820
    double root_total = 0;
1✔
1821
    std::uint64_t root_count = 0;
1✔
1822
    for (std::uint32_t c : arena[0].children) {
9✔
1823
        root_total += arena[c].total;
8✔
1824
        root_count += arena[c].count;
8✔
1825
    }
8✔
1826
    arena[0].total = root_total;
1✔
1827
    arena[0].count = root_count;
1✔
1828
    arena[0].self = 0;
1✔
1829

1830
    auto& b = scratch_json_builder();
1!
1831
    b.start_object();
1✔
1832
    b.append_key_value("truncated", truncated);
1✔
1833
    b.append_comma();
1✔
1834
    b.escape_and_append_with_quotes("tree");
1✔
1835
    b.append_colon();
1✔
1836
    serialize_flame_node(b, arena, 0);
1!
1837
    b.end_object();
1✔
1838
    co_return HttpResponse::ok(std::string(b));
1!
1839
}
33!
1840

1841
// One log-spaced duration bucket of the histogram response.
1842
struct HistBucket {
1843
    double lo;
1844
    double hi;
1845
    std::uint64_t count;
1846
};
1847

1848
template <typename builder_type>
1849
void tag_invoke(simdjson::serialize_tag, builder_type& b, const HistBucket& h) {
80✔
1850
    b.start_object();
80✔
1851
    b.append_key_value("lo", h.lo);
80✔
1852
    b.append_comma();
80✔
1853
    b.append_key_value("hi", h.hi);
80✔
1854
    b.append_comma();
80✔
1855
    b.append_key_value("count", h.count);
80✔
1856
    b.end_object();
80✔
1857
}
80✔
1858

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

1869
    double begin = params.get_double("begin", 0);
3✔
1870
    double end = params.get_double("end", 0);
3✔
1871

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

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

1891
    ViewDefinition view = build_viz_view(params, begin, end, 0);
3!
1892
    int limit = params.get_int("limit", 0);
3✔
1893
    if (limit < 0) limit = 0;
3!
1894
    int nbuckets = params.get_int("buckets", 40);
3✔
1895
    nbuckets = std::clamp(nbuckets, 4, 200);
3!
1896

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

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

1919
    bool truncated =
4✔
1920
        co_await scan_view_events(index, target_files, view, begin, end, limit,
5!
1921
                                  slots, collect, !params.get("file").empty());
3!
1922

1923
    std::vector<double> all;
1✔
1924
    std::size_t total_n = 0;
1✔
1925
    for (auto& p : partials) total_n += p.size();
3✔
1926
    all.reserve(total_n);
1!
1927
    for (auto& p : partials)
3✔
1928
        for (double d : p) all.push_back(d);
52!
1929
    std::sort(all.begin(), all.end());
1!
1930

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

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

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

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

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

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

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

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

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

2128
    std::int64_t b0 = s.bucket_of(begin_abs);
2✔
2129
    std::int64_t b1 = s.bucket_of(end_abs);
2✔
2130
    if (b0 < 0) b0 = 0;
2✔
2131
    if (b1 < 0) b1 = static_cast<std::int64_t>(s.nbuckets) - 1;
2✔
2132

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

2156
    return serialize_density_body(
1!
2157
        {}, dens, original_begin, original_end, threshold, 0, false,
1✔
2158
        ts_normalized, display_global_min, static_cast<double>(s.max_dur));
3!
2159
}
2✔
2160

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

2172
    double begin = params.get_double("begin", 0);
3✔
2173
    double end = params.get_double("end", 0);
3✔
2174
    int summary = params.get_int("summary", 2);
3✔
2175
    if (summary < 1) summary = 1;
3!
2176

2177
    auto query = params.get("query");
3✔
2178
    if (!query.empty() &&
3!
2179
        !utilities::common::query::try_parse(query).has_value()) {
×
2180
        co_return HttpResponse::bad_request("Invalid query: " +
×
2181
                                            std::string(query));
×
2182
    }
2183

2184
    auto ts_norm_param = params.get("ts_normalize");
3✔
2185
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
2186
    std::uint64_t global_min = 0;
3✔
2187
    if (normalize) {
3✔
2188
        global_min = index.global_min_timestamp_us();
1✔
2189
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
2190
            global_min = 0;
2191
    }
1✔
2192
    double original_begin = begin;
3✔
2193
    double original_end = end;
3✔
2194
    if (normalize && global_min > 0) {
3!
2195
        begin += static_cast<double>(global_min);
1✔
2196
        end += static_cast<double>(global_min);
1✔
2197
    }
1✔
2198

2199
    // Bucket width (== the ~1px cutoff). summary 1 => 0 => no aggregation.
2200
    double threshold =
6✔
2201
        duration_threshold(begin, end, static_cast<unsigned>(summary));
3✔
2202

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

2215
    // Enclosing events are fetched by a separate pass (below) so a large
2216
    // `lookback` can never starve the in-window scan's budget.
2217
    double lookback = params.get_double("lookback", 0);
4!
2218
    if (lookback < 0) lookback = 0;
×
2219
    double scan_begin = begin - lookback;
2220
    if (scan_begin < 0) scan_begin = 0;
×
2221

2222
    // No cap: this live path only runs zoomed in (bounded window) or filtered.
2223
    int limit = params.get_int("limit", 0);
×
2224
    if (limit < 0) limit = 0;
×
2225

2226
    struct Acc {
2227
        std::vector<std::string> big;
2228
        DensityMap dens;
2229
        double max_dur = 0;
2230
    };
2231
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
2232
    std::vector<Acc> accs(slots);
×
2233

2234
    // Pass 1 (in-window): scan [begin, end] with the full budget so earlier
2235
    // events never consume it. Small events fold into density blocks.
NEW
2236
    auto on_batch = [&accs, threshold, begin](
×
2237
                        std::size_t w,
2238
                        const std::vector<std::string_view>& events) {
NEW
2239
        Acc& acc = accs[w];  // worker-owned slot, no lock
×
NEW
2240
        for (auto ev : events) {
×
NEW
2241
            double dur = 0;
×
NEW
2242
            if (!fold_density(ev, threshold, begin, acc.dens, &dur))
×
NEW
2243
                acc.big.emplace_back(ev);
×
NEW
2244
            if (dur > acc.max_dur) acc.max_dur = dur;
×
2245
        }
NEW
2246
    };
×
2247
    ViewDefinition win_view = build_viz_view(params, begin, end, 0);
×
2248
    std::vector<const TraceIndex::FileInfo*> win_files =
2249
        select_viz_target_files(index, params, begin, end);
×
2250
    bool single_file = !params.get("file").empty();
×
2251
    bool truncated =
2252
        co_await scan_view_events(index, win_files, win_view, begin, end, limit,
×
2253
                                  slots, on_batch, single_file);
×
2254

2255
    // Pass 2 (enclosers): keep only events still open at `begin`
2256
    // (ts < begin <= ts + dur); these ancestor bars set containment depth.
2257
    if (scan_begin < begin) {
×
NEW
2258
        auto on_batch_enc = [&accs, begin](
×
2259
                                std::size_t w,
2260
                                const std::vector<std::string_view>& events) {
NEW
2261
            Acc& acc = accs[w];
×
NEW
2262
            for (auto ev : events) {
×
NEW
2263
                double ts = 0, dur = 0;
×
NEW
2264
                if (!parse_ts_dur(ev, ts, dur)) continue;
×
NEW
2265
                if (dur > acc.max_dur) acc.max_dur = dur;
×
NEW
2266
                if (ts < begin && ts + dur > begin) acc.big.emplace_back(ev);
×
2267
            }
NEW
2268
        };
×
2269
        ViewDefinition enc_view = build_viz_view(params, scan_begin, begin, 0);
×
2270
        std::vector<const TraceIndex::FileInfo*> enc_files =
2271
            select_viz_target_files(index, params, scan_begin, begin);
×
2272
        bool enc_trunc = co_await scan_view_events(
×
2273
            index, enc_files, enc_view, scan_begin, begin, limit, slots,
2274
            on_batch_enc, single_file);
×
2275
        truncated = truncated || enc_trunc;
×
2276
    }
×
2277

2278
    std::vector<std::string> big;
2279
    DensityMap dens;
×
2280
    // Longest event scanned (folded ones included); the client feeds it back as
2281
    // `lookback` so deep zooms still catch long enclosing events.
2282
    double max_dur = 0;
2283
    for (auto& acc : accs) {
×
2284
        if (acc.max_dur > max_dur) max_dur = acc.max_dur;
×
2285
        for (auto& s : acc.big) big.emplace_back(std::move(s));
×
2286
        for (auto& kv : acc.dens) {
×
2287
            auto it = dens.find(kv.first);
×
2288
            if (it == dens.end())
×
2289
                dens.emplace(kv.first, std::move(kv.second));
×
2290
            else
2291
                it->second.merge_from(kv.second);
×
2292
        }
2293
    }
2294
    // Stable per-event/-block depth (computed in absolute space, before ts
2295
    // normalization rewrites the big strings; order is preserved in place).
2296
    std::vector<std::uint32_t> big_depth =
2297
        assign_view_depths(big, dens, begin, threshold);
×
2298
    if (global_min > 0) {
×
2299
        for (auto& e : big) e = normalize_event_ts(e, global_min);
×
2300
    }
2301

2302
    co_return HttpResponse::ok(serialize_density_body(
×
2303
        big, dens, original_begin, original_end, threshold, limit, truncated,
2304
        global_min > 0, index.global_min_timestamp_us(), max_dur, &big_depth));
2305
}
29!
2306

2307
static std::string serialize_counters_body(const std::vector<double>& read,
2✔
2308
                                           const std::vector<double>& write,
2309
                                           const std::vector<double>& ops,
2310
                                           double original_begin,
2311
                                           double original_end, int buckets,
2312
                                           double bucket_us, bool truncated) {
2313
    auto& b = scratch_json_builder();
2✔
2314
    b.start_object();
2✔
2315
    b.append_key_value("begin", original_begin);
2✔
2316
    b.append_comma();
2✔
2317
    b.append_key_value("end", original_end);
2✔
2318
    b.append_comma();
2✔
2319
    b.append_key_value("buckets", static_cast<std::int64_t>(buckets));
2✔
2320
    b.append_comma();
2✔
2321
    b.append_key_value("bucket_us", bucket_us);
2✔
2322
    b.append_comma();
2✔
2323
    b.append_key_value("truncated", truncated);
2✔
2324
    b.append_comma();
2✔
2325
    b.append_key_value("read_bytes", read);
2✔
2326
    b.append_comma();
2✔
2327
    b.append_key_value("write_bytes", write);
2✔
2328
    b.append_comma();
2✔
2329
    b.append_key_value("ops", ops);
2✔
2330
    b.end_object();
2✔
2331
    return std::string(b);
2!
2332
}
2333

2334
// Re-aggregate the summary's finest counter buckets into `buckets` output
2335
// buckets over [begin_abs, end_abs] (absolute us).
2336
static std::string serve_counters_from_summary(const VizSummary& s,
2✔
2337
                                               double begin_abs, double end_abs,
2338
                                               double original_begin,
2339
                                               double original_end, int buckets,
2340
                                               double bucket_us_out) {
2341
    std::vector<double> read(buckets, 0.0), write(buckets, 0.0),
2!
2342
        ops(buckets, 0.0);
2!
2343
    std::int64_t fb0 = s.bucket_of(begin_abs);
2!
2344
    std::int64_t fb1 = s.bucket_of(end_abs);
2!
2345
    if (fb0 < 0) fb0 = 0;
2✔
2346
    if (fb1 < 0) fb1 = static_cast<std::int64_t>(s.nbuckets) - 1;
2✔
2347
    for (std::int64_t fb = fb0; fb <= fb1; ++fb) {
131,074✔
2348
        double center = static_cast<double>(s.t_begin) +
196,608✔
2349
                        (static_cast<double>(fb) + 0.5) * s.bucket_us;
131,072✔
2350
        long oi = static_cast<long>((center - begin_abs) / bucket_us_out);
131,072✔
2351
        if (oi < 0 || oi >= buckets) continue;
131,072!
2352
        auto f = static_cast<std::size_t>(fb);
131,072✔
2353
        read[oi] += s.read_bytes[f];
131,072✔
2354
        write[oi] += s.write_bytes[f];
131,072✔
2355
        ops[oi] += s.ops[f];
131,072✔
2356
    }
65,536✔
2357
    return serialize_counters_body(read, write, ops, original_begin,
2!
2358
                                   original_end, buckets, bucket_us_out, false);
3!
2359
}
2✔
2360

2361
// GET /api/v1/viz/counters: per-bucket read/write bytes and I/O op counts over
2362
// a time range, for bandwidth/IOPS counter tracks. Aggregated server-side in
2363
// parallel (per-worker arrays merged after join).
2364
static coro::CoroTask<HttpResponse> handle_viz_counters(
8!
2365
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
2366
    if (!params.has("begin") || !params.has("end")) {
3!
2367
        co_return HttpResponse::bad_request(
5!
2368
            "Missing required parameters: begin, end");
4!
2369
    }
2370

2371
    double begin = params.get_double("begin", 0);
3✔
2372
    double end = params.get_double("end", 0);
3✔
2373
    int buckets = params.get_int("buckets", 800);
3✔
2374
    if (buckets < 16) buckets = 16;
3!
2375
    if (buckets > 4000) buckets = 4000;
3!
2376

2377
    auto query = params.get("query");
3✔
2378
    if (!query.empty() &&
3!
2379
        !utilities::common::query::try_parse(query).has_value()) {
×
2380
        co_return HttpResponse::bad_request("Invalid query: " +
×
2381
                                            std::string(query));
×
2382
    }
2383

2384
    auto ts_norm_param = params.get("ts_normalize");
3✔
2385
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
3!
2386
    std::uint64_t global_min = 0;
3✔
2387
    if (normalize) {
3✔
2388
        global_min = index.global_min_timestamp_us();
1✔
2389
        if (global_min == std::numeric_limits<std::uint64_t>::max())
1!
2390
            global_min = 0;
2391
    }
1✔
2392
    double original_begin = begin;
3✔
2393
    double original_end = end;
3✔
2394
    if (normalize && global_min > 0) {
3!
2395
        begin += static_cast<double>(global_min);
1✔
2396
        end += static_cast<double>(global_min);
1✔
2397
    }
1✔
2398

2399
    double bucket_us = (end - begin) / static_cast<double>(buckets);
3✔
2400
    if (bucket_us <= 0) bucket_us = 1;
3!
2401

2402
    // Zoomed-out, unfiltered counter tracks come from the activity summary.
2403
    if (viz_summary_eligible(params)) {
3!
2404
        const VizSummary* s = co_await ensure_viz_summary(index);
4!
2405
        if (s && s->bucket_us > 0 && s->t_end > s->t_begin &&
1!
2406
            bucket_us >= s->bucket_us) {
1✔
2407
            co_return HttpResponse::ok(
1!
2408
                serve_counters_from_summary(*s, begin, end, original_begin,
2!
2409
                                            original_end, buckets, bucket_us));
1✔
2410
        }
2411
    }
1!
2412

2413
    ViewDefinition view = build_viz_view(params, begin, end, 0);
×
2414
    // No cap: only zoomed-in or filtered counter queries reach the live path.
2415
    int limit = params.get_int("limit", 0);
×
2416
    if (limit < 0) limit = 0;
×
2417

2418
    std::vector<const TraceIndex::FileInfo*> target_files =
2419
        select_viz_target_files(index, params, begin, end);
×
2420

2421
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
×
2422
    std::vector<CounterAcc> accs(slots);
×
2423
    for (auto& a : accs) a.init(static_cast<std::size_t>(buckets));
×
NEW
2424
    auto on_batch = [&accs, begin, bucket_us, buckets](
×
2425
                        std::size_t w,
2426
                        const std::vector<std::string_view>& events) {
NEW
2427
        CounterAcc& acc = accs[w];  // worker-owned, no lock
×
NEW
2428
        for (auto ev : events)
×
NEW
2429
            fold_counter(ev, begin, bucket_us,
×
2430
                         static_cast<std::size_t>(buckets), acc);
NEW
2431
    };
×
2432

2433
    bool truncated =
2434
        co_await scan_view_events(index, target_files, view, begin, end, limit,
×
2435
                                  slots, on_batch, !params.get("file").empty());
×
2436

2437
    CounterAcc total;
2438
    total.init(static_cast<std::size_t>(buckets));
×
2439
    for (auto& a : accs) total.merge_from(a);
×
2440

2441
    co_return HttpResponse::ok(serialize_counters_body(
×
2442
        total.read_bytes, total.write_bytes, total.ops, original_begin,
2443
        original_end, buckets, bucket_us, truncated));
2444
}
29!
2445

2446
// Process-spawning calls: dftracer POSIX (exact "fork"/"clone"/...) and kernel
2447
// syscalls ("__arm64_sys_clone"). Excludes library helpers like ibv_*fork* and
2448
// register_tm_clones, which contain "fork"/"clone" but don't spawn.
2449
static bool is_fork_syscall(std::string_view name) {
100✔
2450
    return name == "fork" || name == "vfork" || name == "clone" ||
250!
2451
           name == "clone3" || name == "posix_spawn" ||
200!
2452
           name == "posix_spawnp" ||
150!
2453
           name.find("sys_clone") != std::string_view::npos ||
150!
2454
           name.find("sys_fork") != std::string_view::npos ||
200!
2455
           name.find("sys_vfork") != std::string_view::npos;
150✔
2456
}
2457

2458
// One process in the proctree response. `host` borrows the hostname table;
2459
// `rank` is null when the trace carries no "PR" metadata for the pid, and the
2460
// key is then omitted.
2461
struct ProcNode {
2462
    std::int64_t pid;
2463
    std::int64_t parent;
2464
    std::uint64_t spawn_ts;
2465
    std::uint64_t first_ts;
2466
    std::string_view host;
2467
    std::uint64_t bytes;
2468
    std::uint64_t io_ops;
2469
    double io_busy;
2470
    const std::string* rank;
2471
};
2472

2473
template <typename builder_type>
2474
void tag_invoke(simdjson::serialize_tag, builder_type& b, const ProcNode& n) {
100✔
2475
    b.start_object();
100✔
2476
    b.append_key_value("pid", n.pid);
100✔
2477
    b.append_comma();
100✔
2478
    b.append_key_value("parent", n.parent);
100✔
2479
    b.append_comma();
100✔
2480
    b.append_key_value("spawn_ts", n.spawn_ts);
100✔
2481
    b.append_comma();
100✔
2482
    b.append_key_value("first_ts", n.first_ts);
100✔
2483
    b.append_comma();
100✔
2484
    b.append_key_value("host", n.host);
100✔
2485
    b.append_comma();
100✔
2486
    b.append_key_value("bytes", n.bytes);
100✔
2487
    b.append_comma();
100✔
2488
    b.append_key_value("io_ops", n.io_ops);
100✔
2489
    b.append_comma();
100✔
2490
    b.append_key_value("io_busy", n.io_busy);
100✔
2491
    if (n.rank) {
100✔
NEW
2492
        b.append_comma();
×
NEW
2493
        b.append_key_value("rank", *n.rank);
×
2494
    }
2495
    b.end_object();
100✔
2496
}
100✔
2497

2498
// GET /api/v1/viz/proctree: infer the process fork hierarchy. The traces record
2499
// the fork/clone in the parent but not the child pid, so link each process to
2500
// the nearest preceding clone in another process (child start follows the clone
2501
// by microseconds). Respects ?file= for per-node trees on multi-node traces.
2502
static coro::CoroTask<HttpResponse> handle_viz_proctree(
6!
2503
    const HttpRequest& /*req*/, const QueryParams& params, TraceIndex& index) {
1!
2504
    std::uint64_t gmin = index.global_min_timestamp_us();
1!
2505
    std::uint64_t gmax = index.global_max_timestamp_us();
1!
2506
    if (gmin == std::numeric_limits<std::uint64_t>::max() || gmax <= gmin)
1!
2507
        co_return HttpResponse::ok("{\"nodes\":[]}");
1!
2508

2509
    auto ts_norm_param = params.get("ts_normalize");
1!
2510
    bool normalize = ts_norm_param.empty() || ts_norm_param != "0";
1!
2511
    std::uint64_t base = normalize ? gmin : 0;
1!
2512

2513
    struct Acc {
2!
2514
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
2515
        // Explicit parent from the process metadata's args.ppid.
2516
        ankerl::unordered_dense::map<std::int64_t, std::int64_t> ppid;
2517
        // (ts, parent_pid, child_pid): child_pid is args.ret when the fork
2518
        // event records it (dftracer POSIX), else -1 to fall back to inference.
2519
        std::vector<std::tuple<std::uint64_t, std::int64_t, std::int64_t>>
2520
            forks;
2521
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
2522
        // I/O operation count and busy time (us) per process, over I/O-category
2523
        // (POSIX/STDIO/IO) events only.
2524
        ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
2525
        ankerl::unordered_dense::map<std::int64_t, double> io_busy;
2526
        ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
2527
        // pid -> rank, from "PR" metadata (args.name == "rank").
2528
        ankerl::unordered_dense::map<std::int64_t, std::string> rank;
2529
        dftracer::utils::StringViewMap<std::string> hh;
2530
    };
2531
    std::size_t slots = std::max<std::size_t>(1, index.max_concurrent());
1!
2532
    std::vector<Acc> accs(slots);
1!
2533
    auto on_batch = [&accs](std::size_t w,
3✔
2534
                            const std::vector<std::string_view>& events) {
1✔
2535
        thread_local simdjson::dom::parser parser;
2✔
2536
        thread_local std::string buf;
2✔
2537
        Acc& acc = accs[w];
2✔
2538
        for (auto ev : events) {
102✔
2539
            buf.assign(ev);
100!
2540
            auto res = parser.parse(buf);
100✔
2541
            if (res.error()) continue;
100!
2542
            auto root = res.value_unsafe();
100✔
2543
            if (!root.is_object()) continue;
100✔
2544
            // HH metadata (hhash -> hostname) carries no ts; handle it first.
2545
            {
2546
                auto nm = root["name"];
100✔
2547
                if (!nm.error() && nm.is_string() &&
200!
2548
                    nm.get_string().value_unsafe() == "HH") {
150!
NEW
2549
                    auto a = root["args"];
×
NEW
2550
                    if (!a.error() && a.is_object()) {
×
NEW
2551
                        auto v = a["value"];
×
NEW
2552
                        auto n = a["name"];
×
NEW
2553
                        if (!v.error() && v.is_string() && !n.error() &&
×
NEW
2554
                            n.is_string())
×
NEW
2555
                            acc.hh.emplace(
×
NEW
2556
                                std::string(v.get_string().value_unsafe()),
×
NEW
2557
                                std::string(n.get_string().value_unsafe()));
×
2558
                    }
NEW
2559
                    continue;
×
2560
                }
2561
            }
2562
            // PR metadata (pid -> rank) also carries no ts.
2563
            {
2564
                auto nm = root["name"];
100✔
2565
                if (!nm.error() && nm.is_string() &&
200!
2566
                    nm.get_string().value_unsafe() == "PR") {
150!
NEW
2567
                    auto a = root["args"];
×
NEW
2568
                    auto pp = root["pid"];
×
NEW
2569
                    if (!a.error() && a.is_object() && !pp.error()) {
×
NEW
2570
                        auto an = a["name"];
×
NEW
2571
                        auto av = a["value"];
×
NEW
2572
                        if (!an.error() && an.is_string() &&
×
NEW
2573
                            an.get_string().value_unsafe() == "rank" &&
×
NEW
2574
                            !av.error() && av.is_string())
×
NEW
2575
                            acc.rank.emplace(
×
NEW
2576
                                static_cast<std::int64_t>(
×
NEW
2577
                                    json_number(pp.value_unsafe())),
×
NEW
2578
                                std::string(av.get_string().value_unsafe()));
×
2579
                    }
NEW
2580
                    continue;
×
2581
                }
2582
            }
2583
            auto pr = root["pid"];
100✔
2584
            auto tr = root["ts"];
100✔
2585
            if (pr.error() || tr.error()) continue;
100!
2586
            auto pid =
50✔
2587
                static_cast<std::int64_t>(json_number(pr.value_unsafe()));
100✔
2588
            auto ts =
50✔
2589
                static_cast<std::uint64_t>(json_number(tr.value_unsafe()));
100✔
2590
            auto it = acc.first_ts.find(pid);
100!
2591
            if (it == acc.first_ts.end())
100!
2592
                acc.first_ts.emplace(pid, ts);
100!
NEW
2593
            else if (ts < it->second)
×
NEW
2594
                it->second = ts;
×
2595

2596
            auto nr = root["name"];
100✔
2597
            std::string_view name;
100✔
2598
            if (!nr.error() && nr.is_string())
100!
2599
                name = nr.get_string().value_unsafe();
100✔
2600

2601
            std::int64_t child = -1;
100✔
2602
            double ret = 0;
100✔
2603
            auto args = root["args"];
100✔
2604
            if (!args.error() && args.is_object()) {
100!
2605
                auto ppr = args["ppid"];
100✔
2606
                if (!ppr.error()) {
100✔
2607
                    auto pp = static_cast<std::int64_t>(
NEW
2608
                        json_number(ppr.value_unsafe()));
×
NEW
2609
                    if (pp > 0 && pp != pid) acc.ppid[pid] = pp;
×
2610
                }
2611
                auto rr = args["ret"];
100✔
2612
                if (!rr.error()) {
100✔
2613
                    ret = json_number(rr.value_unsafe());
100✔
2614
                    child = static_cast<std::int64_t>(ret);
100✔
2615
                }
50✔
2616
                auto hr = args["hhash"];
100✔
2617
                if (!hr.error() && hr.is_string() &&
200!
2618
                    acc.pid_hhash.find(pid) == acc.pid_hhash.end())
150!
2619
                    acc.pid_hhash.emplace(
150!
2620
                        pid, std::string(hr.get_string().value_unsafe()));
150!
2621
            }
50✔
2622
            // I/O bytes per process: args.ret on read/write ops.
2623
            if (ret > 0 && (name.find("read") != std::string_view::npos ||
131!
2624
                            name.find("write") != std::string_view::npos))
62✔
2625
                acc.bytes[pid] += static_cast<std::uint64_t>(ret);
76!
2626

2627
            auto cr = root["cat"];
100✔
2628
            std::string_view cat;
100✔
2629
            if (!cr.error() && cr.is_string())
100!
2630
                cat = cr.get_string().value_unsafe();
100✔
2631
            if (cat == "POSIX" || cat == "STDIO" || cat == "IO") {
100!
2632
                acc.io_ops[pid] += 1;
100!
2633
                auto dr = root["dur"];
100✔
2634
                if (!dr.error())
100✔
2635
                    acc.io_busy[pid] += json_number(dr.value_unsafe());
100!
2636
            }
50✔
2637

2638
            if (!name.empty() && is_fork_syscall(name))
100!
NEW
2639
                acc.forks.emplace_back(ts, pid, child > 0 ? child : -1);
×
2640
        }
2641
    };
2✔
2642

2643
    ViewDefinition view;
1✔
2644
    view.name = "viz_proctree";
1!
2645
    view.with_query("ts >= " + std::to_string(gmin) +
2!
2646
                    " and ts <= " + std::to_string(gmax));
1!
2647
    auto files = select_viz_target_files(
2!
2648
        index, params, static_cast<double>(gmin), static_cast<double>(gmax));
1✔
2649
    bool single_file = !params.get("file").empty();
1!
2650
    co_await scan_view_events(index, files, view, static_cast<double>(gmin),
2!
2651
                              static_cast<double>(gmax), 0, slots, on_batch,
1!
2652
                              single_file);
1✔
2653

2654
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> first_ts;
1!
2655
    ankerl::unordered_dense::map<std::int64_t, std::int64_t> parent_of;
1!
2656
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> spawn_of;
1!
2657
    std::vector<std::pair<std::uint64_t, std::int64_t>> inf_forks;  // (ts, pid)
1✔
2658
    for (auto& a : accs) {
3✔
2659
        for (auto& kv : a.first_ts) {
52✔
2660
            auto it = first_ts.find(kv.first);
50!
2661
            if (it == first_ts.end())
50!
2662
                first_ts.emplace(kv.first, kv.second);
50!
2663
            else if (kv.second < it->second)
×
2664
                it->second = kv.second;
2665
        }
50✔
2666
        // Explicit edges from the fork event's args.ret (child pid + spawn ts).
2667
        for (auto& [ts, ppid, child] : a.forks) {
2!
2668
            if (child > 0) {
×
2669
                parent_of[child] = ppid;
×
2670
                spawn_of[child] = ts;
×
2671
            } else {
2672
                inf_forks.emplace_back(ts, ppid);
×
2673
            }
2674
        }
2675
    }
2✔
2676
    // args.ppid metadata fills in any process not linked by a fork event.
2677
    for (auto& a : accs)
3✔
2678
        for (auto& kv : a.ppid)
2!
2679
            if (parent_of.find(kv.first) == parent_of.end())
×
2680
                parent_of.emplace(kv.first, kv.second);
2!
2681
    std::sort(inf_forks.begin(), inf_forks.end());
1!
2682

2683
    // Merge host (hhash -> hostname resolved) and I/O bytes per process.
2684
    dftracer::utils::StringViewMap<std::string> hh;
1!
2685
    ankerl::unordered_dense::map<std::int64_t, std::string> pid_hhash;
1!
2686
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> bytes;
1!
2687
    ankerl::unordered_dense::map<std::int64_t, std::uint64_t> io_ops;
1!
2688
    ankerl::unordered_dense::map<std::int64_t, double> io_busy;
1!
2689
    ankerl::unordered_dense::map<std::int64_t, std::string> rank;
1!
2690
    for (auto& a : accs) {
3✔
2691
        for (auto& kv : a.hh) hh.emplace(kv.first, kv.second);
2!
2692
        for (auto& kv : a.pid_hhash) pid_hhash.emplace(kv.first, kv.second);
52!
2693
        for (auto& kv : a.bytes) bytes[kv.first] += kv.second;
40!
2694
        for (auto& kv : a.io_ops) io_ops[kv.first] += kv.second;
52!
2695
        for (auto& kv : a.io_busy) io_busy[kv.first] += kv.second;
52!
2696
        for (auto& kv : a.rank) rank.emplace(kv.first, kv.second);
2!
2697
    }
2✔
2698

2699
    std::vector<std::pair<std::uint64_t, std::int64_t>>
1✔
2700
        procs;  // (first_ts, pid)
1✔
2701
    procs.reserve(first_ts.size());
1!
2702
    for (auto& kv : first_ts) procs.emplace_back(kv.second, kv.first);
51!
2703
    std::sort(procs.begin(), procs.end());
1!
2704

2705
    // Time-inference fallback (traces without args.ret/ppid): link a process to
2706
    // the nearest preceding clone in another process.
2707
    std::vector<bool> used(inf_forks.size(), false);
1!
2708
    std::vector<ProcNode> nodes;
1✔
2709
    nodes.reserve(procs.size());
1!
2710
    for (auto& [fts, pid] : procs) {
51✔
2711
        std::int64_t parent = -1;
50✔
2712
        std::uint64_t spawn_ts = 0;
50✔
2713
        auto pit = parent_of.find(pid);
50!
2714
        if (pit != parent_of.end()) {
50!
2715
            parent = pit->second;
2716
            auto sit = spawn_of.find(pid);
×
2717
            if (sit != spawn_of.end()) spawn_ts = sit->second - base;
×
2718
        } else {
2719
            auto hi = std::upper_bound(
100!
2720
                inf_forks.begin(), inf_forks.end(),
100✔
2721
                std::make_pair(fts, std::numeric_limits<std::int64_t>::max()));
50!
2722
            for (auto it = hi; it != inf_forks.begin();) {
50!
2723
                --it;
2724
                auto idx = static_cast<std::size_t>(it - inf_forks.begin());
2725
                if (!used[idx] && it->second != pid) {
×
2726
                    used[idx] = true;
×
2727
                    parent = it->second;
2728
                    spawn_ts = it->first - base;
2729
                    break;
2730
                }
2731
            }
×
2732
        }
50✔
2733
        std::string_view host;
50✔
2734
        auto hp = pid_hhash.find(pid);
50!
2735
        if (hp != pid_hhash.end()) {
50!
2736
            auto hn = hh.find(hp->second);
50!
2737
            if (hn != hh.end()) host = hn->second;
50!
2738
        }
50✔
2739
        auto bp = bytes.find(pid);
50!
2740
        auto op = io_ops.find(pid);
50!
2741
        auto ib = io_busy.find(pid);
50!
2742
        auto rk = rank.find(pid);
50!
2743
        nodes.push_back({pid, parent, spawn_ts, fts - base, host,
250!
2744
                         bp != bytes.end() ? bp->second : 0,
50✔
2745
                         op != io_ops.end() ? op->second : 0,
50!
2746
                         ib != io_busy.end() ? ib->second : 0.0,
50!
2747
                         rk != rank.end() ? &rk->second : nullptr});
50!
2748
    }
50✔
2749

2750
    auto& sb = scratch_json_builder();
1!
2751
    sb.start_object();
1✔
2752
    sb.append_key_value("nodes", nodes);
1!
2753
    sb.end_object();
1✔
2754
    co_return HttpResponse::ok(std::string(sb));
1!
2755
}
5!
2756

2757
void register_viz_api(Router& router, TraceIndex& index) {
4✔
2758
    auto* index_ptr = &index;
4✔
2759
    const RouteParam BEGIN{"begin", "Window start (us)", true, "0"};
4!
2760
    const RouteParam END{"end", "Window end (us)", true, "999999999"};
4!
2761
    const RouteParam SUMMARY{"summary", "LOD level (1=full detail)", true, "1"};
4!
2762

2763
    router.get(
6!
2764
        "/api/v1/viz/proctree",
2!
2765
        [index_ptr](const HttpRequest& req,
10!
2766
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
2767
            co_return co_await handle_viz_proctree(req, params, *index_ptr);
5!
2768
        },
4!
2769
        RouteDoc{
12!
2770
            "Inferred process/fork hierarchy with host, rank, and I/O.",
2!
2771
            "Visualization",
2!
2772
            {{"file", "Limit to one trace file", false, ""}},
2!
2773
            R"({"nodes":[{"pid":100,"parent":-1,"host":"node01","rank":"0",)"
2!
2774
            R"("bytes":16384,"io_ops":4,"io_busy":600.0}]})"});
4✔
2775

2776
    router.get(
6!
2777
        "/api/v1/viz/counters",
2!
2778
        [index_ptr](const HttpRequest& req,
10!
2779
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
2780
            co_return co_await handle_viz_counters(req, params, *index_ptr);
5!
2781
        },
4!
2782
        RouteDoc{"Read/write bytes and I/O op counts per time bucket.",
16!
2783
                 "Visualization",
2!
2784
                 {BEGIN, END, SUMMARY},
2!
2785
                 R"({"buckets":[{"ts":0,"read_bytes":4096,"write_bytes":0,)"
2!
2786
                 R"("read_ops":1,"write_ops":0}]})"});
8✔
2787

2788
    router.get(
6!
2789
        "/api/v1/viz/events",
2!
2790
        [index_ptr](const HttpRequest& req,
58!
2791
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
7!
2792
            co_return co_await handle_viz_events(req, params, *index_ptr);
35!
2793
        },
28!
2794
        RouteDoc{"Events for rendering: time-windowed, LOD-aggregated.",
22!
2795
                 "Visualization",
2!
2796
                 {BEGIN,
8!
2797
                  END,
2!
2798
                  SUMMARY,
2!
2799
                  {"pid", "Filter by process id", false, ""},
2!
2800
                  {"cat", "Filter by category", false, ""},
2!
2801
                  {"query", "DSL predicate, e.g. dur >= 1000", false, ""}},
2!
2802
                 R"({"events":[],"metadata":{"begin":0,"end":1000000,)"
2!
2803
                 R"("count":42,"truncated":false,"ts_normalized":true}})"});
14✔
2804

2805
    router.get(
6!
2806
        "/api/v1/viz/density",
2!
2807
        [index_ptr](const HttpRequest& req,
10!
2808
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
2809
            co_return co_await handle_viz_density(req, params, *index_ptr);
5!
2810
        },
4!
2811
        RouteDoc{"Sub-pixel events bucketed into density blocks.",
16!
2812
                 "Visualization",
2!
2813
                 {BEGIN, END, {"summary", "LOD level", true, "2"}},
2!
2814
                 R"({"events":[],"density":[{"pid":100,"tid":100,"ts":0,)"
2!
2815
                 R"("dur":24457,"count":910,"total":22044,"depth":0}]})"});
8✔
2816

2817
    router.get(
6!
2818
        "/api/v1/viz/stats",
2!
2819
        [index_ptr](const HttpRequest& req,
10!
2820
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
2821
            co_return co_await handle_viz_stats(req, params, *index_ptr);
5!
2822
        },
4!
2823
        RouteDoc{"Per-name aggregation over a time range (Analyze).",
16!
2824
                 "Visualization",
2!
2825
                 {BEGIN, END, SUMMARY},
2!
2826
                 R"({"count":100,"total_dur":5000,"names":[{"name":"read",)"
2!
2827
                 R"("count":50,"total":2500,"avg":50,"min":10,"max":90}]})"});
8✔
2828

2829
    router.get(
6!
2830
        "/api/v1/viz/calltree",
2!
2831
        [index_ptr](const HttpRequest& req,
10!
2832
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
2833
            co_return co_await handle_viz_calltree(req, params, *index_ptr);
5!
2834
        },
4!
2835
        RouteDoc{
18!
2836
            "Merged flamegraph tree from ts/dur containment.",
2!
2837
            "Visualization",
2!
2838
            {BEGIN,
4!
2839
             END,
2!
2840
             SUMMARY,
2!
2841
             {"group", "Set to 'pid' to keep processes separate", false, ""}},
2!
2842
            R"({"name":"root","total":5000,"self":0,"count":0,)"
2!
2843
            R"("children":[{"name":"read","total":2500,"self":2500,)"
2844
            R"("count":50}]})"});
10✔
2845

2846
    router.get(
6!
2847
        "/api/v1/viz/histogram",
2!
2848
        [index_ptr](const HttpRequest& req,
10!
2849
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
1!
2850
            co_return co_await handle_viz_histogram(req, params, *index_ptr);
5!
2851
        },
4!
2852
        RouteDoc{"Duration distribution: percentiles + log-spaced buckets.",
18!
2853
                 "Visualization",
2!
2854
                 {BEGIN,
4!
2855
                  END,
2!
2856
                  SUMMARY,
2!
2857
                  {"query", "DSL predicate to narrow to one op", false, ""}},
2!
2858
                 R"({"min":10,"max":900,"p50":150,"p99":880,"buckets":[]})"});
12!
2859

2860
    router.get(
6!
2861
        "/api/v1/viz/layers",
2!
2862
        [index_ptr](const HttpRequest& req,
18!
2863
                    const QueryParams& params) -> coro::CoroTask<HttpResponse> {
2!
2864
            co_return co_await handle_viz_layers(req, params, *index_ptr);
10!
2865
        },
8!
2866
        RouteDoc{"Operation-name to category map; declared vs I/O files.",
6!
2867
                 "Visualization",
2!
2868
                 {},
2✔
2869
                 R"({"layers":{"read":"POSIX","write":"POSIX"},)"
2!
2870
                 R"("total_files":2,"io_files":2})"});
2871
}
4✔
2872

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