aboutsummaryrefslogtreecommitdiff
path: root/src/zen/trace/trace_cmd.cpp
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-04-20 21:50:41 +0200
committerGitHub Enterprise <[email protected]>2026-04-20 21:50:41 +0200
commit2dfb5da16b97a6c12e01977af5b5188522178a4e (patch)
tree428aa0aa8e6079c64438931e0fd4f828c613c94d /src/zen/trace/trace_cmd.cpp
parentAdd CompactString utility type (#990) (diff)
downloadarchived-zen-2dfb5da16b97a6c12e01977af5b5188522178a4e.tar.xz
archived-zen-2dfb5da16b97a6c12e01977af5b5188522178a4e.zip
zen trace analysis support (#945)
Integrates the **tourist** trace analysis library and builds a full `zen trace` command suite for working with Unreal Engine `.utrace` files. ### Trace analysis library (`thirdparty/tourist/`) - Adds the tourist library as a third-party dependency with three modules: **foundation** (platform primitives, memory, scheduling), **trace** (UE Trace protocol decoding), and **analysis** (event dispatching and analyzer framework). - Cross-platform support for Windows, Linux, and macOS. ### `zen trace` CLI commands (`src/zen/cmds/`, `src/zen/trace/`) - **`zen trace analyze`** — Summarize a `.utrace` file: session metadata, thread inventory, command line + build configuration, CPU profiling scopes, timing, event rates, log messages, and (with symbols) memory allocation metrics including live-allocs dumps, callstack-keyed aggregation, and allocation churn. Optional HTML output for memory reports. - **`zen trace inspect`** — Dump the event schema (declared types, fields, sizes) from a trace file. - **`zen trace trim`** — Extract a time-window from a trace into a new `.utrace` file. - **`zen trace serve`** — Launch a local HTTP server hosting an interactive trace viewer; opens in the default browser. ### Symbolication (`src/zen/trace/symbol_resolver.*`, `thirdparty/raw_pdb/`) - Pluggable resolver with multiple backends: `pdb` (in-tree raw_pdb), `dbghelp` (Windows), `llvm-symbolizer` (all platforms), `atos` (macOS). An `auto` backend picks the best available tool per platform. - Microsoft Symbol Server support: downloads PDBs on demand using a redirect-aware HTTP client. - Local PDB cache keyed by image GUID preserves symbols across binary recompilation. - Callstack trimming heuristic strips UE internal noise from reports. - Binary analysis cache (`.ucache_z`) avoids re-resolving the same trace. ### Interactive trace viewer (`src/zen/frontend/html/`, `src/zen/trace/trace_viewer_service.*`) - Timeline: scope-level detail, horizontal zoom/pan, vertical scrolling, viewport-driven loading with pre-computed LOD for responsive navigation of large traces. - Thread grouping (collapsible sidebar sections) synthesized from name suffixes, natural sort order, visual distinction between lane threads and OS threads. - Bookmark and region annotations; region categories with per-category toggles; bookmark marker toggle in the toolbar. - Filterable Logs tab showing captured `UE_LOG` output. - Stats tab with per-scope aggregate statistics. - Memory tab with interactive allocation analysis and an allocation size histogram. - CsvProfiler event parsing and chart UI. ### Other in-branch supporting changes - **Cross-platform browser launcher** (`browser_launcher.{h,cpp}`) used by `trace serve`. - **`ReciprocalU64`** fast 64-bit integer division (zencore/intmath) for trace analyzers. - **`parallelsort`** cross-platform parallel sort helper (zenutil). - Frontend zip build rule so the viewer's HTML assets are bundled into `zen.exe`. - `/Zo` flag for better optimized debug info on Windows release builds. - `trace-tests.cpp` in the `zen-test` harness (harness itself landed on main via #985).
Diffstat (limited to 'src/zen/trace/trace_cmd.cpp')
-rw-r--r--src/zen/trace/trace_cmd.cpp416
1 files changed, 416 insertions, 0 deletions
diff --git a/src/zen/trace/trace_cmd.cpp b/src/zen/trace/trace_cmd.cpp
new file mode 100644
index 000000000..ca24c51a6
--- /dev/null
+++ b/src/zen/trace/trace_cmd.cpp
@@ -0,0 +1,416 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include "trace_cmd.h"
+
+#include "browser_launcher.h"
+#include "consoleprogress.h"
+#include "symbol_resolver.h"
+#include "trace_analyze.h"
+#include "trace_model.h"
+#include "trace_viewer_service.h"
+
+#include <zencore/except_fmt.h>
+#include <zencore/filesystem.h>
+#include <zencore/fmtutils.h>
+#include <zencore/logging.h>
+#include <zencore/string.h>
+#include <zencore/thread.h>
+#include <zencore/workthreadpool.h>
+#include <zenhttp/httpclient.h>
+#include <zenhttp/httpcommon.h>
+#include <zenhttp/httpserver.h>
+
+#include <filesystem>
+#include <numeric>
+
+using namespace std::literals;
+
+namespace zen {
+
+namespace {
+
+#if ZEN_PLATFORM_WINDOWS
+ constexpr const char* kSymbolBackendHelp = "Symbol backend: auto (default), pdb, dbghelp, llvm, off";
+#elif ZEN_PLATFORM_MAC
+ constexpr const char* kSymbolBackendHelp = "Symbol backend: auto (default - prefers llvm, falls back to atos), llvm, atos, off";
+#else
+ constexpr const char* kSymbolBackendHelp = "Symbol backend: auto (default - uses llvm), llvm, off";
+#endif
+
+} // namespace
+
+//////////////////////////////////////////////////////////////////////////
+// TraceAnalyzeSubCmd
+
+TraceAnalyzeSubCmd::TraceAnalyzeSubCmd() : ZenSubCmdBase("analyze", "Analyze a .utrace file")
+{
+ SubOptions().add_option("", "", "file", "Path to .utrace file", cxxopts::value(m_TraceFilePath), "<filepath>");
+ SubOptions().add_option("",
+ "",
+ "live-allocs",
+ "Dump top N live-allocation callstacks (0 = off, default 50)",
+ cxxopts::value(m_LiveAllocs)->default_value("50"),
+ "<count>");
+ SubOptions().add_option("",
+ "",
+ "churn",
+ "Dump top N allocation-churn callstacks (0 = off, default 0)",
+ cxxopts::value(m_Churn)->default_value("0"),
+ "<count>");
+ SubOptions().add_option("",
+ "",
+ "churn-distance",
+ "Max event distance between alloc and free to count as churn (default 1000)",
+ cxxopts::value(m_ChurnMin)->default_value("1000"),
+ "<events>");
+ SubOptions().add_option("", "", "symbols", kSymbolBackendHelp, cxxopts::value(m_Symbols)->default_value("auto"), "<backend>");
+ SubOptions().add_option("",
+ "",
+ "html-report",
+ "Write a standalone HTML memory report (all live leaks + top 100 churn sites)",
+ cxxopts::value(m_HtmlReportPath),
+ "<filepath>");
+ SubOptions().add_option("",
+ "",
+ "callstack-skip",
+ "Semicolon-separated wildcard patterns for frames to hide from analyzed callstacks",
+ cxxopts::value(m_CallstackSkip),
+ "<pattern;...>");
+ SubOptions().add_option("",
+ "",
+ "no-callstack-heuristic",
+ "Disable leading third-party frame trimming in analyzed callstacks",
+ cxxopts::value(m_NoCallstackHeuristic)->default_value("false"),
+ "<no-callstack-heuristic>");
+ SubOptions().add_option("",
+ "",
+ "no-cache",
+ "Skip reading/writing the .ucache_z analysis cache",
+ cxxopts::value(m_NoCache)->default_value("false"),
+ "<no-cache>");
+ SubOptions().parse_positional({"file"});
+ SubOptions().positional_help("<file.utrace>");
+}
+
+void
+TraceAnalyzeSubCmd::Run(const ZenCliOptions& GlobalOptions)
+{
+ ZEN_UNUSED(GlobalOptions);
+
+ std::filesystem::path FilePath = trace_detail::ResolveTraceFile(m_TraceFilePath, SubOptions());
+
+ trace_detail::AnalyzeOptions Options;
+ Options.LiveAllocsLimit = m_LiveAllocs;
+ Options.ChurnLimit = m_Churn;
+ Options.ChurnDistanceThreshold = uint64_t(m_ChurnMin);
+ Options.Symbols = trace_detail::ParseSymbolBackend(m_Symbols);
+ Options.NoCache = m_NoCache;
+ Options.EnableCallstackHeuristic = !m_NoCallstackHeuristic;
+ Options.HtmlReportPath = m_HtmlReportPath;
+ ForEachStrTok(m_CallstackSkip, ';', [&Options](std::string_view Pattern) {
+ if (!Pattern.empty())
+ {
+ Options.CallstackSkipPatterns.emplace_back(Pattern);
+ }
+ return true;
+ });
+ trace_detail::RunAnalyze(FilePath, Options);
+}
+
+//////////////////////////////////////////////////////////////////////////
+// TraceInspectSubCmd
+
+TraceInspectSubCmd::TraceInspectSubCmd() : ZenSubCmdBase("inspect", "Inspect event schemas in a .utrace file")
+{
+ SubOptions().add_option("", "", "file", "Path to .utrace file", cxxopts::value(m_TraceFilePath), "<filepath>");
+ SubOptions().parse_positional({"file"});
+ SubOptions().positional_help("<file.utrace>");
+}
+
+void
+TraceInspectSubCmd::Run(const ZenCliOptions& GlobalOptions)
+{
+ ZEN_UNUSED(GlobalOptions);
+
+ std::filesystem::path FilePath = trace_detail::ResolveTraceFile(m_TraceFilePath, SubOptions());
+ trace_detail::RunInspect(FilePath);
+}
+
+//////////////////////////////////////////////////////////////////////////
+// TraceServeSubCmd
+
+TraceServeSubCmd::TraceServeSubCmd() : ZenSubCmdBase("serve", "Serve an interactive viewer for a .utrace file")
+{
+ AddAlias("view");
+ SubOptions().add_option("", "", "file", "Path to .utrace file", cxxopts::value(m_TraceFilePath), "<filepath>");
+ SubOptions().add_option("", "p", "port", "Port to listen on", cxxopts::value(m_Port)->default_value("1480"), "<port>");
+ SubOptions().add_option("", "", "bind", "Address to bind to", cxxopts::value(m_Bind)->default_value("127.0.0.1"), "<host>");
+ SubOptions().add_option("", "", "symbols", kSymbolBackendHelp, cxxopts::value(m_Symbols)->default_value("auto"), "<backend>");
+ SubOptions().add_option("",
+ "",
+ "no-browser",
+ "Do not launch a web browser after starting the server",
+ cxxopts::value(m_NoBrowser)->default_value("false"),
+ "<no-browser>");
+ SubOptions().parse_positional({"file"});
+ SubOptions().positional_help("<file.utrace>");
+}
+
+void
+TraceServeSubCmd::Run(const ZenCliOptions& GlobalOptions)
+{
+ ZEN_UNUSED(GlobalOptions);
+
+ std::filesystem::path FilePath = trace_detail::ResolveTraceFile(m_TraceFilePath, SubOptions());
+
+ WorkerThreadPool ThreadPool(gsl::narrow<int>(GetHardwareConcurrency()));
+
+ uint64_t FileSize = uint64_t(std::filesystem::file_size(FilePath));
+ ZEN_CONSOLE("Parsing {} ({})", FilePath.filename().string(), zen::NiceBytes(FileSize));
+
+ std::unique_ptr<ProgressBase> ProgressOwner(CreateConsoleProgress(ConsoleProgressMode::Pretty));
+ std::unique_ptr<ProgressBase::ProgressBar> Progress = ProgressOwner->CreateProgressBar("Parse");
+ trace_detail::TraceModel Model =
+ trace_detail::BuildTraceModel(FilePath, ThreadPool, [&](uint64_t BytesProcessed, uint64_t TotalBytes, uint64_t EventsSoFar) {
+ Progress->UpdateState(
+ {
+ .Task = "Parsing trace",
+ .Details = fmt::format("{} events", zen::ThousandsNum(EventsSoFar)),
+ .TotalCount = TotalBytes,
+ .RemainingCount = TotalBytes - std::min(BytesProcessed, TotalBytes),
+ },
+ false);
+ });
+ Progress->Finish();
+
+ ZEN_CONSOLE(" Events: {}", zen::ThousandsNum(Model.TotalEvents));
+ ZEN_CONSOLE(" Threads: {}", Model.Threads.size());
+ ZEN_CONSOLE(
+ " Scopes: {}",
+ zen::ThousandsNum(std::accumulate(Model.Timelines.begin(),
+ Model.Timelines.end(),
+ size_t(0),
+ [](size_t Acc, const trace_detail::ThreadTimeline& T) { return Acc + T.Scopes.size(); })));
+ ZEN_CONSOLE(" Time: {}", zen::NiceTimeSpanMs(Model.ParseTimeMs));
+
+ std::unique_ptr<trace_detail::SymbolResolver> Symbols = trace_detail::CreateSymbolResolver(trace_detail::ParseSymbolBackend(m_Symbols));
+ for (const trace_detail::ModuleInfo& Mod : Model.Modules)
+ {
+ Symbols->LoadModule(Mod);
+ }
+ ZEN_CONSOLE(" Symbols: {} modules loaded", Model.Modules.size());
+ ZEN_CONSOLE("");
+
+ HttpServerConfig Config;
+ Config.ServerClass = "asio";
+ Config.IsDedicatedServer = false;
+ Config.AllowPortProbing = true;
+ Config.ForceLoopback = (m_Bind == "127.0.0.1" || m_Bind == "localhost" || m_Bind == "::1");
+
+ Ref<HttpServer> Server = CreateHttpServer(Config);
+
+ std::filesystem::path TempDir = std::filesystem::temp_directory_path() / "zen-trace-viewer";
+ std::error_code Ec;
+ std::filesystem::create_directories(TempDir, Ec);
+
+ int EffectivePort = Server->Initialize(m_Port, TempDir);
+ if (EffectivePort <= 0)
+ {
+ throw zen::runtime_error("Failed to initialize HTTP server");
+ }
+
+ TraceViewerService ViewerService(Model, std::move(Symbols));
+ Server->RegisterService(ViewerService);
+
+ std::string Url = fmt::format("http://{}:{}{}", m_Bind, EffectivePort, ViewerService.BaseUri());
+ ZEN_CONSOLE("Serving trace viewer at {}", Url);
+ ZEN_CONSOLE("Press Ctrl+C to stop");
+
+ if (!m_NoBrowser)
+ {
+ try
+ {
+ LaunchBrowser(Url);
+ }
+ catch (const std::exception& E)
+ {
+ ZEN_WARN("Failed to launch browser: {}", E.what());
+ }
+ }
+
+ Server->Run(/*IsInteractiveSession=*/true);
+ Server->Close();
+}
+
+//////////////////////////////////////////////////////////////////////////
+// TraceTrimSubCmd
+
+TraceTrimSubCmd::TraceTrimSubCmd() : ZenSubCmdBase("trim", "Trim a .utrace file to a time range while preserving important events")
+{
+ SubOptions().add_option("", "", "file", "Path to input .utrace file", cxxopts::value(m_TraceFilePath), "<filepath>");
+ SubOptions().add_option("", "o", "output", "Path to output .utrace file", cxxopts::value(m_OutputPath), "<filepath>");
+ SubOptions().add_option("",
+ "",
+ "start",
+ "Start of the time window in seconds from the beginning of the trace",
+ cxxopts::value(m_StartSec)->default_value("0"),
+ "<seconds>");
+ SubOptions().add_option("",
+ "",
+ "end",
+ "End of the time window in seconds from the beginning of the trace",
+ cxxopts::value(m_EndSec)->default_value("0"),
+ "<seconds>");
+ SubOptions().parse_positional({"file"});
+ SubOptions().positional_help("<file.utrace>");
+}
+
+void
+TraceTrimSubCmd::Run(const ZenCliOptions& GlobalOptions)
+{
+ ZEN_UNUSED(GlobalOptions);
+
+ std::filesystem::path InputPath = trace_detail::ResolveTraceFile(m_TraceFilePath, SubOptions());
+
+ if (m_OutputPath.empty())
+ {
+ throw zen::OptionParseException("--output is required", SubOptions().help());
+ }
+ if (m_EndSec <= m_StartSec)
+ {
+ throw zen::OptionParseException("--end must be greater than --start", SubOptions().help());
+ }
+
+ trace_detail::TraceTrimArgs Args;
+ Args.InputPath = InputPath;
+ Args.OutputPath = std::filesystem::absolute(m_OutputPath);
+ Args.StartSec = m_StartSec;
+ Args.EndSec = m_EndSec;
+
+ trace_detail::RunTraceTrim(Args);
+}
+
+//////////////////////////////////////////////////////////////////////////
+// TraceStartSubCmd
+
+TraceStartSubCmd::TraceStartSubCmd() : ZenSubCmdBase("start", "Start zen server realtime tracing")
+{
+ SubOptions().add_option("", "u", "hosturl", ZenCmdBase::kHostUrlHelp, cxxopts::value(m_HostName)->default_value(""), "<hosturl>");
+ SubOptions().add_option("", "", "host", "Stream trace data to a remote host", cxxopts::value(m_TraceHost), "<hostip>");
+ SubOptions().add_option("", "", "file", "Write trace data to a file", cxxopts::value(m_TraceFile), "<filepath>");
+}
+
+void
+TraceStartSubCmd::Run(const ZenCliOptions& GlobalOptions)
+{
+ ZEN_UNUSED(GlobalOptions);
+
+ std::string ResolvedHost = ZenCmdBase::ResolveTargetHostSpec(m_HostName);
+ if (ResolvedHost.empty())
+ {
+ throw OptionParseException("Unable to resolve server specification", SubOptions().help());
+ }
+
+ if (m_TraceHost.empty() && m_TraceFile.empty())
+ {
+ throw OptionParseException("Either --host or --file is required", SubOptions().help());
+ }
+ if (!m_TraceHost.empty() && !m_TraceFile.empty())
+ {
+ throw OptionParseException("--host and --file are mutually exclusive", SubOptions().help());
+ }
+
+ std::string StartArg = m_TraceHost.empty() ? fmt::format("file={}", m_TraceFile) : fmt::format("host={}", m_TraceHost);
+
+ zen::HttpClient Http = ZenCmdBase::CreateHttpClient(ResolvedHost);
+ if (zen::HttpClient::Response Response = Http.Post(fmt::format("/admin/trace/start?{}"sv, StartArg)))
+ {
+ ZEN_CONSOLE("OK: {}", Response.ToText());
+ }
+ else
+ {
+ Response.ThrowError("Trace start failed");
+ }
+}
+
+//////////////////////////////////////////////////////////////////////////
+// TraceStopSubCmd
+
+TraceStopSubCmd::TraceStopSubCmd() : ZenSubCmdBase("stop", "Stop zen server realtime tracing")
+{
+ SubOptions().add_option("", "u", "hosturl", ZenCmdBase::kHostUrlHelp, cxxopts::value(m_HostName)->default_value(""), "<hosturl>");
+}
+
+void
+TraceStopSubCmd::Run(const ZenCliOptions& GlobalOptions)
+{
+ ZEN_UNUSED(GlobalOptions);
+
+ std::string ResolvedHost = ZenCmdBase::ResolveTargetHostSpec(m_HostName);
+ if (ResolvedHost.empty())
+ {
+ throw OptionParseException("Unable to resolve server specification", SubOptions().help());
+ }
+
+ zen::HttpClient Http = ZenCmdBase::CreateHttpClient(ResolvedHost);
+ if (zen::HttpClient::Response Response = Http.Post("/admin/trace/stop"sv))
+ {
+ ZEN_CONSOLE("OK: {}", Response.ToText());
+ }
+ else
+ {
+ Response.ThrowError("Trace stop failed");
+ }
+}
+
+//////////////////////////////////////////////////////////////////////////
+// TraceStatusSubCmd
+
+TraceStatusSubCmd::TraceStatusSubCmd() : ZenSubCmdBase("status", "Report zen server realtime tracing status")
+{
+ SubOptions().add_option("", "u", "hosturl", ZenCmdBase::kHostUrlHelp, cxxopts::value(m_HostName)->default_value(""), "<hosturl>");
+}
+
+void
+TraceStatusSubCmd::Run(const ZenCliOptions& GlobalOptions)
+{
+ ZEN_UNUSED(GlobalOptions);
+
+ std::string ResolvedHost = ZenCmdBase::ResolveTargetHostSpec(m_HostName);
+ if (ResolvedHost.empty())
+ {
+ throw OptionParseException("Unable to resolve server specification", SubOptions().help());
+ }
+
+ zen::HttpClient Http = ZenCmdBase::CreateHttpClient(ResolvedHost);
+ if (zen::HttpClient::Response Response = Http.Get("/admin/trace"sv))
+ {
+ ZEN_CONSOLE("OK: {}", Response.ToText());
+ }
+ else
+ {
+ Response.ThrowError("Trace status failed");
+ }
+}
+
+//////////////////////////////////////////////////////////////////////////
+// TraceCommand
+
+TraceCommand::TraceCommand()
+{
+ m_Options.add_options()("h,help", "Print help");
+ m_Options.add_option("__hidden__", "", "subcommand", "", cxxopts::value<std::string>(m_SubCommand)->default_value(""), "");
+ m_Options.parse_positional({"subcommand"});
+
+ AddSubCommand(m_AnalyzeSubCmd);
+ AddSubCommand(m_InspectSubCmd);
+ AddSubCommand(m_ServeSubCmd);
+ AddSubCommand(m_TrimSubCmd);
+ AddSubCommand(m_StartSubCmd);
+ AddSubCommand(m_StopSubCmd);
+ AddSubCommand(m_StatusSubCmd);
+}
+
+TraceCommand::~TraceCommand() = default;
+
+} // namespace zen