aboutsummaryrefslogtreecommitdiff
path: root/src/zen/trace/trace_viewer_service.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/zen/trace/trace_viewer_service.cpp')
-rw-r--r--src/zen/trace/trace_viewer_service.cpp1225
1 files changed, 1225 insertions, 0 deletions
diff --git a/src/zen/trace/trace_viewer_service.cpp b/src/zen/trace/trace_viewer_service.cpp
new file mode 100644
index 000000000..7d8301ae2
--- /dev/null
+++ b/src/zen/trace/trace_viewer_service.cpp
@@ -0,0 +1,1225 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include "trace_viewer_service.h"
+
+#include "timeline_query.h"
+
+#include <zencore/compactbinarybuilder.h>
+#include <zencore/filesystem.h>
+#include <zencore/fmtutils.h>
+#include <zencore/iobuffer.h>
+#include <zencore/logging.h>
+#include <zencore/string.h>
+#include <zenhttp/httpcommon.h>
+
+#include <algorithm>
+#include <charconv>
+#include <cstdio>
+#include <cstdlib>
+#include <string>
+#include <type_traits>
+
+#if !defined(ZEN_EMBED_ZEN_HTML_ZIP)
+# define ZEN_EMBED_ZEN_HTML_ZIP 0
+#endif
+
+#if ZEN_EMBED_ZEN_HTML_ZIP
+static unsigned char gZenHtmlZipData[] = {
+# include <zen-html.zip.h>
+};
+#endif
+
+namespace zen {
+
+namespace {
+
+ // Parse a uint32 query parameter; returns the fallback on error / absent.
+ // The entire string must be a valid base-10 unsigned integer.
+ uint32_t ParseUintParam(std::string_view Value, uint32_t Fallback)
+ {
+ if (Value.empty())
+ {
+ return Fallback;
+ }
+
+ uint32_t Number = 0;
+ auto [Ptr, Ec] = std::from_chars(Value.data(), Value.data() + Value.size(), Number, 10);
+ if (Ec != std::errc() || Ptr != Value.data() + Value.size())
+ {
+ return Fallback;
+ }
+
+ return Number;
+ }
+
+ void WriteNotFound(HttpServerRequest& Request, std::string_view Message = "Not found")
+ {
+ Request.WriteResponse(HttpResponseCode::NotFound, HttpContentType::kText, Message);
+ }
+
+ struct CallstackSummaryInfo
+ {
+ std::string Summary;
+ std::string TopFrame;
+ std::string SecondaryFrame;
+ std::string GroupKey;
+ uint32_t HiddenPrefixCount = 0;
+ bool IncludedThirdPartyBoundary = false;
+ };
+
+ CallstackSummaryInfo BuildCallstackSummary(const trace_detail::FilteredCallstackView& View)
+ {
+ CallstackSummaryInfo Result;
+ Result.HiddenPrefixCount = View.HiddenPrefixCount;
+ Result.IncludedThirdPartyBoundary = View.IncludedThirdPartyBoundary;
+ if (View.Frames.empty())
+ {
+ Result.Summary = "No frames";
+ Result.GroupKey = "No frames";
+ return Result;
+ }
+
+ Result.TopFrame = View.Frames[0].Display;
+ Result.GroupKey = View.Frames[0].Display;
+ if (View.Frames.size() > 1)
+ {
+ Result.SecondaryFrame = View.Frames[1].Display;
+ Result.Summary = fmt::format("{} \xE2\x86\x90 {}", Result.TopFrame, Result.SecondaryFrame);
+ Result.GroupKey = fmt::format("{} | {}", Result.TopFrame, Result.SecondaryFrame);
+ }
+ else
+ {
+ Result.Summary = Result.TopFrame;
+ }
+ return Result;
+ }
+
+ // Append a base-10 unsigned integer to a string builder via std::to_chars.
+ // Reserves the worst-case digit count up front, writes directly into the
+ // builder's buffer, then trims the unused suffix. About 5–10× faster than
+ // going through StringBuilder::operator<<(uint32_t), which routes integer
+ // formatting through snprintf via IntNum.
+ template<typename T>
+ inline void AppendUintFast(StringBuilderBase& Sb, T Value)
+ {
+ static_assert(std::is_unsigned_v<T> && std::is_integral_v<T>);
+ // digits10 is the largest K such that 10^K fits — the longest
+ // printable representation is digits10 + 1 digits. +1 more for safety.
+ constexpr size_t MaxDigits = std::numeric_limits<T>::digits10 + 2;
+
+ const size_t Off = Sb.AddUninitialized(MaxDigits);
+ char* const Begin = Sb.Data() + Off;
+ const auto Result = std::to_chars(Begin, Begin + MaxDigits, Value);
+ const size_t Written = size_t(Result.ptr - Begin);
+ Sb.RemoveSuffix(uint32_t(MaxDigits - Written));
+ }
+
+ // Render a span of TimelineScopeView records directly into a string
+ // builder using the wire format consumed by the trace viewer front-end:
+ // [[beginUs, durationUs, nameId, depth, mergeCount?], ...]
+ // The trailing mergeCount element is only emitted for LOD-merged scopes.
+ // Output is compact (no whitespace) — the viewer parses both forms but
+ // dropping the spaces shaves ~10% off the response size.
+ void AppendScopesJsonArray(StringBuilderBase& Sb, const trace_detail::TimelineScopeView* Scopes, size_t Count)
+ {
+ Sb << '[';
+ for (size_t I = 0; I < Count; ++I)
+ {
+ const trace_detail::TimelineScopeView& S = Scopes[I];
+ if (I > 0)
+ {
+ Sb << ',';
+ }
+ Sb << '[';
+ AppendUintFast(Sb, S.BeginUs);
+ Sb << ',';
+ AppendUintFast(Sb, S.DurationUs);
+ Sb << ',';
+ AppendUintFast(Sb, S.NameId);
+ Sb << ',';
+ AppendUintFast(Sb, S.Depth);
+ if (S.MergeCount > 1)
+ {
+ Sb << ',';
+ AppendUintFast(Sb, S.MergeCount);
+ }
+ Sb << ']';
+ }
+ Sb << ']';
+ }
+
+} // namespace
+
+//////////////////////////////////////////////////////////////////////////////
+
+TraceViewerService::TraceViewerService(const trace_detail::TraceModel& Model,
+ std::unique_ptr<trace_detail::SymbolResolver> Symbols,
+ std::filesystem::path DevHtmlDir)
+: m_Model(Model)
+, m_DevHtmlDir(std::move(DevHtmlDir))
+, m_Symbols(std::move(Symbols))
+, m_CallstackFormatter(m_Model, m_Symbols.get())
+{
+#if ZEN_EMBED_ZEN_HTML_ZIP
+ IoBuffer ZipBuffer(IoBuffer::Wrap, gZenHtmlZipData, sizeof(gZenHtmlZipData) - 1);
+ m_ZipFs = std::make_unique<ZipFs>(std::move(ZipBuffer));
+#endif
+
+ m_TimelineQuery = trace_detail::MakeInMemoryTimelineQuery(m_Model);
+
+ if (m_DevHtmlDir.empty())
+ {
+ // Probe for development layout: walk up from the running executable
+ // until we find a directory named xmake.lua, then look for the html
+ // tree under src/zen/frontend/html.
+ std::filesystem::path Path = GetRunningExecutablePath();
+ std::error_code Ec;
+ while (Path.has_parent_path())
+ {
+ std::filesystem::path Parent = Path.parent_path();
+ if (Parent == Path)
+ {
+ break;
+ }
+ if (IsFile(Parent / "xmake.lua", Ec))
+ {
+ std::filesystem::path Candidate = Parent / "src" / "zen" / "frontend" / "html";
+ if (IsDir(Candidate, Ec))
+ {
+ m_DevHtmlDir = Candidate;
+ }
+ break;
+ }
+ Path = Parent;
+ }
+ }
+
+ if (m_ZipFs)
+ {
+ ZEN_INFO("trace viewer front-end is served from embedded zip");
+ }
+ else if (!m_DevHtmlDir.empty())
+ {
+ ZEN_INFO("trace viewer front-end is served from '{}'", m_DevHtmlDir);
+ }
+ else
+ {
+ ZEN_WARN("trace viewer front-end is NOT AVAILABLE — only /api/* endpoints will respond");
+ }
+}
+
+TraceViewerService::~TraceViewerService() = default;
+
+const char*
+TraceViewerService::BaseUri() const
+{
+ // Mounted at a sub-path so we don't collide with the http.sys server's
+ // own root handler on Windows.
+ return "/trace/";
+}
+
+void
+TraceViewerService::HandleRequest(HttpServerRequest& Request)
+{
+ using namespace std::literals;
+
+ std::string_view Uri = Request.RelativeUriWithExtension();
+ for (; !Uri.empty() && Uri[0] == '/'; Uri = Uri.substr(1))
+ {
+ }
+
+ if (Uri.starts_with("api/"sv))
+ {
+ HandleApiRequest(Request, Uri.substr(4));
+ return;
+ }
+
+ HandleStaticAsset(Request, Uri);
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Static asset handling
+
+void
+TraceViewerService::HandleStaticAsset(HttpServerRequest& Request, std::string_view Uri)
+{
+ using namespace std::literals;
+
+ ExtendableStringBuilder<256> UriBuilder;
+ if (Uri.empty())
+ {
+ Uri = "index.html"sv;
+ }
+ else if (Uri.back() == '/')
+ {
+ UriBuilder << Uri << "index.html"sv;
+ Uri = UriBuilder;
+ }
+
+ // Path traversal guard: reject parent refs, Windows-style separators, and absolute
+ // paths. `std::filesystem::path::operator/=` replaces the base when the RHS is
+ // absolute, so without this check a URI like `C:/Windows/...` would escape m_DevHtmlDir.
+ if (Uri.find("..") != Uri.npos || Uri.find('\\') != Uri.npos || std::filesystem::path(Uri).is_absolute())
+ {
+ Request.WriteResponse(HttpResponseCode::Forbidden);
+ return;
+ }
+
+ HttpContentType ContentType = HttpContentType::kUnknownContentType;
+ if (const size_t DotIndex = Uri.rfind("."); DotIndex != Uri.npos)
+ {
+ const std::string_view DotExt = Uri.substr(DotIndex + 1);
+ ContentType = ParseContentType(DotExt);
+ if (ContentType == HttpContentType::kUnknownContentType)
+ {
+ if (DotExt == "txt"sv || DotExt == "md"sv)
+ {
+ ContentType = HttpContentType::kText;
+ }
+ }
+ }
+
+ if (ContentType == HttpContentType::kUnknownContentType)
+ {
+ Request.WriteResponse(HttpResponseCode::Forbidden);
+ return;
+ }
+
+ // Dev mode: serve from disk first so HTML/JS edits show up without a rebuild
+ if (!m_DevHtmlDir.empty())
+ {
+ std::filesystem::path FullPath = m_DevHtmlDir / std::filesystem::path(Uri).make_preferred();
+ FileContents File = ReadFile(FullPath);
+ if (!File.ErrorCode)
+ {
+ Request.WriteResponse(HttpResponseCode::OK, ContentType, File.Data[0]);
+ return;
+ }
+ }
+
+ // Fallback: embedded zip
+ if (m_ZipFs)
+ {
+ if (IoBuffer File = m_ZipFs->GetFile(Uri))
+ {
+ Request.WriteResponse(HttpResponseCode::OK, ContentType, File);
+ return;
+ }
+ }
+
+ WriteNotFound(Request);
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// REST endpoints
+
+void
+TraceViewerService::HandleApiRequest(HttpServerRequest& Request, std::string_view Path)
+{
+ using namespace std::literals;
+
+ if (Path == "session"sv)
+ {
+ HandleSessionApi(Request);
+ }
+ else if (Path == "threads"sv)
+ {
+ HandleThreadsApi(Request);
+ }
+ else if (Path == "channels"sv)
+ {
+ HandleChannelsApi(Request);
+ }
+ else if (Path == "scope-stats"sv)
+ {
+ HandleScopeStatsApi(Request);
+ }
+ else if (Path == "scope-names"sv)
+ {
+ HandleScopeNamesApi(Request);
+ }
+ else if (Path == "timeline"sv)
+ {
+ HandleTimelineApi(Request);
+ }
+ else if (Path == "timeline-batch"sv)
+ {
+ HandleTimelineBatchApi(Request);
+ }
+ else if (Path == "log-categories"sv)
+ {
+ HandleLogCategoriesApi(Request);
+ }
+ else if (Path == "logs"sv)
+ {
+ HandleLogsApi(Request);
+ }
+ else if (Path == "bookmarks"sv)
+ {
+ HandleBookmarksApi(Request);
+ }
+ else if (Path == "regions"sv)
+ {
+ HandleRegionsApi(Request);
+ }
+ else if (Path == "csv-categories"sv)
+ {
+ HandleCsvCategoriesApi(Request);
+ }
+ else if (Path == "csv-stats"sv)
+ {
+ HandleCsvStatsApi(Request);
+ }
+ else if (Path == "csv-series"sv)
+ {
+ HandleCsvSeriesApi(Request);
+ }
+ else if (Path == "csv-events"sv)
+ {
+ HandleCsvEventsApi(Request);
+ }
+ else if (Path == "csv-metadata"sv)
+ {
+ HandleCsvMetadataApi(Request);
+ }
+ else if (Path == "alloc-summary"sv)
+ {
+ HandleAllocSummaryApi(Request);
+ }
+ else if (Path == "heaps"sv)
+ {
+ HandleHeapsApi(Request);
+ }
+ else if (Path == "alloc-tags"sv)
+ {
+ HandleAllocTagsApi(Request);
+ }
+ else if (Path == "memory-timeline"sv)
+ {
+ HandleMemoryTimelineApi(Request);
+ }
+ else if (Path == "heap-stats"sv)
+ {
+ HandleHeapStatsApi(Request);
+ }
+ else if (Path == "callstacks"sv)
+ {
+ HandleCallstacksApi(Request);
+ }
+ else if (Path == "callstack-stats"sv)
+ {
+ HandleCallstackStatsApi(Request);
+ }
+ else if (Path == "churn-stats"sv)
+ {
+ HandleChurnStatsApi(Request);
+ }
+ else if (Path == "alloc-size-histogram"sv)
+ {
+ HandleAllocSizeHistogramApi(Request);
+ }
+ else
+ {
+ WriteNotFound(Request, "Unknown API endpoint");
+ }
+}
+
+void
+TraceViewerService::HandleSessionApi(HttpServerRequest& Request)
+{
+ const trace_detail::SessionInfo& Session = m_Model.Session;
+
+ CbObjectWriter Obj;
+ Obj << "file_path" << m_Model.FilePath.string();
+ Obj << "file_size" << m_Model.FileSize;
+ Obj << "total_events" << m_Model.TotalEvents;
+ Obj << "parse_time_ms" << m_Model.ParseTimeMs;
+ Obj << "trace_start_us" << m_Model.TraceStartUs;
+ Obj << "trace_end_us" << m_Model.TraceEndUs;
+ Obj << "has_session" << Session.HasSession;
+ Obj << "platform" << Session.Platform;
+ Obj << "app_name" << Session.AppName;
+ Obj << "project_name" << Session.ProjectName;
+ Obj << "command_line" << Session.CommandLine;
+ Obj << "branch" << Session.Branch;
+ Obj << "build_version" << Session.BuildVersion;
+ Obj << "changelist" << Session.Changelist;
+ Obj << "has_memory_data" << m_Model.AllocSummary.HasMemoryData;
+
+ Request.WriteResponse(HttpResponseCode::OK, Obj.Save());
+}
+
+void
+TraceViewerService::HandleThreadsApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const trace_detail::ThreadInfoEntry& Thread : m_Model.Threads)
+ {
+ Writer.BeginObject();
+ Writer << "thread_id" << Thread.ThreadId;
+ Writer << "name" << Thread.Name;
+ Writer << "group" << Thread.GroupName;
+ Writer << "system_id" << Thread.SystemId;
+ Writer << "sort_hint" << Thread.SortHint;
+ // Lane threads use synthetic IDs starting at 2048 (see lane_trace.inl).
+ // SystemId==0 alone is insufficient — the main/trace threads also lack
+ // a system ID in some traces.
+ Writer << "is_lane" << (Thread.SystemId == 0 && Thread.ThreadId >= 2048);
+
+ // Per-thread timeline summary: whether we captured scopes and their span.
+ auto It = std::find_if(m_Model.Timelines.begin(),
+ m_Model.Timelines.end(),
+ [Tid = Thread.ThreadId](const trace_detail::ThreadTimeline& T) { return T.ThreadId == Tid; });
+ if (It != m_Model.Timelines.end())
+ {
+ Writer << "scope_count" << uint64_t(It->Scopes.size());
+ }
+ else
+ {
+ Writer << "scope_count" << uint64_t(0);
+ }
+
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleChannelsApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const trace_detail::ChannelInfo& Channel : m_Model.Channels)
+ {
+ Writer.BeginObject();
+ Writer << "name" << Channel.Name;
+ Writer << "enabled" << Channel.Enabled;
+ Writer << "readonly" << Channel.ReadOnly;
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleScopeStatsApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const trace_detail::CpuScopeStat& Stat : m_Model.ScopeStats)
+ {
+ Writer.BeginObject();
+ Writer << "name" << Stat.Name;
+ Writer << "count" << Stat.Count;
+ Writer << "min_us" << Stat.MinUs;
+ Writer << "max_us" << Stat.MaxUs;
+ Writer.AddFloat("mean_us", Stat.MeanUs);
+ Writer.AddFloat("stdev_us", Stat.StdDevUs);
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleScopeNamesApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const std::string& Name : m_Model.ScopeNames)
+ {
+ Writer.AddString(Name);
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleTimelineApi(HttpServerRequest& Request)
+{
+ HttpServerRequest::QueryParams Params = Request.GetQueryParams();
+
+ std::string_view ThreadStr = Params.GetValue("thread");
+ if (ThreadStr.empty())
+ {
+ Request.WriteResponse(HttpResponseCode::BadRequest, HttpContentType::kText, "thread parameter required");
+ return;
+ }
+
+ uint32_t ThreadId = ParseUintParam(ThreadStr, ~uint32_t(0));
+
+ trace_detail::TimelineQueryRequest Req;
+ Req.StartUs = ParseUintParam(Params.GetValue("start"), 0u);
+ Req.EndUs = ParseUintParam(Params.GetValue("end"), ~uint32_t(0));
+ Req.MinDurUs = ParseUintParam(Params.GetValue("mindur"), 0u);
+ Req.ResolutionUs = ParseUintParam(Params.GetValue("resolution"), 0u);
+
+ std::vector<trace_detail::TimelineScopeView> Scopes;
+ m_TimelineQuery->QueryThread(ThreadId, Req, Scopes);
+
+ // Direct string formatting for the timeline wire format — the compact
+ // [[beginUs,durationUs,nameId,depth,mergeCount?],...] arrays are faster
+ // to serialize directly than via CbWriter. The 64 KB inline buffer
+ // covers small viewport queries without heap traffic.
+ ExtendableStringBuilder<65536> Sb;
+ Sb << R"({"thread_id":)";
+ AppendUintFast(Sb, ThreadId);
+ Sb << R"(,"scopes":)";
+ AppendScopesJsonArray(Sb, Scopes.data(), Scopes.size());
+ Sb << '}';
+ Request.WriteResponse(HttpResponseCode::OK, HttpContentType::kJSON, Sb.ToView());
+}
+
+void
+TraceViewerService::HandleTimelineBatchApi(HttpServerRequest& Request)
+{
+ HttpServerRequest::QueryParams Params = Request.GetQueryParams();
+
+ std::string_view ThreadsStr = Params.GetValue("threads");
+ if (ThreadsStr.empty())
+ {
+ Request.WriteResponse(HttpResponseCode::BadRequest, HttpContentType::kText, "threads parameter required");
+ return;
+ }
+
+ trace_detail::TimelineQueryRequest Req;
+ Req.StartUs = ParseUintParam(Params.GetValue("start"), 0u);
+ Req.EndUs = ParseUintParam(Params.GetValue("end"), ~uint32_t(0));
+ Req.MinDurUs = ParseUintParam(Params.GetValue("mindur"), 0u);
+ Req.ResolutionUs = ParseUintParam(Params.GetValue("resolution"), 0u);
+
+ // Parse comma-separated thread IDs. Tokens with invalid IDs (~0u) are
+ // skipped — same behaviour as the previous handler.
+ std::vector<uint32_t> ThreadIds;
+ {
+ std::string_view Remaining = ThreadsStr;
+ while (!Remaining.empty())
+ {
+ size_t Comma = Remaining.find(',');
+ std::string_view Token = (Comma != std::string_view::npos) ? Remaining.substr(0, Comma) : Remaining;
+ Remaining = (Comma != std::string_view::npos) ? Remaining.substr(Comma + 1) : std::string_view{};
+
+ uint32_t ThreadId = ParseUintParam(Token, ~uint32_t(0));
+ if (ThreadId == ~uint32_t(0))
+ {
+ continue;
+ }
+ ThreadIds.push_back(ThreadId);
+ }
+ }
+
+ trace_detail::TimelineQuery::BatchResult Batch;
+ m_TimelineQuery->QueryBatch(ThreadIds, Req, Batch);
+
+ // Multi-chunk response: one IoBuffer per thread plus the surrounding
+ // "{" / "}" braces. Avoids materialising the entire JSON in a single
+ // contiguous allocation. The transport gathers the chunks at write time.
+ static constexpr char kOpenBrace[] = "{";
+ static constexpr char kCloseBrace[] = "}";
+
+ std::vector<IoBuffer> Chunks;
+ Chunks.reserve(2 + ThreadIds.size());
+ Chunks.emplace_back(IoBuffer::Wrap, kOpenBrace, 1);
+
+ for (size_t I = 0; I < ThreadIds.size(); ++I)
+ {
+ const trace_detail::TimelineQuery::BatchResult::Range R = Batch.Ranges[I];
+
+ // Per-thread chunk: optional leading comma, "<threadId>":{"scopes":[...]}
+ // 32 KB inline covers most threads at typical viewport zoom levels.
+ ExtendableStringBuilder<32768> Sb;
+ if (I > 0)
+ {
+ Sb << ',';
+ }
+ Sb << '"';
+ AppendUintFast(Sb, ThreadIds[I]);
+ Sb << R"(":{"scopes":)";
+ AppendScopesJsonArray(Sb, Batch.Scopes.data() + R.Begin, R.End - R.Begin);
+ Sb << '}';
+
+ // Clone into an IoBuffer so the chunk owns its bytes — the builder
+ // dies at the end of this iteration.
+ const std::string_view View = Sb.ToView();
+ Chunks.emplace_back(IoBuffer::Clone, View.data(), View.size());
+ }
+
+ Chunks.emplace_back(IoBuffer::Wrap, kCloseBrace, 1);
+
+ Request.WriteResponse(HttpResponseCode::OK, HttpContentType::kJSON, std::span<IoBuffer>{Chunks});
+}
+
+void
+TraceViewerService::HandleLogCategoriesApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const trace_detail::LogCategoryInfo& Cat : m_Model.LogCategories)
+ {
+ Writer.BeginObject();
+ Writer << "name" << Cat.Name;
+ Writer << "default_verbosity" << uint32_t(Cat.DefaultVerbosity);
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleLogsApi(HttpServerRequest& Request)
+{
+ HttpServerRequest::QueryParams Params = Request.GetQueryParams();
+
+ uint32_t StartUs = ParseUintParam(Params.GetValue("start"), 0u);
+ uint32_t EndUs = ParseUintParam(Params.GetValue("end"), ~uint32_t(0));
+ uint32_t MinVerb = ParseUintParam(Params.GetValue("min_verbosity"), 0u);
+ uint32_t CategoryId = ParseUintParam(Params.GetValue("category"), ~uint32_t(0));
+ uint32_t Limit = ParseUintParam(Params.GetValue("limit"), 5000u);
+
+ // Binary-search lower bound by TimeUs.
+ const eastl::vector<trace_detail::LogEntry>& Entries = m_Model.LogEntries;
+ auto FirstIt =
+ std::lower_bound(Entries.begin(), Entries.end(), StartUs, [](const trace_detail::LogEntry& E, uint32_t V) { return E.TimeUs < V; });
+
+ CbObjectWriter Obj;
+ Obj << "total" << uint64_t(Entries.size());
+
+ uint32_t Emitted = 0;
+ Obj.BeginArray("entries");
+ for (auto It = FirstIt; It != Entries.end() && Emitted < Limit; ++It)
+ {
+ if (It->TimeUs > EndUs)
+ {
+ break;
+ }
+ if (MinVerb != 0 && It->Verbosity > MinVerb)
+ {
+ // Lower verbosity value = higher severity in UE's ELogVerbosity.
+ // Skip entries less severe than the requested floor.
+ continue;
+ }
+ if (CategoryId != ~uint32_t(0) && It->CategoryIndex != CategoryId)
+ {
+ continue;
+ }
+
+ Obj.BeginObject();
+ Obj << "time_us" << It->TimeUs;
+ Obj << "category_index" << It->CategoryIndex;
+ Obj << "verbosity" << uint32_t(It->Verbosity);
+ Obj << "line" << It->Line;
+ Obj << "file" << It->File;
+ Obj << "message" << It->Message;
+ Obj.EndObject();
+ ++Emitted;
+ }
+ Obj.EndArray();
+
+ Obj << "returned" << Emitted;
+
+ Request.WriteResponse(HttpResponseCode::OK, Obj.Save());
+}
+
+void
+TraceViewerService::HandleBookmarksApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const trace_detail::Bookmark& B : m_Model.Bookmarks)
+ {
+ Writer.BeginObject();
+ Writer << "time_us" << B.TimeUs;
+ Writer << "line" << B.Line;
+ Writer << "file" << B.File;
+ Writer << "text" << B.Text;
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleRegionsApi(HttpServerRequest& Request)
+{
+ CbObjectWriter Obj;
+ Obj.BeginArray("categories");
+ for (const trace_detail::RegionCategory& Cat : m_Model.RegionCategories)
+ {
+ Obj.BeginObject();
+ Obj << "name" << std::string_view(Cat.Name.empty() ? "Uncategorized" : Cat.Name);
+ Obj << "lane_count" << Cat.LaneCount;
+ Obj.BeginArray("regions");
+ for (const trace_detail::RegionEntry& R : Cat.Regions)
+ {
+ Obj.BeginObject();
+ Obj << "begin_us" << R.BeginUs;
+ Obj << "end_us" << R.EndUs;
+ Obj << "depth" << uint32_t(R.Depth);
+ Obj << "name" << R.Name;
+ Obj.EndObject();
+ }
+ Obj.EndArray();
+ Obj.EndObject();
+ }
+ Obj.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Obj.Save());
+}
+
+void
+TraceViewerService::HandleCsvCategoriesApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const auto& Cat : m_Model.CsvCategories)
+ {
+ Writer.BeginObject();
+ Writer << "index" << Cat.Index;
+ Writer << "name" << Cat.Name;
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleCsvStatsApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const auto& Def : m_Model.CsvStatDefs)
+ {
+ Writer.BeginObject();
+ Writer << "stat_id" << Def.StatId;
+ Writer << "category_index" << Def.CategoryIndex;
+ Writer << "name" << Def.Name;
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleCsvSeriesApi(HttpServerRequest& Request)
+{
+ HttpServerRequest::QueryParams Params = Request.GetQueryParams();
+
+ // Accept either a single series index or iterate all for the requested stat+thread.
+ std::string_view StatStr = Params.GetValue("stat");
+ std::string_view ThreadStr = Params.GetValue("thread");
+
+ uint64_t StatId = StatStr.empty() ? 0 : ParseUintParam(StatStr, 0);
+ uint32_t ThreadId = ThreadStr.empty() ? ~uint32_t(0) : ParseUintParam(ThreadStr, ~uint32_t(0));
+
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const auto& S : m_Model.CsvTimeSeries)
+ {
+ if (StatId != 0 && S.StatId != StatId)
+ {
+ continue;
+ }
+ if (ThreadId != ~uint32_t(0) && S.ThreadId != ThreadId)
+ {
+ continue;
+ }
+ Writer.BeginObject();
+ Writer << "stat_id" << S.StatId;
+ Writer << "thread_id" << S.ThreadId;
+ Writer.BeginArray("samples");
+ for (const auto& Sample : S.Samples)
+ {
+ Writer.BeginArray();
+ Writer.AddInteger(uint32_t(Sample.TimeUs));
+ Writer.AddFloat(double(Sample.Value));
+ Writer.EndArray();
+ }
+ Writer.EndArray();
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleCsvEventsApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const auto& E : m_Model.CsvEvents)
+ {
+ Writer.BeginObject();
+ Writer << "time_us" << E.TimeUs;
+ Writer << "category_index" << E.CategoryIndex;
+ Writer << "text" << E.Text;
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleCsvMetadataApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const auto& M : m_Model.CsvMetadata)
+ {
+ Writer.BeginObject();
+ Writer << "key" << M.Key;
+ Writer << "value" << M.Value;
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Memory allocation endpoints
+
+void
+TraceViewerService::HandleAllocSummaryApi(HttpServerRequest& Request)
+{
+ const trace_detail::AllocationSummary& S = m_Model.AllocSummary;
+
+ CbObjectWriter Obj;
+ Obj << "has_memory_data" << S.HasMemoryData;
+ Obj << "total_allocs" << S.TotalAllocs;
+ Obj << "total_frees" << S.TotalFrees;
+ Obj << "total_realloc_allocs" << S.TotalReallocAllocs;
+ Obj << "total_realloc_frees" << S.TotalReallocFrees;
+ Obj << "peak_bytes" << S.PeakBytes;
+ Obj << "peak_time_us" << S.PeakTimeUs;
+ Obj << "end_bytes" << S.EndBytes;
+ Obj << "live_allocations" << S.LiveAllocations;
+
+ Request.WriteResponse(HttpResponseCode::OK, Obj.Save());
+}
+
+void
+TraceViewerService::HandleHeapsApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const trace_detail::HeapInfo& H : m_Model.Heaps)
+ {
+ Writer.BeginObject();
+ Writer << "id" << H.Id;
+ Writer << "parent_id" << H.ParentId;
+ Writer << "flags" << H.Flags;
+ Writer << "name" << H.Name;
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleAllocTagsApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const trace_detail::TagInfo& T : m_Model.Tags)
+ {
+ Writer.BeginObject();
+ Writer << "tag" << T.Tag;
+ Writer << "parent" << T.Parent;
+ Writer << "display" << T.Display;
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleMemoryTimelineApi(HttpServerRequest& Request)
+{
+ const auto& Timeline = m_Model.MemoryTimeline;
+
+ // Parse optional query parameters for range filtering and downsampling.
+ HttpServerRequest::QueryParams Params = Request.GetQueryParams();
+ uint32_t StartUs = ParseUintParam(Params.GetValue("start"), 0);
+ uint32_t EndUs = ParseUintParam(Params.GetValue("end"), ~uint32_t(0));
+ uint32_t MaxSamples = ParseUintParam(Params.GetValue("max_samples"), 2000);
+ if (MaxSamples == 0)
+ {
+ MaxSamples = 2000;
+ }
+
+ // Binary-search for the start offset.
+ size_t Begin = 0;
+ {
+ size_t Lo = 0;
+ size_t Hi = Timeline.size();
+ while (Lo < Hi)
+ {
+ size_t Mid = Lo + (Hi - Lo) / 2;
+ if (Timeline[Mid].TimeUs < StartUs)
+ {
+ Lo = Mid + 1;
+ }
+ else
+ {
+ Hi = Mid;
+ }
+ }
+ Begin = Lo;
+ }
+
+ // Find end offset.
+ size_t End = Timeline.size();
+ {
+ size_t Lo = Begin;
+ size_t Hi = Timeline.size();
+ while (Lo < Hi)
+ {
+ size_t Mid = Lo + (Hi - Lo) / 2;
+ if (Timeline[Mid].TimeUs <= EndUs)
+ {
+ Lo = Mid + 1;
+ }
+ else
+ {
+ Hi = Mid;
+ }
+ }
+ End = Lo;
+ }
+
+ size_t Count = (End > Begin) ? (End - Begin) : 0;
+ size_t Stride = (Count > MaxSamples) ? (Count / MaxSamples) : 1;
+
+ CbObjectWriter Obj;
+
+ uint64_t SampleCount = 0;
+ Obj.BeginArray("samples");
+ for (size_t I = Begin; I < End; I += Stride)
+ {
+ const trace_detail::MemoryTimelineSample& S = Timeline[I];
+ Obj.BeginArray();
+ Obj.AddInteger(S.TimeUs);
+ Obj.AddInteger(S.TotalAllocatedBytes);
+ Obj.AddInteger(S.SystemBytes);
+ Obj.AddInteger(S.VideoBytes);
+ Obj.EndArray();
+ ++SampleCount;
+ }
+ Obj.EndArray();
+
+ Obj << "sample_count" << SampleCount;
+
+ Request.WriteResponse(HttpResponseCode::OK, Obj.Save());
+}
+
+void
+TraceViewerService::HandleHeapStatsApi(HttpServerRequest& Request)
+{
+ CbWriter Writer;
+ Writer.BeginArray();
+ for (const trace_detail::HeapStat& S : m_Model.HeapStats)
+ {
+ Writer.BeginObject();
+ Writer << "heap_id" << S.HeapId;
+ Writer << "current_bytes" << S.CurrentBytes;
+ Writer << "peak_bytes" << S.PeakBytes;
+ Writer << "alloc_count" << S.AllocCount;
+ Writer << "free_count" << S.FreeCount;
+ Writer.EndObject();
+ }
+ Writer.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray());
+}
+
+void
+TraceViewerService::HandleCallstacksApi(HttpServerRequest& Request)
+{
+ HttpServerRequest::QueryParams Params = Request.GetQueryParams();
+ uint32_t Id = ParseUintParam(Params.GetValue("id"), 0);
+
+ if (Id == 0)
+ {
+ WriteNotFound(Request, "Missing or invalid 'id' parameter");
+ return;
+ }
+
+ const trace_detail::CallstackEntry* Entry = m_CallstackFormatter.FindCallstackEntry(Id);
+ if (Entry == nullptr)
+ {
+ WriteNotFound(Request, "Callstack not found");
+ return;
+ }
+
+ trace_detail::FilteredCallstackView Filtered = m_CallstackFormatter.BuildView(*Entry, m_CallstackFilterOptions);
+
+ CbObjectWriter Obj;
+ CallstackSummaryInfo Summary = BuildCallstackSummary(Filtered);
+ Obj << "id" << Entry->Id;
+ Obj << "summary" << Summary.Summary;
+ Obj << "top_frame" << Summary.TopFrame;
+ Obj << "secondary_frame" << Summary.SecondaryFrame;
+ Obj << "group_key" << Summary.GroupKey;
+ Obj << "hidden_prefix_count" << Filtered.HiddenPrefixCount;
+ Obj << "included_third_party_boundary" << Filtered.IncludedThirdPartyBoundary;
+ Obj.BeginArray("frames");
+ for (const trace_detail::FilteredCallstackFrame& FrameView : Filtered.Frames)
+ {
+ const trace_detail::ResolvedFrame& F = *FrameView.Frame;
+ Obj.BeginObject();
+ Obj << "index" << uint64_t(FrameView.OriginalIndex);
+ Obj.AddString("address", fmt::format("0x{:X}", F.Address));
+ Obj << "display" << FrameView.Display;
+ if (F.ModuleIndex != ~0u && F.ModuleIndex < m_Model.Modules.size())
+ {
+ const trace_detail::ModuleInfo& Module = m_Model.Modules[F.ModuleIndex];
+ Obj << "module" << std::string_view(Module.Name);
+ Obj << "module_path" << std::string_view(Module.FullPath);
+ Obj.AddString("offset", fmt::format("0x{:X}", F.Offset));
+ }
+ Obj.EndObject();
+ }
+ Obj.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Obj.Save());
+}
+
+void
+TraceViewerService::HandleCallstackStatsApi(HttpServerRequest& Request)
+{
+ HttpServerRequest::QueryParams Params = Request.GetQueryParams();
+ uint32_t Limit = ParseUintParam(Params.GetValue("limit"), 100);
+ if (Limit == 0)
+ {
+ Limit = 100;
+ }
+
+ size_t Count = std::min(size_t(Limit), m_Model.CallstackStats.size());
+
+ CbObjectWriter Obj;
+ Obj << "total_unique_callstacks" << uint64_t(m_Model.Callstacks.size());
+ Obj.BeginArray("stats");
+ for (size_t I = 0; I < Count; ++I)
+ {
+ const trace_detail::CallstackAllocStat& S = m_Model.CallstackStats[I];
+ Obj.BeginObject();
+ Obj << "callstack_id" << S.CallstackId;
+ Obj << "live_bytes" << S.LiveBytes;
+ Obj << "live_count" << S.LiveCount;
+ if (const trace_detail::CallstackEntry* Entry = m_CallstackFormatter.FindCallstackEntry(S.CallstackId))
+ {
+ CallstackSummaryInfo Summary = BuildCallstackSummary(m_CallstackFormatter.BuildView(*Entry, m_CallstackFilterOptions));
+ Obj << "summary" << Summary.Summary;
+ Obj << "top_frame" << Summary.TopFrame;
+ Obj << "secondary_frame" << Summary.SecondaryFrame;
+ Obj << "group_key" << Summary.GroupKey;
+ Obj << "hidden_prefix_count" << Summary.HiddenPrefixCount;
+ Obj << "included_third_party_boundary" << Summary.IncludedThirdPartyBoundary;
+ }
+ Obj.EndObject();
+ }
+ Obj.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Obj.Save());
+}
+
+void
+TraceViewerService::HandleChurnStatsApi(HttpServerRequest& Request)
+{
+ HttpServerRequest::QueryParams Params = Request.GetQueryParams();
+ uint32_t Limit = ParseUintParam(Params.GetValue("limit"), 100);
+ if (Limit == 0)
+ {
+ Limit = 100;
+ }
+
+ size_t Count = std::min(size_t(Limit), m_Model.ChurnStats.size());
+
+ CbObjectWriter Obj;
+ Obj << "total_unique_callstacks" << uint64_t(m_Model.Callstacks.size());
+ Obj.BeginArray("stats");
+ for (size_t I = 0; I < Count; ++I)
+ {
+ const trace_detail::CallstackChurnStat& S = m_Model.ChurnStats[I];
+ Obj.BeginObject();
+ Obj << "callstack_id" << S.CallstackId;
+ Obj << "churn_allocs" << S.ChurnAllocs;
+ Obj << "churn_bytes" << S.ChurnBytes;
+ Obj << "total_allocs" << S.TotalAllocs;
+ Obj << "total_bytes" << S.TotalBytes;
+ Obj.AddFloat("mean_distance", S.MeanDistance);
+ if (const trace_detail::CallstackEntry* Entry = m_CallstackFormatter.FindCallstackEntry(S.CallstackId))
+ {
+ CallstackSummaryInfo Summary = BuildCallstackSummary(m_CallstackFormatter.BuildView(*Entry, m_CallstackFilterOptions));
+ Obj << "summary" << Summary.Summary;
+ Obj << "top_frame" << Summary.TopFrame;
+ Obj << "secondary_frame" << Summary.SecondaryFrame;
+ Obj << "group_key" << Summary.GroupKey;
+ Obj << "hidden_prefix_count" << Summary.HiddenPrefixCount;
+ Obj << "included_third_party_boundary" << Summary.IncludedThirdPartyBoundary;
+ }
+ Obj.EndObject();
+ }
+ Obj.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Obj.Save());
+}
+
+void
+TraceViewerService::HandleAllocSizeHistogramApi(HttpServerRequest& Request)
+{
+ const auto& Buckets = m_Model.AllocSizeHistogram;
+
+ uint64_t TotalCount = 0;
+ uint64_t TotalBytes = 0;
+ uint64_t MaxCount = 0;
+ uint64_t MaxBytes = 0;
+ for (const trace_detail::AllocSizeBucket& B : Buckets)
+ {
+ TotalCount += B.Count;
+ TotalBytes += B.Bytes;
+ if (B.Count > MaxCount)
+ {
+ MaxCount = B.Count;
+ }
+ if (B.Bytes > MaxBytes)
+ {
+ MaxBytes = B.Bytes;
+ }
+ }
+
+ CbObjectWriter Obj;
+ Obj << "total_count" << TotalCount;
+ Obj << "total_bytes" << TotalBytes;
+ Obj << "max_count" << MaxCount;
+ Obj << "max_bytes" << MaxBytes;
+ Obj.BeginArray("buckets");
+ for (const trace_detail::AllocSizeBucket& B : Buckets)
+ {
+ Obj.BeginObject();
+ Obj << "min_size" << B.MinSize;
+ Obj << "max_size" << B.MaxSize;
+ Obj << "count" << B.Count;
+ Obj << "bytes" << B.Bytes;
+ Obj.EndObject();
+ }
+ Obj.EndArray();
+
+ Request.WriteResponse(HttpResponseCode::OK, Obj.Save());
+}
+
+} // namespace zen