diff options
Diffstat (limited to 'src/zen')
| -rw-r--r-- | src/zen/cmds/sessions_cmd.cpp | 799 | ||||
| -rw-r--r-- | src/zen/cmds/sessions_cmd.h | 75 | ||||
| -rw-r--r-- | src/zen/cmds/ui_cmd.cpp | 2 | ||||
| -rw-r--r-- | src/zen/frontend/html/api.js | 8 | ||||
| -rw-r--r-- | src/zen/frontend/html/counters.js | 404 | ||||
| -rw-r--r-- | src/zen/frontend/html/csvstats.js | 5 | ||||
| -rw-r--r-- | src/zen/frontend/html/index.html | 24 | ||||
| -rw-r--r-- | src/zen/frontend/html/logs.js | 11 | ||||
| -rw-r--r-- | src/zen/frontend/html/memory.js | 73 | ||||
| -rw-r--r-- | src/zen/frontend/html/timeline.js | 170 | ||||
| -rw-r--r-- | src/zen/frontend/html/trace.css | 73 | ||||
| -rw-r--r-- | src/zen/frontend/html/trace.js | 40 | ||||
| -rw-r--r-- | src/zen/frontend/html/util.js | 16 | ||||
| -rw-r--r-- | src/zen/trace/trace_analyze.cpp | 54 | ||||
| -rw-r--r-- | src/zen/trace/trace_cache.cpp | 120 | ||||
| -rw-r--r-- | src/zen/trace/trace_cache.h | 31 | ||||
| -rw-r--r-- | src/zen/trace/trace_model.cpp | 209 | ||||
| -rw-r--r-- | src/zen/trace/trace_model.h | 33 | ||||
| -rw-r--r-- | src/zen/trace/trace_viewer_service.cpp | 91 | ||||
| -rw-r--r-- | src/zen/trace/trace_viewer_service.h | 2 | ||||
| -rw-r--r-- | src/zen/zen.cpp | 27 | ||||
| -rw-r--r-- | src/zen/zen.h | 2 | ||||
| -rw-r--r-- | src/zen/zenserviceclient.cpp | 15 | ||||
| -rw-r--r-- | src/zen/zenserviceclient.h | 6 |
24 files changed, 2164 insertions, 126 deletions
diff --git a/src/zen/cmds/sessions_cmd.cpp b/src/zen/cmds/sessions_cmd.cpp new file mode 100644 index 000000000..b6beaa761 --- /dev/null +++ b/src/zen/cmds/sessions_cmd.cpp @@ -0,0 +1,799 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "sessions_cmd.h" + +#include "../browser_launcher.h" +#include "../zenserviceclient.h" + +#include <zencore/compactbinary.h> +#include <zencore/compactbinaryutil.h> +#include <zencore/fmtutils.h> +#include <zencore/logbase.h> +#include <zencore/logging.h> +#include <zencore/logging/helpers.h> +#include <zencore/string.h> +#include <zencore/thread.h> +#include <zencore/uid.h> +#include <zenhttp/httpclient.h> +#include <zenhttp/httpwsclient.h> +#include <zenutil/consoletui.h> + +#include <algorithm> +#include <atomic> +#include <chrono> +#include <csignal> +#include <vector> + +namespace zen { + +using namespace std::literals; + +namespace { + + // Render a TimeSpan as a compact human-readable duration: "12d 3h", "2h 14m", + // "45m 02s", or "12.345s". Picks the two most-significant non-zero units. + std::string FormatDuration(const TimeSpan& Span) + { + const int Days = Span.GetDays(); + const int Hours = Span.GetHours(); + const int Minutes = Span.GetMinutes(); + const int Seconds = Span.GetSeconds(); + + ExtendableStringBuilder<32> Out; + if (Days > 0) + { + fmt::format_to(StringBuilderAppender(Out), "{}d {}h", Days, Hours); + } + else if (Hours > 0) + { + fmt::format_to(StringBuilderAppender(Out), "{}h {:02}m", Hours, Minutes); + } + else if (Minutes > 0) + { + fmt::format_to(StringBuilderAppender(Out), "{}m {:02}s", Minutes, Seconds); + } + else + { + fmt::format_to(StringBuilderAppender(Out), "{}.{:03}s", Seconds, Span.GetFractionMilli()); + } + return Out.ToString(); + } + + // Short ISO-like timestamp without fractional seconds — matches what 'ps'-style + // listings expect when scanning by eye. + std::string FormatTimestamp(const DateTime& When) { return When.ToString("%Y-%m-%dT%H:%M:%S"); } + + std::string_view OrDash(std::string_view Value) { return Value.empty() ? "-"sv : Value; } + + // File-static abort flag for `sessions tail --follow`. Mirrors the pattern in + // projectstore_cmd.cpp: a signal handler can only touch atomic state, and we + // don't want to share a flag with the global SignalCounter / other tail-style + // commands. RAII'd by ScopedSignalHandler so the previous handler is + // restored when Run() returns. + std::atomic<bool> g_TailAbortFlag{false}; + + void TailSignalHandler(int SigNum) + { + if (SigNum == SIGINT) + { + g_TailAbortFlag.store(true, std::memory_order_relaxed); + } +#if ZEN_PLATFORM_WINDOWS + if (SigNum == SIGBREAK) + { + g_TailAbortFlag.store(true, std::memory_order_relaxed); + } +#endif + } + + // Cached once on first use so we don't re-probe the console / re-toggle VT + // mode for every line. Mirrors the static caching in TuiIsStdoutTty. + bool ColorEnabledForTail() + { + static const bool Cached = []() { + if (!TuiIsStdoutTty()) + { + return false; + } + // On Windows this flips ENABLE_VIRTUAL_TERMINAL_PROCESSING so ANSI + // sequences render. On POSIX it's a no-op and just returns true. + return logging::EnableVTMode(); + }(); + return Cached; + } + + // Render a single log entry in the same shape as zen::logging::FullFormatter: + // [YY-MM-DD HH:MM:SS.mmm] [<lvl-color>lvl</reset>] [<bold>logger</reset>] message + // Continuation lines (embedded \n) are indented to the visible width of the + // prefix so banners and stack traces align under the message column. + // + // Wire schema (see WriteLogEntry in httpsessions.cpp): timestamp (DateTime), + // level (string, optional), logger (string, optional), message (string). + void PrintLogEntry(CbObjectView Entry) + { + const DateTime Ts = Entry["timestamp"sv].AsDateTime(); + const std::string_view Level = Entry["level"sv].AsString(); + const std::string_view Logger = Entry["logger"sv].AsString(); + const std::string_view Message = Entry["message"sv].AsString(); + + const bool UseColor = ColorEnabledForTail(); + + // Resolve the level once. Wire form is the long lowercase name + // ("info"/"warning"/"error"/...); we render the short form to mirror + // FullFormatter, and use the enum to pick the ANSI color. + logging::LogLevel Lvl = logging::Off; + if (!Level.empty()) + { + Lvl = logging::ParseLogLevelString(Level); + } + const bool HasLevel = (Lvl < logging::LogLevelCount && Lvl != logging::Off); + const std::string_view LvlShort = HasLevel ? logging::ShortToString(Lvl) : "---"sv; + + ExtendableStringBuilder<256> Prefix; + size_t VisibleWidth = 0; + + // Timestamp — full date so a tail that crosses midnight stays unambiguous. + fmt::format_to(StringBuilderAppender(Prefix), + "[{:02}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}] ", + Ts.GetYear() % 100, + Ts.GetMonth(), + Ts.GetDay(), + Ts.GetHour(), + Ts.GetMinute(), + Ts.GetSecond(), + Ts.GetMillisecond()); + VisibleWidth = Prefix.Size(); + + // [LVL] — short form. Color goes inside the brackets (matches FullFormatter + // so the brackets themselves stay grep-friendly even on a colorized line). + Prefix.Append('['); + if (UseColor && HasLevel) + { + Prefix.Append(logging::helpers::AnsiColorForLevel(Lvl)); + } + Prefix.Append(LvlShort); + if (UseColor && HasLevel) + { + Prefix.Append(logging::helpers::kAnsiReset); + } + Prefix.Append("] "); + VisibleWidth += 1 + LvlShort.size() + 2; + + // [logger] — bold. Omitted when logger name is empty (mirrors FullFormatter). + if (!Logger.empty()) + { + if (UseColor) + { + Prefix.Append("\033[1m"sv); + } + Prefix.Append('['); + Prefix.Append(Logger); + Prefix.Append(']'); + if (UseColor) + { + Prefix.Append(logging::helpers::kAnsiReset); + } + Prefix.Append(' '); + VisibleWidth += 1 + Logger.size() + 1 + 1; + } + + auto NextLine = [](std::string_view Text, size_t Pos) -> std::pair<std::string_view, size_t> { + const size_t End = Text.find('\n', Pos); + std::string_view Line; + if (End == std::string_view::npos) + { + Line = Text.substr(Pos); + Pos = Text.size(); + } + else + { + Line = Text.substr(Pos, End - Pos); + Pos = End + 1; + } + // Strip a trailing CR so CRLF-terminated lines don't render the carriage + // return (which on terminals would slam the cursor back to column 0). + if (!Line.empty() && Line.back() == '\r') + { + Line.remove_suffix(1); + } + return {Line, Pos}; + }; + + size_t Pos = 0; + bool First = true; + while (true) + { + auto [Line, NextPos] = NextLine(Message, Pos); + + if (First) + { + ZEN_CONSOLE("{}{}", Prefix.ToView(), Line); + First = false; + } + else + { + // Continuation: pad with the same VISIBLE width as the prefix so + // the message column lines up regardless of the embedded ANSI + // bytes. Skip a trailing empty line so a message ending in '\n' + // doesn't add a stray blank row. + if (NextPos >= Message.size() && Line.empty()) + { + break; + } + ZEN_CONSOLE("{:<{}}{}", "", VisibleWidth, Line); + } + + if (NextPos >= Message.size()) + { + break; + } + Pos = NextPos; + } + } + + // WebSocket subscriber for `tail --follow`. The server pushes binary CB + // frames shaped exactly like the HTTP cursor response (see + // HttpSessionsService::BroadcastLogAppended), so we parse and hand each + // entry to PrintLogEntry. Used when /sessions/ws is reachable; if the + // upgrade fails (older server, blocked port, unix socket transport) the + // caller falls back to the HTTP polling loop. + class TailWsSubscriber : public IWsClientHandler + { + public: + enum class State : uint8_t + { + Connecting, + Open, + Closed + }; + + TailWsSubscriber(std::string SessionIdHex, uint64_t StartCursor) : m_SessionIdHex(std::move(SessionIdHex)), m_Cursor(StartCursor) {} + + // Set after construction so OnWsOpen can SendText through the owning + // client. The lifetime contract: the HttpWsClient is declared after + // this subscriber in the caller's scope, so it tears down first and no + // callbacks fire on a dangling m_Client. + void SetClient(HttpWsClient* Client) { m_Client = Client; } + State GetState() const { return m_State.load(std::memory_order_acquire); } + + void OnWsOpen() override + { + // Subscribe to log push for our session, starting after the cursor + // we already drained via the initial HTTP fetch. Wire format + // matches the dashboard (see frontend/html/pages/sessions.js). + ExtendableStringBuilder<128> Frame; + fmt::format_to(StringBuilderAppender(Frame), R"({{"type":"sub_log","session":"{}","cursor":{}}})", m_SessionIdHex, m_Cursor); + if (m_Client) + { + m_Client->SendText(Frame.ToView()); + } + m_State.store(State::Open, std::memory_order_release); + } + + void OnWsMessage(const WebSocketMessage& Msg) override + { + // The text-frame path on this socket is the periodic session-list + // snapshot — irrelevant for tail. We only care about the binary + // log-delta frames. + if (Msg.Opcode != WebSocketOpcode::kBinary) + { + return; + } + IoBuffer Payload = Msg.Payload; + CbValidateError Err = CbValidateError::None; + CbObject Frame = ValidateAndReadCompactBinaryObject(std::move(Payload), Err); + if (Err != CbValidateError::None) + { + return; + } + m_Cursor = Frame["cursor"sv].AsUInt64(m_Cursor); + for (CbFieldView Entry : Frame["entries"sv].AsArrayView()) + { + PrintLogEntry(Entry.AsObjectView()); + } + } + + void OnWsClose(uint16_t /*Code*/, std::string_view /*Reason*/) override { m_State.store(State::Closed, std::memory_order_release); } + + private: + HttpWsClient* m_Client = nullptr; + std::string m_SessionIdHex; + uint64_t m_Cursor = 0; + std::atomic<State> m_State{State::Connecting}; + }; + +} // namespace + +//////////////////////////////////////////////////////////////////////////////// +// SessionsSubCmdBase + +SessionsSubCmdBase::SessionsSubCmdBase(std::string_view Name, std::string_view Description) : ZenSubCmdBase(Name, Description) +{ + m_SubOptions.add_option("", "u", "hosturl", ZenCmdBase::kHostUrlHelp, cxxopts::value(m_HostName)->default_value(""), "<hosturl>"); +} + +//////////////////////////////////////////////////////////////////////////////// +// SessionsLsSubCmd + +SessionsLsSubCmd::SessionsLsSubCmd() : SessionsSubCmdBase("ls", "List sessions known to the zenserver") +{ + m_SubOptions.add_option("", + "s", + "status", + "Filter by status: 'active', 'ended', or 'all' (default)", + cxxopts::value(m_StatusFilter)->default_value("all"), + "<status>"); +} + +void +SessionsLsSubCmd::Run(const ZenCliOptions& /*GlobalOptions*/) +{ + if (m_StatusFilter != "active" && m_StatusFilter != "ended" && m_StatusFilter != "all") + { + throw OptionParseException("--status must be 'active', 'ended', or 'all'", m_SubOptions.help()); + } + + ZenServiceClient Service({.HostSpec = m_HostName, .CommandName = "sessions.ls"}); + HttpClient& Http = Service.Http(); + + const std::string Path = fmt::format("/sessions/?status={}", m_StatusFilter); + + HttpClient::Response Result = Http.Get(Path, HttpClient::Accept(ZenContentType::kCbObject)); + if (!Result) + { + Result.ThrowError("failed to list sessions"sv); + } + + CbObjectView Response = Result.AsObject(); + const Oid SelfId = Response["self_id"sv].AsObjectId(); + CbArrayView Sessions = Response["sessions"sv].AsArrayView(); + + const DateTime Now = DateTime::Now(); + + // Buffer the entries so we can present them in a stable order: active + // sessions first, then ended; within each group sorted by creation time + // (newest first — recent sessions are usually what the operator cares + // about). The CbObjectView keeps a view into the response buffer, which + // lives as long as `Result`. + struct Row + { + CbObjectView Obj; + DateTime CreatedAt{0}; + bool IsEnded = false; + }; + std::vector<Row> Rows; + Rows.reserve(Sessions.Num()); + for (CbFieldView Entry : Sessions) + { + CbObjectView Obj = Entry.AsObjectView(); + Rows.push_back({.Obj = Obj, .CreatedAt = Obj["created_at"sv].AsDateTime(), .IsEnded = Obj["ended_at"sv].HasValue()}); + } + std::sort(Rows.begin(), Rows.end(), [](const Row& A, const Row& B) { + if (A.IsEnded != B.IsEnded) + { + return !A.IsEnded; // active rows first + } + return A.CreatedAt > B.CreatedAt; // newest first within each group + }); + + // ID column is 25 chars wide: 24 chars of Oid plus a one-char self-marker + // slot ('*' for the self session, otherwise a space). Keeping the slot in + // every row lines up the trailing columns whether or not the marker is + // present. + ZEN_CONSOLE("{:<25} {:<6} {:<24} {:>6} {:<19} {:>10} {:>5}", + "ID", + "STATUS", + "APPNAME/MODE", + "PID", + "CREATED", + "DURATION", + "LOGS"); + + for (const Row& R : Rows) + { + const Oid Id = R.Obj["id"sv].AsObjectId(); + const bool IsSelf = (SelfId != Oid::Zero && Id == SelfId); + + ExtendableStringBuilder<32> IdField; + Id.ToString(IdField); + IdField.Append(IsSelf ? '*' : ' '); + + // Combine appname and mode into a single column (e.g. "zen/sessions.tail") + // to save horizontal real estate. Mode is omitted when empty so a session + // with only an appname renders as "zenserver" rather than "zenserver/". + const std::string_view Appname = R.Obj["appname"sv].AsString(); + const std::string_view Mode = R.Obj["mode"sv].AsString(); + ExtendableStringBuilder<48> AppMode; + if (Appname.empty() && Mode.empty()) + { + AppMode.Append('-'); + } + else + { + AppMode.Append(OrDash(Appname)); + if (!Mode.empty()) + { + AppMode.Append('/'); + AppMode.Append(Mode); + } + } + + const DateTime EndAt = R.IsEnded ? R.Obj["ended_at"sv].AsDateTime() : Now; + const TimeSpan Duration = EndAt - R.CreatedAt; + + ZEN_CONSOLE("{:<25} {:<6} {:<24} {:>6} {:<19} {:>10} {:>5}", + IdField.ToView(), + R.IsEnded ? "ended"sv : "active"sv, + AppMode.ToView(), + R.Obj["pid"sv].AsUInt32(0), + FormatTimestamp(R.CreatedAt), + FormatDuration(Duration), + R.Obj["log_count"sv].AsUInt64(0)); + } + + if (Rows.empty()) + { + ZEN_CONSOLE("(no sessions)"); + } + else if (SelfId != Oid::Zero) + { + ZEN_CONSOLE(""); + ZEN_CONSOLE("(* = the zenserver's own session)"); + } +} + +//////////////////////////////////////////////////////////////////////////////// +// SessionsStatusSubCmd + +SessionsStatusSubCmd::SessionsStatusSubCmd() : SessionsSubCmdBase("status", "Show top-level status of the sessions service") +{ +} + +void +SessionsStatusSubCmd::Run(const ZenCliOptions& /*GlobalOptions*/) +{ + ZenServiceClient Service({.HostSpec = m_HostName, .CommandName = "sessions.status"}); + HttpClient& Http = Service.Http(); + + // Two cheap reads: the stats handler exposes the operational counters, and the + // session list lets us split the count into active/ended without per-state + // endpoints (the service itself doesn't expose those separately). + HttpClient::Response StatsResult = Http.Get("/stats/sessions", HttpClient::Accept(ZenContentType::kCbObject)); + if (!StatsResult) + { + StatsResult.ThrowError("failed to get sessions stats"sv); + } + + HttpClient::Response ListResult = Http.Get("/sessions/?status=all", HttpClient::Accept(ZenContentType::kCbObject)); + if (!ListResult) + { + ListResult.ThrowError("failed to list sessions"sv); + } + + CbObjectView Stats = StatsResult.AsObject(); + CbObjectView Sessions = Stats["sessions"sv].AsObjectView(); + CbFieldView RequestsField = Stats["requests"sv]; + + CbObjectView ListObj = ListResult.AsObject(); + const Oid SelfId = ListObj["self_id"sv].AsObjectId(); + CbArrayView SessionsArr = ListObj["sessions"sv].AsArrayView(); + + uint64_t ActiveCount = 0; + uint64_t EndedCount = 0; + for (CbFieldView Entry : SessionsArr) + { + if (Entry.AsObjectView()["ended_at"sv].HasValue()) + { + ++EndedCount; + } + else + { + ++ActiveCount; + } + } + + ZEN_CONSOLE("Sessions service"); + ZEN_CONSOLE(" Self id: {}", SelfId == Oid::Zero ? "(none)"s : SelfId.ToString()); + ZEN_CONSOLE(" Active sessions: {}", ActiveCount); + ZEN_CONSOLE(" Ended sessions: {}", EndedCount); + ZEN_CONSOLE(""); + ZEN_CONSOLE("Counters"); + ZEN_CONSOLE(" Reads: {}", Sessions["readcount"sv].AsUInt64(0)); + ZEN_CONSOLE(" Writes: {}", Sessions["writecount"sv].AsUInt64(0)); + ZEN_CONSOLE(" Deletes: {}", Sessions["deletecount"sv].AsUInt64(0)); + ZEN_CONSOLE(" Lists: {}", Sessions["listcount"sv].AsUInt64(0)); + ZEN_CONSOLE(" Requests: {}", Sessions["requestcount"sv].AsUInt64(0)); + ZEN_CONSOLE(" Bad requests: {}", Sessions["badrequestcount"sv].AsUInt64(0)); + + // Operation timing snapshot is shaped by EmitSnapshot — surface the headline + // fields if present without binding too tightly to its layout. + if (RequestsField.HasValue()) + { + CbObjectView Requests = RequestsField.AsObjectView(); + const uint64_t Total = Requests["count"sv].AsUInt64(0); + if (Total > 0) + { + ZEN_CONSOLE(""); + ZEN_CONSOLE("Request timing"); + ZEN_CONSOLE(" Total: {}", Total); + CbFieldView P50 = Requests["latency_p50_us"sv]; + if (P50.HasValue()) + { + ZEN_CONSOLE(" Latency p50: {} us", P50.AsUInt64(0)); + } + CbFieldView P99 = Requests["latency_p99_us"sv]; + if (P99.HasValue()) + { + ZEN_CONSOLE(" Latency p99: {} us", P99.AsUInt64(0)); + } + } + } +} + +//////////////////////////////////////////////////////////////////////////////// +// SessionsTailSubCmd + +SessionsTailSubCmd::SessionsTailSubCmd() +: SessionsSubCmdBase("tail", "Tail log output from a session (defaults to the zenserver's own session)") +{ + m_SubOptions.add_option("", + "", + "session", + "Session id (24-hex). Omit to tail the zenserver's own session.", + cxxopts::value(m_SessionId)->default_value(""), + "<id>"); + m_SubOptions.add_option("", + "n", + "lines", + "Number of recent lines to show before tailing. 0 = all buffered.", + cxxopts::value(m_Lines)->default_value("50"), + "<n>"); + m_SubOptions.add_option("", + "f", + "follow", + "After printing initial lines, keep streaming new entries until interrupted (Ctrl-C).", + cxxopts::value(m_Follow), + ""); + m_SubOptions.add_option("", + "", + "interval-ms", + "Poll interval in milliseconds for follow mode.", + cxxopts::value(m_PollIntervalMs)->default_value("500"), + "<ms>"); + m_SubOptions.parse_positional({"session"}); +} + +void +SessionsTailSubCmd::Run(const ZenCliOptions& /*GlobalOptions*/) +{ + ZenServiceClient Service({.HostSpec = m_HostName, .CommandName = "sessions.tail"}); + HttpClient& Http = Service.Http(); + + // Resolve the target session id. An explicit argument wins; otherwise we + // fetch the server's self_id from the session list (which is also what the + // `*` marker in `sessions ls` points at). + std::string TargetId = m_SessionId; + if (TargetId.empty()) + { + HttpClient::Response ListResult = Http.Get("/sessions/list", HttpClient::Accept(ZenContentType::kCbObject)); + if (!ListResult) + { + ListResult.ThrowError("failed to resolve self session id"sv); + } + const Oid SelfId = ListResult.AsObject()["self_id"sv].AsObjectId(); + if (SelfId == Oid::Zero) + { + throw std::runtime_error("zenserver did not register a self-session; pass an explicit session id"); + } + TargetId = SelfId.ToString(); + } + + const std::string LogPath = fmt::format("/sessions/{}/log", TargetId); + + // Initial fetch: cursor=0 returns every entry currently buffered in the + // session, plus the cursor we should hand back to keep a follow stream + // gap-free. We then locally trim to the last `--lines` entries before + // printing — no protocol affordance for "last N" so the trim happens here. + HttpClient::Response InitialResult = Http.Get(fmt::format("{}?cursor=0", LogPath), HttpClient::Accept(ZenContentType::kCbObject)); + if (!InitialResult) + { + if (InitialResult.StatusCode == HttpResponseCode::NotFound) + { + throw std::runtime_error(fmt::format("session '{}' not found", TargetId)); + } + InitialResult.ThrowError("failed to fetch session log"sv); + } + + CbObjectView Initial = InitialResult.AsObject(); + uint64_t Cursor = Initial["cursor"sv].AsUInt64(0); + CbArrayView Entries = Initial["entries"sv].AsArrayView(); + const uint64_t Total = Entries.Num(); + const uint64_t SkipCnt = (m_Lines > 0 && Total > m_Lines) ? Total - m_Lines : 0; + + uint64_t Index = 0; + for (CbFieldView Entry : Entries) + { + if (Index >= SkipCnt) + { + PrintLogEntry(Entry.AsObjectView()); + } + ++Index; + } + + if (!m_Follow) + { + return; + } + + // Follow mode. Install a Ctrl-C handler that flips the abort flag rather + // than killing the process — RAII restores the previous handler on exit. + g_TailAbortFlag.store(false, std::memory_order_relaxed); + ScopedSignalHandler SigInt(SIGINT, TailSignalHandler); +#if ZEN_PLATFORM_WINDOWS + ScopedSignalHandler SigBreak(SIGBREAK, TailSignalHandler); +#endif + + // Prefer WebSocket push (zero idle traffic, no poll lag). Fall back to the + // polling loop below if /sessions/ws can't be reached — older servers, + // blocked port, unix socket transport, etc. + auto TryWebSocketTail = [&]() -> bool { + if (Service.IsUnixSocket()) + { + // HttpWsClient supports unix sockets via UnixSocketPath, but + // resolving and rewriting the URL is more plumbing than this + // fallback warrants — polling works fine over unix. + return false; + } + + const std::chrono::milliseconds WsConnectTimeout{3000}; + + std::string WsUrl = HttpToWsUrl(Service.HostSpec(), "/sessions/ws"); + TailWsSubscriber Subscriber(TargetId, Cursor); + HttpWsClientSettings Settings; + Settings.LogCategory = "sessions_tail_ws"; + Settings.ConnectTimeout = WsConnectTimeout; + + HttpWsClient Client(WsUrl, Subscriber, Settings); + Subscriber.SetClient(&Client); + Client.Connect(); + + // Wait for the upgrade to complete (or fail). Honor abort during + // connect so a tail-then-immediate-Ctrl-C doesn't sit in here for the + // full timeout. We add a small slack on top of ConnectTimeout so we + // give the client's own timer a chance to flip state to Closed before + // we declare WS unavailable. + const auto Deadline = std::chrono::steady_clock::now() + WsConnectTimeout + std::chrono::seconds(1); + while (Subscriber.GetState() == TailWsSubscriber::State::Connecting) + { + if (g_TailAbortFlag.load(std::memory_order_relaxed)) + { + return true; // user aborted; Client dtor closes the socket cleanly + } + if (std::chrono::steady_clock::now() >= Deadline) + { + return false; + } + zen::Sleep(50); + } + + if (Subscriber.GetState() != TailWsSubscriber::State::Open) + { + return false; + } + + // WS-driven follow. OnWsMessage runs on the client's IO thread and + // prints incoming entries directly; the main thread just parks here + // until the user aborts or the server closes the socket. + while (Subscriber.GetState() == TailWsSubscriber::State::Open && !g_TailAbortFlag.load(std::memory_order_relaxed)) + { + zen::Sleep(100); + } + + if (Subscriber.GetState() == TailWsSubscriber::State::Closed && !g_TailAbortFlag.load(std::memory_order_relaxed)) + { + ZEN_CONSOLE("(server gone)"); + } + return true; + }; + + if (TryWebSocketTail()) + { + return; + } + + ZEN_DEBUG("WebSocket tail unavailable; falling back to polling at {} ms intervals", m_PollIntervalMs); + + constexpr uint32_t SleepStepMs = 50; + while (!g_TailAbortFlag.load(std::memory_order_relaxed)) + { + // Sleep in small chunks so a Ctrl-C reacts within ~50 ms even when the + // user picks a long --interval-ms. + uint32_t SleptMs = 0; + while (SleptMs < m_PollIntervalMs && !g_TailAbortFlag.load(std::memory_order_relaxed)) + { + zen::Sleep(SleepStepMs); + SleptMs += SleepStepMs; + } + if (g_TailAbortFlag.load(std::memory_order_relaxed)) + { + break; + } + + HttpClient::Response Poll = Http.Get(fmt::format("{}?cursor={}", LogPath, Cursor), HttpClient::Accept(ZenContentType::kCbObject)); + if (!Poll) + { + // Server went away mid-tail (connection refused / timeout / DNS). + // Treat as a clean end-of-stream rather than an error so an operator + // that just stopped the server with `zen down` doesn't see a stack + // trace from their tail. + if (Poll.Error.has_value() && Poll.Error->IsConnectionError()) + { + ZEN_CONSOLE("(server gone)"); + break; + } + if (Poll.StatusCode == HttpResponseCode::NotFound) + { + ZEN_CONSOLE("(session ended)"); + break; + } + Poll.ThrowError("session log poll failed"sv); + } + + CbObjectView P = Poll.AsObject(); + const uint64_t NewCursor = P["cursor"sv].AsUInt64(Cursor); + if (NewCursor == Cursor) + { + continue; + } + Cursor = NewCursor; + for (CbFieldView Entry : P["entries"sv].AsArrayView()) + { + PrintLogEntry(Entry.AsObjectView()); + } + } +} + +//////////////////////////////////////////////////////////////////////////////// +// SessionsUiSubCmd + +SessionsUiSubCmd::SessionsUiSubCmd() : SessionsSubCmdBase("ui", "Open the sessions tab of the zenserver dashboard in a web browser") +{ +} + +void +SessionsUiSubCmd::Run(const ZenCliOptions& /*GlobalOptions*/) +{ + // Resolve through ZenServiceClient so we use the same host-discovery logic + // as the rest of the sessions subcommands (and so this invocation shows up + // as its own session in 'sessions ls', which is a feature, not a bug). + ZenServiceClient Service({.HostSpec = m_HostName, .CommandName = "sessions.ui"}); + + if (Service.IsUnixSocket()) + { + throw std::runtime_error("Cannot open browser for a Unix domain socket connection"); + } + + // Page route in the dashboard SPA — see add_service_nav() in + // frontend/html/pages/page.js. + ExtendableStringBuilder<256> Url; + Url << Service.HostSpec() << "/dashboard/?page=sessions"; + + LaunchBrowser(Url.ToView()); +} + +//////////////////////////////////////////////////////////////////////////////// +// SessionsCommand + +SessionsCommand::SessionsCommand() +{ + m_Options.add_options()("h,help", "Print help"); + + AddSubCommand(m_LsSubCmd); + AddSubCommand(m_StatusSubCmd); + AddSubCommand(m_TailSubCmd); + AddSubCommand(m_UiSubCmd); +} + +SessionsCommand::~SessionsCommand() = default; + +} // namespace zen diff --git a/src/zen/cmds/sessions_cmd.h b/src/zen/cmds/sessions_cmd.h new file mode 100644 index 000000000..996054e84 --- /dev/null +++ b/src/zen/cmds/sessions_cmd.h @@ -0,0 +1,75 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "../zen.h" + +namespace zen { + +class SessionsSubCmdBase : public ZenSubCmdBase +{ +public: + SessionsSubCmdBase(std::string_view Name, std::string_view Description); + +protected: + std::string m_HostName; +}; + +class SessionsLsSubCmd : public SessionsSubCmdBase +{ +public: + SessionsLsSubCmd(); + void Run(const ZenCliOptions& GlobalOptions) override; + +private: + std::string m_StatusFilter = "all"; // "active", "ended", "all" +}; + +class SessionsStatusSubCmd : public SessionsSubCmdBase +{ +public: + SessionsStatusSubCmd(); + void Run(const ZenCliOptions& GlobalOptions) override; +}; + +class SessionsTailSubCmd : public SessionsSubCmdBase +{ +public: + SessionsTailSubCmd(); + void Run(const ZenCliOptions& GlobalOptions) override; + +private: + std::string m_SessionId; // optional positional; empty = self + uint32_t m_Lines = 50; + bool m_Follow = false; + uint32_t m_PollIntervalMs = 500; +}; + +class SessionsUiSubCmd : public SessionsSubCmdBase +{ +public: + SessionsUiSubCmd(); + void Run(const ZenCliOptions& GlobalOptions) override; +}; + +class SessionsCommand : public ZenCmdWithSubCommands +{ +public: + static constexpr char Name[] = "sessions"; + static constexpr char Description[] = "Inspect zen client sessions - ls, status, tail, ui"; + + SessionsCommand(); + ~SessionsCommand(); + + cxxopts::Options& Options() override { return m_Options; } + +private: + cxxopts::Options m_Options{Name, Description}; + + SessionsLsSubCmd m_LsSubCmd; + SessionsStatusSubCmd m_StatusSubCmd; + SessionsTailSubCmd m_TailSubCmd; + SessionsUiSubCmd m_UiSubCmd; +}; + +} // namespace zen diff --git a/src/zen/cmds/ui_cmd.cpp b/src/zen/cmds/ui_cmd.cpp index 5fc242541..cf9ea1492 100644 --- a/src/zen/cmds/ui_cmd.cpp +++ b/src/zen/cmds/ui_cmd.cpp @@ -176,7 +176,7 @@ UiCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv) ZenServiceClient Service({.HostSpec = m_HostName, .CommandName = Name}); - if (IsUnixSocketSpec(Service.HostSpec())) + if (Service.IsUnixSocket()) { throw std::runtime_error("Cannot open browser for a Unix domain socket connection"); } diff --git a/src/zen/frontend/html/api.js b/src/zen/frontend/html/api.js index fbe5304ca..86831220e 100644 --- a/src/zen/frontend/html/api.js +++ b/src/zen/frontend/html/api.js @@ -107,6 +107,14 @@ export function getCsvMetadata() { return getJson("csv-metadata"); } +export function getCounters() { + return getJson("counters"); +} + +export function getCounterSeries(id) { + return getJson(`counter-series?id=${encodeURIComponent(id)}`); +} + export function getAllocSummary() { return getJson("alloc-summary"); } diff --git a/src/zen/frontend/html/counters.js b/src/zen/frontend/html/counters.js new file mode 100644 index 000000000..fe3e4d338 --- /dev/null +++ b/src/zen/frontend/html/counters.js @@ -0,0 +1,404 @@ +// Copyright Epic Games, Inc. All Rights Reserved. +// Counters viewer — list registered TRACE_INT_VALUE / TRACE_FLOAT_VALUE +// counters and chart selected ones. +// +// Mirrors csvstats.js layout (tree on the left, line chart on the right), +// adapted to the simpler counter-id keyed series model. + +import { getCounters, getCounterSeries } from "./api.js"; +import { escapeHtml } from "./util.js"; + +function formatTime(us) { + if (us < 1000) return `${us} µs`; + if (us < 1_000_000) return `${(us / 1000).toFixed(2)} ms`; + return `${(us / 1_000_000).toFixed(2)} s`; +} + +const BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB"]; +function formatBytes(value) { + let v = value; + let i = 0; + while (Math.abs(v) >= 1024 && i + 1 < BYTE_UNITS.length) { + v /= 1024; + ++i; + } + return `${v.toFixed(i === 0 ? 0 : 2)} ${BYTE_UNITS[i]}`; +} + +function formatCounterValue(value, def) { + if (def && def.display_hint === 1) { + return formatBytes(value); + } + if (def && def.type === 0) { + // Integer — render without fractional digits. + return Number(value).toLocaleString(); + } + return value.toFixed(3); +} + +const LINE_COLORS = [ + "#4fc3f7", "#81c784", "#ffb74d", "#e57373", "#ba68c8", + "#4db6ac", "#fff176", "#f06292", "#7986cb", "#a1887f", +]; + +export class CountersView { + constructor(model, containerEl) { + this.model = model; + this.container = containerEl; + this.loaded = false; + this.defs = []; + this.defById = new Map(); + + this.selected = new Set(); + this.seriesData = new Map(); + this.colorIndex = 0; + this.colorById = new Map(); + + this.viewStartUs = 0; + this.viewEndUs = (model.session && model.session.trace_end_us) || 1; + + this.buildLayout(); + } + + buildLayout() { + this.container.innerHTML = + `<div class="csv-layout">` + + `<div class="csv-tree-panel">` + + `<div class="sidebar-label">Counters</div>` + + `<div class="csv-tree counters-tree"></div>` + + `</div>` + + `<div class="csv-chart-panel">` + + `<canvas class="csv-chart-canvas"></canvas>` + + `<div class="csv-chart-tooltip" hidden></div>` + + `</div>` + + `</div>`; + + this.treeEl = this.container.querySelector(".counters-tree"); + this.canvas = this.container.querySelector(".csv-chart-canvas"); + this.tooltipEl = this.container.querySelector(".csv-chart-tooltip"); + this.ctx = this.canvas.getContext("2d"); + this.dpr = Math.max(1, window.devicePixelRatio || 1); + + this.resizeObserver = new ResizeObserver(() => this.drawChart()); + this.resizeObserver.observe(this.canvas); + this.canvas.addEventListener("mousemove", (e) => this.onChartHover(e)); + this.canvas.addEventListener("mouseleave", () => { this.tooltipEl.hidden = true; }); + + this.panning = false; + this.canvas.addEventListener("mousedown", (e) => this.onPanStart(e)); + window.addEventListener("mousemove", (e) => this.onPanMove(e)); + window.addEventListener("mouseup", () => this.onPanEnd()); + this.canvas.addEventListener("wheel", (e) => this.onWheel(e), { passive: false }); + } + + async ensureLoaded() { + if (this.loaded) { + this.drawChart(); + return; + } + try { + this.defs = await getCounters(); + for (const d of this.defs) { + this.defById.set(d.id, d); + } + this.renderTree(); + } catch (e) { + this.treeEl.innerHTML = `<div class="csv-empty">Failed to load counters: ${escapeHtml(e.message)}</div>`; + console.error(e); + } + this.loaded = true; + this.drawChart(); + } + + renderTree() { + // Group by leading path component (everything before the first "/"). + const groups = new Map(); + for (const d of this.defs) { + if (!d.sample_count) continue; + const slash = d.name.indexOf("/"); + const group = slash > 0 ? d.name.substring(0, slash) : ""; + if (!groups.has(group)) groups.set(group, []); + groups.get(group).push(d); + } + + if (groups.size === 0) { + this.treeEl.innerHTML = `<div class="csv-empty">No counters in this trace.</div>`; + return; + } + + const groupNames = Array.from(groups.keys()).sort((a, b) => a.localeCompare(b)); + const parts = []; + for (const g of groupNames) { + const list = groups.get(g); + parts.push(`<div class="csv-cat-header">${escapeHtml(g || "(ungrouped)")}</div>`); + for (const d of list) { + parts.push( + `<label class="csv-stat-row">` + + `<input type="checkbox" data-counter-id="${d.id}">` + + `<span class="csv-stat-name"></span>` + + `<span class="thread-count">${Number(d.sample_count).toLocaleString()}</span>` + + `</label>` + ); + } + } + this.treeEl.innerHTML = parts.join(""); + + // Set names via DOM (XSS safe). + const rows = this.treeEl.querySelectorAll(".csv-stat-row"); + let idx = 0; + for (const g of groupNames) { + for (const d of groups.get(g)) { + if (idx < rows.length) { + const slash = d.name.indexOf("/"); + const display = slash > 0 ? d.name.substring(slash + 1) : d.name; + rows[idx].querySelector(".csv-stat-name").textContent = display; + rows[idx].title = d.name; + } + ++idx; + } + } + + // Wire checkboxes. + for (const cb of this.treeEl.querySelectorAll("input[type=checkbox]")) { + cb.addEventListener("change", () => { + const id = Number(cb.dataset.counterId); + if (cb.checked) { + this.selected.add(id); + if (!this.colorById.has(id)) { + this.colorById.set(id, LINE_COLORS[this.colorIndex++ % LINE_COLORS.length]); + } + this.fetchSeries(id); + } else { + this.selected.delete(id); + this.drawChart(); + } + }); + } + } + + async fetchSeries(id) { + if (this.seriesData.has(id)) { + this.drawChart(); + return; + } + try { + const result = await getCounterSeries(id); + const samples = (result.samples || []).map(([t, v]) => ({ timeUs: t, value: v })); + this.seriesData.set(id, samples); + this.drawChart(); + } catch (e) { + console.error(`Failed to fetch counter series for ${id}: ${e.message}`); + } + } + + resizeCanvas() { + const rect = this.canvas.getBoundingClientRect(); + this.width = Math.floor(rect.width); + this.height = Math.floor(rect.height); + const bw = Math.floor(rect.width * this.dpr); + const bh = Math.floor(rect.height * this.dpr); + if (this.canvas.width !== bw || this.canvas.height !== bh) { + this.canvas.width = bw; + this.canvas.height = bh; + } + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + } + + drawChart() { + this.resizeCanvas(); + const ctx = this.ctx; + const W = this.width; + const H = this.height; + + const bg = getComputedStyle(document.body).getPropertyValue("--bg0") || "#0d1117"; + const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e"; + const border = getComputedStyle(document.body).getPropertyValue("--border") || "#30363d"; + ctx.fillStyle = bg; + ctx.fillRect(0, 0, W, H); + + if (this.selected.size === 0) { + ctx.fillStyle = fg2; + ctx.font = "12px -apple-system, Segoe UI, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("Select counters from the tree to chart them", W / 2, H / 2); + return; + } + + const PAD_L = 70, PAD_R = 12, PAD_T = 12, PAD_B = 28; + const chartW = W - PAD_L - PAD_R; + const chartH = H - PAD_T - PAD_B; + if (chartW <= 0 || chartH <= 0) return; + + const startUs = this.viewStartUs; + const endUs = this.viewEndUs; + const rangeUs = Math.max(1, endUs - startUs); + + let minVal = Infinity, maxVal = -Infinity; + for (const id of this.selected) { + const samples = this.seriesData.get(id); + if (!samples) continue; + for (const s of samples) { + if (s.timeUs < startUs || s.timeUs > endUs) continue; + if (s.value < minVal) minVal = s.value; + if (s.value > maxVal) maxVal = s.value; + } + } + if (!isFinite(minVal)) { minVal = 0; maxVal = 1; } + if (minVal === maxVal) { minVal -= 0.5; maxVal += 0.5; } + const valRange = maxVal - minVal; + const valPad = valRange * 0.05; + minVal -= valPad; + maxVal += valPad; + + const xAt = (us) => PAD_L + (us - startUs) / rangeUs * chartW; + const yAt = (v) => PAD_T + (1 - (v - minVal) / (maxVal - minVal)) * chartH; + + ctx.strokeStyle = border; + ctx.lineWidth = 0.5; + for (let i = 0; i <= 4; i++) { + const y = PAD_T + chartH * i / 4; + ctx.beginPath(); ctx.moveTo(PAD_L, y); ctx.lineTo(PAD_L + chartW, y); ctx.stroke(); + } + + // Format Y-axis labels using the display hint of the first selected counter. + const firstSelected = this.selected.values().next().value; + const firstDef = this.defById.get(firstSelected); + + ctx.fillStyle = fg2; + ctx.font = "10px -apple-system, Segoe UI, sans-serif"; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (let i = 0; i <= 4; i++) { + const v = minVal + (maxVal - minVal) * (1 - i / 4); + const y = PAD_T + chartH * i / 4; + ctx.fillText(formatCounterValue(v, firstDef), PAD_L - 4, y); + } + + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + const tickCount = Math.max(2, Math.min(8, Math.floor(chartW / 80))); + for (let i = 0; i <= tickCount; i++) { + const us = startUs + rangeUs * i / tickCount; + const x = xAt(us); + ctx.fillText(formatTime(us), x, PAD_T + chartH + 4); + } + + // Draw step-style lines (counters change at discrete events). + for (const id of this.selected) { + const samples = this.seriesData.get(id); + if (!samples || samples.length === 0) continue; + const color = this.colorById.get(id) || "#fff"; + + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.beginPath(); + let started = false; + let prevY = 0; + for (const s of samples) { + if (s.timeUs < startUs || s.timeUs > endUs) continue; + const x = xAt(s.timeUs); + const y = yAt(s.value); + if (!started) { ctx.moveTo(x, y); started = true; } + else { ctx.lineTo(x, prevY); ctx.lineTo(x, y); } + prevY = y; + } + ctx.stroke(); + } + + ctx.strokeStyle = border; + ctx.lineWidth = 1; + ctx.strokeRect(PAD_L, PAD_T, chartW, chartH); + + ctx.font = "10px -apple-system, Segoe UI, sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + let legendX = PAD_L + 6; + for (const id of this.selected) { + const def = this.defById.get(id); + const name = def ? def.name : `counter ${id}`; + const color = this.colorById.get(id) || "#fff"; + ctx.fillStyle = color; + ctx.fillRect(legendX, PAD_T + 4, 10, 10); + ctx.fillStyle = "#ccc"; + ctx.fillText(name, legendX + 14, PAD_T + 4); + legendX += ctx.measureText(name).width + 24; + } + + this._chartLayout = { PAD_L, PAD_R, PAD_T, PAD_B, chartW, chartH, startUs, endUs, rangeUs, minVal, maxVal, xAt, yAt, firstDef }; + } + + onChartHover(e) { + if (!this._chartLayout || this.selected.size === 0) { + this.tooltipEl.hidden = true; + return; + } + const rect = this.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const { PAD_L, PAD_T, chartW, chartH, startUs, rangeUs } = this._chartLayout; + + if (mx < PAD_L || mx > PAD_L + chartW || my < PAD_T || my > PAD_T + chartH) { + this.tooltipEl.hidden = true; + return; + } + + const cursorUs = startUs + (mx - PAD_L) / chartW * rangeUs; + const lines = []; + for (const id of this.selected) { + const samples = this.seriesData.get(id); + if (!samples || samples.length === 0) continue; + let best = null, bestDist = Infinity; + for (const s of samples) { + const d = Math.abs(s.timeUs - cursorUs); + if (d < bestDist) { bestDist = d; best = s; } + } + if (best) { + const def = this.defById.get(id); + const name = def ? def.name : `counter ${id}`; + const color = this.colorById.get(id) || "#fff"; + lines.push(`<span style="color:${color}">${escapeHtml(name)}</span>: ${formatCounterValue(best.value, def)}`); + } + } + if (lines.length === 0) { this.tooltipEl.hidden = true; return; } + this.tooltipEl.innerHTML = `<div style="margin-bottom:2px">${formatTime(cursorUs)}</div>` + lines.join("<br>"); + this.tooltipEl.style.left = `${mx + 12}px`; + this.tooltipEl.style.top = `${my + 12}px`; + this.tooltipEl.hidden = false; + } + + onPanStart(e) { + if (e.button !== 0) return; + this.panning = true; + this.panStartX = e.clientX; + this.panStartViewStart = this.viewStartUs; + this.panStartViewEnd = this.viewEndUs; + } + + onPanMove(e) { + if (!this.panning || !this._chartLayout) return; + const dx = e.clientX - this.panStartX; + const usPerPx = (this.panStartViewEnd - this.panStartViewStart) / this._chartLayout.chartW; + const shift = -dx * usPerPx; + this.viewStartUs = this.panStartViewStart + shift; + this.viewEndUs = this.panStartViewEnd + shift; + this.drawChart(); + } + + onPanEnd() { this.panning = false; } + + onWheel(e) { + e.preventDefault(); + if (!this._chartLayout) return; + const rect = this.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const { PAD_L, chartW, startUs, rangeUs } = this._chartLayout; + const cursorUs = startUs + (mx - PAD_L) / chartW * rangeUs; + const factor = e.deltaY > 0 ? 1.25 : 0.8; + const newRange = Math.max(10, (this.viewEndUs - this.viewStartUs) * factor); + const ratio = (mx - PAD_L) / chartW; + this.viewStartUs = cursorUs - ratio * newRange; + this.viewEndUs = this.viewStartUs + newRange; + this.drawChart(); + } +} diff --git a/src/zen/frontend/html/csvstats.js b/src/zen/frontend/html/csvstats.js index a50b2f068..fc006acdc 100644 --- a/src/zen/frontend/html/csvstats.js +++ b/src/zen/frontend/html/csvstats.js @@ -2,10 +2,7 @@ // CSV Profiler stats viewer — category/stat tree with line-chart visualization. import { getCsvSeries } from "./api.js"; - -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c])); -} +import { escapeHtml } from "./util.js"; function formatTime(us) { if (us < 1000) return `${us} \u00b5s`; diff --git a/src/zen/frontend/html/index.html b/src/zen/frontend/html/index.html index 5853a80dc..924abadda 100644 --- a/src/zen/frontend/html/index.html +++ b/src/zen/frontend/html/index.html @@ -9,6 +9,15 @@ <body> <noscript>This viewer requires JavaScript.</noscript> <div class="header"> + <nav class="tabs"> + <button class="tab active" data-tab="timeline">Timeline</button> + <button class="tab" data-tab="stats">Stats</button> + <button class="tab" data-tab="memory">Memory</button> + <button class="tab" data-tab="logs">Logs</button> + <button class="tab" data-tab="csv">CSV</button> + <button class="tab" data-tab="counters">Counters</button> + <button class="tab" data-tab="session">Session</button> + </nav> <div class="header-title">zen trace viewer</div> <div class="header-file" id="hdr-file"></div> <div class="header-stats" id="hdr-stats"></div> @@ -16,14 +25,6 @@ </div> <div class="layout"> <aside class="sidebar"> - <nav class="tabs"> - <button class="tab active" data-tab="timeline">Timeline</button> - <button class="tab" data-tab="stats">Stats</button> - <button class="tab" data-tab="memory">Memory</button> - <button class="tab" data-tab="logs">Logs</button> - <button class="tab" data-tab="csv">CSV</button> - <button class="tab" data-tab="session">Session</button> - </nav> <div class="sidebar-section"> <div class="sidebar-label">Search scopes</div> <input id="search-input" type="text" placeholder="filter scopes..." autocomplete="off" spellcheck="false"> @@ -50,6 +51,10 @@ <input type="checkbox" id="lod-toggle" checked> <span>LOD</span> </label> + <label class="toolbar-toggle" title="Compact rows: thinner swimlanes, hides in-bar labels (shortcut: c)"> + <input type="checkbox" id="compact-toggle"> + <span>Compact</span> + </label> <button id="zoom-reset" class="btn">Reset view</button> </div> <div class="timeline-frame"> @@ -84,6 +89,9 @@ <section class="view view-csv" data-view="csv" hidden> <div id="csv-content"></div> </section> + <section class="view view-counters" data-view="counters" hidden> + <div id="counters-content"></div> + </section> <section class="view view-session" data-view="session" hidden> <div id="session-content" class="session-content"></div> </section> diff --git a/src/zen/frontend/html/logs.js b/src/zen/frontend/html/logs.js index d9646ba39..f0498dde2 100644 --- a/src/zen/frontend/html/logs.js +++ b/src/zen/frontend/html/logs.js @@ -2,6 +2,7 @@ // Log viewer: filterable list of captured Logging.LogMessage events. import { getLogs } from "./api.js"; +import { escapeHtml } from "./util.js"; // UE ELogVerbosity::Type values — lower number = more severe. const VERBOSITY_LABELS = [ @@ -16,16 +17,6 @@ const VERBOSITY_LABELS = [ "All", ]; -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ({ - "&": "&", - "<": "<", - ">": ">", - "\"": """, - "'": "'", - }[c])); -} - function verbosityLabel(v) { return VERBOSITY_LABELS[v] || `V${v}`; } diff --git a/src/zen/frontend/html/memory.js b/src/zen/frontend/html/memory.js index 6b9760439..6e4d51061 100644 --- a/src/zen/frontend/html/memory.js +++ b/src/zen/frontend/html/memory.js @@ -1,17 +1,9 @@ // Copyright Epic Games, Inc. All Rights Reserved. -// Interactive memory analysis view: summary cards, memory timeline, leak/churn/hot callsite tables. +// Interactive memory analysis view: summary cards, memory timeline, and a +// tabbed callsite panel (Leaky / Churn / Hot) sharing one slot below the chart. import { getAllocSummary, getMemoryTimeline, getCallstackStats, getChurnStats, getCallstack, getAllocSizeHistogram } from "./api.js"; - -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }[c])); -} +import { escapeHtml } from "./util.js"; function formatNum(n) { return Number(n || 0).toLocaleString(); @@ -187,10 +179,17 @@ export class MemoryView { churn: { sortKey: "churn_allocs", desc: true, groupMode: "none", filterText: "" }, hot: { sortKey: "total_allocs", desc: true, groupMode: "none", filterText: "" }, }; + this.activeTable = "leaks"; this.loadStateFromUrl(); this.buildLayout(); } + static TAB_DEFS = [ + { name: "leaks", label: "Leaky callsites" }, + { name: "churn", label: "Churn" }, + { name: "hot", label: "Hot callsites" }, + ]; + buildLayout() { this.container.innerHTML = `<div class="memory-view">` + @@ -222,6 +221,12 @@ export class MemoryView { `</div>` + `</div>` + `<div class="memory-grid">` + + `<div class="memory-tabbed">` + + `<div class="memory-tab-bar" role="tablist">` + + MemoryView.TAB_DEFS.map(({ name, label }) => + `<button type="button" class="memory-tab" role="tab" data-mem-tab="${name}" id="memory-tab-${name}" aria-controls="memory-tabpanel-${name}">${escapeHtml(label)}</button>` + ).join("") + + `</div>` + this.buildPanelMarkup("leaks", "Leaky callsites", "Top live allocation stacks", [ ["live_bytes", "Live bytes"], ["live_count", "Live allocs"], @@ -239,6 +244,7 @@ export class MemoryView { ["churn_allocs", "Churn allocs"], ["summary", "Summary"], ]) + + `</div>` + `<div class="memory-panel memory-callstack-panel">` + `<div class="memory-panel-header"><div class="memory-panel-title">Callstack details</div><div class="memory-panel-subtitle" id="memory-callstack-meta">Select a row to inspect its frames</div></div>` + `<div class="memory-callstack-body" id="memory-callstack-body"><div class="memory-empty">No callstack selected.</div></div>` + @@ -339,6 +345,17 @@ export class MemoryView { this.updateFilterButton(name); } + this.tabButtons = {}; + this.tabPanels = {}; + for (const { name } of MemoryView.TAB_DEFS) { + const button = this.container.querySelector(`[data-mem-tab="${name}"]`); + const panel = this.container.querySelector(`[data-mem-tabpanel="${name}"]`); + this.tabButtons[name] = button; + this.tabPanels[name] = panel; + button.addEventListener("click", () => this.setActiveTable(name)); + } + this.setActiveTable(this.activeTable, /*save=*/ false); + this.container.addEventListener("keydown", (e) => { if (e.key !== "/" || e.defaultPrevented) { return; @@ -352,20 +369,39 @@ export class MemoryView { if (activeView && activeView.hidden) { return; } - const firstFilter = this.panelRefs.leaks.filter; - if (firstFilter) { - firstFilter.focus(); - firstFilter.select(); + const activeFilter = this.panelRefs[this.activeTable]?.filter; + if (activeFilter) { + activeFilter.focus(); + activeFilter.select(); } }); this.container.tabIndex = -1; this.container.dataset.memoryView = "true"; } + setActiveTable(name, save = true) { + if (!this.tabButtons || !this.tabButtons[name]) { + return; + } + this.activeTable = name; + for (const { name: tabName } of MemoryView.TAB_DEFS) { + const isActive = tabName === name; + const button = this.tabButtons[tabName]; + const panel = this.tabPanels[tabName]; + button.classList.toggle("active", isActive); + button.setAttribute("aria-selected", isActive ? "true" : "false"); + button.tabIndex = isActive ? 0 : -1; + panel.hidden = !isActive; + } + if (save) { + this.saveStateToUrl(); + } + } + buildPanelMarkup(name, title, subtitle, sortOptions) { const sortHtml = sortOptions.map(([value, label]) => `<option value="${value}">${escapeHtml(label)}</option>`).join(""); return ` - <div class="memory-panel"> + <div class="memory-panel memory-tabpanel" data-mem-tabpanel="${name}" id="memory-tabpanel-${name}" role="tabpanel" aria-labelledby="memory-tab-${name}" hidden> <div class="memory-panel-header memory-panel-header-wrap"> <div> <div class="memory-panel-title">${escapeHtml(title)}</div> @@ -393,6 +429,10 @@ export class MemoryView { if (histogramMetric === "count" || histogramMetric === "bytes") { this.histogramMetric = histogramMetric; } + const activeTable = params.get("mem_table"); + if (activeTable && this.tableState[activeTable]) { + this.activeTable = activeTable; + } for (const [name, state] of Object.entries(this.tableState)) { const sortKey = params.get(`mem_${name}_sort`); const groupMode = params.get(`mem_${name}_group`); @@ -419,6 +459,7 @@ export class MemoryView { saveStateToUrl() { const url = new URL(window.location.href); url.searchParams.set("mem_hist_metric", this.histogramMetric); + url.searchParams.set("mem_table", this.activeTable); for (const [name, state] of Object.entries(this.tableState)) { url.searchParams.set(`mem_${name}_sort`, state.sortKey); url.searchParams.set(`mem_${name}_group`, state.groupMode); diff --git a/src/zen/frontend/html/timeline.js b/src/zen/frontend/html/timeline.js index f463a8418..e0fd64181 100644 --- a/src/zen/frontend/html/timeline.js +++ b/src/zen/frontend/html/timeline.js @@ -2,16 +2,35 @@ // Canvas-drawn flame graph with per-thread swimlanes, pan+zoom, hover // tooltip, click-to-select and scope-name highlighting. -const HEADER_H = 18; // thread name row height -const DEPTH_H = 16; // scope lane row height const MAX_DRAWN_DEPTH = 32; -const MIN_RECT_W = 1.5; // don't draw narrower than this (px) -const RULER_H = 20; -const THREAD_GAP = 6; -const PADDING_X = 0; -const REGION_LANE_H = 18; // region band row height -const REGION_HEADER_H = 16; // category header row height -const REGIONS_GAP = 6; // gap between the region rack and the first thread +const MIN_RECT_W = 1.5; // don't draw narrower than this (px) +const RULER_H = 20; +const THREAD_GAP = 6; +const PADDING_X = 0; +const REGIONS_GAP = 6; // gap between the region rack and the first thread + +// Per-mode row metrics. Compact mode (toggled with 'c') shrinks every +// vertical dimension so the flame graph fits many more threads/depths in +// the viewport at the cost of in-bar text labels. +const METRICS_NORMAL = { + headerH: 18, // thread name row height + depthH: 16, // scope lane row height + regionLaneH: 18, // region band row height + regionHeaderH: 16, // region category header row height + scopeFontPx: 11, // in-bar scope label font size + headerFontPx: 11, // thread header / region row font size + showLabels: true, +}; + +const METRICS_COMPACT = { + headerH: 12, + depthH: 4, + regionLaneH: 6, + regionHeaderH: 12, + scopeFontPx: 0, // labels too tall to fit; suppressed + headerFontPx: 9, + showLabels: false, +}; // Scope colors: golden-angle hue rotation keyed on NameId so the same scope // always renders in the same color across zoom levels. @@ -69,6 +88,8 @@ export class Timeline { this.bookmarks = (this.model.bookmarks || []).slice().sort((a, b) => a.time_us - b.time_us); this.bookmarksVisible = true; + this.compact = false; + this.metrics = METRICS_NORMAL; this.regionCategories = (this.model.regionCategories || []).filter(c => c.lane_count > 0); // All categories enabled by default; renderRegionCategories() calls // setEnabledRegionCategories() shortly after construction. @@ -144,6 +165,17 @@ export class Timeline { this.requestDraw(); } + setCompact(compact) { + const next = !!compact; + if (next === this.compact) return; + this.compact = next; + this.metrics = next ? METRICS_COMPACT : METRICS_NORMAL; + this.recomputeRegionsBlockH(); + this.requestDraw(); + } + + toggleCompact() { this.setCompact(!this.compact); } + setEnabledRegionCategories(indices) { this.enabledRegionCategories = indices instanceof Set ? indices : new Set(indices); this.recomputeRegionsBlockH(); @@ -152,10 +184,11 @@ export class Timeline { recomputeRegionsBlockH() { this.regionsBlockH = 0; + const M = this.metrics || METRICS_NORMAL; for (let i = 0; i < this.regionCategories.length; i++) { if (!this.enabledRegionCategories || !this.enabledRegionCategories.has(i)) continue; const cat = this.regionCategories[i]; - this.regionsBlockH += REGION_HEADER_H + cat.lane_count * REGION_LANE_H; + this.regionsBlockH += M.regionHeaderH + cat.lane_count * M.regionLaneH; } if (this.regionsBlockH > 0) { this.regionsBlockH += REGIONS_GAP; @@ -406,6 +439,7 @@ export class Timeline { if (ma.thread_id !== mb.thread_id) return ma.thread_id - mb.thread_id; return (ma.name || "").localeCompare(mb.name || "", undefined, { numeric: true }); }); + const M = this.metrics; for (const threadId of sorted) { const timeline = this.timelines.get(threadId); const scopes = timeline ? timeline.scopes : []; @@ -414,8 +448,8 @@ export class Timeline { if (s[3] > maxDepth) maxDepth = s[3]; } if (maxDepth > MAX_DRAWN_DEPTH) maxDepth = MAX_DRAWN_DEPTH; - const rowH = HEADER_H + (maxDepth + 1) * DEPTH_H; - rows.push({ threadId, y, headerH: HEADER_H, maxDepth, height: rowH }); + const rowH = M.headerH + (maxDepth + 1) * M.depthH; + rows.push({ threadId, y, headerH: M.headerH, maxDepth, height: rowH }); y += rowH + THREAD_GAP; } // y now points at the bottom of the last row in scrolled coords. @@ -467,6 +501,7 @@ export class Timeline { const fg1 = getComputedStyle(document.body).getPropertyValue("--fg1") || "#c9d1d9"; const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e"; + const M = this.metrics; for (const row of rows) { if (row.y > H) break; if (row.y + row.height < RULER_H) continue; @@ -479,14 +514,14 @@ export class Timeline { const prefix = isLane ? "⬦ " : ""; const label = `${prefix}${(meta && meta.name) || `tid ${row.threadId}`} · ${row.threadId}`; ctx.fillStyle = isLane ? "rgba(180, 140, 255, 0.8)" : fg2; - ctx.font = "11px -apple-system, Segoe UI, sans-serif"; + ctx.font = `${M.headerFontPx}px -apple-system, Segoe UI, sans-serif`; ctx.textBaseline = "middle"; ctx.fillText(label, 6, row.y + row.headerH / 2); // Swimlane backgrounds for (let d = 0; d <= row.maxDepth; d++) { ctx.fillStyle = d % 2 === 0 ? "rgba(255,255,255,0.015)" : "rgba(255,255,255,0.00)"; - ctx.fillRect(0, row.y + row.headerH + d * DEPTH_H, W, DEPTH_H); + ctx.fillRect(0, row.y + row.headerH + d * M.depthH, W, M.depthH); } const timeline = this.timelines.get(row.threadId); @@ -554,6 +589,7 @@ export class Timeline { drawRegions(ctx, W) { if (this.regionCategories.length === 0) return; + const M = this.metrics; const startUs = this.startUs; const endUs = this.endUs; const pxPerUs = this.pxPerUs(); @@ -566,13 +602,15 @@ export class Timeline { const cat = this.regionCategories[ci]; // Category header ctx.fillStyle = bg1; - ctx.fillRect(0, catY, W, REGION_HEADER_H); - ctx.fillStyle = fg2; - ctx.font = "10px -apple-system, Segoe UI, sans-serif"; - ctx.textBaseline = "middle"; - ctx.textAlign = "left"; - ctx.fillText(`Timing Regions \u2013 ${cat.name}`, 6, catY + REGION_HEADER_H / 2); - catY += REGION_HEADER_H; + ctx.fillRect(0, catY, W, M.regionHeaderH); + if (M.showLabels) { + ctx.fillStyle = fg2; + ctx.font = "10px -apple-system, Segoe UI, sans-serif"; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(`Timing Regions \u2013 ${cat.name}`, 6, catY + M.regionHeaderH / 2); + } + catY += M.regionHeaderH; // Region bands for this category for (const r of cat.regions) { @@ -585,35 +623,35 @@ export class Timeline { const w = Math.max(MIN_RECT_W, (endRegUs - beginUs) * pxPerUs); if (w < MIN_RECT_W) continue; - const y = catY + r.depth * REGION_LANE_H; + const y = catY + r.depth * M.regionLaneH; ctx.fillStyle = regionFillColor(r.name); - ctx.fillRect(x, y + 1, w, REGION_LANE_H - 2); + ctx.fillRect(x, y + 1, w, M.regionLaneH - 2); ctx.strokeStyle = "rgba(255,255,255,0.2)"; ctx.lineWidth = 1; - ctx.strokeRect(x + 0.5, y + 1.5, w - 1, REGION_LANE_H - 3); + ctx.strokeRect(x + 0.5, y + 1.5, w - 1, M.regionLaneH - 3); const visX = Math.max(x, 0); const visRight = Math.min(x + w, this.width); const visW = visRight - visX; - if (visW > 24 && r.name) { + if (M.showLabels && visW > 24 && r.name) { ctx.fillStyle = "rgba(255,255,255,0.95)"; ctx.font = "11px -apple-system, Segoe UI, sans-serif"; ctx.textBaseline = "middle"; ctx.textAlign = "left"; ctx.save(); ctx.beginPath(); - ctx.rect(visX + 3, y, visW - 6, REGION_LANE_H); + ctx.rect(visX + 3, y, visW - 6, M.regionLaneH); ctx.clip(); - ctx.fillText(r.name, visX + 5, y + REGION_LANE_H / 2); + ctx.fillText(r.name, visX + 5, y + M.regionLaneH / 2); ctx.restore(); } - this.hits.push({ x, y, w, h: REGION_LANE_H - 2, region: r, regionCategory: cat.name }); + this.hits.push({ x, y, w, h: M.regionLaneH - 2, region: r, regionCategory: cat.name }); } - catY += cat.lane_count * REGION_LANE_H; + catY += cat.lane_count * M.regionLaneH; } } @@ -660,6 +698,8 @@ export class Timeline { drawScopes(ctx, timeline, row, pxPerUs, textColor) { const { scopes, perDepth } = timeline; + const M = this.metrics; + const depthH = M.depthH; const startUs = this.startUs; const endUs = this.endUs; @@ -669,7 +709,9 @@ export class Timeline { ctx.textBaseline = "middle"; ctx.textAlign = "left"; - ctx.font = "11px -apple-system, Segoe UI, sans-serif"; + if (M.showLabels) { + ctx.font = `${M.scopeFontPx}px -apple-system, Segoe UI, sans-serif`; + } const rowTop = row.y + row.headerH; const maxDepth = Math.min(row.maxDepth, perDepth.length - 1); @@ -695,7 +737,11 @@ export class Timeline { } } - const y = rowTop + depth * DEPTH_H; + const y = rowTop + depth * depthH; + // Compact mode shrinks the bar to the row height; normal mode + // leaves a 1px gap top and bottom for readability. + const barTop = M.showLabels ? y + 1 : y; + const barH = M.showLabels ? depthH - 2 : depthH; let rendered = 0; for (let j = lo; j < indices.length; j++) { const s = scopes[indices[j]]; @@ -718,39 +764,44 @@ export class Timeline { ctx.fillStyle = isHighlighted ? `hsl(${hue.toFixed(0)}, 50%, 50%)` : `hsl(${hue.toFixed(0)}, 30%, 35%)`; - ctx.fillRect(x, y + 1, w, DEPTH_H - 2); - ctx.strokeStyle = "rgba(255,255,255,0.25)"; - ctx.lineWidth = 1; - ctx.setLineDash([2, 2]); - ctx.beginPath(); - ctx.moveTo(x, y + 1.5); - ctx.lineTo(x + w, y + 1.5); - ctx.stroke(); - ctx.setLineDash([]); + ctx.fillRect(x, barTop, w, barH); + if (M.showLabels) { + ctx.strokeStyle = "rgba(255,255,255,0.25)"; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.beginPath(); + ctx.moveTo(x, barTop + 0.5); + ctx.lineTo(x + w, barTop + 0.5); + ctx.stroke(); + ctx.setLineDash([]); + } } else { ctx.fillStyle = isHighlighted ? scopeHighlightColor(nameId) : scopeFillColor(nameId); - ctx.fillRect(x, y + 1, w, DEPTH_H - 2); + ctx.fillRect(x, barTop, w, barH); } // Draw the label pinned to the visible portion of the rect - // so zooming into a long scope still shows its name. - const visX = Math.max(x, 0); - const visRight = Math.min(x + w, this.width); - const visW = visRight - visX; - if (visW > 30) { - const name = this.model.scopeNames[nameId] || "?"; - const maxChars = Math.floor((visW - 6) / 6); - const shown = name.length > maxChars ? name.slice(0, Math.max(0, maxChars - 1)) + "…" : name; - ctx.fillStyle = "rgba(255,255,255,0.95)"; - ctx.save(); - ctx.beginPath(); - ctx.rect(visX + 3, y, visW - 6, DEPTH_H); - ctx.clip(); - ctx.fillText(shown, visX + 4, y + DEPTH_H / 2); - ctx.restore(); + // so zooming into a long scope still shows its name. Skipped + // in compact mode where the bar is too short for readable text. + if (M.showLabels) { + const visX = Math.max(x, 0); + const visRight = Math.min(x + w, this.width); + const visW = visRight - visX; + if (visW > 30) { + const name = this.model.scopeNames[nameId] || "?"; + const maxChars = Math.floor((visW - 6) / 6); + const shown = name.length > maxChars ? name.slice(0, Math.max(0, maxChars - 1)) + "…" : name; + ctx.fillStyle = "rgba(255,255,255,0.95)"; + ctx.save(); + ctx.beginPath(); + ctx.rect(visX + 3, y, visW - 6, depthH); + ctx.clip(); + ctx.fillText(shown, visX + 4, y + depthH / 2); + ctx.restore(); + } } - this.hits.push({ x, y, w, h: DEPTH_H - 2, threadId: row.threadId, tuple: s }); + this.hits.push({ x, y, w, h: depthH, threadId: row.threadId, tuple: s }); } } } @@ -764,12 +815,13 @@ export class Timeline { const { rows } = this.layoutThreads(); const row = rows.find((r) => r.threadId === this.selected.threadId); if (!row) return; + const M = this.metrics; const x = this.xAtUs(beginUs); const w = Math.max(MIN_RECT_W, durationUs * this.pxPerUs()); - const y = row.y + row.headerH + depth * DEPTH_H; + const y = row.y + row.headerH + depth * M.depthH; ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1.5; - ctx.strokeRect(x - 0.5, y + 0.5, w + 1, DEPTH_H - 1); + ctx.strokeRect(x - 0.5, y + 0.5, w + 1, M.depthH - 1); } drawViewportInfo() { diff --git a/src/zen/frontend/html/trace.css b/src/zen/frontend/html/trace.css index 2ff324019..5b9bb28c6 100644 --- a/src/zen/frontend/html/trace.css +++ b/src/zen/frontend/html/trace.css @@ -87,18 +87,27 @@ pre, code, .mono { .header { display: flex; - align-items: center; + align-items: stretch; gap: 16px; - padding: 10px 16px; + padding: 0 16px; background: var(--bg1); border-bottom: 1px solid var(--border); flex-shrink: 0; + min-height: 40px; +} + +.header > .header-title, +.header > .header-file, +.header > .header-stats, +.header > .header-btn { + align-self: center; } .header-title { font-weight: 600; color: var(--fg0); font-size: 14px; + white-space: nowrap; } .header-file { @@ -172,16 +181,19 @@ pre, code, .mono { } /* -- tabs ------------------------------------------------------------------ */ +/* Tabs live inside the header bar at the top, alongside the title / file + * path / stats. Each tab is a button that owns its own underline so the + * active state aligns flush with the header's bottom border. */ .tabs { display: flex; - border-bottom: 1px solid var(--border); flex-shrink: 0; + height: 100%; + margin-left: -8px; /* let first tab sit closer to the left edge */ } .tab { - flex: 1; - padding: 10px 8px; + padding: 0 14px; background: transparent; border: none; border-bottom: 2px solid transparent; @@ -191,6 +203,10 @@ pre, code, .mono { cursor: pointer; text-transform: uppercase; letter-spacing: 0.5px; + white-space: nowrap; + display: flex; + align-items: center; + margin-bottom: -1px; /* overlap header's bottom border so the underline sits flush */ } .tab:hover { @@ -1165,7 +1181,7 @@ pre, code, .mono { .memory-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); gap: 16px; } @@ -1173,6 +1189,46 @@ pre, code, .mono { grid-column: 1 / -1; } +.memory-tabbed { + display: flex; + flex-direction: column; + gap: 0; +} + +.memory-tab-bar { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 4px; + border-bottom: 1px solid var(--border-soft); + margin-bottom: -1px; +} + +.memory-tab { + padding: 8px 14px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--fg2); + font-size: 12px; + font-weight: 500; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; + margin-bottom: -1px; +} + +.memory-tab:hover { + color: var(--fg0); + background: var(--bg2); +} + +.memory-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + .memory-table-wrap { overflow: auto; max-height: 360px; @@ -1305,8 +1361,3 @@ pre, code, .mono { font-size: 12px; } -@media (max-width: 1200px) { - .memory-grid { - grid-template-columns: 1fr; - } -} diff --git a/src/zen/frontend/html/trace.js b/src/zen/frontend/html/trace.js index 2910da15d..2c0b7a3bd 100644 --- a/src/zen/frontend/html/trace.js +++ b/src/zen/frontend/html/trace.js @@ -8,16 +8,8 @@ import { StatsView } from "./stats.js"; import { MemoryView } from "./memory.js"; import { LogsView } from "./logs.js"; import { CsvStatsView } from "./csvstats.js"; - -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ({ - "&": "&", - "<": "<", - ">": ">", - "\"": """, - "'": "'", - }[c])); -} +import { CountersView } from "./counters.js"; +import { escapeHtml } from "./util.js"; function formatTimeMs(us) { if (us < 1000) return `${us} µs`; @@ -157,10 +149,11 @@ async function main() { const memoryView = new MemoryView(model, document.getElementById("memory-content")); const logsView = new LogsView(model, document.getElementById("logs-content")); const csvView = new CsvStatsView(model, document.getElementById("csv-content")); + const countersView = new CountersView(model, document.getElementById("counters-content")); const threadsListApi = renderThreadsList(model, timeline); renderRegionCategories(model, timeline); - setupTabs(memoryView, logsView, csvView); + setupTabs(memoryView, logsView, csvView, countersView); setupSearch(model, timeline, stats); const bookmarksToggle = document.getElementById("bookmarks-toggle"); @@ -173,6 +166,26 @@ async function main() { timeline.setLodEnabled(lodToggle.checked); }); + const compactToggle = document.getElementById("compact-toggle"); + compactToggle.addEventListener("change", () => { + timeline.setCompact(compactToggle.checked); + }); + + // 'c' toggles compact mode while the timeline tab is active. Skipped + // when focus is in a text input so typing 'c' in the search box still + // works normally. + document.addEventListener("keydown", (e) => { + if (e.key !== "c" && e.key !== "C") return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + const target = e.target; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; + const timelineTab = document.querySelector(".tab[data-tab='timeline']"); + if (!timelineTab || !timelineTab.classList.contains("active")) return; + e.preventDefault(); + compactToggle.checked = !compactToggle.checked; + timeline.setCompact(compactToggle.checked); + }); + // Enable all threads that actually have captured scopes by default; if // none do, enable every thread so the swimlanes still show up empty. const withScopes = model.threads.filter((t) => t.scope_count > 0).map((t) => t.thread_id); @@ -492,7 +505,7 @@ function renderRegionCategories(model, timeline) { timeline.setEnabledRegionCategories(allIndices); } -function setupTabs(memoryView, logsView, csvView) { +function setupTabs(memoryView, logsView, csvView, countersView) { const tabs = document.querySelectorAll(".tab"); const views = document.querySelectorAll(".view"); const validTabs = new Set(Array.from(tabs, (tab) => tab.dataset.tab)); @@ -518,6 +531,9 @@ function setupTabs(memoryView, logsView, csvView) { if (key === "csv" && csvView) { csvView.ensureLoaded(); } + if (key === "counters" && countersView) { + countersView.ensureLoaded(); + } } for (const tab of tabs) { diff --git a/src/zen/frontend/html/util.js b/src/zen/frontend/html/util.js new file mode 100644 index 000000000..34241c9ae --- /dev/null +++ b/src/zen/frontend/html/util.js @@ -0,0 +1,16 @@ +// Copyright Epic Games, Inc. All Rights Reserved. +// Tiny shared helpers used across the trace-viewer modules. + +// Escape characters that have special meaning inside an HTML context (text or +// attribute) so that user-supplied strings can be safely interpolated into +// .innerHTML / template literals. Coerces the input to string first so callers +// don't have to. +export function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + }[c])); +} diff --git a/src/zen/trace/trace_analyze.cpp b/src/zen/trace/trace_analyze.cpp index ff168cd9c..3538c074d 100644 --- a/src/zen/trace/trace_analyze.cpp +++ b/src/zen/trace/trace_analyze.cpp @@ -174,6 +174,7 @@ public: AppendThreads(); AppendChannels(); AppendCpuScopeStats(); + AppendCounters(); AppendMemorySummary(); AppendLiveAllocationCallstacks(); AppendChurnCallstacks(); @@ -336,6 +337,59 @@ private: ZEN_CONSOLE(""); } + void AppendCounters() const + { + if (m_Model.CounterDefs.empty() && m_Model.CounterTimeSeries.empty()) + { + return; + } + + ZEN_CONSOLE("Counters:"); + ZEN_CONSOLE(""); + ZEN_CONSOLE("{:<48} {:>5} {:>10} {:>14} {:>14} {:>10}", "Counter", "Type", "Samples", "Min", "Max", "Last"); + ZEN_CONSOLE("{:-<{}}", "", 48 + 5 + 10 + 14 + 14 + 10 + 5); + + eastl::hash_map<uint16_t, const TraceModel::CounterSeries*> SeriesById; + SeriesById.reserve(m_Model.CounterTimeSeries.size()); + for (const TraceModel::CounterSeries& S : m_Model.CounterTimeSeries) + { + SeriesById[S.Id] = &S; + } + + auto FormatValue = [](double Value, uint8_t DisplayHint, uint8_t Type) -> std::string { + if (DisplayHint == 1 /* Memory */) + { + return fmt::format("{}", zen::NiceBytes(uint64_t(Value < 0 ? 0 : Value))); + } + if (Type == 0 /* Int */) + { + return fmt::format("{}", zen::ThousandsNum(int64_t(Value))); + } + return fmt::format("{:.3f}", Value); + }; + + for (const TraceModel::CounterDef& Def : m_Model.CounterDefs) + { + auto It = SeriesById.find(Def.Id); + if (It == SeriesById.end()) + { + continue; + } + const TraceModel::CounterSeries& S = *It->second; + const char* TypeStr = (Def.Type == 0) ? "int" : "flt"; + double Last = S.Samples.empty() ? 0.0 : S.Samples.back().Value; + + ZEN_CONSOLE("{:<48.48} {:>5} {:>10} {:>14} {:>14} {:>10}", + Def.Name, + TypeStr, + zen::ThousandsNum(S.Count), + FormatValue(S.Min, Def.DisplayHint, Def.Type), + FormatValue(S.Max, Def.DisplayHint, Def.Type), + FormatValue(Last, Def.DisplayHint, Def.Type)); + } + ZEN_CONSOLE(""); + } + void AppendMemorySummary() const { const AllocationSummary& AllocSummary = m_Model.AllocSummary; diff --git a/src/zen/trace/trace_cache.cpp b/src/zen/trace/trace_cache.cpp index 165c1eecf..954df14e0 100644 --- a/src/zen/trace/trace_cache.cpp +++ b/src/zen/trace/trace_cache.cpp @@ -448,6 +448,53 @@ namespace { return ToSharedBuffer(W); } + // -- Counters section -- + + SharedBuffer WriteCountersSection(const TraceModel& Model, StringTableBuilder& Strings) + { + BinaryWriter W; + + uint32_t DefCount = uint32_t(Model.CounterDefs.size()); + WritePod(W, DefCount); + for (const TraceModel::CounterDef& D : Model.CounterDefs) + { + CounterDefPod P = {}; + P.Id = D.Id; + P.Type = D.Type; + P.DisplayHint = D.DisplayHint; + P.Name = Strings.Intern(D.Name); + WritePod(W, P); + } + + uint32_t SeriesCount = uint32_t(Model.CounterTimeSeries.size()); + WritePod(W, SeriesCount); + // First pass: headers (so the reader can size each series before it + // reads samples). + for (const TraceModel::CounterSeries& S : Model.CounterTimeSeries) + { + CounterHeaderPod H = {}; + H.Id = S.Id; + H.Type = S.Type; + H.SampleCount = uint32_t(S.Samples.size()); + H.Min = S.Min; + H.Max = S.Max; + WritePod(W, H); + } + // Second pass: contiguous sample blob in the same order as headers. + for (const TraceModel::CounterSeries& S : Model.CounterTimeSeries) + { + for (const TraceModel::CounterSample& Sample : S.Samples) + { + CounterSamplePod SP = {}; + SP.TimeUs = Sample.TimeUs; + SP.Value = Sample.Value; + WritePod(W, SP); + } + } + + return ToSharedBuffer(W); + } + // -- Symbols section -- SharedBuffer WriteSymbolsSection(const eastl::hash_map<uint64_t, std::string>& ResolvedSymbols, StringTableBuilder& Strings) @@ -860,6 +907,67 @@ namespace { return true; } + bool ReadCountersSection(const SharedBuffer& Data, const StringTableReader& Strings, TraceModel& Model) + { + BinaryReader R(Data.GetData(), Data.GetSize()); + + uint32_t DefCount = 0; + if (!ReadUint32(R, DefCount)) + { + return false; + } + Model.CounterDefs.resize(DefCount); + for (uint32_t I = 0; I < DefCount; ++I) + { + CounterDefPod P; + if (!ReadPod(R, P)) + { + return false; + } + Model.CounterDefs[I].Id = P.Id; + Model.CounterDefs[I].Type = P.Type; + Model.CounterDefs[I].DisplayHint = P.DisplayHint; + Model.CounterDefs[I].Name = std::string(Strings.Get(P.Name)); + } + + uint32_t SeriesCount = 0; + if (!ReadUint32(R, SeriesCount)) + { + return false; + } + eastl::vector<CounterHeaderPod> Headers(SeriesCount); + for (uint32_t I = 0; I < SeriesCount; ++I) + { + if (!ReadPod(R, Headers[I])) + { + return false; + } + } + Model.CounterTimeSeries.resize(SeriesCount); + for (uint32_t I = 0; I < SeriesCount; ++I) + { + const CounterHeaderPod& H = Headers[I]; + TraceModel::CounterSeries& Out = Model.CounterTimeSeries[I]; + Out.Id = H.Id; + Out.Type = H.Type; + Out.Count = H.SampleCount; + Out.Min = H.Min; + Out.Max = H.Max; + Out.Samples.resize(H.SampleCount); + for (uint32_t J = 0; J < H.SampleCount; ++J) + { + CounterSamplePod SP; + if (!ReadPod(R, SP)) + { + return false; + } + Out.Samples[J].TimeUs = SP.TimeUs; + Out.Samples[J].Value = SP.Value; + } + } + return true; + } + // =========================================================================== // File-level helpers // =========================================================================== @@ -913,6 +1021,7 @@ WriteAnalyzeCache(const std::filesystem::path& CachePath, SharedBuffer MemoryRaw = WriteMemorySection(Model, Strings); SharedBuffer CallstacksRaw = WriteCallstacksSection(Model); SharedBuffer SymbolsRaw = WriteSymbolsSection(ResolvedSymbols, Strings); + SharedBuffer CountersRaw = WriteCountersSection(Model, Strings); SharedBuffer StringTableRaw = Strings.Serialize(); // Compress each section @@ -922,6 +1031,7 @@ WriteAnalyzeCache(const std::filesystem::path& CachePath, Sections[uint32_t(CacheSectionId::Memory)] = CompressSection(MemoryRaw); Sections[uint32_t(CacheSectionId::Callstacks)] = CompressSection(CallstacksRaw); Sections[uint32_t(CacheSectionId::Symbols)] = CompressSection(SymbolsRaw); + Sections[uint32_t(CacheSectionId::Counters)] = CompressSection(CountersRaw); // Build file header CacheFileHeader Header = {}; @@ -1091,6 +1201,16 @@ TryLoadAnalyzeCache(const std::filesystem::path& CachePath, const std::filesyste } } + SharedBuffer CountersData = DecompressSection(Base, Directory[uint32_t(CacheSectionId::Counters)]); + if (!CountersData.IsNull()) + { + if (!ReadCountersSection(CountersData, Strings, Result.Model)) + { + ZEN_DEBUG("Analysis cache: failed to read counters section"); + // Counters are optional -- continue without them. + } + } + ZEN_INFO("Loaded analysis from cache ({})", zen::NiceBytes(FileData.Size())); return Result; } diff --git a/src/zen/trace/trace_cache.h b/src/zen/trace/trace_cache.h index 88778a020..6b5bd1da9 100644 --- a/src/zen/trace/trace_cache.h +++ b/src/zen/trace/trace_cache.h @@ -24,7 +24,7 @@ namespace zen::trace_detail { // --------------------------------------------------------------------------- static constexpr uint32_t kCacheMagic = 0x005A4355; // "UCZ\0" -static constexpr uint32_t kCacheVersion = 1; +static constexpr uint32_t kCacheVersion = 2; // bump on any layout change enum class CacheSectionId : uint32_t { @@ -33,6 +33,7 @@ enum class CacheSectionId : uint32_t Memory = 2, Callstacks = 3, Symbols = 4, + Counters = 5, Count }; @@ -211,6 +212,31 @@ struct SymbolEntryPod uint32_t Pad; }; +struct CounterDefPod +{ + uint16_t Id; + uint8_t Type; + uint8_t DisplayHint; + uint32_t Name; // string index +}; + +struct CounterHeaderPod +{ + uint16_t Id; + uint8_t Type; + uint8_t Pad0; + uint32_t SampleCount; + double Min; + double Max; +}; + +struct CounterSamplePod +{ + uint32_t TimeUs; + uint32_t Pad; + double Value; +}; + // Pin the on-disk layout. Any change here is a cache format change and must // bump kCacheVersion. static_assert(sizeof(CacheFileHeader) == 32); @@ -229,6 +255,9 @@ static_assert(sizeof(CallstackChurnStatPod) == 48); static_assert(sizeof(CallstackHeaderPod) == 16); static_assert(sizeof(ResolvedFramePod) == 24); static_assert(sizeof(SymbolEntryPod) == 16); +static_assert(sizeof(CounterDefPod) == 8); +static_assert(sizeof(CounterHeaderPod) == 24); +static_assert(sizeof(CounterSamplePod) == 16); // --------------------------------------------------------------------------- // Cache read / write API diff --git a/src/zen/trace/trace_model.cpp b/src/zen/trace/trace_model.cpp index ac81161a1..c11e2c47c 100644 --- a/src/zen/trace/trace_model.cpp +++ b/src/zen/trace/trace_model.cpp @@ -387,6 +387,26 @@ begin_outline(CsvProfiler, Metadata) field(uint8[], Key) field(uint8[], Value) end_outline() + +// Counters trace events (UE CountersTrace / zen ZEN_TRACE_INT_VALUE). +begin_outline(Counters, Spec) + field(uint16, Id) + field(uint8, Type) + field(uint8, DisplayHint) + field(uint8[], Name) +end_outline() + +begin_outline(Counters, SetValueInt) + field(uint64, Cycle) + field(int64, Value) + field(uint16, CounterId) +end_outline() + +begin_outline(Counters, SetValueFloat) + field(uint64, Cycle) + field(double, Value) + field(uint16, CounterId) +end_outline() // clang-format on ////////////////////////////////////////////////////////////////////////////// @@ -1471,6 +1491,138 @@ private: }; ////////////////////////////////////////////////////////////////////////////// +// Counters analyzer -- consumes Counters.Spec / SetValueInt / SetValueFloat +// (UE TRACE_INT_VALUE / TRACE_FLOAT_VALUE / zen ZEN_TRACE_INT_VALUE etc.). +// Spec events register a counter id; SetValue events emit a sample. We keep +// per-counter time series for the viewer / report to render. + +class CountersAnalyzer : public Analyzer +{ +public: + explicit CountersAnalyzer(const TraceTiming* Timing = nullptr) : m_Timing(Timing) {} + + void subscribe(Vector<Subscription>& Subs) override + { + Subs.emplace_back(this, &CountersAnalyzer::OnSpec); + Subs.emplace_back(this, &CountersAnalyzer::OnSetValueInt); + Subs.emplace_back(this, &CountersAnalyzer::OnSetValueFloat); + } + + struct EditableSeries + { + zen::trace_detail::TraceModel::CounterSeries Series; + bool HasMin = false; + }; + + const eastl::hash_map<uint16_t, zen::trace_detail::TraceModel::CounterDef>& Defs() const { return m_Defs; } + eastl::hash_map<uint16_t, EditableSeries>& MutableSeriesMap() { return m_Series; } + +private: + uint32_t CycleToTimeUs(uint64_t Cycle) const + { + if (!m_Timing || m_Timing->Freq == 0) + { + return 0; + } + uint64_t Elapsed = (Cycle >= m_Timing->Base) ? (Cycle - m_Timing->Base) : 0; + return uint32_t(Elapsed * 1'000'000 / m_Timing->Freq); + } + + static std::string DecodeAnsiName(const Array<uint8[]>& Data) + { + const uint8_t* P = Data.get(); + size_t Size = Data.get_size(); + if (!P || Size == 0) + { + return {}; + } + return std::string(reinterpret_cast<const char*>(P), Size); + } + + void OnSpec(const Counters_Spec& Ev) + { + uint16_t Id = uint16_t(Ev.Id()); + if (Id == 0) + { + return; + } + zen::trace_detail::TraceModel::CounterDef Def; + Def.Id = Id; + Def.Type = uint8_t(Ev.Type()); + Def.DisplayHint = uint8_t(Ev.DisplayHint()); + Def.Name = DecodeAnsiName(Ev.Name()); + if (Def.Name.empty()) + { + Def.Name = fmt::format("counter_{}", Id); + } + m_Defs[Id] = std::move(Def); + } + + EditableSeries& EnsureSeries(uint16_t Id, uint8_t Type) + { + auto It = m_Series.find(Id); + if (It == m_Series.end()) + { + EditableSeries E; + E.Series.Id = Id; + E.Series.Type = Type; + It = m_Series.emplace(Id, std::move(E)).first; + } + return It->second; + } + + void OnSetValueInt(const Counters_SetValueInt& Ev) + { + uint16_t Id = uint16_t(Ev.CounterId()); + if (Id == 0) + { + return; + } + EditableSeries& E = EnsureSeries(Id, /*Int*/ 0); + double Value = double(int64_t(Ev.Value())); + uint32_t TimeUs = CycleToTimeUs(Ev.Cycle()); + E.Series.Samples.push_back({TimeUs, Value}); + ++E.Series.Count; + if (!E.HasMin || Value < E.Series.Min) + { + E.Series.Min = Value; + E.HasMin = true; + } + if (Value > E.Series.Max) + { + E.Series.Max = Value; + } + } + + void OnSetValueFloat(const Counters_SetValueFloat& Ev) + { + uint16_t Id = uint16_t(Ev.CounterId()); + if (Id == 0) + { + return; + } + EditableSeries& E = EnsureSeries(Id, /*Float*/ 1); + double Value = double(Ev.Value()); + uint32_t TimeUs = CycleToTimeUs(Ev.Cycle()); + E.Series.Samples.push_back({TimeUs, Value}); + ++E.Series.Count; + if (!E.HasMin || Value < E.Series.Min) + { + E.Series.Min = Value; + E.HasMin = true; + } + if (Value > E.Series.Max) + { + E.Series.Max = Value; + } + } + + const TraceTiming* m_Timing = nullptr; + eastl::hash_map<uint16_t, zen::trace_detail::TraceModel::CounterDef> m_Defs; + eastl::hash_map<uint16_t, EditableSeries> m_Series; +}; + +////////////////////////////////////////////////////////////////////////////// // Analyzers class CpuAnalyzer : public Analyzer @@ -3165,6 +3317,7 @@ BuildTraceModel(const std::filesystem::path& FilePath, WorkerThreadPool& ThreadP LogAnalyzer LogAn(&Timing); BookmarksAnalyzer BookmarkAn(&Timing); CsvProfilerAnalyzer CsvAn(&Timing); + CountersAnalyzer CountersAn(&Timing); AllocationAnalyzer AllocAn(&Timing); CallstackAnalyzer CallstackAn; @@ -3182,6 +3335,7 @@ BuildTraceModel(const std::filesystem::path& FilePath, WorkerThreadPool& ThreadP Dispatch.add_analyzer(LogAn); Dispatch.add_analyzer(BookmarkAn); Dispatch.add_analyzer(CsvAn); + Dispatch.add_analyzer(CountersAn); Dispatch.add_analyzer(AllocAn); Dispatch.add_analyzer(CallstackAn); @@ -3434,6 +3588,61 @@ BuildTraceModel(const std::filesystem::path& FilePath, WorkerThreadPool& ThreadP Model.CsvEvents.size()); } + // Counters (TRACE_INT_VALUE / TRACE_FLOAT_VALUE) + { + using CounterDefT = zen::trace_detail::TraceModel::CounterDef; + using CounterSeriesT = zen::trace_detail::TraceModel::CounterSeries; + using CounterSampleT = zen::trace_detail::TraceModel::CounterSample; + + Model.CounterDefs.reserve(CountersAn.Defs().size()); + for (const auto& [Id, Def] : CountersAn.Defs()) + { + Model.CounterDefs.push_back(Def); + } + + auto& SeriesMap = CountersAn.MutableSeriesMap(); + Model.CounterTimeSeries.reserve(SeriesMap.size()); + for (auto& [Id, Editable] : SeriesMap) + { + // Each counter's samples were appended in stream order. Tourist + // guarantees per-thread monotonicity but counters can be set from + // any thread, so a final sort by TimeUs is required. + eastl::sort(Editable.Series.Samples.begin(), + Editable.Series.Samples.end(), + [](const CounterSampleT& A, const CounterSampleT& B) { return A.TimeUs < B.TimeUs; }); + // If the counter never produced a Spec event we still want the + // series visible. Synthesize a default def so the viewer has a name. + if (CountersAn.Defs().find(Id) == CountersAn.Defs().end()) + { + CounterDefT Synth; + Synth.Id = Id; + Synth.Type = Editable.Series.Type; + Synth.Name = fmt::format("counter_{}", Id); + Model.CounterDefs.push_back(std::move(Synth)); + } + Model.CounterTimeSeries.push_back(std::move(Editable.Series)); + } + eastl::sort(Model.CounterDefs.begin(), Model.CounterDefs.end(), [](const CounterDefT& A, const CounterDefT& B) { + return A.Id < B.Id; + }); + eastl::sort(Model.CounterTimeSeries.begin(), Model.CounterTimeSeries.end(), [](const CounterSeriesT& A, const CounterSeriesT& B) { + return A.Id < B.Id; + }); + + size_t TotalSamples = 0; + for (const CounterSeriesT& S : Model.CounterTimeSeries) + { + TotalSamples += S.Samples.size(); + } + if (!Model.CounterDefs.empty() || !Model.CounterTimeSeries.empty()) + { + ZEN_INFO("Counters: {} defined, {} with samples ({} samples total)", + Model.CounterDefs.size(), + Model.CounterTimeSeries.size(), + zen::ThousandsNum(TotalSamples)); + } + } + // Memory allocation data { AllocAn.EmitFinalSample(Model.TraceEndUs); diff --git a/src/zen/trace/trace_model.h b/src/zen/trace/trace_model.h index bd6dcc674..3ac4c0cce 100644 --- a/src/zen/trace/trace_model.h +++ b/src/zen/trace/trace_model.h @@ -260,6 +260,39 @@ struct TraceModel eastl::vector<CsvEvent> CsvEvents; // sorted by TimeUs eastl::vector<CsvMeta> CsvMetadata; + // -- Counters (TRACE_INT_VALUE / TRACE_FLOAT_VALUE / TRACE_MEMORY_VALUE) -- + // One CounterDef per registered counter (Counters.Spec event), and one + // CounterSeries per counter that produced any samples (Counters.SetValueInt + // / SetValueFloat events). + struct CounterDef + { + uint16_t Id = 0; + uint8_t Type = 0; // 0 = Int, 1 = Float + uint8_t DisplayHint = 0; // 0 = None, 1 = Memory + std::string Name; + }; + + struct CounterSample + { + uint32_t TimeUs; + double Value; // int counters are widened to double for transport; + // exact int values up to 2^53 round-trip losslessly. + }; + + struct CounterSeries + { + uint16_t Id = 0; + uint8_t Type = 0; // mirrors CounterDef::Type + uint8_t Pad = 0; + uint32_t Count = 0; + double Min = 0.0; + double Max = 0.0; + eastl::vector<CounterSample> Samples; // sorted by TimeUs + }; + + eastl::vector<CounterDef> CounterDefs; // sorted by Id + eastl::vector<CounterSeries> CounterTimeSeries; // one per counter that produced samples, sorted by Id + // -- Event type counts (sorted by count descending) -- struct EventTypeCount { diff --git a/src/zen/trace/trace_viewer_service.cpp b/src/zen/trace/trace_viewer_service.cpp index 7d8301ae2..cd5517613 100644 --- a/src/zen/trace/trace_viewer_service.cpp +++ b/src/zen/trace/trace_viewer_service.cpp @@ -382,6 +382,14 @@ TraceViewerService::HandleApiRequest(HttpServerRequest& Request, std::string_vie { HandleCsvMetadataApi(Request); } + else if (Path == "counters"sv) + { + HandleCountersApi(Request); + } + else if (Path == "counter-series"sv) + { + HandleCounterSeriesApi(Request); + } else if (Path == "alloc-summary"sv) { HandleAllocSummaryApi(Request); @@ -887,6 +895,89 @@ TraceViewerService::HandleCsvMetadataApi(HttpServerRequest& Request) Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray()); } +void +TraceViewerService::HandleCountersApi(HttpServerRequest& Request) +{ + // Map id -> sample count so the front-end can hide empty defs without a + // second round-trip. + eastl::hash_map<uint16_t, uint32_t> SeriesByCounterId; + SeriesByCounterId.reserve(m_Model.CounterTimeSeries.size()); + for (const trace_detail::TraceModel::CounterSeries& S : m_Model.CounterTimeSeries) + { + SeriesByCounterId[S.Id] = S.Count; + } + + CbWriter Writer; + Writer.BeginArray(); + for (const trace_detail::TraceModel::CounterDef& D : m_Model.CounterDefs) + { + auto It = SeriesByCounterId.find(D.Id); + Writer.BeginObject(); + Writer << "id" << D.Id; + Writer << "type" << D.Type; + Writer << "display_hint" << D.DisplayHint; + Writer << "name" << D.Name; + Writer << "sample_count" << (It != SeriesByCounterId.end() ? It->second : 0u); + Writer.EndObject(); + } + Writer.EndArray(); + + Request.WriteResponse(HttpResponseCode::OK, Writer.Save().AsArray()); +} + +void +TraceViewerService::HandleCounterSeriesApi(HttpServerRequest& Request) +{ + HttpServerRequest::QueryParams Params = Request.GetQueryParams(); + std::string_view IdStr = Params.GetValue("id"); + if (IdStr.empty()) + { + Request.WriteResponse(HttpResponseCode::BadRequest, HttpContentType::kText, "missing required query parameter 'id'"); + return; + } + uint32_t WantedId = ParseUintParam(IdStr, 0); + if (WantedId == 0 || WantedId > 0xFFFF) + { + Request.WriteResponse(HttpResponseCode::BadRequest, HttpContentType::kText, "invalid 'id' (must be 1..65535)"); + return; + } + + const trace_detail::TraceModel::CounterSeries* Found = nullptr; + for (const trace_detail::TraceModel::CounterSeries& S : m_Model.CounterTimeSeries) + { + if (S.Id == uint16_t(WantedId)) + { + Found = &S; + break; + } + } + + CbObjectWriter Obj; + Obj << "id" << uint32_t(WantedId); + if (Found != nullptr) + { + Obj << "type" << uint32_t(Found->Type); + Obj << "count" << Found->Count; + Obj << "min" << Found->Min; + Obj << "max" << Found->Max; + } + Obj.BeginArray("samples"); + if (Found != nullptr) + { + // [time_us, value] tuples to keep the payload tight for large series. + for (const trace_detail::TraceModel::CounterSample& S : Found->Samples) + { + Obj.BeginArray(); + Obj.AddInteger(uint32_t(S.TimeUs)); + Obj.AddFloat(S.Value); + Obj.EndArray(); + } + } + Obj.EndArray(); + + Request.WriteResponse(HttpResponseCode::OK, Obj.Save()); +} + ////////////////////////////////////////////////////////////////////////////// // Memory allocation endpoints diff --git a/src/zen/trace/trace_viewer_service.h b/src/zen/trace/trace_viewer_service.h index f7bc51499..af74134b1 100644 --- a/src/zen/trace/trace_viewer_service.h +++ b/src/zen/trace/trace_viewer_service.h @@ -49,6 +49,8 @@ private: void HandleCsvSeriesApi(HttpServerRequest& Request); void HandleCsvEventsApi(HttpServerRequest& Request); void HandleCsvMetadataApi(HttpServerRequest& Request); + void HandleCountersApi(HttpServerRequest& Request); + void HandleCounterSeriesApi(HttpServerRequest& Request); void HandleAllocSummaryApi(HttpServerRequest& Request); void HandleHeapsApi(HttpServerRequest& Request); void HandleAllocTagsApi(HttpServerRequest& Request); diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index 7f5c50b2f..550edb7ef 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -19,6 +19,7 @@ #include "cmds/projectstore_cmd.h" #include "cmds/serve_cmd.h" #include "cmds/service_cmd.h" +#include "cmds/sessions_cmd.h" #include "cmds/status_cmd.h" #include "cmds/top_cmd.h" #include "cmds/ui_cmd.h" @@ -38,6 +39,7 @@ #include <zencore/process.h> #include <zencore/scopeguard.h> #include <zencore/sentryintegration.h> +#include <zencore/session.h> #include <zencore/string.h> #include <zencore/trace.h> #include <zencore/windows.h> @@ -687,6 +689,7 @@ main(int argc, char** argv) RpcStopRecordingCommand RpcStopRecordingCmd; ScrubCommand ScrubCmd; ServeCommand ServeCmd; + SessionsCommand SessionsCmd; StatusCommand StatusCmd; LoggingCommand LoggingCmd; TopCommand TopCmd; @@ -754,6 +757,7 @@ main(int argc, char** argv) {RpcStopRecordingCommand::Name, &RpcStopRecordingCmd, RpcStopRecordingCommand::Description}, {ScrubCommand::Name, &ScrubCmd, ScrubCommand::Description}, {ServeCommand::Name, &ServeCmd, ServeCommand::Description}, + {SessionsCommand::Name, &SessionsCmd, SessionsCommand::Description}, {StatusCommand::Name, &StatusCmd, StatusCommand::Description}, {TopCommand::Name, &TopCmd, TopCommand::Description}, {TraceCommand::Name, &TraceCmd, TraceCommand::Description}, @@ -863,6 +867,7 @@ main(int argc, char** argv) GlobalOptions.PassthroughArgV = PassthroughArgV; std::string MemoryOptions; + std::string ParentSessionId; std::string SubCommand = "<None>"; @@ -879,6 +884,9 @@ main(int argc, char** argv) Options.add_options()("httpclient", "Select HTTP client implementation", cxxopts::value<std::string>(GlobalOptions.HttpClientBackend)->default_value("curl")); + Options.add_options()("parent-session", + "Specify parent session id used to associate this process with another session", + cxxopts::value<std::string>(ParentSessionId)); int CoreLimit = 0; @@ -979,6 +987,19 @@ main(int argc, char** argv) LimitHardwareConcurrency(CoreLimit); + if (!ParentSessionId.empty()) + { + Oid ParsedParentSessionId; + if (!Oid::TryParse(ParentSessionId, ParsedParentSessionId) || ParsedParentSessionId == Oid::Zero) + { + throw zen::OptionParseException( + fmt::format("invalid parent session id '{}': expected a 24-character object id", ParentSessionId), + Options.help()); + } + GlobalOptions.ParentSessionId = ParsedParentSessionId; + SetParentSessionId(ParsedParentSessionId); + } + #if ZEN_USE_SENTRY { EnvironmentOptions EnvOptions; @@ -1070,6 +1091,12 @@ main(int argc, char** argv) { try { + // Bootstrap window is closed: option parsing is done, + // logging is fully wired, the command is about to run. + // Drop the captured backlog so it doesn't pin memory + // for the rest of the command's lifetime. + DisableLogBacklog(); + CmdInfo.Cmd->Run(GlobalOptions, (int)CommandArgVec.size(), CommandArgVec.data()); return (int)ReturnCode::kSuccess; } diff --git a/src/zen/zen.h b/src/zen/zen.h index ff9df8371..2f0a02e6a 100644 --- a/src/zen/zen.h +++ b/src/zen/zen.h @@ -4,6 +4,7 @@ #include <zencore/except.h> #include <zencore/timer.h> +#include <zencore/uid.h> #include <zencore/zencore.h> #include <zenhttp/httpclient.h> #include <zenutil/config/commandlineoptions.h> @@ -22,6 +23,7 @@ struct ZenCliOptions ZenLoggingConfig LoggingConfig; std::string HttpClientBackend; // Choice of HTTP client implementation + Oid ParentSessionId = Oid::Zero; // Arguments after " -- " on command line are passed through and not parsed std::string PassthroughCommandLine; diff --git a/src/zen/zenserviceclient.cpp b/src/zen/zenserviceclient.cpp index 87d0a6c26..255a028be 100644 --- a/src/zen/zenserviceclient.cpp +++ b/src/zen/zenserviceclient.cpp @@ -21,10 +21,11 @@ ZenServiceClient::ZenServiceClient(Options Opts) } SessionsServiceClient::Options SessionOpts{ - .TargetUrl = m_HostSpec, - .AppName = "zen", - .Mode = std::move(Opts.CommandName), - .SessionId = GetSessionId(), + .TargetUrl = m_HostSpec, + .AppName = "zen", + .Mode = std::move(Opts.CommandName), + .SessionId = GetSessionId(), + .ParentSessionId = GetParentSessionId(), }; // For unix socket connections, forward the socket path to the sessions client @@ -52,4 +53,10 @@ ZenServiceClient::~ZenServiceClient() } } +bool +ZenServiceClient::IsUnixSocket() const +{ + return ZenCmdBase::IsUnixSocketSpec(m_HostSpec); +} + } // namespace zen diff --git a/src/zen/zenserviceclient.h b/src/zen/zenserviceclient.h index 00178b455..4e0f13982 100644 --- a/src/zen/zenserviceclient.h +++ b/src/zen/zenserviceclient.h @@ -33,6 +33,12 @@ public: HttpClient& Http() { return m_Http; } const std::string& HostSpec() const { return m_HostSpec; } + /// True if the resolved host spec targets a Unix domain socket + /// (i.e. `unix:///...`). Useful for command paths that don't make + /// sense over a unix transport (browser launches, WebSocket dialing + /// without UnixSocketPath plumbing, etc.). + bool IsUnixSocket() const; + private: std::string m_HostSpec; HttpClient m_Http; |