diff options
Diffstat (limited to 'src')
72 files changed, 7048 insertions, 735 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; diff --git a/src/zencore/compactbinaryvalidation.cpp b/src/zencore/compactbinaryvalidation.cpp index 3e78f8ef1..5c3b9ff19 100644 --- a/src/zencore/compactbinaryvalidation.cpp +++ b/src/zencore/compactbinaryvalidation.cpp @@ -10,6 +10,9 @@ #include <algorithm> +#include <EASTL/fixed_vector.h> +#include <EASTL/sort.h> + namespace zen { namespace CbValidationPrivate { @@ -240,7 +243,7 @@ ValidateCbObject(MemoryView& View, CbValidateMode Mode, CbValidateError& Error, if (Size > 0) { - std::vector<std::string_view> Names; + eastl::fixed_vector<std::string_view, 32> Names; const bool bUniformObject = CbFieldTypeOps::GetType(ObjectType) == CbFieldType::UniformObject; const CbFieldType ExternalType = bUniformObject ? ValidateCbFieldType(ObjectView, Mode, Error) : CbFieldType::HasFieldType; @@ -265,7 +268,7 @@ ValidateCbObject(MemoryView& View, CbValidateMode Mode, CbValidateError& Error, if (EnumHasAnyFlags(Mode, CbValidateMode::Names) && Names.size() > 1) { - std::sort(begin(Names), end(Names), [](std::string_view L, std::string_view R) { return L.compare(R) < 0; }); + std::sort(Names.begin(), Names.end(), [](std::string_view L, std::string_view R) { return L.compare(R) < 0; }); for (const std::string_view *NamesIt = Names.data(), *NamesEnd = NamesIt + Names.size() - 1; NamesIt != NamesEnd; ++NamesIt) { @@ -639,8 +642,8 @@ ValidateObjectAttachment(MemoryView View, CbValidateMode Mode) CbValidateError ValidateCompactBinaryPackage(MemoryView View, CbValidateMode Mode) { - std::vector<IoHash> Attachments; - CbValidateError Error = CbValidateError::None; + eastl::fixed_vector<IoHash, 32> Attachments; + CbValidateError Error = CbValidateError::None; if (EnumHasAnyFlags(Mode, CbValidateMode::All)) { uint32_t ObjectCount = 0; @@ -684,7 +687,7 @@ ValidateCompactBinaryPackage(MemoryView View, CbValidateMode Mode) if (Attachments.size() && EnumHasAnyFlags(Mode, CbValidateMode::Package)) { - std::sort(begin(Attachments), end(Attachments)); + eastl::sort(Attachments.begin(), Attachments.end()); for (const IoHash *It = Attachments.data(), *End = It + Attachments.size() - 1; It != End; ++It) { if (It[0] == It[1]) diff --git a/src/zencore/include/zencore/logging/backlogsink.h b/src/zencore/include/zencore/logging/backlogsink.h new file mode 100644 index 000000000..be170db9d --- /dev/null +++ b/src/zencore/include/zencore/logging/backlogsink.h @@ -0,0 +1,104 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include <zencore/logging/sink.h> +#include <zencore/memory/memoryarena.h> +#include <zencore/thread.h> + +ZEN_THIRD_PARTY_INCLUDES_START +#include <EASTL/vector.h> +ZEN_THIRD_PARTY_INCLUDES_END + +#include <atomic> +#include <chrono> +#include <limits> +#include <memory> + +namespace zen::logging { + +/// Captures every message it sees so that sinks attached *after* logging +/// has already been emitting can replay the early window into themselves. +/// +/// Typical lifecycle: +/// 1. At process start, install one BacklogSink as a child of the +/// default broadcast sink. Capture begins immediately. +/// 2. As real sinks come online (file, console, in-proc session, …), +/// they're attached to the broadcast and can call Replay() to +/// receive the lines emitted before they joined. +/// 3. At a well-defined "we're past the bootstrap window" point — the +/// server's `OnReady()` for zenserver, command dispatch for the zen +/// CLI — call Disable() to stop capturing and free everything. +/// +/// Storage is bounded and arena-backed: the vector and every captured +/// string are allocated from a single MemoryArena that gets dropped +/// wholesale on Disable(). The vector is reserved up front so push_back +/// never reallocates; once full we drop new entries (the early-startup +/// lines are the ones we most want to preserve, so keep-oldest is the +/// right policy here). +class BacklogSink : public Sink +{ +public: + /// @param MaxEntries Hard cap on captured messages. The vector is + /// reserved up front from the arena. 256 is a + /// comfortable cap for the bootstrap window — + /// typical zen process startup emits a few dozen + /// lines, well under the limit. + explicit BacklogSink(size_t MaxEntries = 256); + ~BacklogSink() override; + + BacklogSink(const BacklogSink&) = delete; + BacklogSink& operator=(const BacklogSink&) = delete; + + void Log(const LogMessage& Msg) override; + void Flush() override; + void SetFormatter(std::unique_ptr<Formatter> InFormatter) override; + + /// Replay captured messages into Target in arrival order. By default + /// every entry currently in the buffer is replayed; pass @p MaxEntries + /// to stop after that many (useful when a caller has snapshotted a + /// cursor to avoid re-delivering messages the target already received + /// live — see AttachSinkWithBacklogReplay). Target's ShouldLog() filter + /// is honored. Replay does NOT remove entries from the buffer — it's + /// safe to call multiple times. + void Replay(Sink& Target, size_t MaxEntries = std::numeric_limits<size_t>::max()); + + /// Stop capturing and free the entire arena (vector buffer + every + /// captured string in one shot). Subsequent Log() calls return + /// immediately. Idempotent. + void Disable(); + + bool IsEnabled() const { return m_Enabled.load(std::memory_order_acquire); } + size_t Size() const; + size_t Dropped() const { return m_DroppedCount.load(std::memory_order_relaxed); } + +private: + struct Entry + { + // LogPoint pointers come from static / near-static instances + // owned by the ZEN_LOG macros (see asyncsink.cpp's note); the + // pointer outlives any plausible backlog buffer. + const LogPoint* Point = nullptr; + + // Both strings live in m_Arena — null-terminated, so we keep them + // as plain const char* and reconstruct string_views on replay. + const char* LoggerName = nullptr; + const char* Payload = nullptr; + + std::chrono::system_clock::time_point Time; + int ThreadId = 0; + }; + + mutable RwLock m_Lock; + // Declaration order matters: m_Arena must be initialized before + // m_Entries because m_Entries' allocator references m_Arena. Both + // outlive every captured Entry — they're released as a unit when + // this BacklogSink is destroyed. + MemoryArena m_Arena{32768}; + eastl::vector<Entry, ArenaAlloc> m_Entries{ArenaAlloc(m_Arena)}; + std::atomic<bool> m_Enabled{true}; + std::atomic<uint64_t> m_DroppedCount{0}; + size_t m_MaxEntries; +}; + +} // namespace zen::logging diff --git a/src/zencore/include/zencore/logging/broadcastsink.h b/src/zencore/include/zencore/logging/broadcastsink.h index 474662888..2b96e04f4 100644 --- a/src/zencore/include/zencore/logging/broadcastsink.h +++ b/src/zencore/include/zencore/logging/broadcastsink.h @@ -72,6 +72,21 @@ public: m_Sinks.push_back(std::move(InSink)); } + /// Add a child sink and run @p OnAddedUnderLock with the broadcast's + /// exclusive lock still held. Useful for callers that need to capture + /// some external state atomically with the moment the sink becomes + /// visible to fanout — e.g. snapshotting a backlog cursor so a + /// subsequent replay can de-duplicate against messages that already + /// reached the new sink via Log(). The callback must not call back + /// into this BroadcastSink (would self-deadlock). + template<typename F> + void AddSinkAtomic(SinkPtr InSink, F&& OnAddedUnderLock) + { + RwLock::ExclusiveLockScope Lock(m_Lock); + m_Sinks.push_back(std::move(InSink)); + OnAddedUnderLock(); + } + void RemoveSink(const SinkPtr& InSink) { RwLock::ExclusiveLockScope Lock(m_Lock); diff --git a/src/zencore/include/zencore/memory/memoryarena.h b/src/zencore/include/zencore/memory/memoryarena.h index 551415aac..167449036 100644 --- a/src/zencore/include/zencore/memory/memoryarena.h +++ b/src/zencore/include/zencore/memory/memoryarena.h @@ -3,7 +3,8 @@ #pragma once #include <zencore/thread.h> -#include <vector> + +#include <EASTL/fixed_vector.h> namespace zen { @@ -14,6 +15,10 @@ namespace zen { * All allocations are freed when the arena is destroyed, therefore there * is no support for individual deallocation. * + * Chunk size is configurable at construction (default 64 KiB). Allocations + * larger than the configured chunk size get their own dedicated chunk so + * they don't waste or overrun the normal chunk. + * * For convenience, we include operator new/delete overloads below which * take a MemoryArena reference as a placement argument. * @@ -22,7 +27,12 @@ namespace zen { class MemoryArena { public: - MemoryArena() = default; + /// Default chunk size (64 KiB). Sized to fit comfortably in L2 and + /// to minimize the number of chunks needed for typical workloads. + static constexpr size_t kDefaultChunkSize = 64 * 1024; + + /// @param ChunkSize Bytes per chunk. Zero is treated as the default. + explicit MemoryArena(size_t ChunkSize = kDefaultChunkSize); ~MemoryArena(); void* AllocateAligned(size_t ByteCount, size_t align); @@ -30,17 +40,45 @@ public: void* Allocate(size_t Size); const char* DuplicateString(std::string_view Str); + /// Configured chunk size in bytes. + size_t GetChunkSize() const { return m_ChunkSize; } + + /// Total bytes the arena currently holds across all chunks + /// (regular + oversize). This is the reserved footprint, not the + /// fraction of it that's been bump-pointer-allocated to callers — + /// useful as a "how big did this arena ever grow" diagnostic. + size_t GetReservedBytes() const; + MemoryArena(const MemoryArena&) = delete; MemoryArena& operator=(const MemoryArena&) = delete; private: - static constexpr size_t ChunkSize = 16 * 1024; + // True when the request's worst-case footprint exceeds m_ChunkSize. + // Such requests get a dedicated chunk so the regular bump pointer is + // preserved for small allocations. + bool IsOversizeRequest(size_t WorstCaseBytes) const; + + // Allocates a chunk and pushes it into m_Chunks. Caller is responsible + // for any bookkeeping (current pointer/offset). Must be called with + // m_Lock held exclusive. Returns nullptr on out-of-memory. + uint8_t* AllocateDedicatedChunk(size_t Capacity); + // TODO: should just chain the memory blocks together and avoid this // vector altogether, saving us a memory allocation - std::vector<uint8_t*> m_Chunks; - uint8_t* m_CurrentChunk = nullptr; - size_t m_CurrentOffset = 0; - RwLock m_Lock; + struct Chunk + { + uint8_t* Base = nullptr; + size_t Capacity = 0; // May exceed m_ChunkSize for oversize allocations. + }; + // 32 inline chunks keeps the vector itself allocation-free for any + // arena that stays under ~2 MiB at the default chunk size; growth + // beyond that falls back to heap. + eastl::fixed_vector<Chunk, 32> m_Chunks; + uint8_t* m_CurrentChunk = nullptr; + size_t m_CurrentChunkCapacity = 0; + size_t m_CurrentOffset = 0; + size_t m_ChunkSize; + mutable RwLock m_Lock; }; // Allocator suitable for use with EASTL diff --git a/src/zencore/include/zencore/oidguid.h b/src/zencore/include/zencore/oidguid.h new file mode 100644 index 000000000..16d8cd101 --- /dev/null +++ b/src/zencore/include/zencore/oidguid.h @@ -0,0 +1,81 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include <zencore/guid.h> +#include <zencore/uid.h> + +#include <cstdint> +#include <cstring> + +namespace zen { + +/// Convert a 12-byte Oid into a UUID-shaped 16-byte Guid that +/// round-trips back through GuidToOid. Used to bridge zen session ids +/// (Oid, 12 bytes) and tools that consume UUID-shaped session +/// identifiers — most notably UE Trace, whose session id is an FGuid +/// memcpy'd into the trace stream's `Desc.SessionGuid`. +/// +/// The transform packs the 12 bytes as three big-endian uint32s into +/// (A, B, C), stashes the bits of B/C that we're about to overwrite +/// into D, then sets the RFC 4122 version-4 (random) and variant +/// (10xx) markers in B and C. The result is a syntactically valid v4 +/// UUID — analyzers won't reject it — and the original 12 bytes are +/// recoverable byte-for-byte via GuidToOid. +/// +/// Note: the inverse direction (Guid→Oid→Guid for an arbitrary input +/// Guid) is NOT lossless. GuidToOid restores 6 bits (version + variant) +/// from D regardless of where they came from in the input, so the +/// version/variant nibbles of an arbitrary FGuid are clobbered. +/// Lossless only in the Oid→Guid→Oid direction, which is exactly what +/// we need for "given a session Oid, get a UUID I can put in the +/// trace; given a UUID from a trace I produced, get the session Oid +/// back." +inline Guid +OidToGuid(const Oid& Id) +{ + const uint8_t* Bytes = reinterpret_cast<const uint8_t*>(Id.OidBits); + uint32_t A = (uint32_t(Bytes[0]) << 24) | (uint32_t(Bytes[1]) << 16) | (uint32_t(Bytes[2]) << 8) | uint32_t(Bytes[3]); + uint32_t B = (uint32_t(Bytes[4]) << 24) | (uint32_t(Bytes[5]) << 16) | (uint32_t(Bytes[6]) << 8) | uint32_t(Bytes[7]); + uint32_t C = (uint32_t(Bytes[8]) << 24) | (uint32_t(Bytes[9]) << 16) | (uint32_t(Bytes[10]) << 8) | uint32_t(Bytes[11]); + + // Stash the two bytes that the version/variant markers below are + // about to overwrite. D ends up with only those two bytes set; all + // other bits are zero. + uint32_t D = (B & 0x0000FF00u) | (C & 0xFF000000u); + + // RFC 4122 markers: version 4 in B[15..12], variant 10xx in C[31..30]. + B = (B & ~0x0000F000u) | 0x00004000u; + C = (C & ~0xC0000000u) | 0x80000000u; + + return Guid{A, B, C, D}; +} + +/// Inverse of OidToGuid. Restores the two bytes of B and C that were +/// clobbered with version/variant markers by pulling them back out of +/// D, then unpacks the three uint32s into 12 big-endian bytes. +inline Oid +GuidToOid(const Guid& Source) +{ + const uint32_t Bits0 = Source.A; + const uint32_t Bits1 = (Source.B & ~0x0000FF00u) | (Source.D & 0x0000FF00u); + const uint32_t Bits2 = (Source.C & ~0xFF000000u) | (Source.D & 0xFF000000u); + + Oid Out{}; + uint8_t* Bytes = reinterpret_cast<uint8_t*>(Out.OidBits); + Bytes[0] = uint8_t(Bits0 >> 24); + Bytes[1] = uint8_t(Bits0 >> 16); + Bytes[2] = uint8_t(Bits0 >> 8); + Bytes[3] = uint8_t(Bits0); + Bytes[4] = uint8_t(Bits1 >> 24); + Bytes[5] = uint8_t(Bits1 >> 16); + Bytes[6] = uint8_t(Bits1 >> 8); + Bytes[7] = uint8_t(Bits1); + Bytes[8] = uint8_t(Bits2 >> 24); + Bytes[9] = uint8_t(Bits2 >> 16); + Bytes[10] = uint8_t(Bits2 >> 8); + Bytes[11] = uint8_t(Bits2); + return Out; +} + +} // namespace zen diff --git a/src/zencore/include/zencore/profiling/counterstrace.h b/src/zencore/include/zencore/profiling/counterstrace.h new file mode 100644 index 000000000..1fa40a5f3 --- /dev/null +++ b/src/zencore/include/zencore/profiling/counterstrace.h @@ -0,0 +1,221 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +// Counter trace points: emit named time-series of int or float values into the +// active .utrace stream. Wire-compatible with UE's Counters.Spec / +// SetValueInt / SetValueFloat events (CountersChannel), so traces produced by +// zen are readable by Unreal Insights and zen's own `zen trace` tooling. +// +// Typical use: +// +// ZEN_TRACE_INT_VALUE("MySubsystem/QueueDepth", QueueSize); +// ZEN_TRACE_FLOAT_VALUE("MySubsystem/HitRate", Hits / Total); +// ZEN_TRACE_MEMORY_VALUE("MySubsystem/CacheBytes", BytesInUse); +// +// Each macro instantiates a function-static counter object the first time it +// is hit, lazily registering the counter (Spec event) and then emitting a +// SetValue event. Repeating the same value is suppressed (matching UE's +// TCounter::Set semantics) -- use the _ALWAYS variants if every sample must +// be emitted. + +#include <zencore/zencore.h> + +#include <atomic> +#include <cstdint> +#include <string_view> +#include <type_traits> + +namespace zen { + +enum class TraceCounterType : uint8_t +{ + Int = 0, + Float = 1, +}; + +enum class TraceCounterDisplayHint : uint8_t +{ + None = 0, + Memory = 1, +}; + +#if ZEN_WITH_TRACE + +namespace counters_detail { + + // Returns the assigned counter id (1..0xFFFF) on success, or 0 if tracing is + // disabled / the channel is off. Emits a Counters.Spec event on first + // successful registration. + uint16_t OutputInitCounter(const char* Name, TraceCounterType Type, TraceCounterDisplayHint Hint); + + // Emit a SetValueInt / SetValueFloat event. No-op when Id == 0. + void OutputSetValueInt(uint16_t Id, int64_t Value); + void OutputSetValueFloat(uint16_t Id, double Value); + + // True when the Counters channel is currently active. The macros use this to + // skip work when nobody is listening. + bool IsCountersChannelEnabled(); + +} // namespace counters_detail + +template<typename ValueType, TraceCounterType Type, typename StoredType = ValueType, bool bUnchecked = false> +class TraceCounter +{ +public: + TraceCounter(const char* InName, TraceCounterDisplayHint InHint) : m_Name(InName), m_Hint(InHint), m_Id(0) + { + uint32_t Id = counters_detail::OutputInitCounter(InName, Type, InHint); + m_Id.store(Id, std::memory_order_relaxed); + } + + TraceCounter(const TraceCounter&) = delete; + TraceCounter& operator=(const TraceCounter&) = delete; + + ValueType Get() const { return ValueType(m_Value); } + + void Set(ValueType InValue) + { + if (bUnchecked || ValueType(m_Value) != InValue) + { + m_Value = InValue; + Emit(); + } + } + + void SetAlways(ValueType InValue) + { + m_Value = InValue; + Emit(); + } + + void Add(ValueType InValue) + { + if (bUnchecked || InValue != ValueType{}) + { + m_Value += InValue; + Emit(); + } + } + + void Subtract(ValueType InValue) + { + if (bUnchecked || InValue != ValueType{}) + { + m_Value -= InValue; + Emit(); + } + } + + void Increment() + { + ++m_Value; + Emit(); + } + + void Decrement() + { + --m_Value; + Emit(); + } + +private: + void Emit() + { + if (!counters_detail::IsCountersChannelEnabled()) + { + return; + } + LateInit(); + uint16_t Id = uint16_t(m_Id.load(std::memory_order_relaxed)); + if constexpr (Type == TraceCounterType::Int) + { + counters_detail::OutputSetValueInt(Id, int64_t(ValueType(m_Value))); + } + else + { + counters_detail::OutputSetValueFloat(Id, double(ValueType(m_Value))); + } + } + + void LateInit() + { + uint32_t Old = m_Id.load(std::memory_order_relaxed); + if (Old != 0) + { + return; + } + uint32_t New = counters_detail::OutputInitCounter(m_Name, Type, m_Hint); + if (New != 0) + { + m_Id.compare_exchange_strong(Old, New, std::memory_order_relaxed); + } + } + + StoredType m_Value{}; + const char* m_Name = nullptr; + TraceCounterDisplayHint m_Hint = TraceCounterDisplayHint::None; + std::atomic<uint32_t> m_Id; +}; + +using TraceCounterInt = TraceCounter<int64_t, TraceCounterType::Int>; +using TraceCounterFloat = TraceCounter<double, TraceCounterType::Float>; +using TraceCounterAtomicInt = TraceCounter<int64_t, TraceCounterType::Int, std::atomic<int64_t>>; + +#else // ZEN_WITH_TRACE + +// Stub implementation -- inlines compile to nothing. +namespace counters_detail { + inline uint16_t OutputInitCounter(const char*, TraceCounterType, TraceCounterDisplayHint) { return 0; } + inline void OutputSetValueInt(uint16_t, int64_t) {} + inline void OutputSetValueFloat(uint16_t, double) {} + inline bool IsCountersChannelEnabled() { return false; } +} // namespace counters_detail + +template<typename ValueType, TraceCounterType, typename = ValueType, bool = false> +class TraceCounter +{ +public: + TraceCounter(const char*, TraceCounterDisplayHint) {} + ValueType Get() const { return ValueType{}; } + void Set(ValueType) {} + void SetAlways(ValueType) {} + void Add(ValueType) {} + void Subtract(ValueType) {} + void Increment() {} + void Decrement() {} +}; + +using TraceCounterInt = TraceCounter<int64_t, TraceCounterType::Int>; +using TraceCounterFloat = TraceCounter<double, TraceCounterType::Float>; +using TraceCounterAtomicInt = TraceCounter<int64_t, TraceCounterType::Int>; + +#endif // ZEN_WITH_TRACE + +} // namespace zen + +// --------------------------------------------------------------------------- +// Public emit macros (mirror UE TRACE_INT_VALUE / TRACE_FLOAT_VALUE etc.) +// --------------------------------------------------------------------------- + +#define ZEN_COUNTERS_TRACE_CONCAT_IMPL(a, b) a##b +#define ZEN_COUNTERS_TRACE_CONCAT(a, b) ZEN_COUNTERS_TRACE_CONCAT_IMPL(a, b) + +#define ZEN_COUNTERS_TRACE_DECLARE_AND_SET(CounterCls, Name, Hint, Value) \ + do \ + { \ + static ::zen::CounterCls ZEN_COUNTERS_TRACE_CONCAT(__ZenTraceCounter_, __LINE__){(Name), (Hint)}; \ + ZEN_COUNTERS_TRACE_CONCAT(__ZenTraceCounter_, __LINE__).Set(Value); \ + } while (0) + +// Set an integer counter to Value. Suppresses repeated identical values. +#define ZEN_TRACE_INT_VALUE(Name, Value) \ + ZEN_COUNTERS_TRACE_DECLARE_AND_SET(TraceCounterInt, Name, ::zen::TraceCounterDisplayHint::None, int64_t(Value)) + +// Set a floating-point counter to Value. +#define ZEN_TRACE_FLOAT_VALUE(Name, Value) \ + ZEN_COUNTERS_TRACE_DECLARE_AND_SET(TraceCounterFloat, Name, ::zen::TraceCounterDisplayHint::None, double(Value)) + +// Set a memory-style counter (formatted as bytes by the viewer). +#define ZEN_TRACE_MEMORY_VALUE(Name, Bytes) \ + ZEN_COUNTERS_TRACE_DECLARE_AND_SET(TraceCounterInt, Name, ::zen::TraceCounterDisplayHint::Memory, int64_t(Bytes)) diff --git a/src/zencore/include/zencore/session.h b/src/zencore/include/zencore/session.h index 10c33da24..807af878e 100644 --- a/src/zencore/include/zencore/session.h +++ b/src/zencore/include/zencore/session.h @@ -12,4 +12,8 @@ struct Oid; [[nodiscard]] Oid GetSessionId(); [[nodiscard]] std::string_view GetSessionIdString(); +void SetParentSessionId(const Oid& ParentSessionId); +[[nodiscard]] Oid GetParentSessionId(); +[[nodiscard]] std::string_view GetParentSessionIdString(); + } // namespace zen diff --git a/src/zencore/logging/backlogsink.cpp b/src/zencore/logging/backlogsink.cpp new file mode 100644 index 000000000..f7c02c2e4 --- /dev/null +++ b/src/zencore/logging/backlogsink.cpp @@ -0,0 +1,118 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include <zencore/logging/backlogsink.h> + +#include <zencore/logging/logmsg.h> + +namespace zen::logging { + +BacklogSink::BacklogSink(size_t MaxEntries) : m_MaxEntries(MaxEntries) +{ + // Reserve up front so push_back never reallocates. The reserve itself + // takes one chunk from the arena; subsequent string duplications carve + // from the same arena, so the entire backlog footprint is one + // contiguous allocation domain released wholesale when this object is + // destroyed (see DisableLogBacklog: it drops the last Ref). + m_Entries.reserve(MaxEntries); +} + +BacklogSink::~BacklogSink() = default; + +void +BacklogSink::Log(const LogMessage& Msg) +{ + // Cheap acquire-load gate: once Disable() has been called we want + // further log calls to do as little as possible. This is the common + // case after the bootstrap window closes — every log line in the + // process flows through here for the rest of the run. + if (!m_Enabled.load(std::memory_order_acquire)) + { + return; + } + + RwLock::ExclusiveLockScope Lock(m_Lock); + + // Re-check under the lock: a Disable() racing with Log() may have + // flipped the flag between our acquire and the lock acquisition. + if (!m_Enabled.load(std::memory_order_relaxed)) + { + return; + } + + if (m_Entries.size() >= m_MaxEntries) + { + m_DroppedCount.fetch_add(1, std::memory_order_relaxed); + return; + } + + Entry& Captured = m_Entries.emplace_back(); + Captured.Point = &Msg.GetLogPoint(); + Captured.LoggerName = m_Arena.DuplicateString(Msg.GetLoggerName()); + Captured.Payload = m_Arena.DuplicateString(Msg.GetPayload()); + Captured.Time = Msg.GetTime(); + Captured.ThreadId = Msg.GetThreadId(); +} + +void +BacklogSink::Flush() +{ + // Nothing to do — we're an in-memory buffer, not an output device. +} + +void +BacklogSink::SetFormatter(std::unique_ptr<Formatter> InFormatter) +{ + // We don't format anything — entries are replayed verbatim into the + // target sink, which applies its own formatter. Accept and discard + // for interface compatibility. + (void)InFormatter; +} + +void +BacklogSink::Replay(Sink& Target, size_t MaxEntries) +{ + RwLock::SharedLockScope Lock(m_Lock); + const size_t Limit = std::min(MaxEntries, m_Entries.size()); + for (size_t Index = 0; Index < Limit; ++Index) + { + const Entry& Captured = m_Entries[Index]; + if (!Captured.Point) + { + continue; + } + const std::string_view LoggerName = Captured.LoggerName ? std::string_view(Captured.LoggerName) : std::string_view{}; + const std::string_view Payload = Captured.Payload ? std::string_view(Captured.Payload) : std::string_view{}; + LogMessage Msg(*Captured.Point, LoggerName, Payload); + Msg.SetTime(Captured.Time); + Msg.SetThreadId(Captured.ThreadId); + if (Target.ShouldLog(Msg.GetLevel())) + { + try + { + Target.Log(Msg); + } + catch (const std::exception&) + { + // Match the swallow-on-replay semantics of AsyncSink's + // dispatcher so a misbehaving target doesn't poison the + // rest of the replay. + } + } + } +} + +void +BacklogSink::Disable() +{ + RwLock::ExclusiveLockScope Lock(m_Lock); + m_Enabled.store(false, std::memory_order_release); +} + +size_t +BacklogSink::Size() const +{ + RwLock::SharedLockScope Lock(m_Lock); + return m_Entries.size(); +} + +} // namespace zen::logging diff --git a/src/zencore/memory/memoryarena.cpp b/src/zencore/memory/memoryarena.cpp index 8807f3264..9eabaf71e 100644 --- a/src/zencore/memory/memoryarena.cpp +++ b/src/zencore/memory/memoryarena.cpp @@ -2,18 +2,52 @@ #include <zencore/memory/memoryarena.h> +#include <algorithm> #include <cstring> namespace zen { +MemoryArena::MemoryArena(size_t ChunkSize) : m_ChunkSize(ChunkSize == 0 ? kDefaultChunkSize : ChunkSize) +{ +} + MemoryArena::~MemoryArena() { - for (auto Chunk : m_Chunks) - delete[] Chunk; + for (const Chunk& C : m_Chunks) + { + delete[] C.Base; + } +} + +// An allocation whose worst-case footprint won't fit inside a freshly +// allocated standard-sized chunk is "oversize" — we route it to its own +// dedicated chunk so it doesn't waste the rest of the regular chunk's +// space, and so a subsequent small allocation still has a near-empty +// regular chunk to bump-pointer into. +// +// Note: WorstCaseBytes is the upper bound on bytes the allocation could +// consume from a freshly aligned chunk start. The actual returned +// pointer + size fits within this footprint by construction. +bool +MemoryArena::IsOversizeRequest(size_t WorstCaseBytes) const +{ + return WorstCaseBytes > m_ChunkSize; +} + +uint8_t* +MemoryArena::AllocateDedicatedChunk(size_t Capacity) +{ + uint8_t* NewChunk = new (std::nothrow) uint8_t[Capacity]; + if (!NewChunk) + { + return nullptr; + } + m_Chunks.push_back({NewChunk, Capacity}); + return NewChunk; } void* -MemoryArena::AllocateAligned(size_t ByteCount, size_t align) +MemoryArena::AllocateAligned(size_t ByteCount, size_t Align) { if (ByteCount == 0) { @@ -23,19 +57,31 @@ MemoryArena::AllocateAligned(size_t ByteCount, size_t align) void* Ptr = nullptr; m_Lock.WithExclusiveLock([&] { - size_t AlignedOffset = (m_CurrentOffset + (align - 1)) & ~(align - 1); + // Oversize fast path — give the request its own chunk, leave the + // regular bump pointer untouched so subsequent small allocs still + // reuse it. + const size_t WorstCase = ByteCount + Align - 1; + if (IsOversizeRequest(WorstCase)) + { + if (uint8_t* Dedicated = AllocateDedicatedChunk(WorstCase)) + { + const size_t Aligned = (reinterpret_cast<uintptr_t>(Dedicated) + (Align - 1)) & ~(uintptr_t(Align - 1)); + Ptr = reinterpret_cast<void*>(Aligned); + } + return; + } - if (m_CurrentChunk == nullptr || AlignedOffset + ByteCount > ChunkSize) + size_t AlignedOffset = (m_CurrentOffset + (Align - 1)) & ~(Align - 1); + if (m_CurrentChunk == nullptr || AlignedOffset + ByteCount > m_CurrentChunkCapacity) { - uint8_t* NewChunk = new uint8_t[ChunkSize]; + uint8_t* NewChunk = AllocateDedicatedChunk(m_ChunkSize); if (!NewChunk) { return; } - - m_Chunks.push_back(NewChunk); - m_CurrentChunk = NewChunk; - AlignedOffset = 0; + m_CurrentChunk = NewChunk; + m_CurrentChunkCapacity = m_ChunkSize; + AlignedOffset = 0; } Ptr = m_CurrentChunk + AlignedOffset; @@ -46,7 +92,7 @@ MemoryArena::AllocateAligned(size_t ByteCount, size_t align) } void* -MemoryArena::AllocateAlignedWithOffset(size_t ByteCount, size_t align, size_t offset) +MemoryArena::AllocateAlignedWithOffset(size_t ByteCount, size_t Align, size_t Offset) { if (ByteCount == 0) { @@ -56,19 +102,29 @@ MemoryArena::AllocateAlignedWithOffset(size_t ByteCount, size_t align, size_t of void* Ptr = nullptr; m_Lock.WithExclusiveLock([&] { - size_t AlignedOffset = (m_CurrentOffset + (align - 1) + offset) & ~(align - 1); + const size_t WorstCase = ByteCount + Align - 1 + Offset; + if (IsOversizeRequest(WorstCase)) + { + if (uint8_t* Dedicated = AllocateDedicatedChunk(WorstCase)) + { + const uintptr_t Base = reinterpret_cast<uintptr_t>(Dedicated); + const uintptr_t Aligned = (Base + (Align - 1) + Offset) & ~(uintptr_t(Align - 1)); + Ptr = reinterpret_cast<void*>(Aligned); + } + return; + } - if (m_CurrentChunk == nullptr || AlignedOffset + ByteCount > ChunkSize) + size_t AlignedOffset = (m_CurrentOffset + (Align - 1) + Offset) & ~(Align - 1); + if (m_CurrentChunk == nullptr || AlignedOffset + ByteCount > m_CurrentChunkCapacity) { - uint8_t* NewChunk = new uint8_t[ChunkSize]; + uint8_t* NewChunk = AllocateDedicatedChunk(m_ChunkSize); if (!NewChunk) { return; } - - m_Chunks.push_back(NewChunk); - m_CurrentChunk = NewChunk; - AlignedOffset = offset; + m_CurrentChunk = NewChunk; + m_CurrentChunkCapacity = m_ChunkSize; + AlignedOffset = Offset; } Ptr = m_CurrentChunk + AlignedOffset; @@ -90,19 +146,28 @@ MemoryArena::Allocate(size_t Size) constexpr size_t Alignment = alignof(std::max_align_t); m_Lock.WithExclusiveLock([&] { - size_t AlignedOffset = (m_CurrentOffset + Alignment - 1) & ~(Alignment - 1); + const size_t WorstCase = Size + Alignment - 1; + if (IsOversizeRequest(WorstCase)) + { + if (uint8_t* Dedicated = AllocateDedicatedChunk(WorstCase)) + { + const uintptr_t Aligned = (reinterpret_cast<uintptr_t>(Dedicated) + Alignment - 1) & ~(uintptr_t(Alignment - 1)); + Ptr = reinterpret_cast<void*>(Aligned); + } + return; + } - if (m_CurrentChunk == nullptr || AlignedOffset + Size > ChunkSize) + size_t AlignedOffset = (m_CurrentOffset + Alignment - 1) & ~(Alignment - 1); + if (m_CurrentChunk == nullptr || AlignedOffset + Size > m_CurrentChunkCapacity) { - uint8_t* NewChunk = new uint8_t[ChunkSize]; + uint8_t* NewChunk = AllocateDedicatedChunk(m_ChunkSize); if (!NewChunk) { return; } - - m_Chunks.push_back(NewChunk); - m_CurrentChunk = NewChunk; - AlignedOffset = 0; + m_CurrentChunk = NewChunk; + m_CurrentChunkCapacity = m_ChunkSize; + AlignedOffset = 0; } Ptr = m_CurrentChunk + AlignedOffset; @@ -112,6 +177,19 @@ MemoryArena::Allocate(size_t Size) return Ptr; } +size_t +MemoryArena::GetReservedBytes() const +{ + size_t Total = 0; + m_Lock.WithSharedLock([&] { + for (const Chunk& Item : m_Chunks) + { + Total += Item.Capacity; + } + }); + return Total; +} + const char* MemoryArena::DuplicateString(std::string_view Str) { diff --git a/src/zencore/profiling/counterstrace.cpp b/src/zencore/profiling/counterstrace.cpp new file mode 100644 index 000000000..63e8a2dd3 --- /dev/null +++ b/src/zencore/profiling/counterstrace.cpp @@ -0,0 +1,99 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include <zencore/profiling/counterstrace.h> + +#if ZEN_WITH_TRACE + +# include <zencore/timer.h> +# include <zencore/trace.h> + +# include <atomic> +# include <cstring> + +namespace { + +// Wire-compatible with UE's CountersTrace events (Counters.Spec / +// SetValueInt / SetValueFloat). Channel name matches UE so users can mix & +// match analyzers. +UE_TRACE_CHANNEL_DEFINE(CountersChannel) + +UE_TRACE_EVENT_BEGIN(Counters, Spec, NoSync | Important) + UE_TRACE_EVENT_FIELD(uint16_t, Id) + UE_TRACE_EVENT_FIELD(uint8_t, Type) + UE_TRACE_EVENT_FIELD(uint8_t, DisplayHint) + UE_TRACE_EVENT_FIELD(UE::Trace::AnsiString, Name) +UE_TRACE_EVENT_END() + +UE_TRACE_EVENT_BEGIN(Counters, SetValueInt) + UE_TRACE_EVENT_FIELD(uint64_t, Cycle) + UE_TRACE_EVENT_FIELD(int64_t, Value) + UE_TRACE_EVENT_FIELD(uint16_t, CounterId) +UE_TRACE_EVENT_END() + +UE_TRACE_EVENT_BEGIN(Counters, SetValueFloat) + UE_TRACE_EVENT_FIELD(uint64_t, Cycle) + UE_TRACE_EVENT_FIELD(double, Value) + UE_TRACE_EVENT_FIELD(uint16_t, CounterId) +UE_TRACE_EVENT_END() + +} // namespace + +namespace zen::counters_detail { + +uint16_t +OutputInitCounter(const char* Name, TraceCounterType Type, TraceCounterDisplayHint Hint) +{ + if (!UE_TRACE_CHANNELEXPR_IS_ENABLED(CountersChannel) || Name == nullptr) + { + return 0; + } + + // Counter ids are uint16; tourist's analyzer truncates anyway. Wrapping + // past 0xFFFF would re-use ids, so we cap allocation -- in practice no + // real-world trace uses anywhere near 65k distinct counters. + static std::atomic<uint32_t> g_NextId{0}; + uint32_t Allocated = g_NextId.fetch_add(1, std::memory_order_relaxed) + 1; + if (Allocated > 0xFFFFu) + { + return 0; + } + uint16_t Id = uint16_t(Allocated); + + uint16_t NameLen = uint16_t(std::strlen(Name)); + UE_TRACE_LOG(Counters, Spec, CountersChannel, NameLen * sizeof(char)) + << Spec.Id(Id) << Spec.Type(uint8_t(Type)) << Spec.DisplayHint(uint8_t(Hint)) << Spec.Name(Name, NameLen); + + return Id; +} + +void +OutputSetValueInt(uint16_t Id, int64_t Value) +{ + if (Id == 0 || !UE_TRACE_CHANNELEXPR_IS_ENABLED(CountersChannel)) + { + return; + } + UE_TRACE_LOG(Counters, SetValueInt, CountersChannel) + << SetValueInt.Cycle(zen::GetHifreqTimerValue()) << SetValueInt.Value(Value) << SetValueInt.CounterId(Id); +} + +void +OutputSetValueFloat(uint16_t Id, double Value) +{ + if (Id == 0 || !UE_TRACE_CHANNELEXPR_IS_ENABLED(CountersChannel)) + { + return; + } + UE_TRACE_LOG(Counters, SetValueFloat, CountersChannel) + << SetValueFloat.Cycle(zen::GetHifreqTimerValue()) << SetValueFloat.Value(Value) << SetValueFloat.CounterId(Id); +} + +bool +IsCountersChannelEnabled() +{ + return UE_TRACE_CHANNELEXPR_IS_ENABLED(CountersChannel); +} + +} // namespace zen::counters_detail + +#endif // ZEN_WITH_TRACE diff --git a/src/zencore/session.cpp b/src/zencore/session.cpp index 50c47c628..d6386e43b 100644 --- a/src/zencore/session.cpp +++ b/src/zencore/session.cpp @@ -12,6 +12,10 @@ static Oid GlobalSessionId; static Oid::String_t GlobalSessionString; static std::once_flag SessionInitFlag; +static Oid GlobalParentSessionId; +static Oid::String_t GlobalParentSessionString; +static std::mutex GlobalParentSessionMutex; + Oid GetSessionId() { @@ -32,4 +36,30 @@ GetSessionIdString() return std::string_view(GlobalSessionString, Oid::StringLength); } +void +SetParentSessionId(const Oid& ParentSessionId) +{ + std::lock_guard<std::mutex> Lock(GlobalParentSessionMutex); + GlobalParentSessionId = ParentSessionId; + GlobalParentSessionId.ToString(GlobalParentSessionString); +} + +Oid +GetParentSessionId() +{ + std::lock_guard<std::mutex> Lock(GlobalParentSessionMutex); + return GlobalParentSessionId; +} + +std::string_view +GetParentSessionIdString() +{ + std::lock_guard<std::mutex> Lock(GlobalParentSessionMutex); + if (GlobalParentSessionId == Oid::Zero) + { + return {}; + } + return std::string_view(GlobalParentSessionString, Oid::StringLength); +} + } // namespace zen diff --git a/src/zencore/trace.cpp b/src/zencore/trace.cpp index d6a0b2e92..61db5a714 100644 --- a/src/zencore/trace.cpp +++ b/src/zencore/trace.cpp @@ -76,6 +76,11 @@ TraceConfigure(const TraceOptions& Options) // does not emit. Redirect to the zenlog channel so --trace=log does what users expect. ProcessChannelList("zenlog"sv); } + else if (Arg == "metrics"sv) + { + // Convenience alias for counter-based metric tracing. + ProcessChannelList("counters"sv); + } else { // Presume that the argument is a trace channel name diff --git a/src/zencore/uid.cpp b/src/zencore/uid.cpp index 971683721..a7e68acb2 100644 --- a/src/zencore/uid.cpp +++ b/src/zencore/uid.cpp @@ -3,6 +3,7 @@ #include <zencore/uid.h> #include <zencore/endian.h> +#include <zencore/oidguid.h> #include <zencore/string.h> #include <zencore/testing.h> @@ -187,6 +188,24 @@ TEST_CASE("Oid") } } +TEST_CASE("OidGuid roundtrip") +{ + // Lossless in the Oid -> Guid -> Oid direction by construction — + // see oidguid.h. The version (4) and variant (10xx) markers in the + // produced Guid mean it's also a syntactically valid v4 UUID. + for (int Iteration = 0; Iteration < 1000; ++Iteration) + { + Oid Original = Oid::NewOid(); + Guid AsGuid = OidToGuid(Original); + Oid Restored = GuidToOid(AsGuid); + CHECK(Restored == Original); + + // Version and variant bits should always be set. + CHECK((AsGuid.B & 0x0000F000u) == 0x00004000u); + CHECK((AsGuid.C & 0xC0000000u) == 0x80000000u); + } +} + TEST_SUITE_END(); void diff --git a/src/zenhttp/httpserver.cpp b/src/zenhttp/httpserver.cpp index 03117ee6c..31c94eb3b 100644 --- a/src/zenhttp/httpserver.cpp +++ b/src/zenhttp/httpserver.cpp @@ -939,9 +939,8 @@ HttpRequestRouter::HandleRequest(zen::HttpServerRequest& Request) const std::vector<int>& Matchers = Handler.ComponentIndices; bool IsMatch = true; - std::vector<std::string_view> CapturedSegments; - - CapturedSegments.emplace_back(Uri); + RouterRequest.m_CapturedSegments.clear(); + RouterRequest.m_CapturedSegments.emplace_back(Uri); for (size_t MatcherOffset = 0; MatcherOffset < Matchers.size(); MatcherOffset++) { @@ -991,7 +990,7 @@ HttpRequestRouter::HandleRequest(zen::HttpServerRequest& Request) if (m_MatcherFunctions[MatcherIndex](Segment)) { - CapturedSegments.push_back(Segment); + RouterRequest.m_CapturedSegments.push_back(Segment); } else { @@ -1019,7 +1018,6 @@ HttpRequestRouter::HandleRequest(zen::HttpServerRequest& Request) } #endif - RouterRequest.m_CapturedSegments = std::move(CapturedSegments); Handler.Handler(RouterRequest); return true; // Route matched diff --git a/src/zenhttp/include/zenhttp/httpserver.h b/src/zenhttp/include/zenhttp/httpserver.h index 955b8ed15..bccabf95b 100644 --- a/src/zenhttp/include/zenhttp/httpserver.h +++ b/src/zenhttp/include/zenhttp/httpserver.h @@ -17,6 +17,8 @@ #include <zentelemetry/hyperloglog.h> #include <zentelemetry/stats.h> +#include <EASTL/fixed_vector.h> + #include <filesystem> #include <functional> #include <gsl/gsl-lite.hpp> @@ -405,8 +407,8 @@ private: HttpRouterRequest(const HttpRouterRequest&) = delete; HttpRouterRequest& operator=(const HttpRouterRequest&) = delete; - HttpServerRequest& m_HttpRequest; - std::vector<std::string_view> m_CapturedSegments; + HttpServerRequest& m_HttpRequest; + eastl::fixed_vector<std::string_view, 8, /*bEnableOverflow=*/true> m_CapturedSegments; friend class HttpRequestRouter; }; diff --git a/src/zenhttp/packageformat.cpp b/src/zenhttp/packageformat.cpp index 267ce386c..4f001a14f 100644 --- a/src/zenhttp/packageformat.cpp +++ b/src/zenhttp/packageformat.cpp @@ -130,7 +130,7 @@ FormatPackageMessageBuffer(const CbPackage& Data, FormatFlags Flags, void* Targe static void MarshalLocal(CbAttachmentEntry*& AttachmentInfo, - const std::string& Path8, + std::string_view Path8, CbAttachmentReferenceHeader& LocalRef, const IoHash& AttachmentHash, bool IsCompressed, @@ -156,7 +156,7 @@ IsLocalRef(tsl::robin_map<void*, std::string>& FileNameMap, bool DenyPartialLocalReferences, void* TargetProcessHandle, CbAttachmentReferenceHeader& LocalRef, - std::string& Path8) + StringBuilderBase& Path8) { const SharedBuffer& Segment = AttachmentBinary.GetSegments().front(); IoBufferFileReference Ref; @@ -172,9 +172,11 @@ IsLocalRef(tsl::robin_map<void*, std::string>& FileNameMap, return false; } + Path8.Reset(); + if (auto It = FileNameMap.find(Ref.FileHandle); It != FileNameMap.end()) { - Path8 = It->second; + Path8.Append(It->second); } else { @@ -193,7 +195,7 @@ IsLocalRef(tsl::robin_map<void*, std::string>& FileNameMap, if (OK) { DuplicatedHandles.push_back((void*)TargetHandle); - Path8 = fmt::format("{}{}", HandlePrefix, reinterpret_cast<uint64_t>(TargetHandle)); + fmt::format_to(StringBuilderAppender(Path8), "{}{}", HandlePrefix, reinterpret_cast<uint64_t>(TargetHandle)); UseFilePath = false; } } @@ -205,21 +207,19 @@ IsLocalRef(tsl::robin_map<void*, std::string>& FileNameMap, #endif // ZEN_PLATFORM_WINDOWS if (UseFilePath) { - ExtendablePathBuilder<256> LocalRefFile; - std::error_code Ec; - std::filesystem::path FilePath = PathFromHandle(Ref.FileHandle, Ec); + std::error_code Ec; + std::filesystem::path FilePath = PathFromHandle(Ref.FileHandle, Ec); if (Ec) { ZEN_WARN("Failed to get path for file handle {} in IsLocalRef check, reason '{}'", Ref.FileHandle, Ec.message()); return false; } - LocalRefFile.Append(std::filesystem::absolute(FilePath)); - Path8 = LocalRefFile.ToUtf8(); + PathToUtf8(std::filesystem::absolute(FilePath), Path8); } - FileNameMap.insert_or_assign(Ref.FileHandle, Path8); + FileNameMap.insert_or_assign(Ref.FileHandle, Path8.ToString()); } - LocalRef.AbsolutePathLength = gsl::narrow<uint16_t>(Path8.size()); + LocalRef.AbsolutePathLength = gsl::narrow<uint16_t>(Path8.Size()); LocalRef.PayloadByteOffset = Ref.FileChunkOffset; LocalRef.PayloadByteSize = Ref.FileChunkSize; @@ -302,8 +302,8 @@ FormatPackageMessageInternal(const CbPackage& Data, FormatFlags Flags, void* Tar bool MarshalByLocalRef = EnumHasAllFlags(Flags, FormatFlags::kAllowLocalReferences) && (Compressed.GetSegments().size() == 1); bool DenyPartialLocalReferences = EnumHasAllFlags(Flags, FormatFlags::kDenyPartialLocalReferences); - CbAttachmentReferenceHeader LocalRef; - std::string Path8; + CbAttachmentReferenceHeader LocalRef; + ExtendableStringBuilder<128> Path8; if (MarshalByLocalRef) { @@ -321,7 +321,7 @@ FormatPackageMessageInternal(const CbPackage& Data, FormatFlags Flags, void* Tar const bool IsCompressed = true; bool IsHandle = false; #if ZEN_PLATFORM_WINDOWS - IsHandle = Path8.starts_with(HandlePrefix); + IsHandle = Path8.ToView().starts_with(HandlePrefix); #endif MarshalLocal(AttachmentInfo, Path8, LocalRef, AttachmentHash, IsCompressed, ResponseBuffers); ZEN_DEBUG("Marshalled '{}' as file {} of {} bytes", Path8, IsHandle ? "handle" : "path", Compressed.GetSize()); @@ -357,8 +357,8 @@ FormatPackageMessageInternal(const CbPackage& Data, FormatFlags Flags, void* Tar EnumHasAllFlags(Flags, FormatFlags::kAllowLocalReferences) && (AttachmentBinary.GetSegments().size() == 1); bool DenyPartialLocalReferences = EnumHasAllFlags(Flags, FormatFlags::kDenyPartialLocalReferences); - CbAttachmentReferenceHeader LocalRef; - std::string Path8; + CbAttachmentReferenceHeader LocalRef; + ExtendableStringBuilder<128> Path8; if (MarshalByLocalRef) { @@ -376,7 +376,7 @@ FormatPackageMessageInternal(const CbPackage& Data, FormatFlags Flags, void* Tar const bool IsCompressed = false; bool IsHandle = false; #if ZEN_PLATFORM_WINDOWS - IsHandle = Path8.starts_with(HandlePrefix); + IsHandle = Path8.ToView().starts_with(HandlePrefix); #endif MarshalLocal(AttachmentInfo, Path8, LocalRef, AttachmentHash, IsCompressed, ResponseBuffers); ZEN_DEBUG("Marshalled '{}' as file {} of {} bytes", Path8, IsHandle ? "handle" : "path", AttachmentBinary.GetSize()); diff --git a/src/zenserver/compute/computeserver.cpp b/src/zenserver/compute/computeserver.cpp index b110f7538..4c39e8870 100644 --- a/src/zenserver/compute/computeserver.cpp +++ b/src/zenserver/compute/computeserver.cpp @@ -982,10 +982,14 @@ ZenComputeServer::Run() SetNewState(kRunning); - OnReady(); - + // Register the self-session and replay the backlog into it BEFORE + // OnReady disables the backlog — otherwise the in-proc session sink + // attaches against a disabled backlog and shows nothing from the + // startup window. StartSelfSession("zencompute"); + OnReady(); + PostAnnounce(); EnqueueAnnounceTimer(); InitializeOrchestratorWebSocket(); diff --git a/src/zenserver/config/config.cpp b/src/zenserver/config/config.cpp index 2a89fc637..5f53374fc 100644 --- a/src/zenserver/config/config.cpp +++ b/src/zenserver/config/config.cpp @@ -16,6 +16,7 @@ #include <zencore/fmtutils.h> #include <zencore/iobuffer.h> #include <zencore/logging.h> +#include <zencore/session.h> #include <zencore/string.h> #include <zenutil/config/commandlineoptions.h> #include <zenutil/config/environmentoptions.h> @@ -147,6 +148,10 @@ ZenServerConfiguratorBase::AddCommonConfigOptions(LuaConfig::Options& LuaOptions LuaOptions.AddOption("server.clean"sv, ServerOptions.IsCleanStart, "clean"sv); LuaOptions.AddOption("server.security.configpath"sv, ServerOptions.SecurityConfigPath, "security-config-path"sv); + ////// sessions + + LuaOptions.AddOption("sessions.url"sv, ServerOptions.SessionsTargetUrl, "sessions-url"sv); + ////// network LuaOptions.AddOption("network.httpclientbackend"sv, ServerOptions.HttpClient.Backend, "httpclient"sv); @@ -205,6 +210,7 @@ struct ZenServerCmdLineOptions std::string SecurityConfigPath; std::string UnixSocketPath; std::string PortStr; + std::string ParentSessionId; ZenLoggingCmdLineOptions LoggingOptions; @@ -285,10 +291,22 @@ ZenServerCmdLineOptions::AddCliOptions(cxxopts::Options& options, ZenServerConfi .add_option("lifetime", "", "owner-pid", "Specify owning process id", cxxopts::value<int>(ServerOptions.OwnerPid), "<identifier>"); options.add_option("lifetime", "", + "parent-session", + "Specify parent session id used to associate this process with another session", + cxxopts::value<std::string>(ParentSessionId), + "<oid>"); + options.add_option("lifetime", + "", "child-id", "Specify id which can be used to signal parent", cxxopts::value<std::string>(ServerOptions.ChildId), "<identifier>"); + options.add_option("lifetime", + "", + "sessions-url", + "URL of remote zenserver to announce session to", + cxxopts::value<std::string>(ServerOptions.SessionsTargetUrl), + "<url>"); #if ZEN_PLATFORM_WINDOWS options.add_option("lifetime", @@ -519,6 +537,18 @@ ZenServerCmdLineOptions::ApplyOptions(cxxopts::Options& options, ZenServerConfig ServerOptions.BasePort = Port; } + if (!ParentSessionId.empty()) + { + Oid ParsedParentSessionId; + if (!Oid::TryParse(ParentSessionId, ParsedParentSessionId) || ParsedParentSessionId == Oid::Zero) + { + throw OptionParseException(fmt::format("invalid parent session id '{}': expected a 24-character object id", ParentSessionId), + options.help()); + } + ServerOptions.ParentSessionId = ParsedParentSessionId; + SetParentSessionId(ParsedParentSessionId); + } + LoggingOptions.ApplyOptions(ServerOptions.LoggingConfig); #if ZEN_WITH_HTTPSYS @@ -688,6 +718,16 @@ ZenServerConfiguratorBase::Configure(int argc, char* argv[]) { m_ServerOptions.BasePort = ZenServerConfig::kDefaultBasePort; } + + // Resolve sessions target. If neither CLI nor config supplied a value, + // fall back to the ZEN_SESSIONS_URL environment variable. When a remote + // target is configured, session logs flow through SessionsServiceClient + // instead of the in-proc broadcast sink. + if (m_ServerOptions.SessionsTargetUrl.empty()) + { + m_ServerOptions.SessionsTargetUrl = GetEnvVariable("ZEN_SESSIONS_URL").value_or(""); + } + m_ServerOptions.UseInProcSessionLogging = m_ServerOptions.SessionsTargetUrl.empty(); } catch (const OptionParseException& e) { diff --git a/src/zenserver/config/config.h b/src/zenserver/config/config.h index d35a1a8c7..186b80b6b 100644 --- a/src/zenserver/config/config.h +++ b/src/zenserver/config/config.h @@ -4,6 +4,7 @@ #include <zencore/logbase.h> #include <zencore/trace.h> +#include <zencore/uid.h> #include <zencore/zencore.h> #include <zenhttp/httpserver.h> #include <zenutil/config/loggingconfig.h> @@ -50,27 +51,30 @@ struct ZenServerConfig ZenSentryConfig SentryConfig; ZenStatsConfig StatsConfig; ZenLoggingConfig LoggingConfig; - static constexpr int kDefaultBasePort = 8558; - int BasePort = 0; // Service listen port; 0 = auto (resolved per mode) - int OwnerPid = 0; // Parent process id (zero for standalone) - bool IsDebug = false; - bool IsCleanStart = false; // Indicates whether all state should be wiped on startup or not - bool IsPowerCycle = false; // When true, the process shuts down immediately after initialization - bool IsTest = false; - bool Detach = true; // Whether zenserver should detach from existing process group (Mac/Linux) - int CoreLimit = 0; // If set, hardware concurrency queries are capped at this number - int LieCpu = 0; - bool IsDedicated = false; // Indicates a dedicated/shared instance, with larger resource requirements - bool AllowPortProbing = true; // Automatically false if IsDedicated is true - bool ShouldCrash = false; // Option for testing crash handling - bool IsFirstRun = false; - std::filesystem::path ConfigFile; // Path to Lua config file - std::filesystem::path SystemRootDir; // System root directory (used for machine level config) - std::filesystem::path ContentDir; // Root directory for serving frontend content (experimental) - std::filesystem::path DataDir; // Root directory for state (used for testing) - std::filesystem::path BaseSnapshotDir; // Path to server state snapshot (will be copied into data dir on start) - std::string ChildId; // Id assigned by parent process (used for lifetime management) - std::filesystem::path SecurityConfigPath; // Path to a Json security configuration file + static constexpr int kDefaultBasePort = 8558; + int BasePort = 0; // Service listen port; 0 = auto (resolved per mode) + int OwnerPid = 0; // Parent process id (zero for standalone) + Oid ParentSessionId = Oid::Zero; + bool IsDebug = false; + bool IsCleanStart = false; // Indicates whether all state should be wiped on startup or not + bool IsPowerCycle = false; // When true, the process shuts down immediately after initialization + bool IsTest = false; + bool Detach = true; // Whether zenserver should detach from existing process group (Mac/Linux) + int CoreLimit = 0; // If set, hardware concurrency queries are capped at this number + int LieCpu = 0; + bool IsDedicated = false; // Indicates a dedicated/shared instance, with larger resource requirements + bool AllowPortProbing = true; // Automatically false if IsDedicated is true + bool ShouldCrash = false; // Option for testing crash handling + bool IsFirstRun = false; + bool UseInProcSessionLogging = true; // When false, session logs are expected to be forwarded externally. + std::string SessionsTargetUrl; // URL of remote zenserver to announce session to (overrides ZEN_SESSIONS_URL). + std::filesystem::path ConfigFile; // Path to Lua config file + std::filesystem::path SystemRootDir; // System root directory (used for machine level config) + std::filesystem::path ContentDir; // Root directory for serving frontend content (experimental) + std::filesystem::path DataDir; // Root directory for state (used for testing) + std::filesystem::path BaseSnapshotDir; // Path to server state snapshot (will be copied into data dir on start) + std::string ChildId; // Id assigned by parent process (used for lifetime management) + std::filesystem::path SecurityConfigPath; // Path to a Json security configuration file #if ZEN_WITH_TRACE bool HasTraceCommandlineOptions = false; diff --git a/src/zenserver/diag/otlphttp.cpp b/src/zenserver/diag/otlphttp.cpp index d6e24cbe3..f7306a9fe 100644 --- a/src/zenserver/diag/otlphttp.cpp +++ b/src/zenserver/diag/otlphttp.cpp @@ -31,6 +31,10 @@ OtelHttpProtobufSink::OtelHttpProtobufSink(const std::string_view& Uri) : m_Otel m_Encoder.AddResourceAttribute("service.version", ZEN_CFG_VERSION); m_Encoder.AddResourceAttribute("host.name", GetMachineName()); m_Encoder.AddResourceAttribute("session.id", GetSessionIdString()); + if (std::string_view ParentSessionId = GetParentSessionIdString(); !ParentSessionId.empty()) + { + m_Encoder.AddResourceAttribute("parent_session.id", ParentSessionId); + } m_Encoder.AddResourceAttribute("process.id", zen::GetCurrentProcessId()); m_TraceRecorder = new TraceRecorder(this); diff --git a/src/zenserver/frontend/html/pages/platform_icons.js b/src/zenserver/frontend/html/pages/platform_icons.js new file mode 100644 index 000000000..65a04c840 --- /dev/null +++ b/src/zenserver/frontend/html/pages/platform_icons.js @@ -0,0 +1,61 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +// SimpleIcons-style platform glyphs (viewBox 0 0 24 24, fill: currentColor) +// used by the sessions table to render a recognizable icon instead of a raw +// platform label. Lives in its own module because the data table is bulky and +// other pages may want to reuse the resolver. + +"use strict"; + +// Path data is intentionally one-line per entry to keep the icon table dense. +// Unknown platforms fall through to a text cell at the call site. +export const PLATFORM_ICONS = { + windows: { label: "Windows", path: "M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-13.051-1.351" }, + macos: { label: "macOS", path: "M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" }, + ios: { label: "iOS", path: "M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" }, + linux: { label: "Linux", path: "M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.2.271-.53.4-.953.58-.42.17-.94.33-1.31.84-.5.84-.33 1.83.03 2.72.39.89.95 1.64.89 2.13-.06.72.21 1.24.59 1.48.38.24.83.24 1.17.24.34 0 .57-.01.67-.06.19-.1.38-.25.55-.26.22-.02.43.11.72.26.67.35 1.3.43 1.88.3.57-.13 1.08-.44 1.52-.78.9-.74 1.7-1.5 2.6-1.63.24-.03.5-.05.78-.05.13 0 .25 0 .39.02.11.01.26.1.5.26.25.17.6.4 1 .59.4.17.87.32 1.4.3.59-.02 1.18-.22 1.73-.63 1.57-1.19 4.21-.97 5.13-2.08.11-.14.18-.3.2-.48.02-.18 0-.36-.07-.54-.06-.19-.17-.38-.32-.57-.15-.19-.35-.38-.56-.55-.38-.31-.58-.67-.57-1.11.01-.28.14-.58.28-.85.11-.28.24-.54.24-.81-.02-.28-.13-.55-.36-.77-.23-.23-.55-.42-1.01-.51a.424.424 0 00-.14-.02c-.3-.04-.57-.04-.87-.04-.13-.31-.28-.62-.43-.91-.9-1.72-2.23-3.13-3.27-4.31-.92-1.02-1.55-2-1.62-3.07-.12-.9-.07-1.94-.1-2.93-.02-.92-.15-1.81-.67-2.5-.49-.7-1.32-1.22-2.73-1.22z" }, + wine: { label: "Wine", path: "M8 2h8l-1 7a4 4 0 01-3 4v5h3v2H9v-2h3v-5a4 4 0 01-3-4L8 2z" }, + android: { label: "Android", path: "M17.523 15.3414c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993.0001.5511-.4482.9997-.9993.9997m-11.046 0c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993 0 .5511-.4482.9997-.9993.9997m11.4045-6.02l1.9973-3.4592a.416.416 0 00-.1521-.5676.416.416 0 00-.5677.1521l-2.0223 3.503C15.5902 8.2439 13.8533 7.8508 12 7.8508s-3.5902.3931-5.1367 1.0989L4.841 5.4467a.4161.4161 0 00-.5677-.1521.4157.4157 0 00-.1521.5676l1.9973 3.4592C2.6889 11.1867.3432 14.6589 0 18.761h24c-.3432-4.1021-2.6889-7.5743-6.1185-9.4396" }, + playstation: { label: "PlayStation", path: "M8.985 2.596v17.548l3.915 1.261V6.688c0-.69.304-1.151.794-.991.636.181.76.814.76 1.505v5.875c2.441 1.193 4.362 0 4.362-3.118 0-3.198-1.13-4.63-4.442-5.76-1.313-.444-3.697-1.203-5.389-1.603zM0 17.81c.069.24.213.489.487.728C4.024 21.22 9.45 22.395 15.03 22.395c.58 0 1.142-.034 1.725-.08-5.423-1.39-9.33-3.77-15.203-4.87a5.78 5.78 0 01-1.55-.364v.728zm18.7-8.97c.057-.035.114-.057.194-.08.695-.148 1.15.217 1.15.908 0 .706-.478 1.283-1.162 1.486-.08.023-.137.034-.194.057v4.74c.079-.023.148-.034.228-.057 3.426-1.193 4.374-2.796 4.374-5.502 0-2.637-1.37-4.063-3.357-4.748-.387-.137-.764-.228-1.162-.32l-.068-.023v3.54l-.003-.001z" }, + xbox: { label: "Xbox", path: "M4.102 21.033C6.211 22.881 8.977 24 12 24c3.026 0 5.789-1.119 7.902-2.965 1.16-1.016-4.553-6.929-7.902-9.518-3.349 2.589-9.063 8.499-7.898 9.516zm11.08-18.52c2.699-1.159 5.062-1.169 6.52-.546l.025.033c-1.377-2.152-3.624-4.001-7.004-3.978-2.049 0-4.062.826-5.725 2.208 1.964.468 4.114 1.404 6.184 2.283zM2.27 1.976l.025-.033c1.458-.623 3.82-.613 6.519.546 2.07-.879 4.22-1.815 6.184-2.283C13.335.824 11.32-.002 9.272 0 5.891-.02 3.646 1.828 2.27 1.976zM1.62 19.46l-.012.003C.597 17.8 0 15.838 0 13.749c0-1.749.425-3.399 1.157-4.85.9-1.784 4.126-5.59 5.73-7.37.118-.131-4.425 1.976-5.267 9.931-.025.221-.025.442-.025.663 0 2.586.741 5 2.025 7.017zm20.763 0l-.011-.003c1.283-2.017 2.025-4.431 2.025-7.017 0-.22 0-.442-.025-.663-.842-7.955-5.386-10.062-5.267-9.93 1.604 1.779 4.83 5.585 5.73 7.37.732 1.45 1.157 3.1 1.157 4.849 0 2.09-.596 4.051-1.609 5.714l.013.003-.014-.324z" }, + nintendo: { label: "Nintendo", path: "M14.176 24h3.674c3.376 0 6.15-2.774 6.15-6.15V6.15C24 2.775 21.226 0 17.85 0H14.16c-.205 0-.38.174-.38.38v23.24c0 .206.19.38.396.38zM8.252 24c.212 0 .39-.174.39-.384V.374c0-.21-.178-.374-.39-.374h-2.1A6.167 6.167 0 0 0 0 6.15v11.7C0 21.224 2.775 24 6.152 24h2.1zm-4.59-15.763a2.578 2.578 0 0 1 2.58-2.58 2.577 2.577 0 0 1 2.578 2.58 2.579 2.579 0 1 1-5.157 0zm12.556 11.928a3.063 3.063 0 0 1 3.067-3.065 3.063 3.063 0 0 1 3.065 3.065 3.067 3.067 0 0 1-3.065 3.07 3.065 3.065 0 0 1-3.067-3.07z" }, +}; + +// Resolve a platform string (as reported by the client) to an icon entry. +// Intentionally liberal so UE-style variants like "Win64", "PS5", "XSX", +// "NintendoSwitch" all land on the right icon. +export function resolve_platform_icon(platform) +{ + const p = platform.toLowerCase(); + if (p.includes("windows") || p === "win32" || p === "win64" || p === "win") return PLATFORM_ICONS.windows; + if (p === "wine") return PLATFORM_ICONS.wine; + if (p.includes("android")) return PLATFORM_ICONS.android; + if (p === "ios" || p === "iphone" || p === "ipad" || p === "ipados" || p === "tvos") return PLATFORM_ICONS.ios; + if (p === "mac" || p === "macos" || p === "osx" || p === "darwin") return PLATFORM_ICONS.macos; + if (p === "linux") return PLATFORM_ICONS.linux; + if (p.includes("playstation") || /^ps\d/.test(p) || p === "psvita") return PLATFORM_ICONS.playstation; + if (p.includes("xbox") || p === "xsx" || p === "xss") return PLATFORM_ICONS.xbox; + if (p.includes("nintendo") || p === "switch") return PLATFORM_ICONS.nintendo; + return null; +} + +// Build a <span> element representing the platform — either an inline SVG +// glyph (when the platform resolves) or a plain text fallback. +export function make_platform_cell(platform) +{ + const el = document.createElement("span"); + if (!platform) { return el; } + const icon = resolve_platform_icon(platform); + if (!icon) + { + // Unknown platform — fall back to the raw label. + el.textContent = platform; + return el; + } + el.className = "platform-icon"; + el.title = icon.label; + el.setAttribute("aria-label", icon.label); + // Paths are hard-coded (no user-controlled input), so innerHTML is safe. + el.innerHTML = `<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="${icon.path}"/></svg>`; + return el; +} diff --git a/src/zenserver/frontend/html/pages/sessions.js b/src/zenserver/frontend/html/pages/sessions.js index c74ede14e..70b850698 100644 --- a/src/zenserver/frontend/html/pages/sessions.js +++ b/src/zenserver/frontend/html/pages/sessions.js @@ -4,7 +4,69 @@ import { ZenPage } from "./page.js" import { Fetcher } from "../util/fetcher.js" +import { CbObject } from "../util/compactbinary.js" import { Table, PropTable } from "../util/widgets.js" +import { make_platform_cell } from "./platform_icons.js" + +// Run @p fn and swallow any thrown error, but log it at debug level under +// @p label so the failure isn't completely invisible. Use this in places +// where the failure mode is genuinely "drop the frame and move on" — JSON / +// CB parse failures, optional WebSocket setup, transient send errors. The +// debug-level log keeps normal consoles clean; surface them by enabling +// "Verbose" in DevTools. +function quietly(label, fn) +{ + try { return fn(); } + catch (e) + { + console.debug(`[sessions] ${label}:`, e); + return undefined; + } +} + +// Dev tools read better with 24h + zero-padded fields; don't defer to the +// browser's default locale which is 12h AM/PM for en-US. +const TIME_OPTS = { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }; +const DATE_OPTS = { year: "numeric", month: "2-digit", day: "2-digit", ...TIME_OPTS }; + +// UE log levels in ascending order of severity. Each incoming entry's +// level string (lowercased) maps to a numeric rank we can compare against +// the user-selected threshold when filtering. Both the short ("warn") and +// long ("warning") spellings are covered because zencore emits the long +// form but older clients / tests may use the short one. +const LEVEL_RANK = { + trace: 0, + debug: 1, + info: 2, + warning: 3, warn: 3, + error: 4, err: 4, + critical: 5, +}; + +// Cap on the in-DOM log line count. The server's deque is bounded +// (MaxLogEntries on the C++ side) but the WS push delivers every new +// entry forever during a long Cook, so the browser DOM would grow +// without bound. At 5000 lines the panel still feels live for tail- +// following while keeping the page responsive. Older entries fall off +// the far end on every append. +const MAX_LOG_LINES_IN_DOM = 5000; + +// Level-filter dropdown options. `rank` is the minimum rank that survives +// — entries with a lower rank are hidden. -1 disables level filtering. +const LEVEL_FILTER_OPTIONS = [ + { value: "all", label: "All levels", rank: -1 }, + { value: "debug", label: "Debug+", rank: 1 }, + { value: "info", label: "Info+", rank: 2 }, + { value: "warn", label: "Warn+", rank: 3 }, + { value: "error", label: "Error+", rank: 4 }, +]; + +// Double-chevron icons for the expand / collapse panel toggle. Up when +// collapsed (click to grow the log panel upward into the table's space); +// down when expanded (click to shrink back down). currentColor so the +// surrounding button styles control tinting. +const ICON_CHEVRON_UP = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 17 12 11 18 17"/><polyline points="6 11 12 5 18 11"/></svg>'; +const ICON_CHEVRON_DOWN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 7 12 13 18 7"/><polyline points="6 13 12 19 18 13"/></svg>'; function fmt_date(iso) { @@ -13,15 +75,15 @@ function fmt_date(iso) const now = new Date(); if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) { - return d.toLocaleTimeString(); + return d.toLocaleTimeString([], TIME_OPTS); } - return d.toLocaleString(); + return d.toLocaleString([], DATE_OPTS); } function fmt_time(iso) { if (!iso) { return ""; } - return new Date(iso).toLocaleTimeString(); + return new Date(iso).toLocaleTimeString([], TIME_OPTS); } //////////////////////////////////////////////////////////////////////////////// @@ -33,32 +95,43 @@ export class Page extends ZenPage { this.set_title("sessions"); - this._status = this.get_param("status", "active"); + this._status = this.get_param("status", "all"); const section = this.add_section("Sessions"); - section._parent.inner().classList.add("sessions-section"); - - this._init_status_tabs(section, this._status); + const section_dom = section._parent.inner(); + section_dom.classList.add("sessions-section"); + // The "Sessions" nav item in the banner already identifies the page; + // drop the auto-generated section heading so it doesn't duplicate. + const heading = section_dom.querySelector(":scope > h1, :scope > h2"); + if (heading) { heading.remove(); } const query = (this._status === "ended" || this._status === "all") ? "?status=" + this._status : ""; const data = await new Fetcher().resource("/sessions/" + query).json(); const sessions = data.sessions || []; this._self_id = data.self_id || null; - // Layout: table on the left, detail panel on the right - this._container = section.tag().classify("sessions-layout"); - this._table_host = this._container.tag().classify("sessions-table"); - this._detail_panel = this._container.tag().classify("sessions-detail"); - this._detail_panel.tag().classify("sessions-detail-placeholder").text("Select a session to view details."); + // Flat vertical layout: header row, then table, then the bottom panel + // (tabs for log and metadata). All session-level info that used to + // live in a side panel is now shown inline in the table columns. + this._init_status_tabs(section, this._status); + this._table_host = section.tag().classify("sessions-table"); this._selected_id = this._self_id; this._selected_row = null; - this._page_size = 25; + this._page_size = 10; this._page = 0; - - // Log panel below the table/detail layout - this._log_panel = section.tag().classify("sessions-log-panel"); - this._log_panel.inner().style.display = "none"; - this._log_poll_timer = null; + this._text_filter = ""; + this._sort_key = "created_at"; + this._sort_asc = false; + + this._panel = section.tag().classify("sessions-log-panel"); + this._panel.inner().style.display = "none"; + this._active_tab = "log"; + this._log_expanded = false; + // Persist the level threshold across session selections so users + // don't have to re-pick after clicking a different session. + this._log_min_level = -1; + this._log_min_level_name = "all"; + this._collapsed_session_groups = new Set(); this._render_sessions(sessions); this._connect_ws(); @@ -68,8 +141,16 @@ export class Page extends ZenPage { const status = this._status; - // Clear existing table content + // Clear existing table content (and any prior pager contents; the + // pager lives in the header row but its state depends on what we're + // about to render). this._table_host.inner().replaceChildren(); + if (this._pager_host) + { + this._pager_host.replaceChildren(); + } + + this._last_sessions = sessions; if (sessions.length === 0) { @@ -79,74 +160,247 @@ export class Page extends ZenPage return; } - let columns; + // Apply the text filter (case-insensitive substring match across the + // session fields a user is likely to scan for: id, appname, mode). + const filter = this._text_filter; + let filtered = filter + ? sessions.filter(s => { + const haystack = [s.id, s.appname, s.mode].filter(v => v).join(" ").toLowerCase(); + return haystack.includes(filter); + }) + : sessions; + + if (filtered.length === 0) + { + this._table_host.tag().classify("empty-state").text("No sessions match the filter."); + this._selected_row = null; + return; + } + + // When the log panel is expanded, collapse the row set to just the + // selected session so the log gets the maximum vertical real estate. + // The expand toggle lives in the panel header — see _show_session_panel. + if (this._log_expanded && this._selected_id) + { + const selected = sessions.find(s => s.id === this._selected_id); + if (selected) { filtered = [selected]; } + } + + // Column specs carry both the header label and how to extract the + // real sort value so date columns compare chronologically rather + // than by locale-formatted text. + const str_val = (field) => (s) => (s[field] || "").toLowerCase(); + const date_val = (field) => (s) => s[field] ? new Date(s[field]).getTime() : 0; + + const common = [ + { name: "appname", key: "appname", kind: "str", get: str_val("appname") }, + { name: "mode", key: "mode", kind: "str", get: str_val("mode") }, + { name: "platform", key: "platform", kind: "str", get: str_val("platform") }, + { name: "id", key: "id", kind: "str", get: str_val("id") }, + { name: "created", key: "created_at", kind: "date", get: date_val("created_at") }, + ]; + let last_col; if (status === "all") { - columns = ["id", "appname", "mode", "created", "last activity"]; + last_col = { name: "last activity", key: "last_activity", kind: "date", + get: s => new Date(s.ended_at || s.updated_at || 0).getTime() }; } else if (status === "ended") { - columns = ["id", "appname", "mode", "created", "ended"]; + last_col = { name: "ended", key: "ended_at", kind: "date", get: date_val("ended_at") }; } else { - columns = ["id", "appname", "mode", "created", "updated"]; + last_col = { name: "updated", key: "updated_at", kind: "date", get: date_val("updated_at") }; } - this._last_sessions = sessions; - const total = sessions.length; + const col_specs = [...common, last_col]; + + // Pick the active sort column (fall back to created_at if the current + // sort key isn't in this tab's column set — e.g. switching from "all" + // back to "ended" after sorting by last_activity). + const sort_col = col_specs.find(c => c.key === this._sort_key) || col_specs.find(c => c.key === "created_at"); + const dir = this._sort_asc ? 1 : -1; + const compare_sessions = (a, b) => { + const av = sort_col.get(a), bv = sort_col.get(b); + if (av < bv) return -1 * dir; + if (av > bv) return 1 * dir; + return 0; + }; + const grouped = this._build_session_groups(filtered); + grouped.parents.sort(compare_sessions); + for (const parent of grouped.parents) + { + grouped.children.get(parent.id)?.sort(compare_sessions); + } + + const total = grouped.parents.length; const page_count = this._page_size > 0 ? Math.ceil(total / this._page_size) : 1; if (this._page >= page_count) { this._page = Math.max(0, page_count - 1); } const start = this._page_size > 0 ? this._page * this._page_size : 0; - const visible = this._page_size > 0 ? sessions.slice(start, start + this._page_size) : sessions; + const visible = this._page_size > 0 ? grouped.parents.slice(start, start + this._page_size) : grouped.parents; - const table = new Table(this._table_host, columns, Table.Flag_FitLeft); + const column_names = col_specs.map(c => c.name); + const table = new Table(this._table_host, column_names, Table.Flag_FitLeft, -1); + + // Attach header click handlers + active-column indicator. + const zen_table = this._table_host.inner().querySelector(".zen_table"); + const header_elem = zen_table ? zen_table.firstElementChild : null; + if (header_elem) + { + const header_cells = header_elem.children; + for (let i = 0; i < col_specs.length; i++) + { + const col = col_specs[i]; + const cell = header_cells[i]; + if (!cell) { continue; } + cell.style.cursor = "pointer"; + cell.style.userSelect = "none"; + if (col.key === sort_col.key) + { + cell.textContent = col.name + (this._sort_asc ? " \u25B2" : " \u25BC"); + cell.classList.add("sessions-sort-active"); + } + cell.addEventListener("click", () => { + if (this._sort_key === col.key) + { + this._sort_asc = !this._sort_asc; + } + else + { + this._sort_key = col.key; + // New column defaults: dates start descending (newest + // first — the natural reading for timestamps); string + // columns start ascending (A→Z). + this._sort_asc = col.kind !== "date"; + } + this._page = 0; + this._render_sessions(this._last_sessions); + }); + } + } let new_selected_row = null; let new_selected_session = null; - for (const session of visible) - { + const render_session_row = (session, is_child = false, child_count = 0) => { const created = fmt_date(session.created_at); const updated = fmt_date(session.updated_at); const ended = fmt_date(session.ended_at); const mode = session.mode || "-"; + const appname = session.appname || "-"; + const platform = session.platform || ""; + const full_id = session.id || ""; + // Elide the middle of the 24-char OID so the column stays narrow; + // the full id is still available as a tooltip on the cell below. + const id_display = full_id.length > 12 + ? full_id.slice(0, 8) + "\u2026" + full_id.slice(-4) + : (full_id || "-"); + let row_values; if (status === "all") { const last_activity = session.ended_at ? ended : updated; - row_values = [session.id || "-", session.appname || "-", mode, created, last_activity]; + row_values = [appname, mode, platform, id_display, created, last_activity]; } else if (status === "ended") { - row_values = [session.id || "-", session.appname || "-", mode, created, ended]; + row_values = [appname, mode, platform, id_display, created, ended]; } else { - row_values = [session.id || "-", session.appname || "-", mode, created, updated]; + row_values = [appname, mode, platform, id_display, created, updated]; } const row = table.add_row(...row_values); + // Swap the platform cell's text for a recognizable icon. Sort + // already runs on session.platform so the cell content doesn't + // affect ordering. + const platform_cell = row.get_cell(2).inner(); + platform_cell.replaceChildren(make_platform_cell(platform)); + + if (full_id) + { + row.get_cell(3).inner().title = full_id; + } + + // Indicator layout in the appname cell: [group toggle] [child elbow] + // [dot] appname [this] [log]. The pills sit after the name so their + // widths don't push names around and misalign the column across rows. + const appname_cell = row.get_cell(0); + if (child_count > 0) + { + const collapsed = this._collapsed_session_groups.has(session.id); + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "sessions-group-toggle"; + toggle.textContent = collapsed ? "\u25B8" : "\u25BE"; + toggle.title = collapsed ? "Expand child sessions" : "Collapse child sessions"; + toggle.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (collapsed) + { + this._collapsed_session_groups.delete(session.id); + } + else + { + this._collapsed_session_groups.add(session.id); + } + this._render_sessions(this._last_sessions); + }); + appname_cell.inner().prepend(toggle); + } + else if (is_child) + { + const spacer = document.createElement("span"); + spacer.className = "sessions-group-child-spacer"; + spacer.textContent = "\u2514"; + appname_cell.inner().prepend(spacer); + } + else + { + const spacer = document.createElement("span"); + spacer.className = "sessions-group-toggle-spacer"; + appname_cell.inner().prepend(spacer); + } + if (status === "all" && !session.ended_at) { - const id_cell = row.get_cell(0); const dot = document.createElement("span"); dot.className = "health-dot health-green"; dot.style.marginRight = "6px"; dot.style.width = "8px"; dot.style.height = "8px"; dot.title = "active"; - id_cell.inner().prepend(dot); + appname_cell.inner().insertBefore(dot, appname_cell.inner().firstChild.nextSibling); } if (this._self_id && session.id === this._self_id) { const pill = document.createElement("span"); - pill.className = "sessions-self-pill"; + pill.className = "sessions-pill sessions-self-pill"; pill.textContent = "this"; - row.get_cell(1).inner().prepend(pill); + appname_cell.inner().appendChild(pill); + } + + if (session.log_count) + { + const log_pill = document.createElement("span"); + log_pill.className = "sessions-pill sessions-log-indicator-pill"; + log_pill.textContent = "log"; + log_pill.title = session.log_count + " log entr" + (session.log_count === 1 ? "y" : "ies"); + appname_cell.inner().appendChild(log_pill); + } + + const row_elem = row.inner(); + if (is_child) + { + for (const cell of row_elem.children) + { + cell.classList.add("sessions-child-row"); + } } // Restore selection @@ -157,12 +411,28 @@ export class Page extends ZenPage } // Table rows use display:contents so we attach click to each cell - const row_elem = row.inner(); for (const cell of row_elem.children) { cell.style.cursor = "pointer"; cell.addEventListener("click", () => this._select_session(row, session)); } + }; + + const render_session_tree = (session, is_child = false) => { + const children = grouped.children.get(session.id) || []; + render_session_row(session, is_child, children.length); + if (!this._collapsed_session_groups.has(session.id)) + { + for (const child of children) + { + render_session_tree(child, true); + } + } + }; + + for (const session of visible) + { + render_session_tree(session); } this._selected_row = null; @@ -171,40 +441,106 @@ export class Page extends ZenPage this._select_session(new_selected_row, new_selected_session); } - if (this._page_size > 0 && total > this._page_size) + this._render_pager(total, page_count); + } + + _build_session_groups(sessions) + { + const by_id = new Map(); + for (const session of sessions) { - const footer = document.createElement("div"); - footer.className = "sessions-pager"; + if (session.id) + { + by_id.set(session.id, session); + } + } - const make_btn = (label, enabled, on_click) => { - const btn = document.createElement("button"); - btn.className = "history-tab"; - btn.textContent = label; - btn.disabled = !enabled; - if (enabled) + const parents = []; + const children = new Map(); + for (const session of sessions) + { + const parent_id = session.parent_session_id; + if (parent_id && by_id.has(parent_id) && parent_id !== session.id) + { + let group = children.get(parent_id); + if (!group) { - btn.addEventListener("click", on_click); + group = []; + children.set(parent_id, group); } - return btn; - }; + group.push(session); + } + else + { + parents.push(session); + } + } - footer.appendChild(make_btn("\u25C0", this._page > 0, () => { - this._page--; - this._render_sessions(sessions); - })); + // Keep the currently selected child visible when live updates rebuild the + // table by automatically expanding the group that contains it. + if (this._selected_id) + { + for (const [parent_id, group] of children) + { + if (group.some(s => s.id === this._selected_id)) + { + this._collapsed_session_groups.delete(parent_id); + break; + } + } + } - const label = document.createElement("span"); - label.className = "sessions-pager-label"; - label.textContent = `${this._page + 1} / ${page_count}`; - footer.appendChild(label); + return { parents, children }; + } - footer.appendChild(make_btn("\u25B6", this._page < page_count - 1, () => { - this._page++; - this._render_sessions(sessions); - })); + // Shared button.history-tab builder used for both the pager arrows and + // the status-mode tab strip. opts: { active?, disabled?, on_click? }. + _make_history_tab(label, opts) + { + const btn = document.createElement("button"); + btn.className = "history-tab"; + btn.textContent = label; + if (opts.active) { btn.classList.add("active"); } + if (opts.disabled) { btn.disabled = true; } + if (opts.on_click && !opts.disabled) + { + btn.addEventListener("click", opts.on_click); + } + return btn; + } - this._table_host.inner().appendChild(footer); + _render_pager(total, page_count) + { + if (!this._pager_host) + { + return; + } + this._pager_host.replaceChildren(); + if (!(this._page_size > 0 && total > this._page_size)) + { + return; } + + this._pager_host.appendChild(this._make_history_tab("\u25C0", { + disabled: this._page === 0, + on_click: () => { + this._page--; + this._render_sessions(this._last_sessions); + }, + })); + + const label = document.createElement("span"); + label.className = "sessions-pager-label"; + label.textContent = `${this._page + 1} / ${page_count}`; + this._pager_host.appendChild(label); + + this._pager_host.appendChild(this._make_history_tab("\u25B6", { + disabled: this._page >= page_count - 1, + on_click: () => { + this._page++; + this._render_sessions(this._last_sessions); + }, + })); } _filter_sessions(all_sessions) @@ -222,11 +558,15 @@ export class Page extends ZenPage _connect_ws() { - try - { + quietly("ws connect", () => { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${proto}//${location.host}/sessions/ws`); - try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) { this._ws_paused = false; } + // Log-push frames arrive as compact-binary over binary frames; + // asking for ArrayBuffer (default in modern browsers, explicit + // here) lets us feed the bytes straight into our CB parser. + ws.binaryType = "arraybuffer"; + this._ws_paused = quietly("ws-paused storage read", () => + localStorage.getItem("zen-ws-paused") === "true") === true; document.addEventListener("zen-ws-toggle", (e) => { this._ws_paused = e.detail.paused; }); @@ -236,52 +576,189 @@ export class Page extends ZenPage { return; } - try + // Two transports share this socket: + // - text/JSON: the session-list snapshots broadcast on a + // timer (untyped for backward compat). + // - binary/CB: event-driven log deltas, stamped with + // type="log" so future frame types can be added. + if (typeof ev.data === "string") { - const data = JSON.parse(ev.data); - if (data.self_id) { this._self_id = data.self_id; } - const all_sessions = data.sessions || []; - const filtered = this._filter_sessions(all_sessions); - this._render_sessions(filtered); + quietly("ws json frame", () => { + const data = JSON.parse(ev.data); + if (data.self_id) { this._self_id = data.self_id; } + const all_sessions = data.sessions || []; + const filtered = this._filter_sessions(all_sessions); + this._render_sessions(filtered); + }); + } + else if (ev.data instanceof ArrayBuffer) + { + quietly("ws cb frame", () => { + const bytes = new Uint8Array(ev.data); + // CbObject extends CbFieldView, not CbObjectView — + // to_js_object() lives on CbObjectView.prototype. + // Bridge via as_object() which wraps the field as + // a view of the same underlying bytes. + const frame = new CbObject(bytes).as_object().to_js_object(); + this._handle_ws_frame(frame); + }); } - catch (e) { /* ignore parse errors */ } }; + ws.onopen = () => { + // Resubscribe after a (re)connect if a session is already + // selected, so live tailing resumes without a reselect. + this._resubscribe_log(); + }; ws.onclose = () => { this._ws = null; }; ws.onerror = () => { ws.close(); }; this._ws = ws; + }); + } + + _handle_ws_frame(frame) + { + if (frame && frame.type === "log") + { + // Guard against stale deltas that arrive after the user has + // switched sessions — the server does its best but there's + // always a window. + if (frame.session !== this._log_session_id) + { + return; + } + if (typeof frame.cursor === "number" && frame.cursor < this._log_cursor) + { + // Cursor regressed (session reset while we were subscribed). + this._resync_log_from_zero(); + return; + } + this._log_cursor = frame.cursor || this._log_cursor; + if (Array.isArray(frame.entries) && frame.entries.length > 0) + { + this._append_log_entries(frame.entries); + } } - catch (e) { /* WebSocket not available */ } + } + + // Wipe the panel and re-replay the log from cursor 0, then re- + // subscribe so the WS feeds deltas from the fresh tail. Used both + // when the WS frame reports a cursor regression (session reset + // while we were subscribed) and when an HTTP refetch sees the same. + // Returns the underlying fetch promise so callers can await if they + // need ordering guarantees. + _resync_log_from_zero() + { + this._log_cursor = 0; + if (this._log_body) { this._log_body.replaceChildren(); } + return this._fetch_log().then(() => this._subscribe_log()); + } + + _ws_send(obj) + { + const ws = this._ws; + if (!ws || ws.readyState !== WebSocket.OPEN) { return false; } + return quietly("ws send", () => { ws.send(JSON.stringify(obj)); return true; }) === true; + } + + _subscribe_log() + { + if (!this._log_session_id) { return; } + // Don't subscribe until the initial replay has resolved — the + // cursor is stale 0 until then and the server would flush the + // entire history we're about to fetch via HTTP, duplicating + // every line in the DOM. + if (!this._log_fetch_done) { return; } + this._ws_send({ type: "sub_log", session: this._log_session_id, cursor: this._log_cursor | 0 }); + } + + _unsubscribe_log() + { + this._ws_send({ type: "unsub_log" }); + } + + // Called on ws.onopen to restore the subscription after a reconnect. + _resubscribe_log() + { + this._subscribe_log(); } _init_status_tabs(host, active_status) { + const row = document.createElement("div"); + row.className = "sessions-header-row"; + host.tag().inner().appendChild(row); + const tabs_el = document.createElement("div"); tabs_el.className = "history-tabs"; - tabs_el.style.marginBottom = "8px"; - tabs_el.style.width = "fit-content"; - host.tag().inner().appendChild(tabs_el); + row.appendChild(tabs_el); const make_tab = (label, mode) => { - const btn = document.createElement("button"); - btn.className = "history-tab"; - btn.textContent = label; - if (mode === active_status) - { - btn.classList.add("active"); - } - btn.addEventListener("click", () => { - if (mode === active_status) { return; } - this.set_param("status", mode); - this.reload(); - }); - tabs_el.appendChild(btn); + tabs_el.appendChild(this._make_history_tab(label, { + active: mode === active_status, + on_click: mode === active_status ? null : () => { + this.set_param("status", mode); + this.reload(); + }, + })); }; make_tab("Active", "active"); make_tab("Ended", "ended"); make_tab("All", "all"); + + const filter_input = document.createElement("input"); + filter_input.type = "search"; + filter_input.className = "sessions-list-filter"; + filter_input.placeholder = "Filter\u2026"; + filter_input.autocomplete = "off"; + filter_input.spellcheck = false; + filter_input.addEventListener("input", () => { + this._text_filter = filter_input.value.toLowerCase().trim(); + this._page = 0; + if (this._last_sessions) + { + this._render_sessions(this._last_sessions); + } + }); + row.appendChild(filter_input); + + // Right-aligned pager host; populated per-render in _render_sessions + // so the arrows don't live inside the table (where they'd shift + // vertically as the table grows/shrinks between pages). + const spacer = document.createElement("span"); + spacer.style.flex = "1"; + row.appendChild(spacer); + + this._pager_host = document.createElement("div"); + this._pager_host.className = "sessions-header-pager"; + row.appendChild(this._pager_host); + } + + _session_detail_metadata(session) + { + const details = { + status: session.ended_at ? "ended" : "active", + session_id: session.id || "-", + parent_session_id: session.parent_session_id || "-", + appname: session.appname || "-", + mode: session.mode || "-", + platform: session.platform || "-", + pid: session.pid || "-", + jobid: session.jobid || "-", + created_at: session.created_at ? fmt_date(session.created_at) : "-", + updated_at: session.updated_at ? fmt_date(session.updated_at) : "-", + }; + if (session.ended_at) + { + details.ended_at = fmt_date(session.ended_at); + } + if (session.log_count) + { + details.log_count = session.log_count; + } + return details; } _select_session(row, session) @@ -304,72 +781,89 @@ export class Page extends ZenPage cell.classList.add("sessions-selected"); } - // Only rebuild the detail panel and log when the session changes - if (!changed) - { - return; - } - - // Rebuild detail panel - const panel = this._detail_panel; - panel.inner().replaceChildren(); - - panel.tag("h3").text("Session Details"); - - const props = new PropTable(panel); - props.add_property("id", session.id || "-"); - props.add_property("appname", session.appname || "-"); - if (session.mode) + // Rebuild the bottom panel only when the selection actually changes. + if (changed) { - props.add_property("mode", session.mode); + this._show_session_panel(session); } - if (session.jobid) - { - props.add_property("jobid", session.jobid); - } - props.add_property("created", fmt_date(session.created_at)); - props.add_property("updated", fmt_date(session.updated_at)); - if (session.ended_at) - { - props.add_property("ended", fmt_date(session.ended_at)); - } - - if (session.metadata && Object.keys(session.metadata).length > 0) - { - panel.tag("h3").text("Metadata"); - const meta_props = new PropTable(panel); - meta_props.add_object(session.metadata); - } - - // Show log panel for this session - this._show_log(session.id); } - _show_log(session_id) + _show_session_panel(session) { - // Stop any existing poll - if (this._log_poll_timer) + // Unsubscribe from the previous session's log stream (if any) + // before we switch. The server treats a subsequent sub_log as a + // replacement, but an explicit unsub makes the intent clear and + // stops any in-flight pushes that could arrive as we're wiping + // the panel. + if (this._log_session_id && this._log_session_id !== session.id) { - clearInterval(this._log_poll_timer); - this._log_poll_timer = null; + this._unsubscribe_log(); } - this._log_session_id = session_id; + this._log_session_id = session.id; this._log_cursor = 0; // monotonic cursor for incremental fetching + this._log_fetch_done = false; // gates _subscribe_log until replay resolves this._log_follow = true; this._log_newest_first = true; + this._log_filter = ""; - this._log_panel.inner().style.display = ""; - this._log_panel.inner().replaceChildren(); + this._panel.inner().style.display = ""; + this._panel.inner().replaceChildren(); - // Header + // Header with tab strip, filter, and log-view controls const header = document.createElement("div"); header.className = "sessions-log-header"; - const title = document.createElement("span"); - title.className = "sessions-log-title"; - title.textContent = "Log"; - header.appendChild(title); + const tabs = document.createElement("div"); + tabs.className = "sessions-panel-tabs"; + header.appendChild(tabs); + + const log_tab = document.createElement("button"); + log_tab.type = "button"; + log_tab.className = "sessions-panel-tab"; + log_tab.textContent = "Log"; + tabs.appendChild(log_tab); + + const meta_tab = document.createElement("button"); + meta_tab.type = "button"; + meta_tab.className = "sessions-panel-tab"; + meta_tab.textContent = "Metadata"; + tabs.appendChild(meta_tab); + + // Spacer sits between the tab strip and the right-hand controls so + // the Expand button stays flush right on both tabs (log_controls is + // hidden on Metadata — if the spacer lived there too, the button + // would jump left). + const spacer = document.createElement("span"); + spacer.className = "sessions-log-spacer"; + header.appendChild(spacer); + + // Log-only controls: filter, newest-first, follow. Hidden when the + // Metadata tab is active since they don't apply there. + const log_controls = document.createElement("span"); + log_controls.className = "sessions-log-controls"; + header.appendChild(log_controls); + + // Level filter: hides entries below the selected severity. Sits + // before the text filter since level is a coarser cut than text. + const level_select = document.createElement("select"); + level_select.className = "sessions-log-level-filter"; + level_select.title = "Hide log entries below this severity level"; + for (const opt of LEVEL_FILTER_OPTIONS) + { + const o = document.createElement("option"); + o.value = opt.value; + o.textContent = opt.label; + level_select.appendChild(o); + } + level_select.value = this._log_min_level_name; + level_select.addEventListener("change", () => { + this._log_min_level_name = level_select.value; + const opt = LEVEL_FILTER_OPTIONS.find(o => o.value === level_select.value); + this._log_min_level = opt ? opt.rank : -1; + this._apply_log_filter(); + }); + log_controls.appendChild(level_select); const filter_input = document.createElement("input"); filter_input.type = "text"; @@ -379,12 +873,7 @@ export class Page extends ZenPage this._log_filter = filter_input.value.toLowerCase(); this._apply_log_filter(); }); - header.appendChild(filter_input); - this._log_filter = ""; - - const spacer = document.createElement("span"); - spacer.style.flex = "1"; - header.appendChild(spacer); + log_controls.appendChild(filter_input); const order_btn = document.createElement("button"); order_btn.className = "history-tab active"; @@ -394,7 +883,7 @@ export class Page extends ZenPage order_btn.classList.toggle("active", this._log_newest_first); this._reorder_log(); }); - header.appendChild(order_btn); + log_controls.appendChild(order_btn); const follow_btn = document.createElement("button"); follow_btn.className = "history-tab active"; @@ -407,30 +896,113 @@ export class Page extends ZenPage this._scroll_to_follow(); } }); - header.appendChild(follow_btn); + log_controls.appendChild(follow_btn); this._log_follow_btn = follow_btn; - this._log_panel.inner().appendChild(header); + // Expand / collapse toggle: applies to the whole page layout (table + // vs log panel balance) so it lives outside log_controls and stays + // visible on both tabs. Double-chevron direction mirrors the way + // the panel grows — up when there's room to expand, down when + // expanded and ready to collapse back. + const expand_btn = document.createElement("button"); + expand_btn.type = "button"; + expand_btn.className = "history-tab sessions-panel-toggle"; + const refresh_toggle = () => { + expand_btn.innerHTML = this._log_expanded ? ICON_CHEVRON_DOWN : ICON_CHEVRON_UP; + expand_btn.title = this._log_expanded + ? "Restore the sessions table" + : "Collapse the sessions table to focus on this session's log"; + expand_btn.setAttribute("aria-label", this._log_expanded ? "Collapse log panel" : "Expand log panel"); + expand_btn.classList.toggle("active", this._log_expanded); + }; + refresh_toggle(); + expand_btn.addEventListener("click", () => { + this._log_expanded = !this._log_expanded; + refresh_toggle(); + if (this._last_sessions) { this._render_sessions(this._last_sessions); } + }); + header.appendChild(expand_btn); + + this._panel.inner().appendChild(header); // Log body - const body = document.createElement("div"); - body.className = "sessions-log-body"; - body.addEventListener("scroll", () => { + const log_body = document.createElement("div"); + log_body.className = "sessions-log-body"; + log_body.addEventListener("scroll", () => { const at_follow_edge = this._log_newest_first - ? (body.scrollTop <= 4) - : (body.scrollTop + body.clientHeight >= body.scrollHeight - 4); + ? (log_body.scrollTop <= 4) + : (log_body.scrollTop + log_body.clientHeight >= log_body.scrollHeight - 4); if (this._log_follow !== at_follow_edge) { this._log_follow = at_follow_edge; this._log_follow_btn.classList.toggle("active", this._log_follow); } }); - this._log_panel.inner().appendChild(body); - this._log_body = body; + this._panel.inner().appendChild(log_body); + this._log_body = log_body; + + // Metadata/details body. Keep polling running regardless of which tab is + // visible so cursors stay fresh. Free-form metadata gets the primary + // left-hand panel; core session fields sit beside it on the right. + // Use .tag() so child panels are real Components — PropTable reaches into + // its parent's DOM element through the Component API. + const meta_body_widget = this._panel.tag().classify("sessions-metadata-body"); + const meta_body = meta_body_widget.inner(); + const meta_layout = meta_body_widget.tag().classify("sessions-metadata-layout"); + const metadata_panel = meta_layout.tag().classify("sessions-metadata-panel"); + const details_panel = meta_layout.tag().classify("sessions-metadata-panel").classify("sessions-metadata-core-panel"); + + const metadata_heading = document.createElement("div"); + metadata_heading.className = "sessions-metadata-heading"; + metadata_heading.textContent = "Metadata"; + metadata_panel.inner().appendChild(metadata_heading); + + const has_metadata = session.metadata && Object.keys(session.metadata).length > 0; + if (has_metadata) + { + const meta_props = new PropTable(metadata_panel); + meta_props.add_object(session.metadata); + } + else + { + const empty = document.createElement("div"); + empty.className = "sessions-log-empty"; + empty.textContent = "No metadata."; + metadata_panel.inner().appendChild(empty); + } - // Initial fetch + start polling - this._fetch_log(); - this._log_poll_timer = setInterval(() => this._fetch_log(), 2000); + const details_heading = document.createElement("div"); + details_heading.className = "sessions-metadata-heading"; + details_heading.textContent = "Session Information"; + details_panel.inner().appendChild(details_heading); + + const detail_props = new PropTable(details_panel); + detail_props.add_object(this._session_detail_metadata(session)); + + const set_active_tab = (tab) => { + this._active_tab = tab; + const is_log = tab === "log"; + log_tab.classList.toggle("active", is_log); + meta_tab.classList.toggle("active", !is_log); + log_body.style.display = is_log ? "" : "none"; + meta_body.style.display = is_log ? "none" : ""; + log_controls.style.display = is_log ? "" : "none"; + }; + log_tab.addEventListener("click", () => set_active_tab("log")); + meta_tab.addEventListener("click", () => set_active_tab("meta")); + set_active_tab(this._active_tab || "log"); + + // Initial HTTP fetch gives us the full history in one shot; after + // it returns we hand off to the WebSocket for live deltas. Mark + // the panel as "fetch done" so _resubscribe_log (fired from + // ws.onopen) can avoid racing a too-early subscribe with + // cursor=0 that'd cause a duplicate flush. No more setInterval + // — pushes arrive the moment an entry is appended. See + // _handle_ws_frame. + this._fetch_log().then(() => { + this._log_fetch_done = true; + this._subscribe_log(); + }); } async _fetch_log() @@ -453,10 +1025,10 @@ export class Page extends ZenPage if (cursor < this._log_cursor) { - // Cursor went backwards — session was reset. Full re-render. - this._log_cursor = 0; - this._log_body.replaceChildren(); - this._fetch_log(); + // Cursor went backwards — session was reset. Resync via + // the shared helper so the WS-frame and HTTP-fetch paths + // stay in lockstep. + await this._resync_log_from_zero(); return; } @@ -471,7 +1043,12 @@ export class Page extends ZenPage this._show_log_empty(); } } - catch (e) { /* ignore */ } + catch (e) + { + // quietly() can't wrap an awaited body, so the catch is open-coded + // here — same debug-log policy as the sync paths above. + console.debug("[sessions] fetch log:", e); + } } _show_log_empty() @@ -520,6 +1097,30 @@ export class Page extends ZenPage } } + // Cap DOM size. Drop the oldest lines from whichever end of the + // container holds them — that's the bottom in newest-first mode, + // the top in oldest-first mode. The user can no longer scroll + // further back than MAX_LOG_LINES_IN_DOM until they switch + // sessions and replay from cursor 0. + const overflow = body.children.length - MAX_LOG_LINES_IN_DOM; + if (overflow > 0) + { + if (this._log_newest_first) + { + for (let i = 0; i < overflow; i++) + { + body.removeChild(body.lastElementChild); + } + } + else + { + for (let i = 0; i < overflow; i++) + { + body.removeChild(body.firstElementChild); + } + } + } + if (this._log_follow) { this._scroll_to_follow(); @@ -572,12 +1173,56 @@ export class Page extends ZenPage if (entry.level) { + const key = entry.level.toLowerCase(); + const rank = LEVEL_RANK[key]; + // Stamp the rank so _line_passes_filters can check it later + // without re-parsing the text. Unknown levels leave it unset. + if (rank !== undefined) { line.dataset.levelRank = String(rank); } const lvl = document.createElement("span"); - lvl.className = "sessions-log-level sessions-log-level-" + entry.level.toLowerCase(); + lvl.className = "sessions-log-level sessions-log-level-" + key; lvl.textContent = entry.level; line.appendChild(lvl); } + // Always render the logger column (even if empty) so the message + // column stays aligned across rows whether or not a category is set. + const cat = document.createElement("span"); + cat.className = "sessions-log-logger"; + if (entry.logger) + { + cat.textContent = entry.logger; + cat.title = entry.logger; + } + line.appendChild(cat); + + // Marker for UE_LOGFMT structured entries. The server pre-renders + // `format` against `fields` into `message`, but both raw pieces ride + // along in the JSON so we can flag them visually and let a future UI + // hook in for field-level drill-down. Tooltip shows the raw template + // plus the arguments bag so you can see exactly what UE sent. + if (entry.format) + { + const fmt_marker = document.createElement("span"); + fmt_marker.className = "sessions-log-fmt-marker"; + fmt_marker.textContent = "{\u2026}"; + let tooltip = "format: " + entry.format; + if (entry.fields && Object.keys(entry.fields).length > 0) + { + try + { + tooltip += "\nfields: " + JSON.stringify(entry.fields, null, 2); + } + catch (_e) + { + // Shouldn't happen for server-produced JSON, but guard + // against self-referential structures just in case. + tooltip += "\nfields: <unserializable>"; + } + } + fmt_marker.title = tooltip; + line.appendChild(fmt_marker); + } + if (entry.message) { const msg = document.createElement("span"); @@ -586,20 +1231,36 @@ export class Page extends ZenPage line.appendChild(msg); } - if (entry.data && Object.keys(entry.data).length > 0) + if (!this._line_passes_filters(line)) { - const data_span = document.createElement("span"); - data_span.className = "sessions-log-data"; - data_span.textContent = JSON.stringify(entry.data); - line.appendChild(data_span); + line.style.display = "none"; } + return line; + } + + // Shared predicate for both the initial render (_create_log_line) and + // full sweeps (_apply_log_filter). Keeps the two paths in sync. + _line_passes_filters(line) + { + if (this._log_min_level >= 0) + { + const rank_str = line.dataset.levelRank; + // Entries without a known level rank pass through — they may + // carry info that shouldn't be silently dropped (e.g. the + // synthetic "session ended" line or legacy entries without a + // level field). + if (rank_str !== undefined && rank_str !== "") + { + const rank = Number(rank_str); + if (!Number.isNaN(rank) && rank < this._log_min_level) { return false; } + } + } if (this._log_filter && !line.textContent.toLowerCase().includes(this._log_filter)) { - line.style.display = "none"; + return false; } - - return line; + return true; } _apply_log_filter() @@ -608,17 +1269,9 @@ export class Page extends ZenPage { return; } - const filter = this._log_filter; for (const line of this._log_body.querySelectorAll(".sessions-log-line")) { - if (!filter || line.textContent.toLowerCase().includes(filter)) - { - line.style.display = ""; - } - else - { - line.style.display = "none"; - } + line.style.display = this._line_passes_filters(line) ? "" : "none"; } } } diff --git a/src/zenserver/frontend/html/theme.js b/src/zenserver/frontend/html/theme.js index 52ca116ab..7382d3ea0 100644 --- a/src/zenserver/frontend/html/theme.js +++ b/src/zenserver/frontend/html/theme.js @@ -4,18 +4,25 @@ // Persists choice in localStorage. Applies data-theme attribute on <html>. (function() { - var KEY = 'zen-theme'; - - function getStored() { - try { return localStorage.getItem(KEY); } catch (e) { return null; } + // Wrap localStorage so a single key's get/set/clear all swallow the + // SecurityError that fires in private-mode / cookies-disabled browsers. + // `clear` removes the key entirely (used for theme to reset to system + // preference); `set` stores the raw value passed (callers serialize). + function safeStorage(key) { + return { + get: function() { + try { return localStorage.getItem(key); } catch (e) { return null; } + }, + set: function(value) { + try { localStorage.setItem(key, value); } catch (e) {} + }, + clear: function() { + try { localStorage.removeItem(key); } catch (e) {} + }, + }; } - function setStored(value) { - try { - if (value) localStorage.setItem(KEY, value); - else localStorage.removeItem(KEY); - } catch (e) {} - } + var themeStore = safeStorage('zen-theme'); function apply(theme) { if (theme) @@ -30,32 +37,53 @@ } // Apply stored preference immediately (before paint) - var stored = getStored(); - apply(stored); + apply(themeStore.get()); + + // Wide-mode preference: persisted across sessions, applied before paint + // so the layout doesn't flash at the default width on reload. Lifts the + // 1400px #container cap and the body's horizontal padding so the main + // content fills the viewport edge-to-edge. + var wideStore = safeStorage('zen-wide'); + function getWide() { return wideStore.get() === 'true'; } + function setWide(value) { + if (value) wideStore.set('true'); + else wideStore.clear(); + } + function applyWide(wide) { + if (wide) document.documentElement.setAttribute('data-wide', 'true'); + else document.documentElement.removeAttribute('data-wide'); + } + applyWide(getWide()); + + // Double-chevron SVGs for the wide toggle — outward when content is + // narrow (click to fill the viewport), inward when wide (click to snap + // back to the 1400px cap). currentColor so button styles tint it. + var ICON_WIDEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="8 6 2 12 8 18"/><polyline points="16 6 22 12 16 18"/></svg>'; + var ICON_NARROW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 6 10 12 4 18"/><polyline points="20 6 14 12 20 18"/></svg>'; // Create toggle button once DOM is ready function createToggle() { var btn = document.createElement('button'); btn.id = 'zen_theme_toggle'; + btn.className = 'zen-floating-toggle'; btn.title = 'Toggle theme'; function updateIcon() { - var effective = getEffective(getStored()); + var effective = getEffective(themeStore.get()); // Show sun in dark mode (click to go light), moon in light mode (click to go dark) btn.textContent = effective === 'dark' ? '\u2600' : '\u263E'; - var isManual = getStored() != null; + var isManual = themeStore.get() != null; btn.title = isManual ? 'Theme: ' + effective + ' (click to change, double-click for system)' : 'Theme: system (click to change)'; } btn.addEventListener('click', function() { - var current = getStored(); - var effective = getEffective(current); + var effective = getEffective(themeStore.get()); // Toggle to the opposite var next = effective === 'dark' ? 'light' : 'dark'; - setStored(next); + themeStore.set(next); apply(next); updateIcon(); }); @@ -63,26 +91,26 @@ btn.addEventListener('dblclick', function(e) { e.preventDefault(); // Reset to system preference - setStored(null); + themeStore.clear(); apply(null); updateIcon(); }); // Update icon when system preference changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() { - if (!getStored()) updateIcon(); + if (!themeStore.get()) updateIcon(); }); updateIcon(); document.body.appendChild(btn); // WebSocket pause/play toggle - var WS_KEY = 'zen-ws-paused'; + var wsStore = safeStorage('zen-ws-paused'); var wsBtn = document.createElement('button'); wsBtn.id = 'zen_ws_toggle'; + wsBtn.className = 'zen-floating-toggle'; - var initialPaused = false; - try { initialPaused = localStorage.getItem(WS_KEY) === 'true'; } catch (e) {} + var initialPaused = wsStore.get() === 'true'; function updateWsIcon(paused) { wsBtn.dataset.paused = paused ? 'true' : 'false'; @@ -92,21 +120,43 @@ updateWsIcon(initialPaused); - // Fire initial event so pages pick up persisted state - document.addEventListener('DOMContentLoaded', function() { - if (initialPaused) { - document.dispatchEvent(new CustomEvent('zen-ws-toggle', { detail: { paused: true } })); - } - }); + // No initial event is dispatched: createToggle runs at (or after) + // DOMContentLoaded, so any listener gated on DOMContentLoaded would + // not fire. Page scripts read localStorage('zen-ws-paused') directly + // for their initial paused state and subscribe to zen-ws-toggle for + // subsequent transitions. wsBtn.addEventListener('click', function() { var paused = wsBtn.dataset.paused !== 'true'; - try { localStorage.setItem(WS_KEY, paused ? 'true' : 'false'); } catch (e) {} + wsStore.set(paused ? 'true' : 'false'); updateWsIcon(paused); document.dispatchEvent(new CustomEvent('zen-ws-toggle', { detail: { paused: paused } })); }); document.body.appendChild(wsBtn); + + // Wide-mode toggle. Sits to the left of the pause and theme toggles. + var wideBtn = document.createElement('button'); + wideBtn.id = 'zen_wide_toggle'; + wideBtn.className = 'zen-floating-toggle'; + + function updateWideIcon(wide) { + wideBtn.dataset.wide = wide ? 'true' : 'false'; + wideBtn.innerHTML = wide ? ICON_NARROW : ICON_WIDEN; + wideBtn.title = wide ? 'Narrow the main content' : 'Fill the viewport width'; + wideBtn.setAttribute('aria-label', wide ? 'Narrow content' : 'Widen content'); + } + + updateWideIcon(getWide()); + + wideBtn.addEventListener('click', function() { + var wide = !getWide(); + setWide(wide); + applyWide(wide); + updateWideIcon(wide); + }); + + document.body.appendChild(wideBtn); } if (document.readyState === 'loading') diff --git a/src/zenserver/frontend/html/util/compactbinary.js b/src/zenserver/frontend/html/util/compactbinary.js index 270c96a2f..bd5bf95b3 100644 --- a/src/zenserver/frontend/html/util/compactbinary.js +++ b/src/zenserver/frontend/html/util/compactbinary.js @@ -334,7 +334,7 @@ CbFieldView.prototype.clone = function() { const ret = new CbFieldView() ret._type = this._type; - ret._name = ret._name; + ret._name = this._name; ret._data_view = new Uint8Array(this._data_view); return ret; } @@ -352,8 +352,10 @@ CbObjectView.prototype[Symbol.iterator] = function() var data_view = this.get_payload(); const [payload_size, varint_len] = VarInt.read_uint(data_view); + // Empty object — return a proper empty iterator, not a bare `{}` which + // would crash `for...of` with "undefined is not a function". if (payload_size == 0) - return {}; + return [][Symbol.iterator](); data_view = data_view.subarray(varint_len, payload_size + varint_len); var uniform_type = CbFieldType.HasFieldType; @@ -467,8 +469,10 @@ CbArrayView.prototype[Symbol.iterator] = function() data_view = data_view.subarray(varint_len, payload_size + varint_len); const item_count_bytes = VarInt.measure(data_view); + // Empty array — return a proper empty iterator, not a bare `{}` which + // would crash `for...of` with "undefined is not a function". if (item_count_bytes >= payload_size) - return {}; + return [][Symbol.iterator](); data_view = data_view.subarray(item_count_bytes); var uniform_type = CbFieldType.HasFieldType; diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css index d3c6c9036..46714a83d 100644 --- a/src/zenserver/frontend/html/zen.css +++ b/src/zenserver/frontend/html/zen.css @@ -2,64 +2,12 @@ /* theme -------------------------------------------------------------------- */ -/* system preference (default) */ -@media (prefers-color-scheme: light) { - :root { - --theme_g0: #1f2328; - --theme_g1: #656d76; - --theme_g2: #d0d7de; - --theme_g3: #f6f8fa; - --theme_g4: #ffffff; - - --theme_p0: #0969da; - --theme_p4: #ddf4ff; - --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); - --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); - --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); - - --theme_ln: var(--theme_p0); - --theme_er: #ffebe9; - - --theme_ok: #1a7f37; - --theme_warn: #9a6700; - --theme_fail: #cf222e; - - --theme_bright: #1f2328; - --theme_faint: #6e7781; - --theme_border_subtle: #d8dee4; - --theme_highlight: #b8860b44; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --theme_g0: #c9d1d9; - --theme_g1: #8b949e; - --theme_g2: #30363d; - --theme_g3: #161b22; - --theme_g4: #0d1117; - - --theme_p0: #58a6ff; - --theme_p4: #1c2128; - --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); - --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); - --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); - - --theme_ln: #58a6ff; - --theme_er: #1c1c1c; - - --theme_ok: #3fb950; - --theme_warn: #d29922; - --theme_fail: #f85149; - - --theme_bright: #f0f6fc; - --theme_faint: #6e7681; - --theme_border_subtle: #21262d; - --theme_highlight: #e3b341aa; - } -} - -/* manual overrides (higher specificity than media queries) */ +/* Light tokens apply to the explicit data-theme="light" override and as the + default when no system preference matches the dark @media query below. + Dark tokens apply to data-theme="dark" and (when no explicit preference is + set) to dark system preference. Selector lists keep each token list defined + exactly once. */ +:root, :root[data-theme="light"] { --theme_g0: #1f2328; --theme_g1: #656d76; @@ -67,6 +15,10 @@ --theme_g3: #f6f8fa; --theme_g4: #ffffff; + /* surface backgrounds: bg0 matches the body, bg1 is one step raised */ + --theme_bg0: var(--theme_g4); + --theme_bg1: var(--theme_g3); + --theme_p0: #0969da; --theme_p4: #ddf4ff; --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); @@ -86,6 +38,32 @@ --theme_highlight: #b8860b44; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --theme_g0: #c9d1d9; + --theme_g1: #8b949e; + --theme_g2: #30363d; + --theme_g3: #161b22; + --theme_g4: #0d1117; + + --theme_p0: #58a6ff; + --theme_p4: #1c2128; + + --theme_ln: #58a6ff; + --theme_er: #1c1c1c; + + --theme_ok: #3fb950; + --theme_warn: #d29922; + --theme_fail: #f85149; + + --theme_bright: #f0f6fc; + --theme_faint: #6e7681; + --theme_border_subtle: #21262d; + --theme_highlight: #e3b341aa; + } +} + +/* Manual data-theme="dark" wins over system preference. */ :root[data-theme="dark"] { --theme_g0: #c9d1d9; --theme_g1: #8b949e; @@ -95,9 +73,6 @@ --theme_p0: #58a6ff; --theme_p4: #1c2128; - --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); - --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); - --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); --theme_ln: #58a6ff; --theme_er: #1c1c1c; @@ -114,10 +89,12 @@ /* theme toggle ------------------------------------------------------------- */ -#zen_ws_toggle { +/* Shared shape for the fixed top-right utility buttons (theme, wide, ws-pause). + Per-button rules below add only the `right` offset and any glyph-specific + typography (font-size for emoji buttons, padding-zero for SVG buttons). */ +.zen-floating-toggle { position: fixed; top: 16px; - right: 60px; z-index: 10; width: 36px; height: 36px; @@ -129,43 +106,48 @@ display: flex; align-items: center; justify-content: center; - font-size: 18px; - line-height: 1; transition: color 0.15s, background 0.15s, border-color 0.15s; user-select: none; } -#zen_ws_toggle:hover { +.zen-floating-toggle:hover { color: var(--theme_g0); background: var(--theme_p4); border-color: var(--theme_g1); } #zen_theme_toggle { - position: fixed; - top: 16px; right: 16px; - z-index: 10; - width: 36px; - height: 36px; - border-radius: 6px; - border: 1px solid var(--theme_g2); - background: var(--theme_g3); - color: var(--theme_g1); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; font-size: 18px; line-height: 1; - transition: color 0.15s, background 0.15s, border-color 0.15s; - user-select: none; } -#zen_theme_toggle:hover { - color: var(--theme_g0); - background: var(--theme_p4); - border-color: var(--theme_g1); +#zen_ws_toggle { + right: 60px; + font-size: 18px; + line-height: 1; +} + +#zen_wide_toggle { + right: 104px; + padding: 0; +} + +#zen_wide_toggle svg { + width: 18px; + height: 18px; + display: block; +} + +/* Wide mode: lift the 1400px cap on the main container and drop the body's + horizontal padding so content fills the viewport edge-to-edge. Vertical + body padding stays so content doesn't touch the top of the viewport. */ +html[data-wide="true"] body { + padding-left: 0; + padding-right: 0; +} +html[data-wide="true"] #container { + max-width: none; } /* page --------------------------------------------------------------------- */ @@ -342,15 +324,39 @@ a { height: calc(100vh - 80px); } -.sessions-layout { +.sessions-header-row { display: flex; - gap: 1.5em; - align-items: flex-start; - flex-shrink: 0; + align-items: center; + gap: 12px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.sessions-list-filter { + padding: 6px 12px; + font-size: 14px; + font-family: inherit; + border: 1px solid var(--theme_g2); + border-radius: 6px; + background: var(--theme_bg1); + color: var(--theme_bright); + outline: none; + width: 240px; + max-width: 100%; +} +.sessions-list-filter:focus { + border-color: var(--theme_ln); + background: var(--theme_bg0); +} +.sessions-list-filter::placeholder { + color: var(--theme_g1); } .sessions-table { - flex: 1; + /* Natural height so the bottom panel sits right below the last row. The + section's column-flex lets .sessions-log-panel (flex: 1) absorb the + remaining vertical space for log viewing. */ + flex-shrink: 0; min-width: 0; } @@ -358,31 +364,157 @@ a { text-align: right; } -.sessions-table .zen_table > div > div:nth-child(2) { +/* appname (col 1), mode (col 2), platform (col 3) read better left-aligned */ +.sessions-table .zen_table > div > div:nth-child(1), +.sessions-table .zen_table > div > div:nth-child(2), +.sessions-table .zen_table > div > div:nth-child(3) { + text-align: left; +} + +/* id (col 4) is a hex string — monospace */ +.sessions-table .zen_table > div > div:nth-child(4) { font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; } -.sessions-detail { - width: 600px; - flex-shrink: 0; - font-size: 13px; +/* Platform-column icon: sized to the table row height, picks up theme color + via currentColor. Unknown platforms fall back to a plain text label. */ +.platform-icon { + display: inline-flex; + align-items: center; + color: var(--theme_g0); + opacity: 0.85; +} +.platform-icon svg { + width: 16px; + height: 16px; + fill: currentColor; } -.sessions-detail h3 { - margin: 0 0 0.6em 0; - font-size: 13px; - text-transform: uppercase; - letter-spacing: 0.5px; +/* Clickable column headers: the first row gets hover affordance. */ +.sessions-table .zen_table > div:first-child > div:hover { + color: var(--theme_g0); +} +.sessions-table .zen_table > div:first-child > div.sessions-sort-active { + color: var(--theme_ln); +} + +.sessions-group-toggle, +.sessions-group-toggle-spacer, +.sessions-group-child-spacer { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + margin-right: 4px; color: var(--theme_g1); + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; } -.sessions-detail .zen_table { - margin-bottom: 1em; +.sessions-group-toggle { + border: 0; + padding: 0; + background: transparent; + cursor: pointer; +} + +.sessions-group-toggle:hover { + color: var(--theme_ln); +} + +.sessions-child-row { + background-color: color-mix(in srgb, var(--theme_bg1) 75%, transparent); +} + +.sessions-child-row:first-child { + padding-left: 14px; } -.sessions-detail-placeholder { +/* Bottom-panel tab strip (Log / Metadata). Lives inside + .sessions-log-header alongside the log-view controls. */ +.sessions-panel-tabs { + display: flex; + gap: 2px; +} +.sessions-panel-tab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + padding: 4px 10px; color: var(--theme_g1); - font-style: italic; + font: inherit; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; +} +.sessions-panel-tab:hover { + color: var(--theme_g0); +} +.sessions-panel-tab.active { + color: var(--theme_ln); + border-bottom-color: var(--theme_ln); +} + +/* Log-only controls (filter, newest-first, follow). Wrapped so we can hide + them as a group when the Metadata tab is active. Natural width — the + sibling .sessions-log-spacer does the pushing. */ +.sessions-log-controls { + display: flex; + align-items: center; + gap: 8px; +} + +/* Flex spacer between the tab strip and the right-hand controls. Keeps the + Expand button flush right even when log_controls is hidden. */ +.sessions-log-spacer { + flex: 1; +} + +.sessions-metadata-body { + padding: 10px 12px; + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.sessions-metadata-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, max-content); + gap: 16px; + align-items: start; +} + +.sessions-metadata-panel { + min-width: 0; +} + +.sessions-metadata-core-panel { + border-left: 1px solid var(--theme_g3); + padding-left: 16px; +} + +.sessions-metadata-heading { + margin: 10px 0 6px; + color: var(--theme_ln); + font-weight: 600; +} + +.sessions-metadata-heading:first-child { + margin-top: 0; +} + +@media (max-width: 900px) { + .sessions-metadata-layout { + grid-template-columns: 1fr; + } + + .sessions-metadata-core-panel { + border-left: 0; + border-top: 1px solid var(--theme_g3); + padding-left: 0; + padding-top: 10px; + } } .sessions-selected { @@ -415,7 +547,6 @@ a { color: var(--theme_g1); } .sessions-log-filter { - margin-left: 12px; padding: 6px 12px; font-size: 14px; font-family: inherit; @@ -433,6 +564,37 @@ a { .sessions-log-filter::placeholder { color: var(--theme_g1); } +.sessions-log-level-filter { + padding: 4px 8px; + font-size: 12px; + font-family: inherit; + border: 1px solid var(--theme_g2); + border-radius: 6px; + background: var(--theme_bg1); + color: var(--theme_bright); + outline: none; + cursor: pointer; +} +.sessions-log-level-filter:focus { + border-color: var(--theme_ln); +} + +/* Chevron-icon variant of .history-tab for the log-panel expand toggle. + Overrides the text-button padding/letterspacing so the icon sits in a + roughly square pill. Double-chevron up when collapsed, down when + expanded — see ICON_CHEVRON_* in sessions.js. */ +.sessions-panel-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 8px; + letter-spacing: 0; +} +.sessions-panel-toggle svg { + width: 14px; + height: 14px; + display: block; +} .sessions-log-body { flex: 1; min-height: 0; @@ -474,29 +636,49 @@ a { font-weight: 600; } .sessions-log-level-info { color: var(--theme_ln); } -.sessions-log-level-warn { color: #d29922; } -.sessions-log-level-error { color: #f85149; } +.sessions-log-level-warn { color: var(--theme_warn); } +.sessions-log-level-error { color: var(--theme_fail); } .sessions-log-level-debug { color: var(--theme_g1); } +.sessions-log-logger { + flex-shrink: 0; + width: 12em; + color: var(--theme_ln); + opacity: 0.75; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .sessions-log-msg { white-space: pre-wrap; word-break: break-all; } -.sessions-log-data { - color: var(--theme_g1); - white-space: pre-wrap; - word-break: break-all; +.sessions-log-fmt-marker { + flex-shrink: 0; + color: var(--theme_ln); + opacity: 0.6; + font-weight: 600; + cursor: help; + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; } -.sessions-self-pill { +.sessions-pill { display: inline-block; font-size: 0.7em; font-weight: 600; padding: 1px 6px; - margin-right: 6px; + margin-left: 6px; border-radius: 8px; + vertical-align: middle; + text-transform: uppercase; + letter-spacing: 0.3px; +} +.sessions-self-pill { background-color: var(--theme_p4); color: var(--theme_g0); - vertical-align: middle; +} +.sessions-log-indicator-pill { + background-color: var(--theme_g2); + color: var(--theme_g0); } .objectstore-bucket-detail { @@ -535,6 +717,17 @@ a { opacity: 0.7; } +/* Pager lives in the header row, to the right of the filter input. No + top margin since it's on the same baseline as tabs/filter. */ +.sessions-header-pager { + display: flex; + align-items: center; + gap: 6px; +} +.sessions-header-pager:empty { + display: none; +} + /* expandable cell ---------------------------------------------------------- */ .zen_expand_icon { diff --git a/src/zenserver/hub/zenhubserver.cpp b/src/zenserver/hub/zenhubserver.cpp index a2a366a80..e9749afe8 100644 --- a/src/zenserver/hub/zenhubserver.cpp +++ b/src/zenserver/hub/zenhubserver.cpp @@ -981,10 +981,14 @@ ZenHubServer::Run() SetNewState(kRunning); - OnReady(); - + // Register the self-session and replay the backlog into it BEFORE + // OnReady disables the backlog — otherwise the in-proc session sink + // attaches against a disabled backlog and shows nothing from the + // startup window. StartSelfSession("zenhub"); + OnReady(); + m_Http->Run(IsInteractiveMode); SetNewState(kShuttingDown); diff --git a/src/zenserver/proxy/zenproxyserver.cpp b/src/zenserver/proxy/zenproxyserver.cpp index ffa9a4295..b3ce208c3 100644 --- a/src/zenserver/proxy/zenproxyserver.cpp +++ b/src/zenserver/proxy/zenproxyserver.cpp @@ -383,10 +383,14 @@ ZenProxyServer::Run() SetNewState(kRunning); - OnReady(); - + // Register the self-session and replay the backlog into it BEFORE + // OnReady disables the backlog — otherwise the in-proc session sink + // attaches against a disabled backlog and shows nothing from the + // startup window. StartSelfSession("zenproxy"); + OnReady(); + m_Http->Run(IsInteractiveMode); SetNewState(kShuttingDown); diff --git a/src/zenserver/sessions/httpsessions.cpp b/src/zenserver/sessions/httpsessions.cpp index 88db36828..1678ede60 100644 --- a/src/zenserver/sessions/httpsessions.cpp +++ b/src/zenserver/sessions/httpsessions.cpp @@ -7,8 +7,17 @@ #include <zencore/logging.h> #include <zencore/string.h> #include <zencore/trace.h> +#include "logtemplate.h" #include "sessions.h" +ZEN_THIRD_PARTY_INCLUDES_START +#include <EASTL/fixed_list.h> +#include <EASTL/fixed_vector.h> +#include <json11.hpp> +ZEN_THIRD_PARTY_INCLUDES_END + +#include <limits> + namespace zen { using namespace std::literals; @@ -21,13 +30,21 @@ HttpSessionsService::HttpSessionsService(HttpStatusService& StatusService, , m_StatsService(StatsService) , m_Sessions(Sessions) , m_PushTimer(IoContext) +, m_CleanupTimer(IoContext) +, m_LivenessTimer(IoContext) { Initialize(); } HttpSessionsService::~HttpSessionsService() { + // Break the callback edge before tearing anything else down so a + // late AppendLog on another thread can't fire BroadcastLogAppended + // after our subscriber list is gone. + m_Sessions.SetLogAppendedCallback({}); m_PushTimer.cancel(); + m_CleanupTimer.cancel(); + m_LivenessTimer.cancel(); m_StatsService.UnregisterHandler("sessions", *this); m_StatusService.UnregisterHandler("sessions", *this); } @@ -135,12 +152,36 @@ HttpSessionsService::Initialize() m_StatsService.RegisterHandler("sessions", *this); m_StatusService.RegisterHandler("sessions", *this); + // Event-driven log push: the service fires this every time an entry + // is appended (including the synthetic "session ended" line emitted + // by RemoveSession). Subscribers receive a binary CB frame carrying + // the delta. Safe to call BroadcastLogAppended from any thread — it + // does its own locking and SendBinary is async-queued by the WS + // transport. + m_Sessions.SetLogAppendedCallback([this](const Oid& SessionId, uint64_t NewCursor) { BroadcastLogAppended(SessionId, NewCursor); }); + EnqueuePushTimer(); + + // Run a cleanup pass shortly after startup so freshly-loaded historical + // data is pruned even if the server doesn't stay up for an hour. + m_CleanupTimer.expires_after(std::chrono::seconds(30)); + m_CleanupTimer.async_wait([this](const asio::error_code& Ec) { + if (Ec) + { + return; + } + RunCleanup(); + EnqueueCleanupTimer(); + }); + + EnqueueLivenessTimer(); } static void -WriteSessionInfo(CbWriter& Writer, const SessionsService::SessionInfo& Info) +WriteSessionInfo(CbWriter& Writer, const SessionsService::Session& Session) { + const SessionsService::SessionInfo& Info = Session.Info(); + Writer << "id" << Info.Id; if (!Info.AppName.empty()) { @@ -150,6 +191,18 @@ WriteSessionInfo(CbWriter& Writer, const SessionsService::SessionInfo& Info) { Writer << "mode" << Info.Mode; } + if (!Info.Platform.empty()) + { + Writer << "platform" << Info.Platform; + } + if (Info.ClientPid != 0) + { + Writer << "pid" << Info.ClientPid; + } + if (Info.ParentSessionId != Oid::Zero) + { + Writer << "parent_session_id" << Info.ParentSessionId; + } if (Info.JobId != Oid::Zero) { Writer << "jobid" << Info.JobId; @@ -161,6 +214,11 @@ WriteSessionInfo(CbWriter& Writer, const SessionsService::SessionInfo& Info) Writer << "ended_at" << Info.EndedAt; } + if (const uint64_t LogCount = Session.GetLogCount(); LogCount > 0) + { + Writer << "log_count" << LogCount; + } + if (Info.Metadata.GetSize() > 0) { Writer.AddObject("metadata"sv, Info.Metadata); @@ -182,13 +240,13 @@ HttpSessionsService::BuildSessionListResponse() for (const Ref<SessionsService::Session>& Session : Active) { Response.BeginObject(); - WriteSessionInfo(Response, Session->Info()); + WriteSessionInfo(Response, *Session); Response.EndObject(); } for (const Ref<SessionsService::Session>& Session : Ended) { Response.BeginObject(); - WriteSessionInfo(Response, Session->Info()); + WriteSessionInfo(Response, *Session); Response.EndObject(); } Response.EndArray(); @@ -231,7 +289,7 @@ HttpSessionsService::ListSessionsRequest(HttpRouterRequest& Req) for (const Ref<SessionsService::Session>& Session : Sessions) { Response.BeginObject(); - WriteSessionInfo(Response, Session->Info()); + WriteSessionInfo(Response, *Session); Response.EndObject(); } Response.EndArray(); @@ -262,24 +320,51 @@ HttpSessionsService::SessionRequest(HttpRouterRequest& Req) { CbObject RequestObject = ServerRequest.ReadPayloadObject(); + // Render the id into a stack buffer once for any success-reply + // paths below — avoids a std::string per POST/PUT. + char IdBuf[Oid::StringLength + 1] = {}; + SessionId.ToString(IdBuf); + const std::string_view IdStr(IdBuf, Oid::StringLength); + if (ServerRequest.RequestVerb() == HttpVerb::kPost) { std::string AppName(RequestObject["appname"sv].AsString()); std::string Mode(RequestObject["mode"sv].AsString()); - Oid JobId = RequestObject["jobid"sv].AsObjectId(); - CbObjectView MetadataView = RequestObject["metadata"sv].AsObjectView(); + std::string Platform(RequestObject["platform"sv].AsString()); + Oid ParentSessionId = RequestObject["parent_session_id"sv].AsObjectId(); + Oid JobId = RequestObject["jobid"sv].AsObjectId(); + CbObjectView MetadataView = RequestObject["metadata"sv].AsObjectView(); + + // Only trust a client-reported pid when the HTTP layer + // says the request is local (unix socket or a loopback + // TCP peer). A remote client's pid refers to a different + // machine's process table — opening a local handle with + // it would at best be meaningless, at worst a liveness + // false positive. + uint32_t ClientPid = 0; + if (ServerRequest.IsLocalMachineRequest()) + { + ClientPid = RequestObject["pid"sv].AsUInt32(); + } m_SessionsStats.SessionWriteCount++; - if (m_Sessions.RegisterSession(SessionId, std::move(AppName), std::move(Mode), JobId, MetadataView)) + if (m_Sessions.RegisterSession(SessionId, + std::move(AppName), + std::move(Mode), + std::move(Platform), + ClientPid, + ParentSessionId, + JobId, + MetadataView)) { - return ServerRequest.WriteResponse(HttpResponseCode::Created, HttpContentType::kText, fmt::format("{}", SessionId)); + return ServerRequest.WriteResponse(HttpResponseCode::Created, HttpContentType::kText, IdStr); } else { // Already exists - try update instead if (m_Sessions.UpdateSession(SessionId, MetadataView)) { - return ServerRequest.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, fmt::format("{}", SessionId)); + return ServerRequest.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, IdStr); } return ServerRequest.WriteResponse(HttpResponseCode::InternalServerError); } @@ -290,7 +375,7 @@ HttpSessionsService::SessionRequest(HttpRouterRequest& Req) m_SessionsStats.SessionWriteCount++; if (m_Sessions.UpdateSession(SessionId, RequestObject["metadata"sv].AsObjectView())) { - return ServerRequest.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, fmt::format("{}", SessionId)); + return ServerRequest.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, IdStr); } return ServerRequest.WriteResponse(HttpResponseCode::NotFound, HttpContentType::kText, @@ -304,7 +389,7 @@ HttpSessionsService::SessionRequest(HttpRouterRequest& Req) if (Session) { CbObjectWriter Response; - WriteSessionInfo(Response, Session->Info()); + WriteSessionInfo(Response, *Session); return ServerRequest.WriteResponse(HttpResponseCode::OK, Response.Save()); } return ServerRequest.WriteResponse(HttpResponseCode::NotFound); @@ -312,7 +397,7 @@ HttpSessionsService::SessionRequest(HttpRouterRequest& Req) case HttpVerb::kDelete: { m_SessionsStats.SessionDeleteCount++; - if (m_Sessions.RemoveSession(SessionId)) + if (m_Sessions.RemoveSession(SessionId, "client request"sv)) { return ServerRequest.WriteResponse(HttpResponseCode::OK); } @@ -334,17 +419,33 @@ static void WriteLogEntry(CbWriter& Writer, const SessionsService::LogEntry& Entry) { Writer << "timestamp" << Entry.Timestamp; - if (!Entry.Level.empty()) + if (Entry.Level != logging::Off) { - Writer << "level" << Entry.Level; + // Frontend renders on the string form (CSS class derives from it), so + // keep the wire format as the canonical lowercase name. + Writer << "level" << logging::ToString(Entry.Level); } - if (!Entry.Message.empty()) + const std::string_view LoggerName{Entry.LoggerName}; + if (!LoggerName.empty()) { - Writer << "message" << Entry.Message; + Writer << "logger" << LoggerName; } - if (Entry.Data.GetSize() > 0) + const std::string_view Message{Entry.Message}; + if (!Message.empty()) { - Writer.AddObject("data"sv, Entry.Data); + Writer << "message" << Message; + } + // Structured-log form alongside the rendered message so a future UI + // can offer field-level drill-down without another schema bump. The + // existing UI only looks at "message" and is unaffected. + const std::string_view Format{Entry.Format}; + if (!Format.empty()) + { + Writer << "format" << Format; + if (Entry.Fields.GetSize() > 0) + { + Writer.AddObject("fields"sv, Entry.Fields); + } } } @@ -378,12 +479,21 @@ HttpSessionsService::SessionLogRequest(HttpRouterRequest& Req) if (ServerRequest.RequestContentType() == HttpContentType::kText) { - // Raw text - split by newlines, one entry per line + // Raw text - split by newlines, one entry per line. Collect + // into a batch and append atomically: keeps a single client's + // payload contiguous on the wire even when other clients race + // in, and fires the WS push observer just once for the whole + // batch instead of once per line. IoBuffer Payload = ServerRequest.ReadPayload(); std::string_view Text(reinterpret_cast<const char*>(Payload.GetData()), Payload.GetSize()); const DateTime Now = DateTime::Now(); - size_t Pos = 0; + // 64 inline slots covers the typical SendLogBatch posting size + // (~50) without touching the heap. Spills to heap beyond that. + // LogEntryInput's string_views point into the request payload + // (Text), which lives for the duration of this handler. + eastl::fixed_vector<SessionsService::LogEntryInput, 64> Batch; + size_t Pos = 0; while (Pos < Text.size()) { size_t End = Text.find('\n', Pos); @@ -401,60 +511,115 @@ HttpSessionsService::SessionLogRequest(HttpRouterRequest& Req) if (!Line.empty()) { - Session->AppendLog(SessionsService::LogEntry{ + Batch.push_back(SessionsService::LogEntryInput{ .Timestamp = Now, - .Message = std::string(Line), + .Message = Line, }); } Pos = End + 1; } + m_Sessions.AppendLogBatch(SessionId, Batch); } else { - // Structured log (JSON or CbObject) - // Accepts a single record or an "entries" array of records + // Structured log (JSON or CbObject). Accepts a single record + // or an "entries" array of records — collect into a batch so + // a single POST lands atomically and fires one WS push. CbObject RequestObject = ServerRequest.ReadPayloadObject(); const DateTime Now = DateTime::Now(); + // 64 inline slots covers the typical SendLogBatch posting size + // (~50) without touching the heap. Spills to heap beyond that. + // LogEntryInput's string_views borrow from the parsed + // RequestObject's underlying buffer (the logger / message / + // format strings on the wire); we keep RequestObject alive + // for the whole intake. + eastl::fixed_vector<SessionsService::LogEntryInput, 64> Batch; + + // Stable backing for messages we render from a structured + // template. fixed_list never moves nodes on insertion, so + // string_views into these strings stay valid until the list + // is destroyed at handler exit. 64 inline nodes match the + // batch's fixed-vector inline cap; spills to heap if a POST + // brings more. + eastl::fixed_list<std::string, 64> RenderedMessages; + auto AppendFromObject = [&](CbObjectView Obj) { - CbFieldView LevelField = Obj["level"sv]; - std::string_view Level; + CbFieldView LevelField = Obj["level"sv]; + logging::LogLevel Level = logging::Off; if (LevelField.IsString()) { - Level = LevelField.AsString(); + Level = logging::ParseLogLevelString(LevelField.AsString()); } else if (LevelField.IsInteger()) { int32_t LevelInt = LevelField.AsInt32(); if (LevelInt >= 0 && LevelInt < logging::LogLevelCount) { - Level = logging::ToString(static_cast<logging::LogLevel>(LevelInt)); + Level = static_cast<logging::LogLevel>(LevelInt); } } - std::string Message(Obj["message"sv].AsString()); - CbObjectView DataView = Obj["data"sv].AsObjectView(); - - Session->AppendLog(SessionsService::LogEntry{ - .Timestamp = Now, - .Level = std::string(Level), - .Message = std::move(Message), - .Data = CbObject::Clone(DataView), + const std::string_view LoggerName = Obj["logger"sv].AsString(); + + // Two entry shapes. Structured entries carry `format` + + // `fields` and no `message` — we render the template right + // here so the rest of the pipeline (in-memory deque, + // persisted log.bin, UI GET response) keeps working the + // same way for both shapes. + CbFieldView FormatField = Obj["format"sv]; + if (FormatField.IsString()) + { + const std::string_view FormatView = FormatField.AsString(); + CbObjectView FieldsView = Obj["fields"sv].AsObjectView(); + ExtendableStringBuilder<256> RenderedBuilder; + RenderLogTemplate(FormatView, FieldsView, RenderedBuilder); + + // Anchor the rendered string in the stable list so the + // LogEntryInput's view into it stays valid until the + // AppendLogBatch call below. + RenderedMessages.emplace_back(RenderedBuilder.ToView()); + const std::string& StoredRendered = RenderedMessages.back(); + + Batch.push_back(SessionsService::LogEntryInput{ + .Timestamp = Now, + .Level = Level, + .LoggerName = LoggerName, + .Message = StoredRendered, + .Format = FormatView, + .Fields = CbObject::Clone(FieldsView), + }); + return; + } + + // Plain entry. + Batch.push_back(SessionsService::LogEntryInput{ + .Timestamp = Now, + .Level = Level, + .LoggerName = LoggerName, + .Message = Obj["message"sv].AsString(), }); }; CbFieldView EntriesField = RequestObject["entries"sv]; if (EntriesField.IsArray()) { - for (CbFieldView Entry : EntriesField) + // Pre-reserve so the 50-ish entries from a typical + // SendLogBatch don't trigger 4-5 reallocations as the + // vector grows. + CbArrayView Arr = EntriesField.AsArrayView(); + Batch.reserve(Arr.Num()); + for (CbFieldView Entry : Arr) { AppendFromObject(Entry.AsObjectView()); } } else { + Batch.reserve(1); AppendFromObject(RequestObject); } + m_Sessions.AppendLogBatch(SessionId, Batch); } return ServerRequest.WriteResponse(HttpResponseCode::OK); @@ -547,13 +712,78 @@ HttpSessionsService::OnWebSocketOpen(Ref<WebSocketConnection> Connection, std::s { ZEN_UNUSED(RelativeUri); ZEN_INFO("Sessions WebSocket client connected"); - m_WsConnectionsLock.WithExclusiveLock([&] { m_WsConnections.push_back(std::move(Connection)); }); + const uint64_t NewId = m_NextSubscriberId.fetch_add(1, std::memory_order_relaxed); + m_WsConnectionsLock.WithExclusiveLock( + [&] { m_WsConnections.push_back(WsSubscriber{.Connection = std::move(Connection), .Id = NewId}); }); } void -HttpSessionsService::OnWebSocketMessage(WebSocketConnection& /*Conn*/, const WebSocketMessage& /*Msg*/) +HttpSessionsService::OnWebSocketMessage(WebSocketConnection& Conn, const WebSocketMessage& Msg) { - // No client-to-server messages expected + // Expected client→server protocol is JSON text frames; see + // sessions.js → _ws_send. Binary frames and malformed JSON are logged + // at debug and ignored so a confused client can't disturb others. + if (Msg.Opcode != WebSocketOpcode::kText) + { + return; + } + std::string_view PayloadText(static_cast<const char*>(Msg.Payload.GetData()), Msg.Payload.GetSize()); + std::string ParseError; + json11::Json Parsed = json11::Json::parse(std::string(PayloadText), ParseError); + if (!ParseError.empty() || !Parsed.is_object()) + { + ZEN_DEBUG("Ignoring malformed WebSocket frame: {}", ParseError.empty() ? "not an object" : ParseError); + return; + } + + const std::string& Type = Parsed["type"].string_value(); + if (Type == "sub_log") + { + const Oid SessionId = Oid::TryFromHexString(Parsed["session"].string_value()); + if (SessionId == Oid::Zero) + { + ZEN_DEBUG("sub_log with invalid session id '{}'", Parsed["session"].string_value()); + return; + } + // json11 reports int via int_value() (32-bit); cursors fit easily + // inside a session's lifetime so this is fine for the foreseeable + // future. Negative values are treated as 0. + const int CursorRaw = Parsed["cursor"].int_value(); + const uint64_t Cursor = CursorRaw > 0 ? static_cast<uint64_t>(CursorRaw) : 0; + + // Record the subscription and fire an immediate delta so we don't + // drop entries that landed between the client's HTTP replay and + // this frame. See BroadcastLogAppended for the broadcast flow. + m_WsConnectionsLock.WithExclusiveLock([&] { + for (WsSubscriber& Sub : m_WsConnections) + { + if (Sub.Connection.Get() == &Conn) + { + Sub.SubscribedSessionId = SessionId; + Sub.LastSentCursor = Cursor; + break; + } + } + }); + // Pass UINT64_MAX to force a flush even if the cursor hasn't + // advanced — the subscriber's LastSentCursor may already lag the + // tail (e.g. rapid posts before the client subscribed). + BroadcastLogAppended(SessionId, std::numeric_limits<uint64_t>::max()); + } + else if (Type == "unsub_log") + { + m_WsConnectionsLock.WithExclusiveLock([&] { + for (WsSubscriber& Sub : m_WsConnections) + { + if (Sub.Connection.Get() == &Conn) + { + Sub.Unsubscribe(); + break; + } + } + }); + } + // Unknown types are silently ignored so the protocol can grow. } void @@ -561,8 +791,8 @@ HttpSessionsService::OnWebSocketClose(WebSocketConnection& Conn, [[maybe_unused] { ZEN_INFO("Sessions WebSocket client disconnected (code {})", Code); m_WsConnectionsLock.WithExclusiveLock([&] { - auto It = std::remove_if(m_WsConnections.begin(), m_WsConnections.end(), [&Conn](const Ref<WebSocketConnection>& C) { - return C.Get() == &Conn; + auto It = std::remove_if(m_WsConnections.begin(), m_WsConnections.end(), [&Conn](const WsSubscriber& Sub) { + return Sub.Connection.Get() == &Conn; }); m_WsConnections.erase(It, m_WsConnections.end()); }); @@ -571,8 +801,15 @@ HttpSessionsService::OnWebSocketClose(WebSocketConnection& Conn, [[maybe_unused] void HttpSessionsService::BroadcastSessions() { - std::vector<Ref<WebSocketConnection>> Connections; - m_WsConnectionsLock.WithSharedLock([&] { Connections = m_WsConnections; }); + // 8 inline slots covers any realistic number of concurrent UI tabs; + // spills to heap beyond that. + eastl::fixed_vector<Ref<WebSocketConnection>, 8> Connections; + m_WsConnectionsLock.WithSharedLock([&] { + for (const WsSubscriber& Sub : m_WsConnections) + { + Connections.push_back(Sub.Connection); + } + }); if (Connections.empty()) { @@ -593,6 +830,107 @@ HttpSessionsService::BroadcastSessions() } void +HttpSessionsService::BroadcastLogAppended(const Oid& SessionId, uint64_t NewCursor) +{ + Ref<SessionsService::Session> Session = m_Sessions.GetSession(SessionId); + if (!Session) + { + // Session vanished (e.g. pruned) between the append and the + // broadcast. No entries to ship. + return; + } + + // Claim each subscriber's cursor and snapshot its delta atomically under + // the exclusive WS lock. Doing claim+fetch+cursor-bump together — rather + // than snapshot-shared / fetch-unlocked / bump-exclusive — closes the + // race where two concurrent BroadcastLogAppended calls would both + // observe the same FromCursor, fetch overlapping ranges, and ship the + // subscriber duplicate entries. Sends still happen after the lock is + // released to avoid holding it across async socket I/O. + struct PendingSend + { + Ref<WebSocketConnection> Connection; + SessionsService::Session::CursorResult Delta; + bool InitialSend; // true when FromCursor == 0 + }; + // 8 inline slots keeps the broadcast allocation-free for the typical UI + // case (1-2 tabs tailing one session); spills to heap if many clients + // happen to subscribe to the same session at once. + eastl::fixed_vector<PendingSend, 8> Sends; + m_WsConnectionsLock.WithExclusiveLock([&] { + for (WsSubscriber& Sub : m_WsConnections) + { + if (!Sub.IsSubscribedTo(SessionId)) + { + continue; + } + // Cheap gate: if the subscriber already has everything up to + // NewCursor, skip. Sub_log uses UINT64_MAX to force a flush. + if (NewCursor != std::numeric_limits<uint64_t>::max() && Sub.LastSentCursor >= NewCursor) + { + continue; + } + if (!Sub.Connection->IsOpen()) + { + continue; + } + const uint64_t FromCursor = Sub.LastSentCursor; + SessionsService::Session::CursorResult Delta = Session->GetLogEntriesAfter(FromCursor); + Sub.LastSentCursor = Delta.Cursor; + Sends.push_back({Sub.Connection, std::move(Delta), FromCursor == 0}); + } + }); + if (Sends.empty()) + { + return; + } + + // Render the hex id into a stack buffer — CbWriter only needs a + // string_view, so we avoid the 24-byte std::string allocation that + // Oid::ToString() would otherwise do on every broadcast. The buffer + // is StringLength + 1 because ToString writes a trailing NUL beyond + // the 24 hex chars; the view itself excludes the NUL. + char HexSessionIdBuf[Oid::StringLength + 1]; + SessionId.ToString(HexSessionIdBuf); + const std::string_view HexSessionId(HexSessionIdBuf, Oid::StringLength); + for (const PendingSend& Send : Sends) + { + if (Send.Delta.Entries.empty() && !Send.InitialSend) + { + // Nothing new and the subscriber was primed — nothing to send. + continue; + } + + // Binary CB frame — the client already has a CB parser + // (util/compactbinary.js). CB keeps structured entries typed end- + // to-end (hashes, ints, dates stay that way on the wire) and skips + // JSON escaping overhead on every append. Shape mirrors the HTTP + // GET response plus two routing fields (type + session). A fresh + // CbObjectWriter per iteration is required because the ctor calls + // BeginObject() to set up the implicit outer object — Save() then + // finalizes that object, leaving the writer in a state that + // Reset() doesn't restore. + CbObjectWriter Response; + Response << "type"sv + << "log"sv; + Response << "session"sv << HexSessionId; + Response << "cursor"sv << Send.Delta.Cursor; + Response << "count"sv << Send.Delta.Count; + Response.BeginArray("entries"sv); + for (const SessionsService::LogEntry& Entry : Send.Delta.Entries) + { + Response.BeginObject(); + WriteLogEntry(Response, Entry); + Response.EndObject(); + } + Response.EndArray(); + + CbObject Obj = Response.Save(); + Send.Connection->SendBinary(Obj.GetView()); + } +} + +void HttpSessionsService::EnqueuePushTimer() { m_PushTimer.expires_after(std::chrono::seconds(2)); @@ -607,4 +945,82 @@ HttpSessionsService::EnqueuePushTimer() }); } +////////////////////////////////////////////////////////////////////////// +// +// Periodic cleanup of expired / excess sessions +// + +void +HttpSessionsService::RunCleanup() +{ + const TimeSpan MaxAge = TimeSpan(SessionsService::kDefaultMaxSessionAgeDays, 0, 0, 0); + const size_t MaxCount = SessionsService::kDefaultMaxSessionCount; + const uint64_t MaxBytes = SessionsService::kDefaultMaxStorageBytes; + const SessionsService::PruneResult Result = m_Sessions.PruneExpired(MaxAge, MaxCount, MaxBytes); + if (Result.ExpiredByAge + Result.ExpiredByCount + Result.ExpiredByStorage > 0) + { + ZEN_INFO("Sessions cleanup: pruned {} by age, {} by count, {} by storage (max {} days, max {} sessions, max {} MiB)", + Result.ExpiredByAge, + Result.ExpiredByCount, + Result.ExpiredByStorage, + SessionsService::kDefaultMaxSessionAgeDays, + MaxCount, + MaxBytes / (1024 * 1024)); + } +} + +void +HttpSessionsService::EnqueueCleanupTimer() +{ + m_CleanupTimer.expires_after(std::chrono::hours(1)); + m_CleanupTimer.async_wait([this](const asio::error_code& Ec) { + if (Ec) + { + return; + } + RunCleanup(); + EnqueueCleanupTimer(); + }); +} + +////////////////////////////////////////////////////////////////////////// +// +// Periodic liveness check for tracked local client processes +// + +void +HttpSessionsService::RunLivenessCheck() +{ + const size_t EndedByDeadClient = m_Sessions.CheckProcessLiveness(); + if (EndedByDeadClient > 0) + { + ZEN_INFO("Sessions liveness: ended {} session(s) whose client process had exited", EndedByDeadClient); + } + else + { + // Debug-level so this doesn't spam at info every 30s, but lets an + // operator who's specifically investigating why their crashed + // session didn't clean up see whether anything is being tracked. + ZEN_DEBUG("Sessions liveness: no dead client processes found"); + } +} + +void +HttpSessionsService::EnqueueLivenessTimer() +{ + // 30s strikes a balance between crash-detection latency and + // per-session OpenProcess/GetExitCode overhead. Active sessions with + // no reported pid (remote clients) are skipped in the inner loop so + // the cost scales with local sessions only. + m_LivenessTimer.expires_after(std::chrono::seconds(30)); + m_LivenessTimer.async_wait([this](const asio::error_code& Ec) { + if (Ec) + { + return; + } + RunLivenessCheck(); + EnqueueLivenessTimer(); + }); +} + } // namespace zen diff --git a/src/zenserver/sessions/httpsessions.h b/src/zenserver/sessions/httpsessions.h index 6ebe61c8d..2c0185176 100644 --- a/src/zenserver/sessions/httpsessions.h +++ b/src/zenserver/sessions/httpsessions.h @@ -13,6 +13,8 @@ ZEN_THIRD_PARTY_INCLUDES_START #include <asio/steady_timer.hpp> ZEN_THIRD_PARTY_INCLUDES_END +#include <atomic> + namespace zen { class SessionsService; @@ -69,14 +71,64 @@ private: SessionsStats m_SessionsStats; metrics::OperationTiming m_HttpRequests; - // WebSocket push - RwLock m_WsConnectionsLock; - std::vector<Ref<WebSocketConnection>> m_WsConnections; - asio::steady_timer m_PushTimer; + // WebSocket push. + // + // Each connection can subscribe to a single session's log stream. The + // subscription is optional (SubscribedSessionId == Oid::Zero means + // "session-list broadcasts only"). LastSentCursor is the cursor value + // most recently delivered for the subscribed session; the broadcaster + // uses it to pull the correct delta from the service. + // + // Id is a process-monotonic generation token assigned at OnWebSocket- + // Open. Pointer matching is fine for OnWebSocketMessage / + // OnWebSocketClose where the live `WebSocketConnection&` parameter is + // unambiguous; Id-based matching is reserved for any future code path + // that wants to refer to a subscriber across lock releases without + // risking a slot-reuse mix-up. + struct WsSubscriber + { + Ref<WebSocketConnection> Connection; + uint64_t Id = 0; + Oid SubscribedSessionId = Oid::Zero; + uint64_t LastSentCursor = 0; + + // True iff the subscriber is currently subscribed to `Session`. + // Centralizes the (SubscribedSessionId != Oid::Zero) sentinel + // check that broadcaster, cursor-bump path, and any future filter + // would otherwise each open-code. + bool IsSubscribedTo(const Oid& Session) const { return SubscribedSessionId != Oid::Zero && SubscribedSessionId == Session; } + + // Drop the active subscription. After this returns, IsSubscribedTo + // is false for every session id. + void Unsubscribe() + { + SubscribedSessionId = Oid::Zero; + LastSentCursor = 0; + } + }; + RwLock m_WsConnectionsLock; + std::vector<WsSubscriber> m_WsConnections; + std::atomic_uint64_t m_NextSubscriberId{1}; // 0 reserved as "not yet assigned" + asio::steady_timer m_PushTimer; void BroadcastSessions(); void EnqueuePushTimer(); + // Event-driven log push. Called from SessionsService's log-appended + // callback; iterates subscribers of the given session and ships any + // entries they haven't seen yet. + void BroadcastLogAppended(const Oid& SessionId, uint64_t NewCursor); + + // Periodic cleanup of old / excess ended sessions + asio::steady_timer m_CleanupTimer; + void EnqueueCleanupTimer(); + void RunCleanup(); + + // Periodic client-process liveness check for locally-connected sessions. + asio::steady_timer m_LivenessTimer; + void EnqueueLivenessTimer(); + void RunLivenessCheck(); + Oid m_SelfSessionId = Oid::Zero; CbObject BuildSessionListResponse(); diff --git a/src/zenserver/sessions/inprocsessionlogsink.cpp b/src/zenserver/sessions/inprocsessionlogsink.cpp index 04c5f7312..c935522bc 100644 --- a/src/zenserver/sessions/inprocsessionlogsink.cpp +++ b/src/zenserver/sessions/inprocsessionlogsink.cpp @@ -12,28 +12,31 @@ static constexpr uint64_t UnixEpochBiasSeconds = uint64_t(double(1970 - 1) * 365 static DateTime TimePointToDateTime(logging::LogClock::time_point Time) { - auto Duration = Time.time_since_epoch(); - auto Seconds = std::chrono::duration_cast<std::chrono::seconds>(Duration); - uint64_t Ticks = (UnixEpochBiasSeconds + static_cast<uint64_t>(Seconds.count())) * TimeSpan::TicksPerSecond; - return DateTime{Ticks}; + // DateTime ticks are 100 ns each. Splitting the time_point into whole-second + // and sub-second parts and converting both lets us preserve sub-second + // precision; the previous implementation truncated to seconds, which made + // every entry land at .000 ms in tail / dashboard renderings. + auto Duration = Time.time_since_epoch(); + auto Seconds = std::chrono::duration_cast<std::chrono::seconds>(Duration); + auto SubSecondNanos = std::chrono::duration_cast<std::chrono::nanoseconds>(Duration - Seconds); + uint64_t SecondsTicks = (UnixEpochBiasSeconds + static_cast<uint64_t>(Seconds.count())) * TimeSpan::TicksPerSecond; + uint64_t SubSecondTicks = static_cast<uint64_t>(SubSecondNanos.count()) / static_cast<uint64_t>(TimeSpan::NanosecondsPerTick); + return DateTime{SecondsTicks + SubSecondTicks}; } void InProcSessionLogSink::Log(const logging::LogMessage& Msg) { - Ref<SessionsService::Session> Session = m_Service.GetSession(m_SessionId); - if (!Session) - { - return; - } - - SessionsService::LogEntry Entry{ - .Timestamp = TimePointToDateTime(Msg.GetTime()), - .Level = std::string(logging::ToString(Msg.GetLevel())), - .Message = std::string(Msg.GetPayload()), - }; - - Session->AppendLog(std::move(Entry)); + // Route through the service-level AppendLog so the log-appended + // callback fires — otherwise WS subscribers tailing the self-session + // don't see in-proc lines until they reload and re-fetch via HTTP. + m_Service.AppendLog(m_SessionId, + SessionsService::LogEntryInput{ + .Timestamp = TimePointToDateTime(Msg.GetTime()), + .Level = Msg.GetLevel(), + .LoggerName = Msg.GetLoggerName(), + .Message = Msg.GetPayload(), + }); } } // namespace zen diff --git a/src/zenserver/sessions/logtemplate.cpp b/src/zenserver/sessions/logtemplate.cpp new file mode 100644 index 000000000..b4d8f37e8 --- /dev/null +++ b/src/zenserver/sessions/logtemplate.cpp @@ -0,0 +1,390 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "logtemplate.h" + +#include <zencore/fmtutils.h> +#include <zencore/guid.h> +#include <zencore/iohash.h> +#include <zencore/string.h> +#include <zencore/uid.h> + +ZEN_THIRD_PARTY_INCLUDES_START +#include <fmt/format.h> +ZEN_THIRD_PARTY_INCLUDES_END + +namespace zen { +using namespace std::literals; + +namespace { + + // Bounded recursion so pathological nesting (e.g. an object that references + // itself through $format) can't stack-overflow the server. Depth counts + // every nested template expansion OR value descent. + constexpr size_t kMaxRecursionDepth = 16; + + void RenderTemplateInto(std::string_view Template, CbObjectView Fields, StringBuilderBase& Out, bool Localized, size_t Depth); + void RenderValue(CbFieldView Field, StringBuilderBase& Out, bool Localized, size_t Depth); + + ////////////////////////////////////////////////////////////////////////// + // + // Path resolution: walk a UE field_path — `name` followed by zero or + // more `.name` / `[N]` segments — starting from the fields root. Returns + // an empty field on any miss. + // + + CbFieldView ResolvePath(CbObjectView Root, std::string_view Path) + { + CbFieldView Cur; + bool Entered = false; // have we applied at least one segment? + + const auto ApplyName = [&](std::string_view Name) -> bool { + if (Name.empty()) + { + return false; + } + if (!Entered) + { + Cur = Root[Name]; + } + else + { + if (!Cur.IsObject()) + { + return false; + } + Cur = Cur.AsObjectView()[Name]; + } + Entered = true; + return Cur.operator bool(); + }; + + const auto ApplyIndex = [&](uint64_t Idx) -> bool { + if (!Entered || !Cur.IsArray()) + { + return false; + } + uint64_t N = 0; + for (CbFieldView Elem : Cur.AsArrayView().CreateViewIterator()) + { + if (N == Idx) + { + Cur = Elem; + return Cur.operator bool(); + } + ++N; + } + return false; + }; + + size_t i = 0; + while (i < Path.size()) + { + const char C = Path[i]; + if (C == '.') + { + ++i; + continue; + } + if (C == '[') + { + const size_t End = Path.find(']', i + 1); + if (End == std::string_view::npos) + { + return {}; + } + uint64_t Idx = 0; + for (size_t j = i + 1; j < End; ++j) + { + const char D = Path[j]; + if (D < '0' || D > '9') + { + return {}; + } + Idx = Idx * 10 + uint64_t(D - '0'); + } + if (!ApplyIndex(Idx)) + { + return {}; + } + i = End + 1; + continue; + } + // Name segment: run until the next '.' or '['. + const size_t NameStart = i; + while (i < Path.size() && Path[i] != '.' && Path[i] != '[') + { + ++i; + } + if (!ApplyName(Path.substr(NameStart, i - NameStart))) + { + return {}; + } + } + return Entered ? Cur : CbFieldView{}; + } + + ////////////////////////////////////////////////////////////////////////// + // + // Primitive rendering. Uses natural string forms for each CbField type. + // Non-string values are emitted without quotes — the caller (the JSON-ish + // fallback) adds quotes only around string values. + // + + void RenderPrimitive(CbFieldView Field, StringBuilderBase& Out) + { + if (Field.IsString()) + { + Out << Field.AsString(); + return; + } + if (Field.IsBool()) + { + Out << (Field.AsBool() ? "true"sv : "false"sv); + return; + } + if (Field.IsInteger()) + { + Out << Field.AsInt64(); + return; + } + if (Field.IsFloat()) + { + // format_to into the builder directly — avoids the std::string + // fmt::format would otherwise build just to hand to Append. + fmt::format_to(StringBuilderAppender(Out), "{}", Field.AsDouble()); + return; + } + if (Field.IsDateTime()) + { + Out.Append(Field.AsDateTime().ToIso8601()); + return; + } + if (Field.IsObjectId()) + { + // ToString(char[]) writes the 24-char hex into a caller buffer; + // the std::string overload would allocate. + char Buf[Oid::StringLength + 1] = {}; + Field.AsObjectId().ToString(Buf); + Out << std::string_view(Buf, Oid::StringLength); + return; + } + if (Field.IsHash()) + { + // Appender overload writes the 40-char hex directly into the + // builder; the std::string overload would allocate. + Field.AsHash().ToHexString(Out); + return; + } + if (Field.IsUuid()) + { + Guid G = Field.AsUuid(); + G.ToString(Out); + return; + } + if (Field.IsNull()) + { + Out << "null"sv; + return; + } + // Binary / attachment / custom / unknown → emit nothing rather than + // a stream of garbage bytes. + } + + ////////////////////////////////////////////////////////////////////////// + // + // JSON-ish fallback for bare objects / nested arrays. Compact single-line + // with quoted string keys and string values, raw other types. Intended + // for debug display — not strictly RFC-8259 JSON. + // + + void AppendJsonishString(std::string_view S, StringBuilderBase& Out) + { + Out << '"'; + for (char C : S) + { + switch (C) + { + case '"': + Out << "\\\""sv; + break; + case '\\': + Out << "\\\\"sv; + break; + case '\n': + Out << "\\n"sv; + break; + case '\r': + Out << "\\r"sv; + break; + case '\t': + Out << "\\t"sv; + break; + default: + Out << C; + break; + } + } + Out << '"'; + } + + void AppendJsonishValue(CbFieldView Field, StringBuilderBase& Out, bool Localized, size_t Depth) + { + if (Field.IsString()) + { + AppendJsonishString(Field.AsString(), Out); + return; + } + // Non-string leaves and nested objects/arrays go through RenderValue + // so object short-circuits ($text / $format / ...) still apply. + RenderValue(Field, Out, Localized, Depth); + } + + ////////////////////////////////////////////////////////////////////////// + // + // Value rendering (the decision tree from the plan). + // + + void RenderValue(CbFieldView Field, StringBuilderBase& Out, bool Localized, size_t Depth) + { + if (Depth >= kMaxRecursionDepth) + { + Out << "…"sv; + return; + } + if (Field.IsObject()) + { + CbObjectView Obj = Field.AsObjectView(); + + if (CbFieldView Text = Obj["$text"sv]; Text.IsString()) + { + Out << Text.AsString(); + return; + } + if (CbFieldView Format = Obj["$format"sv]; Format.IsString()) + { + RenderTemplateInto(Format.AsString(), Obj, Out, /*Localized=*/false, Depth + 1); + return; + } + if (CbFieldView LocFormat = Obj["$locformat"sv]; LocFormat.IsString()) + { + RenderTemplateInto(LocFormat.AsString(), Obj, Out, /*Localized=*/true, Depth + 1); + return; + } + + // Bare object — JSON-ish fallback. + Out << '{'; + bool First = true; + for (CbFieldView Entry : Obj.CreateViewIterator()) + { + if (!First) + { + Out << ", "sv; + } + First = false; + AppendJsonishString(Entry.GetName(), Out); + Out << ": "sv; + AppendJsonishValue(Entry, Out, Localized, Depth + 1); + } + Out << '}'; + return; + } + if (Field.IsArray()) + { + Out << '['; + bool First = true; + for (CbFieldView Elem : Field.AsArrayView().CreateViewIterator()) + { + if (!First) + { + Out << ", "sv; + } + First = false; + AppendJsonishValue(Elem, Out, Localized, Depth + 1); + } + Out << ']'; + return; + } + RenderPrimitive(Field, Out); + } + + ////////////////////////////////////////////////////////////////////////// + // + // Template tokenizer + renderer. + // + + void RenderTemplateInto(std::string_view Template, CbObjectView Fields, StringBuilderBase& Out, bool Localized, size_t Depth) + { + if (Depth >= kMaxRecursionDepth) + { + Out << "…"sv; + return; + } + + size_t i = 0; + while (i < Template.size()) + { + const char C = Template[i]; + + // Localized escape: ` followed by {, }, or ` → literal. + if (Localized && C == '`' && i + 1 < Template.size()) + { + const char Next = Template[i + 1]; + if (Next == '{' || Next == '}' || Next == '`') + { + Out << Next; + i += 2; + continue; + } + } + + // Non-localized escape: {{ or }} → literal { or }. + if (!Localized && C == '{' && i + 1 < Template.size() && Template[i + 1] == '{') + { + Out << '{'; + i += 2; + continue; + } + if (!Localized && C == '}' && i + 1 < Template.size() && Template[i + 1] == '}') + { + Out << '}'; + i += 2; + continue; + } + + if (C == '{') + { + // Placeholder: scan until matching '}'. + const size_t End = Template.find('}', i + 1); + if (End == std::string_view::npos) + { + // Unterminated placeholder — emit the rest literally so we + // don't silently drop data. UE would have asserted at emit. + Out << Template.substr(i); + return; + } + const std::string_view Path = Template.substr(i + 1, End - i - 1); + const CbFieldView Resolved = ResolvePath(Fields, Path); + if (Resolved) + { + RenderValue(Resolved, Out, Localized, Depth + 1); + } + // Missing placeholder: emit nothing. (UE asserts at emit time, + // so in well-formed input this never fires.) + i = End + 1; + continue; + } + + Out << C; + ++i; + } + } + +} // namespace + +void +RenderLogTemplate(std::string_view Template, CbObjectView Fields, StringBuilderBase& Out, bool Localized) +{ + RenderTemplateInto(Template, Fields, Out, Localized, 0); +} + +} // namespace zen diff --git a/src/zenserver/sessions/logtemplate.h b/src/zenserver/sessions/logtemplate.h new file mode 100644 index 000000000..e8b07e63d --- /dev/null +++ b/src/zenserver/sessions/logtemplate.h @@ -0,0 +1,42 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include <zencore/compactbinary.h> +#include <zencore/string.h> + +#include <string_view> + +namespace zen { + +/// Render a UE structured-log template (as produced by UE_LOGFMT) against a +/// fields bag, writing the result into a caller-provided builder. Grammar: +/// +/// format := (text | escape | placeholder)* +/// escape := '{{' | '}}' (non-localized, default) +/// | '`{' | '`}' | '``' (localized, $locformat) +/// placeholder := '{' field_path '}' +/// field_path := name ('.' name | '[' digits ']')* +/// name := [A-Za-z0-9] [A-Za-z0-9_]* (leading '_' reserved) +/// +/// There are NO inline format specs — `{Name:spec}` is not part of the +/// grammar. Formatting control lives on the value side via nested +/// objects carrying `$text` / `$format` / `$locformat` (see the value- +/// rendering rules in logtemplate.cpp). +/// +/// Missing paths render as empty (UE asserts these at emit time, so in +/// practice they don't occur; the empty-render is defensive). Unknown +/// primitive CbField types render as empty. +/// +/// Typical use: pass an `ExtendableStringBuilder<256>` so typical messages +/// render on the stack with no heap allocation. The builder is appended to, +/// not cleared, so callers can compose multiple writes if they want. +/// +/// @param Template The format string. +/// @param Fields The top-level fields bag referenced by placeholders. +/// @param Out Builder to append the rendered text to. +/// @param Localized True for $locformat templates (backtick escapes); +/// false (default) for the top-level `format` field. +void RenderLogTemplate(std::string_view Template, CbObjectView Fields, StringBuilderBase& Out, bool Localized = false); + +} // namespace zen diff --git a/src/zenserver/sessions/sessions.cpp b/src/zenserver/sessions/sessions.cpp index 9d4e3120c..470117c6a 100644 --- a/src/zenserver/sessions/sessions.cpp +++ b/src/zenserver/sessions/sessions.cpp @@ -3,59 +3,733 @@ #include "sessions.h" #include <zencore/basicfile.h> +#include <zencore/compactbinarybuilder.h> #include <zencore/fmtutils.h> +#include <zencore/iobuffer.h> #include <zencore/logging.h> +#include <mutex> + namespace zen { using namespace std::literals; +namespace { + + // Per-session log file layout: + // [LogFileHeader][Record]* + // where Record = [uint32_t ByteLength][CbObject bytes of ByteLength]. + // Records are written in order. A partial trailing record (short write after + // crash) is ignored on load. + constexpr uint32_t kLogFileMagic = 0x5A534C47u; // 'ZSLG' + constexpr uint32_t kLogFileVersion = 1; + +#pragma pack(push, 1) + struct LogFileHeader + { + uint32_t Magic; + uint32_t Version; + }; +#pragma pack(pop) + + constexpr uint64_t kLogFileHeaderSize = sizeof(LogFileHeader); + + void WriteLogEntryFields(CbObjectWriter& Writer, const SessionsService::LogEntryInput& Entry) + { + Writer << "ts" << Entry.Timestamp; + if (Entry.Level != logging::Off) + { + // Store as a small integer (int8_t range) rather than a string — + // fixed-width, one byte in the serialized CbObject. + Writer << "lvl" << static_cast<int32_t>(Entry.Level); + } + if (!Entry.LoggerName.empty()) + { + Writer << "cat" << Entry.LoggerName; + } + if (!Entry.Message.empty()) + { + Writer << "msg" << Entry.Message; + } + // Structured-log template + fields. Only present for UE_LOGFMT-shaped + // entries; Message already holds the rendered text for those so a + // reader that ignores these two can still display the line. + if (!Entry.Format.empty()) + { + Writer << "fmt" << Entry.Format; + if (Entry.Fields.GetSize() > 0) + { + Writer.AddObject("flds"sv, Entry.Fields); + } + } + } + + // Parse a serialized record into an input form. The string_views in + // the result borrow from `Obj`'s underlying buffer; the caller must + // keep that buffer alive (or arena-copy via PreloadEntries) before + // the views are used. + bool ReadLogEntry(CbObjectView Obj, SessionsService::LogEntryInput& OutInput) + { + CbFieldView TsField = Obj["ts"sv]; + if (!TsField) + { + return false; + } + OutInput.Timestamp = TsField.AsDateTime(); + + // New format: integer. Legacy format (pre-refactor log.bin files on + // this same branch): string. Accept either so existing persisted + // entries keep their level when loaded. + CbFieldView LvlField = Obj["lvl"sv]; + if (LvlField.IsInteger()) + { + const int32_t Lvl = LvlField.AsInt32(); + if (Lvl >= 0 && Lvl < logging::LogLevelCount) + { + OutInput.Level = static_cast<logging::LogLevel>(Lvl); + } + } + else if (LvlField.IsString()) + { + OutInput.Level = logging::ParseLogLevelString(LvlField.AsString()); + } + + OutInput.LoggerName = Obj["cat"sv].AsString(); + OutInput.Message = Obj["msg"sv].AsString(); + OutInput.Format = Obj["fmt"sv].AsString(); + if (CbObjectView FieldsView = Obj["flds"sv].AsObjectView(); FieldsView.GetSize() > 0) + { + OutInput.Fields = CbObject::Clone(FieldsView); + } + return true; + } + + void WriteSessionInfoFields(CbObjectWriter& Writer, const SessionsService::SessionInfo& Info) + { + Writer << "id" << Info.Id; + if (!Info.AppName.empty()) + { + Writer << "app" << Info.AppName; + } + if (!Info.Mode.empty()) + { + Writer << "mode" << Info.Mode; + } + if (!Info.Platform.empty()) + { + Writer << "plat" << Info.Platform; + } + if (Info.ClientPid != 0) + { + Writer << "pid" << Info.ClientPid; + } + if (Info.ParentSessionId != Oid::Zero) + { + Writer << "parent_session_id" << Info.ParentSessionId; + } + if (Info.JobId != Oid::Zero) + { + Writer << "jobid" << Info.JobId; + } + Writer << "ca" << Info.CreatedAt; + Writer << "ua" << Info.UpdatedAt; + if (Info.EndedAt.GetTicks() != 0) + { + Writer << "ea" << Info.EndedAt; + } + if (Info.Metadata.GetSize() > 0) + { + Writer.AddObject("meta"sv, Info.Metadata); + } + } + + bool ReadSessionInfo(CbObjectView Obj, SessionsService::SessionInfo& OutInfo) + { + OutInfo.Id = Obj["id"sv].AsObjectId(); + if (OutInfo.Id == Oid::Zero) + { + return false; + } + OutInfo.AppName = std::string(Obj["app"sv].AsString()); + OutInfo.Mode = std::string(Obj["mode"sv].AsString()); + OutInfo.Platform = std::string(Obj["plat"sv].AsString()); + OutInfo.ClientPid = Obj["pid"sv].AsUInt32(); + OutInfo.ParentSessionId = Obj["parent_session_id"sv].AsObjectId(); + OutInfo.JobId = Obj["jobid"sv].AsObjectId(); + OutInfo.CreatedAt = Obj["ca"sv].AsDateTime(); + OutInfo.UpdatedAt = Obj["ua"sv].AsDateTime(); + OutInfo.EndedAt = Obj["ea"sv].AsDateTime(DateTime{0}); + CbObjectView MetaView = Obj["meta"sv].AsObjectView(); + if (MetaView.GetSize() > 0) + { + OutInfo.Metadata = CbObject::Clone(MetaView); + } + return true; + } + + std::filesystem::path SessionDir(const std::filesystem::path& Root, const Oid& Id) { return Root / Id.ToString(); } + +#if ZEN_PLATFORM_WINDOWS + // Turn a Windows process exit code into a human-friendly termination + // reason. Most abnormal terminations surface as NTSTATUS values (high + // bit set); the ones below are what you'll actually encounter in the + // wild. Anything we don't recognize falls through to a formatted hex + // (NTSTATUS-shaped) or decimal (application-level) exit code. + std::string DescribeWindowsExitCode(uint32_t ExitCode) + { + struct Named + { + uint32_t Code; + std::string_view Name; + }; + using namespace std::literals; + static constexpr Named kKnown[] = { + {0xC000013Au, "interrupted (Ctrl-C)"sv}, + {0xC0000005u, "access violation"sv}, + {0xC000001Du, "illegal instruction"sv}, + {0xC0000094u, "integer divide by zero"sv}, + {0xC0000096u, "privileged instruction"sv}, + {0xC00000FDu, "stack overflow"sv}, + {0xC0000409u, "stack buffer overrun"sv}, + {0xC0000374u, "heap corruption"sv}, + {0xC0000135u, "DLL not found"sv}, + {0xC0000142u, "DLL initialization failed"sv}, + {0xC000007Bu, "invalid image format"sv}, + {0xC0000420u, "assertion failure"sv}, + {0xC0000008u, "invalid handle"sv}, + {0xC000008Eu, "float divide by zero"sv}, + {0xC0000091u, "float overflow"sv}, + {0xC0000093u, "float underflow"sv}, + {0x80000003u, "breakpoint"sv}, + {0x40000015u, "fatal app exit"sv}, + }; + for (const Named& Entry : kKnown) + { + if (Entry.Code == ExitCode) + { + return fmt::format("process exited ({}, exit code 0x{:08X})", Entry.Name, ExitCode); + } + } + // NTSTATUS-shaped codes have the high bit set; show them as hex so + // they're recognizable (and matchable against Microsoft's doc tables). + if ((ExitCode & 0x80000000u) != 0) + { + return fmt::format("process exited (exit code 0x{:08X})", ExitCode); + } + return fmt::format("process exited (exit code {})", ExitCode); + } +#endif + +} // namespace + class SessionLog : public TRefCounted<SessionLog> { public: - SessionLog(std::filesystem::path LogFilePath) { m_LogFile.Open(LogFilePath, BasicFile::Mode::kWrite); } + explicit SessionLog(std::filesystem::path LogFilePath); + + void Append(const SessionsService::LogEntryInput& Entry); + + // LoadTail returns input-form entries: their string_views borrow + // from m_OwnedBuffers (held internally) so the caller's PreloadEntries + // can intern/copy them into the session arena before the buffers are + // dropped at LoadResult destruction. + struct LoadResult + { + std::vector<SessionsService::LogEntryInput> TailEntries; + // Backing memory for the views in TailEntries. Each ParsedRecord + // keeps a CbObject alive whose payload bytes back the strings. + std::vector<CbObject> OwnedBuffers; + uint64_t TotalCount = 0; + }; + LoadResult LoadTail(size_t MaxEntries); private: - BasicFile m_LogFile; + static LoggerRef Log() + { + static LoggerRef L(logging::Get("sessions")); + return L; + } + + std::filesystem::path m_Path; + std::mutex m_Mutex; + BasicFile m_File; + uint64_t m_WriteOffset = 0; + bool m_Enabled = false; }; -class SessionLogStore +SessionLog::SessionLog(std::filesystem::path LogFilePath) : m_Path(std::move(LogFilePath)) { -public: - SessionLogStore(std::filesystem::path StoragePath) : m_StoragePath(std::move(StoragePath)) {} + std::error_code Ec; + std::filesystem::create_directories(m_Path.parent_path(), Ec); - ~SessionLogStore() = default; + m_File.Open(m_Path, BasicFile::Mode::kWrite, Ec); + if (Ec) + { + ZEN_WARN("Session log '{}' could not be opened: {} - persistence disabled", m_Path, Ec.message()); + return; + } - Ref<SessionLog> GetLogForSession(const Oid& SessionId) + const uint64_t Size = m_File.FileSize(Ec); + if (Ec) { - // For now, just return a new log for each session. We can implement actual log storage and retrieval later. - return Ref(new SessionLog(m_StoragePath / (SessionId.ToString() + ".log"))); + m_File.Close(); + ZEN_WARN("Session log '{}' could not be sized: {} - persistence disabled", m_Path, Ec.message()); + return; } - Ref<SessionLog> CreateLogForSession(const Oid& SessionId) + LogFileHeader Header{}; + bool NeedsInit = Size < kLogFileHeaderSize; + if (!NeedsInit) + { + // Read is throwing-only; guard so a read failure doesn't escape. + try + { + m_File.Read(&Header, sizeof(Header), 0); + } + catch (const std::exception& E) + { + ZEN_WARN("Session log '{}' header read failed: {} - reinitializing", m_Path, E.what()); + NeedsInit = true; + } + if (!NeedsInit && (Header.Magic != kLogFileMagic || Header.Version != kLogFileVersion)) + { + NeedsInit = true; + } + } + + if (NeedsInit) + { + m_File.SetFileSize(0); + Header = LogFileHeader{.Magic = kLogFileMagic, .Version = kLogFileVersion}; + m_File.Write(&Header, sizeof(Header), 0, Ec); + if (Ec) + { + m_File.Close(); + ZEN_WARN("Session log '{}' header write failed: {} - persistence disabled", m_Path, Ec.message()); + return; + } + m_WriteOffset = kLogFileHeaderSize; + } + else + { + m_WriteOffset = Size; + } + + m_Enabled = true; +} + +void +SessionLog::Append(const SessionsService::LogEntryInput& Entry) +{ + if (!m_Enabled) { - // For now, just return a new log for each session. We can implement actual log storage and retrieval later. - return Ref(new SessionLog(m_StoragePath / (SessionId.ToString() + ".log"))); + return; } + CbObjectWriter Writer; + WriteLogEntryFields(Writer, Entry); + CbObject Obj = Writer.Save(); + + // Write directly from the CbObject's owned buffer — no need to allocate + // a fresh UniqueBuffer and memcpy just to hand the bytes to BasicFile::Write. + const MemoryView View = Obj.GetView(); + const uint64_t ObjSize = View.GetSize(); + if (ObjSize == 0 || ObjSize > std::numeric_limits<uint32_t>::max()) + { + return; + } + const uint32_t Len = static_cast<uint32_t>(ObjSize); + + std::lock_guard<std::mutex> Lock(m_Mutex); + std::error_code Ec; + m_File.Write(&Len, sizeof(Len), m_WriteOffset, Ec); + if (Ec) + { + return; + } + m_File.Write(View.GetData(), ObjSize, m_WriteOffset + sizeof(Len), Ec); + if (Ec) + { + return; + } + m_WriteOffset += sizeof(Len) + ObjSize; +} + +SessionLog::LoadResult +SessionLog::LoadTail(size_t MaxEntries) +{ + std::lock_guard<std::mutex> Lock(m_Mutex); + LoadResult Result; + + if (!m_Enabled) + { + return Result; + } + + std::error_code Ec; + const uint64_t Size = m_File.FileSize(Ec); + if (Ec || Size <= kLogFileHeaderSize) + { + return Result; + } + + IoBuffer Buffer; + try + { + Buffer = m_File.ReadRange(kLogFileHeaderSize, Size - kLogFileHeaderSize); + } + catch (const std::exception& E) + { + ZEN_WARN("Session log '{}' tail read failed: {}", m_Path, E.what()); + return Result; + } + const uint8_t* Data = reinterpret_cast<const uint8_t*>(Buffer.GetData()); + const uint64_t DataSize = Buffer.GetSize(); + + // Walk all valid record positions, ignoring any partial trailing record. + struct RecRef + { + const uint8_t* Ptr; + uint32_t Len; + }; + std::vector<RecRef> Records; + uint64_t Pos = 0; + while (Pos + sizeof(uint32_t) <= DataSize) + { + uint32_t RecLen = 0; + std::memcpy(&RecLen, Data + Pos, sizeof(RecLen)); + const uint64_t Next = Pos + sizeof(uint32_t) + RecLen; + if (RecLen == 0 || Next > DataSize) + { + break; + } + Records.push_back(RecRef{.Ptr = Data + Pos + sizeof(uint32_t), .Len = RecLen}); + Pos = Next; + } + + // Parse every record so TotalCount reflects how many actually decode + // — cursors anchor to this number, and counting CB-corrupt records + // would shift subsequent cursor math off. Only the trailing window + // of size MaxEntries is materialized into TailEntries; head records + // are parsed purely for the count and discarded. + const size_t TailStart = Records.size() > MaxEntries ? Records.size() - MaxEntries : 0; + Result.TailEntries.reserve(Records.size() - TailStart); + Result.OwnedBuffers.reserve(Records.size() - TailStart); + + for (size_t i = 0; i < Records.size(); ++i) + { + try + { + IoBuffer RecBuf = IoBufferBuilder::MakeCloneFromMemory(MemoryView(Records[i].Ptr, Records[i].Len)); + CbObject Obj = LoadCompactBinaryObject(std::move(RecBuf)); + + SessionsService::LogEntryInput Input; + if (ReadLogEntry(Obj, Input)) + { + ++Result.TotalCount; + if (i >= TailStart) + { + // Keep the CbObject alive for as long as the views + // in the input are needed — PreloadEntries will copy + // the strings into the session arena and we drop the + // buffer set when LoadResult is destroyed. + Result.TailEntries.push_back(std::move(Input)); + Result.OwnedBuffers.push_back(std::move(Obj)); + } + } + } + catch (const std::exception&) + { + // Skip malformed record — does not contribute to TotalCount. + } + } + + return Result; +} + +////////////////////////////////////////////////////////////////////////// + +class SessionLogStore +{ +public: + explicit SessionLogStore(std::filesystem::path StoragePath); + + Ref<SessionLog> GetOrCreateLogForSession(const Oid& SessionId); + void WriteSessionInfoFile(const SessionsService::SessionInfo& Info); + void DeleteSession(const Oid& SessionId); + uint64_t GetSessionSize(const Oid& SessionId) const; + + struct PersistedSession + { + SessionsService::SessionInfo Info; + std::filesystem::path LogPath; + }; + std::vector<PersistedSession> Scan() const; + private: + static LoggerRef Log() + { + static LoggerRef L(logging::Get("sessions")); + return L; + } + std::filesystem::path m_StoragePath; }; -SessionsService::Session::Session(const SessionInfo& Info) : m_Info(Info) +SessionLogStore::SessionLogStore(std::filesystem::path StoragePath) : m_StoragePath(std::move(StoragePath)) { + std::error_code Ec; + std::filesystem::create_directories(m_StoragePath, Ec); +} + +Ref<SessionLog> +SessionLogStore::GetOrCreateLogForSession(const Oid& SessionId) +{ + const std::filesystem::path Dir = SessionDir(m_StoragePath, SessionId); + std::error_code Ec; + std::filesystem::create_directories(Dir, Ec); + return Ref(new SessionLog(Dir / "log.bin")); +} + +void +SessionLogStore::DeleteSession(const Oid& SessionId) +{ + const std::filesystem::path Dir = SessionDir(m_StoragePath, SessionId); + std::error_code Ec; + std::filesystem::remove_all(Dir, Ec); + if (Ec) + { + ZEN_WARN("Failed to remove session directory '{}': {}", Dir, Ec.message()); + } +} + +uint64_t +SessionLogStore::GetSessionSize(const Oid& SessionId) const +{ + const std::filesystem::path Dir = SessionDir(m_StoragePath, SessionId); + std::error_code Ec; + uint64_t Total = 0; + std::filesystem::directory_iterator It{Dir, Ec}; + if (Ec) + { + return 0; + } + for (const std::filesystem::directory_entry& Entry : It) + { + std::error_code FileEc; + if (Entry.is_regular_file(FileEc)) + { + const uintmax_t Size = Entry.file_size(FileEc); + if (!FileEc) + { + Total += uint64_t(Size); + } + } + } + return Total; } -SessionsService::Session::~Session() = default; void -SessionsService::Session::AppendLog(LogEntry Entry) +SessionLogStore::WriteSessionInfoFile(const SessionsService::SessionInfo& Info) { + const std::filesystem::path Dir = SessionDir(m_StoragePath, Info.Id); + std::error_code Ec; + std::filesystem::create_directories(Dir, Ec); + + CbObjectWriter Writer; + WriteSessionInfoFields(Writer, Info); + CbObject Obj = Writer.Save(); + const MemoryView View = Obj.GetView(); + if (View.GetSize() == 0) + { + return; + } + TemporaryFile::SafeWriteFile(Dir / "info.cb", View, Ec); +} + +std::vector<SessionLogStore::PersistedSession> +SessionLogStore::Scan() const +{ + std::vector<PersistedSession> Result; + std::error_code Ec; + + if (!std::filesystem::exists(m_StoragePath, Ec)) + { + return Result; + } + + for (const std::filesystem::directory_entry& Entry : std::filesystem::directory_iterator{m_StoragePath, Ec}) + { + if (Ec || !Entry.is_directory(Ec)) + { + continue; + } + + const std::filesystem::path InfoPath = Entry.path() / "info.cb"; + const std::filesystem::path LogPath = Entry.path() / "log.bin"; + + if (!std::filesystem::exists(InfoPath, Ec)) + { + continue; + } + + try + { + BasicFile InfoFile; + std::error_code OpenEc; + InfoFile.Open(InfoPath, BasicFile::Mode::kRead, OpenEc); + if (OpenEc) + { + continue; + } + IoBuffer InfoBuf = InfoFile.ReadAll(); + if (InfoBuf.GetSize() == 0) + { + continue; + } + CbObject InfoObj = LoadCompactBinaryObject(std::move(InfoBuf)); + PersistedSession PS{.Info = SessionsService::SessionInfo{ + .Id = Oid::Zero, + .CreatedAt = DateTime{0}, + .UpdatedAt = DateTime{0}, + }}; + if (!ReadSessionInfo(InfoObj, PS.Info)) + { + continue; + } + PS.LogPath = LogPath; + Result.push_back(std::move(PS)); + } + catch (const std::exception& E) + { + ZEN_WARN("Skipping session directory '{}': {}", Entry.path(), E.what()); + } + } + + return Result; +} + +////////////////////////////////////////////////////////////////////////// + +SessionsService::Session::Session(const SessionInfo& Info, Ref<SessionLog> Log, ProcessHandle ClientProcess) +: m_Info(Info) +, m_Log(std::move(Log)) +, m_ClientProcess(std::move(ClientProcess)) +{ +} +SessionsService::Session::~Session() = default; + +const char* +SessionsService::Session::InternLoggerNameLocked(std::string_view Name) +{ + if (Name.empty()) + { + return ""; + } + if (auto It = m_InternedLoggerNames.find(Name); It != m_InternedLoggerNames.end()) + { + return It->second; + } + const char* Arena = m_LogArena.DuplicateString(Name); + // The map's key view borrows from Arena (which lives as long as the + // session does), so it's safe to outlive `Name`. + m_InternedLoggerNames.emplace(std::string_view{Arena, Name.size()}, Arena); + return Arena; +} + +const char* +SessionsService::Session::AllocateLogStringLocked(std::string_view Str) +{ + if (Str.empty()) + { + return ""; + } + return m_LogArena.DuplicateString(Str); +} + +uint64_t +SessionsService::Session::AppendLog(LogEntryInput Input) +{ + // Persist first (outside the deque lock ordering) so disk I/O doesn't + // starve cursor readers holding the shared lock. The SessionLog has its + // own internal mutex that serializes file writes. + if (m_Log) + { + m_Log->Append(Input); + } + RwLock::ExclusiveLockScope Lock(m_LogLock); - m_LogEntries.push_back(std::move(Entry)); - ++m_TotalAppended; + m_LogEntries.emplace_back(); + LogEntry& Entry = m_LogEntries.back(); + Entry.Timestamp = Input.Timestamp; + Entry.Level = Input.Level; + Entry.LoggerName = InternLoggerNameLocked(Input.LoggerName); + Entry.Message = AllocateLogStringLocked(Input.Message); + Entry.Format = AllocateLogStringLocked(Input.Format); + Entry.Fields = std::move(Input.Fields); + const uint64_t NewCursor = ++m_TotalAppended; while (m_LogEntries.size() > MaxLogEntries) { m_LogEntries.pop_front(); } + return NewCursor; +} + +uint64_t +SessionsService::Session::AppendLogBatch(std::span<LogEntryInput> Inputs) +{ + if (Inputs.empty()) + { + return 0; + } + + // Persist first (per-entry; SessionLog's internal mutex serializes + // these writes). We do this outside m_LogLock so file I/O doesn't + // stall cursor readers. + if (m_Log) + { + for (LogEntryInput& Input : Inputs) + { + m_Log->Append(Input); + } + } + + RwLock::ExclusiveLockScope Lock(m_LogLock); + for (LogEntryInput& Input : Inputs) + { + m_LogEntries.emplace_back(); + LogEntry& Entry = m_LogEntries.back(); + Entry.Timestamp = Input.Timestamp; + Entry.Level = Input.Level; + Entry.LoggerName = InternLoggerNameLocked(Input.LoggerName); + Entry.Message = AllocateLogStringLocked(Input.Message); + Entry.Format = AllocateLogStringLocked(Input.Format); + Entry.Fields = std::move(Input.Fields); + ++m_TotalAppended; + } + while (m_LogEntries.size() > MaxLogEntries) + { + m_LogEntries.pop_front(); + } + return m_TotalAppended; +} + +void +SessionsService::Session::PreloadEntries(std::span<const LogEntryInput> Tail, uint64_t TotalCount) +{ + RwLock::ExclusiveLockScope Lock(m_LogLock); + m_LogEntries.clear(); + for (const LogEntryInput& Input : Tail) + { + m_LogEntries.emplace_back(); + LogEntry& Entry = m_LogEntries.back(); + Entry.Timestamp = Input.Timestamp; + Entry.Level = Input.Level; + Entry.LoggerName = InternLoggerNameLocked(Input.LoggerName); + Entry.Message = AllocateLogStringLocked(Input.Message); + Entry.Format = AllocateLogStringLocked(Input.Format); + Entry.Fields = Input.Fields; + } + m_TotalAppended = TotalCount; } std::vector<SessionsService::LogEntry> @@ -118,17 +792,134 @@ SessionsService::Session::GetLogEntriesAfter(uint64_t AfterCursor) const }; } +uint64_t +SessionsService::AppendLog(const Oid& SessionId, LogEntryInput Input) +{ + // Resolve the session without holding any external lock — GetSession + // acquires m_Lock shared briefly and returns a ref-counted handle. + Ref<Session> Target = GetSession(SessionId); + if (!Target) + { + return 0; + } + + const uint64_t NewCursor = Target->AppendLog(std::move(Input)); + + // Fire after Session::m_LogLock is released (inside AppendLog) so the + // callback can safely call back into this service (e.g. to resolve + // the session again and fetch the delta for its subscribers) without + // any nested-lock concerns. + if (m_LogAppendedCallback) + { + m_LogAppendedCallback(SessionId, NewCursor); + } + return NewCursor; +} + +uint64_t +SessionsService::AppendLogBatch(const Oid& SessionId, std::span<LogEntryInput> Inputs) +{ + if (Inputs.empty()) + { + return 0; + } + Ref<Session> Target = GetSession(SessionId); + if (!Target) + { + return 0; + } + + const uint64_t NewCursor = Target->AppendLogBatch(Inputs); + + // One callback fires for the whole batch — subscribers see all + // entries in a single delta rather than N separate deltas. Fired + // after Session::m_LogLock is released for the same reason as the + // single-entry path. + if (m_LogAppendedCallback) + { + m_LogAppendedCallback(SessionId, NewCursor); + } + return NewCursor; +} + +void +SessionsService::SetLogAppendedCallback(LogAppendedCallback Callback) +{ + m_LogAppendedCallback = std::move(Callback); +} + ////////////////////////////////////////////////////////////////////////// -SessionsService::SessionsService() : m_Log(logging::Get("sessions")) +SessionsService::SessionsService(std::filesystem::path StorageRoot) : m_Log(logging::Get("sessions")) { + if (StorageRoot.empty()) + { + return; + } + + m_SessionLogs = std::make_unique<SessionLogStore>(StorageRoot); + + // Load all previously-persisted sessions as ended sessions. Their log + // tails are preloaded into the in-memory deque so the UI can view them. + std::vector<SessionLogStore::PersistedSession> Persisted = m_SessionLogs->Scan(); + for (SessionLogStore::PersistedSession& PS : Persisted) + { + // Sessions that were active at shutdown need a synthetic ended time so + // they sort correctly in the UI. + if (PS.Info.EndedAt.GetTicks() == 0) + { + PS.Info.EndedAt = PS.Info.UpdatedAt; + } + + Ref<Session> S = Ref(new Session(PS.Info)); + + // Load the log tail (no SessionLog attached — historical sessions do + // not receive new appends and the file handle is released here). + // PreloadEntries copies/interns each input's strings into the + // session's arena, after which Loaded.OwnedBuffers can be + // released along with the rest of the LoadResult. + SessionLog Reader(PS.LogPath); + SessionLog::LoadResult Loaded = Reader.LoadTail(Session::MaxLogEntries); + S->PreloadEntries(Loaded.TailEntries, Loaded.TotalCount); + + m_EndedSessions.push_back(std::move(S)); + } + + ZEN_INFO("Sessions service loaded {} persisted session(s) from '{}'", m_EndedSessions.size(), StorageRoot); } SessionsService::~SessionsService() = default; bool -SessionsService::RegisterSession(const Oid& SessionId, std::string AppName, std::string Mode, const Oid& JobId, CbObjectView Metadata) +SessionsService::RegisterSession(const Oid& SessionId, + std::string AppName, + std::string Mode, + std::string Platform, + uint32_t ClientPid, + const Oid& ParentSessionId, + const Oid& JobId, + CbObjectView Metadata) { + // Open a process handle eagerly — BEFORE any pid-reuse window opens. On + // Windows the handle is tied to the specific process instance, so a + // later pid recycle can't fool later liveness checks. Do the syscall + // outside the service lock (it's a kernel round-trip). On POSIX this is + // effectively a no-op (just stores the pid). + ProcessHandle ClientProcess; + if (ClientPid != 0) + { + std::error_code Ec; + ClientProcess.Initialize(static_cast<int>(ClientPid), Ec); + if (Ec) + { + ZEN_WARN("Session {} registered with pid {} but OpenProcess failed: {} — liveness tracking disabled", + SessionId, + ClientPid, + Ec.message()); + } + } + + SessionInfo PersistedInfo{.Id = Oid::Zero, .CreatedAt = DateTime{0}, .UpdatedAt = DateTime{0}}; // Log outside the lock scope - InProcSessionLogSink calls back into // GetSession() which acquires m_Lock shared, so logging while holding // m_Lock exclusively would deadlock. @@ -141,35 +932,69 @@ SessionsService::RegisterSession(const Oid& SessionId, std::string AppName, std: } const DateTime Now = DateTime::Now(); - m_Sessions.emplace(SessionId, - Ref(new Session(SessionInfo{.Id = SessionId, - .AppName = AppName, - .Mode = Mode, - .JobId = JobId, - .Metadata = CbObject::Clone(Metadata), - .CreatedAt = Now, - .UpdatedAt = Now}))); + SessionInfo Info{.Id = SessionId, + .AppName = AppName, + .Mode = Mode, + .Platform = Platform, + .ClientPid = ClientPid, + .ParentSessionId = ParentSessionId, + .JobId = JobId, + .Metadata = CbObject::Clone(Metadata), + .CreatedAt = Now, + .UpdatedAt = Now}; + + Ref<SessionLog> Log; + if (m_SessionLogs) + { + Log = m_SessionLogs->GetOrCreateLogForSession(SessionId); + } + m_Sessions.emplace(SessionId, Ref(new Session(Info, std::move(Log), std::move(ClientProcess)))); + PersistedInfo = std::move(Info); + } + + if (m_SessionLogs) + { + m_SessionLogs->WriteSessionInfoFile(PersistedInfo); } - ZEN_INFO("Session {} registered (AppName: {}, Mode: {}, JobId: {})", SessionId, AppName, Mode, JobId); + // Include the tracked pid so the log makes it obvious whether the client + // opted into liveness tracking (and whether our OpenProcess succeeded). + // "pid: 0" = no liveness tracking (remote or client didn't report); + // "pid: N" with an immediately-prior warning = OpenProcess failed. + ZEN_INFO("Session {} registered (AppName: {}, Mode: {}, Platform: {}, Pid: {}, ParentSessionId: {}, JobId: {})", + SessionId, + AppName, + Mode, + Platform, + ClientPid, + ParentSessionId, + JobId); return true; } bool SessionsService::UpdateSession(const Oid& SessionId, CbObjectView Metadata) { - RwLock::ExclusiveLockScope Lock(m_Lock); - - auto It = m_Sessions.find(SessionId); - if (It == m_Sessions.end()) + SessionInfo PersistedInfo{.Id = Oid::Zero, .CreatedAt = DateTime{0}, .UpdatedAt = DateTime{0}}; { - return false; + RwLock::ExclusiveLockScope Lock(m_Lock); + + auto It = m_Sessions.find(SessionId); + if (It == m_Sessions.end()) + { + return false; + } + + It.value()->UpdateMetadata(Metadata); + PersistedInfo = It.value()->Info(); } - It.value()->UpdateMetadata(Metadata); + if (m_SessionLogs) + { + m_SessionLogs->WriteSessionInfoFile(PersistedInfo); + } - const SessionInfo& Info = It.value()->Info(); - ZEN_DEBUG("Session {} updated (AppName: {}, JobId: {})", SessionId, Info.AppName, Info.JobId); + ZEN_DEBUG("Session {} updated (AppName: {}, JobId: {})", SessionId, PersistedInfo.AppName, PersistedInfo.JobId); return true; } @@ -178,13 +1003,23 @@ SessionsService::GetSession(const Oid& SessionId) const { RwLock::SharedLockScope Lock(m_Lock); - auto It = m_Sessions.find(SessionId); - if (It == m_Sessions.end()) + if (auto It = m_Sessions.find(SessionId); It != m_Sessions.end()) { - return {}; + return It->second; + } + + // Fall back to ended sessions so HTTP consumers can fetch logs/metadata + // for sessions that have finished (including sessions loaded from disk on + // startup as historical ended sessions). + for (const Ref<Session>& Ended : m_EndedSessions) + { + if (Ended->Info().Id == SessionId) + { + return Ended; + } } - return It->second; + return {}; } std::vector<Ref<SessionsService::Session>> @@ -202,10 +1037,14 @@ SessionsService::GetSessions() const } bool -SessionsService::RemoveSession(const Oid& SessionId) +SessionsService::RemoveSession(const Oid& SessionId, std::string_view Reason) { - std::string RemovedAppName; - Oid RemovedJobId; + std::string RemovedAppName; + Oid RemovedJobId; + SessionInfo PersistedInfo{.Id = Oid::Zero, .CreatedAt = DateTime{0}, .UpdatedAt = DateTime{0}}; + bool Persist = false; + Ref<Session> Ended; + const DateTime EndTime = DateTime::Now(); { RwLock::ExclusiveLockScope Lock(m_Lock); @@ -219,13 +1058,50 @@ SessionsService::RemoveSession(const Oid& SessionId) RemovedAppName = It.value()->Info().AppName; RemovedJobId = It.value()->Info().JobId; - Ref<Session> Ended = It.value(); - Ended->SetEndedAt(DateTime::Now()); - m_EndedSessions.push_back(std::move(Ended)); + Ended = It.value(); + Ended->SetEndedAt(EndTime); + if (m_SessionLogs) + { + PersistedInfo = Ended->Info(); + Persist = true; + } + m_EndedSessions.push_back(Ended); m_Sessions.erase(It); } + // Synthetic "Session ended" entry is appended *after* m_Lock is + // released. AppendLog hits disk via SessionLog::Append, so doing it + // inside the exclusive scope would block every other session lookup + // / registration / list while the I/O completes. The callback that + // pushes the delta to WS subscribers also fires from outside the + // lock — same self-deadlock concern as the GetSession path. + uint64_t SyntheticEndCursor = 0; + { + ExtendableStringBuilder<128> MsgBuilder; + MsgBuilder << "Session ended"sv; + if (!Reason.empty()) + { + MsgBuilder << ": "sv << Reason; + } + SyntheticEndCursor = Ended->AppendLog(LogEntryInput{ + .Timestamp = EndTime, + .Level = logging::Info, + .LoggerName = "sessions"sv, + .Message = MsgBuilder.ToView(), + }); + } + + if (m_LogAppendedCallback) + { + m_LogAppendedCallback(SessionId, SyntheticEndCursor); + } + + if (Persist) + { + m_SessionLogs->WriteSessionInfoFile(PersistedInfo); + } + ZEN_INFO("Session {} removed (AppName: {}, JobId: {})", SessionId, RemovedAppName, RemovedJobId); return true; } @@ -244,4 +1120,202 @@ SessionsService::GetSessionCount() const return m_Sessions.size(); } +// The "when was this session last relevant" timestamp used for age checks +// and count-based eviction ordering: EndedAt if set, otherwise UpdatedAt. +static DateTime +ReferenceTime(const SessionsService::SessionInfo& Info) +{ + return Info.EndedAt.GetTicks() != 0 ? Info.EndedAt : Info.UpdatedAt; +} + +size_t +SessionsService::CheckProcessLiveness() +{ + // Snapshot active sessions under a shared lock, then probe liveness and + // drop dead ones without holding the service lock (IsRunning() is a + // kernel round-trip and RemoveSession() takes the lock exclusively). + std::vector<Ref<Session>> Candidates; + { + RwLock::SharedLockScope Lock(m_Lock); + Candidates.reserve(m_Sessions.size()); + for (const auto& [Id, SessionRef] : m_Sessions) + { + if (SessionRef->GetClientProcess().IsValid()) + { + Candidates.push_back(SessionRef); + } + } + } + + size_t Ended = 0; + for (const Ref<Session>& S : Candidates) + { + // m_ClientProcess is set once at construction and never mutated, so + // reading it here without synchronization is safe. + if (!S->GetClientProcess().IsRunning()) + { + // Build the termination reason. On Windows, GetExitCode() returns + // the real OS exit code and is cheap right after !IsRunning(); we + // map the common NTSTATUS codes (Ctrl-C, access violation, DLL + // init failure, …) to human-readable names. On POSIX the exit + // code is only populated via Wait() which we never call, so + // stick with the plain reason. + std::string Reason = "process exited"; +#if ZEN_PLATFORM_WINDOWS + const uint32_t ExitCode = static_cast<uint32_t>(S->GetClientProcess().GetExitCode()); + if (ExitCode != 0) + { + Reason = DescribeWindowsExitCode(ExitCode); + } +#endif + if (RemoveSession(S->Info().Id, Reason)) + { + ++Ended; + } + } + } + return Ended; +} + +SessionsService::PruneResult +SessionsService::PruneExpired(TimeSpan MaxAge, size_t MaxCount, uint64_t MaxStorageBytes) +{ + PruneResult Result; + std::vector<Oid> ToDelete; + + // Phase 1: age + count pruning (fast; purely in-memory). + { + RwLock::ExclusiveLockScope Lock(m_Lock); + + const uint64_t NowTicks = DateTime::Now().GetTicks(); + const uint64_t CutoffTicks = NowTicks > MaxAge.GetTicks() ? NowTicks - MaxAge.GetTicks() : 0; + const DateTime Cutoff{CutoffTicks}; + + // Age-based pruning: drop ended sessions whose reference time is + // older than Cutoff. + auto ExpiredIt = std::remove_if(m_EndedSessions.begin(), m_EndedSessions.end(), [&](const Ref<Session>& S) { + if (ReferenceTime(S->Info()) < Cutoff) + { + ToDelete.push_back(S->Info().Id); + return true; + } + return false; + }); + Result.ExpiredByAge = size_t(m_EndedSessions.end() - ExpiredIt); + m_EndedSessions.erase(ExpiredIt, m_EndedSessions.end()); + + // Count-based pruning: keep at most MaxCount sessions total. Active + // sessions are never touched; if there are already >= MaxCount active + // sessions then all ended sessions get evicted. + const size_t ActiveCount = m_Sessions.size(); + const size_t EndedTarget = MaxCount > ActiveCount ? MaxCount - ActiveCount : 0; + if (m_EndedSessions.size() > EndedTarget) + { + const size_t ToRemove = m_EndedSessions.size() - EndedTarget; + // Move the `ToRemove` oldest entries to the front. + std::partial_sort( + m_EndedSessions.begin(), + m_EndedSessions.begin() + ToRemove, + m_EndedSessions.end(), + [](const Ref<Session>& A, const Ref<Session>& B) { return ReferenceTime(A->Info()) < ReferenceTime(B->Info()); }); + for (size_t i = 0; i < ToRemove; ++i) + { + ToDelete.push_back(m_EndedSessions[i]->Info().Id); + } + m_EndedSessions.erase(m_EndedSessions.begin(), m_EndedSessions.begin() + ToRemove); + Result.ExpiredByCount = ToRemove; + } + } + + // Phase 1 disk deletion, outside the service lock. + if (m_SessionLogs) + { + for (const Oid& Id : ToDelete) + { + m_SessionLogs->DeleteSession(Id); + } + } + + // Phase 2: storage-footprint pruning. Snapshot remaining ended sessions + // (id + reference time) under a shared lock, then stat each directory + // outside the lock so we don't hold writers off during filesystem calls. + if (!m_SessionLogs) + { + return Result; + } + + struct Candidate + { + Oid Id; + DateTime RefTime; + uint64_t Size = 0; + }; + std::vector<Candidate> Candidates; + { + RwLock::SharedLockScope Lock(m_Lock); + Candidates.reserve(m_EndedSessions.size()); + for (const Ref<Session>& S : m_EndedSessions) + { + Candidates.push_back(Candidate{.Id = S->Info().Id, .RefTime = ReferenceTime(S->Info())}); + } + } + + uint64_t TotalBytes = 0; + for (Candidate& C : Candidates) + { + C.Size = m_SessionLogs->GetSessionSize(C.Id); + TotalBytes += C.Size; + } + + if (TotalBytes <= MaxStorageBytes) + { + return Result; + } + + // Oldest first so we evict in chronological order. + std::sort(Candidates.begin(), Candidates.end(), [](const Candidate& A, const Candidate& B) { return A.RefTime < B.RefTime; }); + + std::vector<Oid> StorageDelete; + uint64_t Reclaimed = 0; + const uint64_t NeedBytes = TotalBytes - MaxStorageBytes; + for (const Candidate& C : Candidates) + { + if (Reclaimed >= NeedBytes) + { + break; + } + StorageDelete.push_back(C.Id); + Reclaimed += C.Size; + } + + if (StorageDelete.empty()) + { + return Result; + } + + // Erase from m_EndedSessions under exclusive lock. Concurrent RemoveSession + // calls between the snapshot and here will have inserted new entries at + // the back, which we safely leave alone. + { + RwLock::ExclusiveLockScope Lock(m_Lock); + tsl::robin_map<Oid, uint8_t, Oid::Hasher> IdSet; + for (const Oid& Id : StorageDelete) + { + IdSet[Id] = 1; + } + auto It = std::remove_if(m_EndedSessions.begin(), m_EndedSessions.end(), [&](const Ref<Session>& S) { + return IdSet.contains(S->Info().Id); + }); + m_EndedSessions.erase(It, m_EndedSessions.end()); + } + + for (const Oid& Id : StorageDelete) + { + m_SessionLogs->DeleteSession(Id); + } + Result.ExpiredByStorage = StorageDelete.size(); + + return Result; +} + } // namespace zen diff --git a/src/zenserver/sessions/sessions.h b/src/zenserver/sessions/sessions.h index a84ca6506..a722704e0 100644 --- a/src/zenserver/sessions/sessions.h +++ b/src/zenserver/sessions/sessions.h @@ -4,6 +4,8 @@ #include <zencore/compactbinary.h> #include <zencore/logbase.h> +#include <zencore/memory/memoryarena.h> +#include <zencore/process.h> #include <zencore/thread.h> #include <zencore/uid.h> @@ -11,7 +13,10 @@ ZEN_THIRD_PARTY_INCLUDES_START #include <EASTL/deque.h> #include <tsl/robin_map.h> ZEN_THIRD_PARTY_INCLUDES_END +#include <filesystem> +#include <functional> #include <optional> +#include <span> #include <string> #include <vector> @@ -34,25 +39,62 @@ public: Oid Id; std::string AppName; std::string Mode; - Oid JobId; - CbObject Metadata; - DateTime CreatedAt; - DateTime UpdatedAt; - DateTime EndedAt{0}; + std::string Platform; // Reported by the client, e.g. "windows", "linux", "macos" + uint32_t ClientPid = 0; // Non-zero = local PID to probe for liveness. 0 = don't track. + Oid ParentSessionId; + // Optional task/action identifier used to associate this session with a + // specific unit of work. Distinct from ParentSessionId, which records + // process/session ancestry. + Oid JobId; + CbObject Metadata; + DateTime CreatedAt; + DateTime UpdatedAt; + DateTime EndedAt{0}; }; + /// Stored form of a log entry. The string fields are arena-borrowed + /// `const char*` — they live in the owning Session's MemoryArena and + /// are valid only for that Session's lifetime. Default copy is + /// intentionally shallow (string pointers are shared with the source); + /// callers must not let copies outlive the originating Session. + /// + /// Build entries via `LogEntryInput` and route them through + /// `Session::AppendLog` / `AppendLogBatch`, which intern logger names + /// and arena-allocate the other strings before storing. struct LogEntry { - DateTime Timestamp; - std::string Level; - std::string Message; - CbObject Data; + DateTime Timestamp{0}; + // Sentinel: Off means "no level set" (e.g. plain-text POSTed entries + // where the client didn't include a level). Real log entries use + // Trace..Critical, so Off is free to reuse as "omit on serialize". + logging::LogLevel Level = logging::Off; + // Arena pointers (null-terminated). Empty string is the default + // — never null, so callers don't need to guard. + const char* LoggerName = ""; // Interned: one canonical copy per unique name across the session. + const char* Message = ""; // For structured entries: the rendered form (populated at intake). + const char* Format = ""; // UE_LOGFMT template; "" for plain entries. + CbObject Fields; // Present only when Format is non-empty. + }; + + /// Input form used to build an entry on the way into a Session. The + /// string_view fields are caller-borrowed; AppendLog interns/copies + /// them into the Session's arena before any LogEntry is built. Use + /// this struct rather than constructing LogEntry directly so the + /// arena ownership invariant stays one-sided. + struct LogEntryInput + { + DateTime Timestamp{0}; + logging::LogLevel Level = logging::Off; + std::string_view LoggerName; + std::string_view Message; + std::string_view Format; + CbObject Fields; }; class Session : public TRefCounted<Session> { public: - Session(const SessionInfo& Info); + Session(const SessionInfo& Info, Ref<SessionLog> Log = {}, ProcessHandle ClientProcess = {}); ~Session(); Session(Session&&) = delete; @@ -67,12 +109,29 @@ public: void SetEndedAt(DateTime When) { m_Info.EndedAt = When; } - void AppendLog(LogEntry Entry); + /// Appends an entry to the in-memory deque and to the persisted + /// log. Returns the new cursor value (m_TotalAppended post- + /// increment). Logger name is interned, message and format are + /// arena-allocated — the input's string_views may safely be + /// caller-stack-bound. + uint64_t AppendLog(LogEntryInput Input); + + /// Append-many counterpart that takes the deque lock exactly once + /// for the whole batch. Use this when an inbound HTTP POST carries + /// multiple entries — single-lock semantics keep entries from one + /// caller contiguous on the wire even when other appends race in, + /// and the WS-push observer can fire just once for the whole batch. + /// Returns the new cursor (the value at the tail of the batch). + uint64_t AppendLogBatch(std::span<LogEntryInput> Inputs); + std::vector<LogEntry> GetLogEntries(uint32_t Limit = 0, uint32_t Offset = 0) const; uint64_t GetLogCount() const; /// Returns entries appended after the given cursor and the new cursor value. /// A cursor of 0 returns all entries currently in the deque. + /// The returned LogEntries borrow strings from this Session's + /// arena — callers must hold a Ref<Session> for as long as they + /// keep the result. struct CursorResult { std::vector<LogEntry> Entries; @@ -81,26 +140,118 @@ public: }; CursorResult GetLogEntriesAfter(uint64_t AfterCursor) const; + // Seed this session with pre-existing log entries (e.g. loaded from disk + // on startup). Sets the total-appended counter to reflect what was on + // disk so cursors remain meaningful for historical sessions. The inputs + // are interned/arena-allocated into this session. + void PreloadEntries(std::span<const LogEntryInput> Tail, uint64_t TotalCount); + + /// Process handle used for client-liveness checks. Acquired at + /// registration time (while the pid is known to refer to the reporting + /// process) and held for the session's lifetime; on Windows this is a + /// real HANDLE tied to the specific process instance and is immune to + /// pid reuse. Invalid (IsValid() == false) for remote sessions or when + /// OpenProcess() failed. Set once at construction — no synchronization + /// needed for readers. + const ProcessHandle& GetClientProcess() const { return m_ClientProcess; } + ProcessHandle& GetClientProcess() { return m_ClientProcess; } + + static constexpr uint32_t MaxLogEntries = 10000; + private: + // Intern a logger name into m_LogArena and return the canonical + // pointer for that name. Subsequent calls with the same string + // return the same pointer. Caller must hold m_LogLock exclusive. + const char* InternLoggerNameLocked(std::string_view Name); + + // Allocate a copy of Str into m_LogArena and return a null- + // terminated pointer. No deduplication. Caller must hold m_LogLock + // exclusive. Empty input returns "" (no allocation). + const char* AllocateLogStringLocked(std::string_view Str); + SessionInfo m_Info; Ref<SessionLog> m_Log; + ProcessHandle m_ClientProcess; mutable RwLock m_LogLock; eastl::deque<LogEntry> m_LogEntries; uint64_t m_TotalAppended = 0; // monotonically increasing counter - - static constexpr uint32_t MaxLogEntries = 10000; + // String storage for the in-memory deque. LoggerName is interned + // (one canonical copy per unique name); Message and Format are + // duplicated per entry. Both die with the Session — so the + // LogEntry pointers do too. Sized to fit a typical session's + // strings in one chunk; spills to additional chunks otherwise. + MemoryArena m_LogArena{4096}; + tsl::robin_map<std::string_view, const char*> m_InternedLoggerNames; }; - SessionsService(); + /// Construct a SessionsService. If StorageRoot is non-empty, session + /// metadata and logs are persisted under that directory (one subdirectory + /// per session id) and previously-persisted sessions are loaded as ended. + explicit SessionsService(std::filesystem::path StorageRoot = {}); ~SessionsService(); - bool RegisterSession(const Oid& SessionId, std::string AppName, std::string Mode, const Oid& JobId, CbObjectView Metadata); - bool UpdateSession(const Oid& SessionId, CbObjectView Metadata); - Ref<Session> GetSession(const Oid& SessionId) const; + bool RegisterSession(const Oid& SessionId, + std::string AppName, + std::string Mode, + std::string Platform, + uint32_t ClientPid, + const Oid& ParentSessionId, + const Oid& JobId, + CbObjectView Metadata); + bool UpdateSession(const Oid& SessionId, CbObjectView Metadata); + Ref<Session> GetSession(const Oid& SessionId) const; std::vector<Ref<Session>> GetSessions() const; std::vector<Ref<Session>> GetEndedSessions() const; - bool RemoveSession(const Oid& SessionId); - uint64_t GetSessionCount() const; + /// Ends a session. If Reason is non-empty, a synthetic log line is + /// appended to the session log before it's moved to ended so the + /// historical log has a clear closing event. + bool RemoveSession(const Oid& SessionId, std::string_view Reason = {}); + uint64_t GetSessionCount() const; + + /// Appends a log entry to `SessionId` and, if the session exists, + /// invokes the log-appended callback with the new cursor so downstream + /// push subscribers (e.g. the HTTP WS broadcast) can deliver the delta + /// without polling. Returns the new cursor, or 0 if the session is + /// unknown. Fires the callback AFTER any internal locks are released + /// so the callback can safely call back into this service. + uint64_t AppendLog(const Oid& SessionId, LogEntryInput Input); + + /// Batch counterpart of AppendLog. Atomic with respect to other + /// appends to the same session — entries land contiguously on the + /// wire and persist in order — and fires exactly one push-callback + /// for the whole batch. Empty batches and unknown sessions are + /// no-ops returning 0. + uint64_t AppendLogBatch(const Oid& SessionId, std::span<LogEntryInput> Inputs); + + /// Observer fired after an entry is appended to any session. Replaces + /// any previously set callback. Pass {} to clear. Only one listener is + /// supported — the single consumer today is the HTTP WebSocket push. + using LogAppendedCallback = std::function<void(const Oid& SessionId, uint64_t NewCursor)>; + void SetLogAppendedCallback(LogAppendedCallback Callback); + + /// Drop ended sessions that are too old, that push us over the count + /// limit, or that push the on-disk footprint over the byte budget, and + /// delete their persisted directories. Active sessions are never + /// pruned. Returns the number removed by each criterion. + struct PruneResult + { + size_t ExpiredByAge = 0; + size_t ExpiredByCount = 0; + size_t ExpiredByStorage = 0; + }; + PruneResult PruneExpired(TimeSpan MaxAge, size_t MaxCount, uint64_t MaxStorageBytes); + + /// End any active session whose tracked client process is no longer + /// running. Sessions with an invalid ProcessHandle (remote, or + /// OpenProcess failed at registration) are skipped. Returns the number + /// of sessions ended by this pass. + size_t CheckProcessLiveness(); + + // Tuning defaults. Expressed in whole days / bytes so they're easy to + // override from a future command-line flag without touching internals. + static constexpr int kDefaultMaxSessionAgeDays = 365; + static constexpr size_t kDefaultMaxSessionCount = 1000; + static constexpr uint64_t kDefaultMaxStorageBytes = 50ull * 1024 * 1024; // 50 MiB private: LoggerRef& Log() { return m_Log; } @@ -110,6 +261,10 @@ private: tsl::robin_map<Oid, Ref<Session>, Oid::Hasher> m_Sessions; std::vector<Ref<Session>> m_EndedSessions; std::unique_ptr<SessionLogStore> m_SessionLogs; + // Set once at wiring-time (single consumer), never reassigned while + // hot, so no dedicated lock — just a plain member. Copy-on-call + // guards against the theoretical re-register race below. + LogAppendedCallback m_LogAppendedCallback; }; } // namespace zen diff --git a/src/zenserver/stats/statsreporter.cpp b/src/zenserver/stats/statsreporter.cpp index ff055cf18..4898f3ad5 100644 --- a/src/zenserver/stats/statsreporter.cpp +++ b/src/zenserver/stats/statsreporter.cpp @@ -3,12 +3,85 @@ #include "statsreporter.h" #include <zencore/logging.h> +#include <zencore/profiling/counterstrace.h> #include <zencore/trace.h> #include <zennet/statsdclient.h> +ZEN_THIRD_PARTY_INCLUDES_START +#include <EASTL/functional.h> +#include <EASTL/hash_map.h> +ZEN_THIRD_PARTY_INCLUDES_END + +#include <memory> +#include <string> + namespace zen { -StatsReporter::StatsReporter() +// Decorator that forwards every metric call to an inner StatsMetrics (statsd +// in production) and additionally emits a counter trace point so the metric +// shows up in the .utrace stream / `zen trace serve` viewer. +// +// Counter ids are allocated lazily on first sighting of a metric name. The +// name string is owned by the lookup map so the TraceCounter keeps a stable +// pointer to it for late-init re-emission. +class TracingStatsMetrics : public StatsMetrics +{ +public: + void Rebind(StatsMetrics* Inner) { m_Inner = Inner; } + + void Increment(std::string_view Metric) override + { + if (m_Inner) + m_Inner->Increment(Metric); + Counter(Metric).Increment(); + } + + void Decrement(std::string_view Metric) override + { + if (m_Inner) + m_Inner->Decrement(Metric); + Counter(Metric).Decrement(); + } + + void Count(std::string_view Metric, int64_t CountDelta) override + { + if (m_Inner) + m_Inner->Count(Metric, CountDelta); + Counter(Metric).Add(CountDelta); + } + + void Gauge(std::string_view Metric, uint64_t CurrentValue) override + { + if (m_Inner) + m_Inner->Gauge(Metric, CurrentValue); + Counter(Metric).SetAlways(int64_t(CurrentValue)); + } + + void Meter(std::string_view Metric, uint64_t IncrementValue) override + { + if (m_Inner) + m_Inner->Meter(Metric, IncrementValue); + Counter(Metric).Add(int64_t(IncrementValue)); + } + +private: + TraceCounterInt& Counter(std::string_view Metric) + { + if (auto It = m_Counters.find_as(Metric, std::hash<std::string_view>(), eastl::equal_to_2<std::string, std::string_view>()); + It != m_Counters.end()) + { + return *It->second; + } + auto [Inserted, _] = m_Counters.try_emplace(std::string(Metric), nullptr); + Inserted->second = std::make_unique<TraceCounterInt>(Inserted->first.c_str(), TraceCounterDisplayHint::None); + return *Inserted->second; + } + + StatsMetrics* m_Inner = nullptr; + eastl::hash_map<std::string, std::unique_ptr<TraceCounterInt>, std::hash<std::string>, std::equal_to<std::string>> m_Counters; +}; + +StatsReporter::StatsReporter() : m_TracingMetrics(std::make_unique<TracingStatsMetrics>()) { } @@ -52,13 +125,18 @@ void StatsReporter::ReportStats() { RwLock::ExclusiveLockScope _(m_Lock); - if (m_Statsd) + + // Always run providers through the tracing decorator so counter trace + // points fire even when statsd is disabled. The decorator no-ops the + // inner forward when m_Statsd is null. + m_TracingMetrics->Rebind(m_Statsd.get()); + for (StatsProvider* Provider : m_Providers) { - for (StatsProvider* Provider : m_Providers) - { - Provider->ReportMetrics(*m_Statsd); - } + Provider->ReportMetrics(*m_TracingMetrics); + } + if (m_Statsd) + { m_Statsd->Flush(); } } diff --git a/src/zenserver/stats/statsreporter.h b/src/zenserver/stats/statsreporter.h index b4174073c..82219bc14 100644 --- a/src/zenserver/stats/statsreporter.h +++ b/src/zenserver/stats/statsreporter.h @@ -10,6 +10,7 @@ namespace zen { class StatsDaemonClient; +class TracingStatsMetrics; class StatsReporter { @@ -26,9 +27,10 @@ public: void AddProvider(StatsProvider* Provider); private: - RwLock m_Lock; - std::unique_ptr<StatsDaemonClient> m_Statsd; - std::vector<StatsProvider*> m_Providers; + RwLock m_Lock; + std::unique_ptr<StatsDaemonClient> m_Statsd; + std::unique_ptr<TracingStatsMetrics> m_TracingMetrics; // owns counter id table; rebinds to m_Statsd + std::vector<StatsProvider*> m_Providers; }; } // namespace zen diff --git a/src/zenserver/storage/storageconfig.cpp b/src/zenserver/storage/storageconfig.cpp index bb4f053e4..b4d97257d 100644 --- a/src/zenserver/storage/storageconfig.cpp +++ b/src/zenserver/storage/storageconfig.cpp @@ -379,7 +379,6 @@ ZenStorageServerConfigurator::AddConfigOptions(LuaConfig::Options& LuaOptions) ////// server LuaOptions.AddOption("server.pluginsconfigfile"sv, ServerOptions.PluginsConfigFile, "plugins-config"sv); - LuaOptions.AddOption("sessions.url"sv, ServerOptions.SessionsTargetUrl, "sessions-url"sv); ////// objectstore LuaOptions.AddOption("server.objectstore.enabled"sv, ServerOptions.ObjectStoreEnabled, "objectstore-enabled"sv); @@ -630,8 +629,6 @@ ZenStorageServerCmdLineOptions::AddCliOptions(cxxopts::Options& options, ZenStor cxxopts::value(ServerOptions.ScrubOptions)->implicit_value("yes"), "(nocas,nogc,nodelete,yes,no)*"); - options.add_options()("sessions-url", "URL of remote zenserver to announce session to", cxxopts::value<std::string>(SessionsTargetUrl)); - AddSecurityOptions(options, ServerOptions); AddCacheOptions(options, ServerOptions); AddGcOptions(options, ServerOptions); @@ -1088,7 +1085,6 @@ ZenStorageServerCmdLineOptions::ApplyOptions(cxxopts::Options& options, ZenStora {.Name = OpenIdProviderName, .Url = OpenIdProviderUrl, .ClientId = OpenIdClientId}); } - ServerOptions.SessionsTargetUrl = SessionsTargetUrl; ServerOptions.ObjectStoreConfig = ParseBucketConfigs(BucketConfigs); ServerOptions.OidcTokenExecutable = MakeSafeAbsolutePath(OidcTokenExecutable); } diff --git a/src/zenserver/storage/storageconfig.h b/src/zenserver/storage/storageconfig.h index fec8fd70b..bb6c929a9 100644 --- a/src/zenserver/storage/storageconfig.h +++ b/src/zenserver/storage/storageconfig.h @@ -163,7 +163,6 @@ struct ZenStorageServerConfig : public ZenServerConfig bool RestrictContentTypes = false; std::filesystem::path OidcTokenExecutable; bool AllowExternalOidcTokenExe = true; - std::string SessionsTargetUrl; }; struct ZenStorageServerCmdLineOptions @@ -185,8 +184,6 @@ struct ZenStorageServerCmdLineOptions void AddSecurityOptions(cxxopts::Options& options, ZenStorageServerConfig& ServerOptions); - std::string SessionsTargetUrl; - std::string UpstreamCachePolicyOptions; void AddCacheOptions(cxxopts::Options& options, ZenStorageServerConfig& ServerOptions); diff --git a/src/zenserver/storage/zenstorageserver.cpp b/src/zenserver/storage/zenstorageserver.cpp index 44291395a..e7561e446 100644 --- a/src/zenserver/storage/zenstorageserver.cpp +++ b/src/zenserver/storage/zenstorageserver.cpp @@ -249,10 +249,11 @@ ZenStorageServer::InitializeServices(const ZenStorageServerConfig& ServerOptions if (!ServerOptions.SessionsTargetUrl.empty()) { m_SessionsClient = std::make_unique<SessionsServiceClient>(SessionsServiceClient::Options{ - .TargetUrl = ServerOptions.SessionsTargetUrl, - .AppName = "zenserver", - .Mode = GetServerMode(), - .SessionId = GetSessionId(), + .TargetUrl = ServerOptions.SessionsTargetUrl, + .AppName = "zenserver", + .Mode = GetServerMode(), + .SessionId = GetSessionId(), + .ParentSessionId = GetParentSessionId(), }); } @@ -846,10 +847,14 @@ ZenStorageServer::Run() SetNewState(kRunning); - OnReady(); - + // Register the self-session and replay the backlog into it BEFORE + // OnReady disables the backlog — otherwise the in-proc session sink + // attaches against a disabled backlog and shows nothing from the + // startup window. StartSelfSession("zenserver"); + OnReady(); + if (m_SessionsClient) { m_SessionsClient->Announce(); diff --git a/src/zenserver/zenserver.cpp b/src/zenserver/zenserver.cpp index 6bf22eef8..44fa01ea4 100644 --- a/src/zenserver/zenserver.cpp +++ b/src/zenserver/zenserver.cpp @@ -15,6 +15,7 @@ #include <zencore/logging.h> #include <zencore/logging/broadcastsink.h> #include <zencore/memory/fmalloc.h> +#include <zencore/process.h> #include <zencore/scopeguard.h> #include <zencore/sentryintegration.h> #include <zencore/session.h> @@ -190,10 +191,10 @@ ZenServerBase::Initialize(const ZenServerConfig& ServerOptions, ZenServerState:: m_Http->RegisterService(m_StatsService); m_StatsReporter.Initialize(ServerOptions.StatsConfig); - if (ServerOptions.StatsConfig.Enabled) - { - EnqueueStatsReportingTimer(); - } + // Run the reporting timer unconditionally: even when statsd is disabled + // the StatsReporter still fans out to providers so counter trace points + // fire into the active .utrace stream (see TracingStatsMetrics). + EnqueueStatsReportingTimer(); // clang-format off HealthServiceInfo HealthInfo { @@ -232,7 +233,7 @@ ZenServerBase::Initialize(const ZenServerConfig& ServerOptions, ZenServerState:: LogSettingsSummary(ServerOptions); - InitializeSessions(); + InitializeSessions(ServerOptions.UseInProcSessionLogging); return EffectiveBasePort; } @@ -265,7 +266,7 @@ ZenServerBase::ShutdownServices() if (m_SessionsService) { - m_SessionsService->RemoveSession(GetSessionId()); + m_SessionsService->RemoveSession(GetSessionId(), "server shutdown"); } m_HttpSessionsService.reset(); @@ -279,15 +280,31 @@ ZenServerBase::ShutdownServices() } void -ZenServerBase::InitializeSessions() +ZenServerBase::InitializeSessions(bool UseInProcSessionLogging) { - m_SessionsService = std::make_unique<SessionsService>(); + // Persist session metadata and logs under <DataRoot>/sessions. If no data + // root is configured (e.g. some test contexts) fall back to in-memory only. + std::filesystem::path SessionsRoot; + if (!m_DataRoot.empty()) + { + SessionsRoot = m_DataRoot / "sessions"; + } + + m_SessionsService = std::make_unique<SessionsService>(std::move(SessionsRoot)); m_HttpSessionsService = std::make_unique<HttpSessionsService>(m_StatusService, m_StatsService, *m_SessionsService, m_IoContext); m_HttpSessionsService->SetSelfSessionId(GetSessionId()); - m_InProcSessionLogSink = logging::SinkPtr(new InProcSessionLogSink(*m_SessionsService)); - m_InProcSessionLogSink->SetLevel(logging::Info); - GetDefaultBroadcastSink()->AddSink(m_InProcSessionLogSink); + if (UseInProcSessionLogging) + { + // Create the sink up front but don't attach it to the broadcast + // yet — the self-session isn't registered with the service until + // StartSelfSession runs, and a sink that fires Log() before then + // silently drops every line because Service.AppendLog can't + // resolve the session id. The attach (with backlog replay) is + // performed once StartSelfSession has registered. + m_InProcSessionLogSink = logging::SinkPtr(new InProcSessionLogSink(*m_SessionsService)); + m_InProcSessionLogSink->SetLevel(logging::Info); + } } void @@ -295,7 +312,29 @@ ZenServerBase::StartSelfSession(std::string_view AppName) { if (m_SessionsService) { - m_SessionsService->RegisterSession(GetSessionId(), std::string(AppName), GetServerMode(), Oid::Zero, {}); + m_SessionsService->RegisterSession(GetSessionId(), + std::string(AppName), + GetServerMode(), + std::string(GetRuntimePlatformName()), + // Report our own pid so it's visible in `zen sessions + // ls` and the dashboard. The liveness sweep will probe + // this entry and always find it alive — that's + // consistent (we're running iff the sweep is running) + // and the extra IsRunning check is cheap. + static_cast<uint32_t>(GetCurrentProcessId()), + GetParentSessionId(), + Oid::Zero, + {}); + + // Now that the self-session exists in the service, attach the + // in-proc sink and replay everything that was buffered into the + // log backlog up to this point. This brings every line emitted + // since process start into the self-session's persisted log, + // even though the session itself was only registered just now. + if (m_InProcSessionLogSink) + { + AttachSinkWithBacklogReplay(m_InProcSessionLogSink); + } } } @@ -345,6 +384,12 @@ ZenServerBase::EnsureIoRunner() void ZenServerBase::OnReady() { + // Bootstrap window is closed: every sink that's going to attach has + // attached, the run loop is up. Drop the captured early-startup + // backlog so it doesn't pin ~256KB of memory for the rest of the + // process. Subsequent log calls bypass the backlog cheaply. + DisableLogBacklog(); + if (m_ServerEntry) { m_ServerEntry->SignalReady(); diff --git a/src/zenserver/zenserver.h b/src/zenserver/zenserver.h index 995ff054f..b7c82ca28 100644 --- a/src/zenserver/zenserver.h +++ b/src/zenserver/zenserver.h @@ -146,7 +146,7 @@ protected: virtual void HandleStatusRequest(HttpServerRequest& Request) override; private: - void InitializeSessions(); + void InitializeSessions(bool UseInProcSessionLogging); void InitializeSecuritySettings(const ZenServerConfig& ServerOptions); }; class ZenServerMain diff --git a/src/zenstore/include/zenstore/cidstore.h b/src/zenstore/include/zenstore/cidstore.h index c00e0449f..afaf23cb7 100644 --- a/src/zenstore/include/zenstore/cidstore.h +++ b/src/zenstore/include/zenstore/cidstore.h @@ -30,9 +30,9 @@ struct CidStoreSize struct CidStoreStats { - uint64_t HitCount; - uint64_t MissCount; - uint64_t WriteCount; + uint64_t HitCount = 0; + uint64_t MissCount = 0; + uint64_t WriteCount = 0; metrics::RequestStatsSnapshot AddChunkOps; metrics::RequestStatsSnapshot FindChunkOps; // metrics::RequestStatsSnapshot ContainChunkOps; diff --git a/src/zentelemetry/include/zentelemetry/stats.h b/src/zentelemetry/include/zentelemetry/stats.h index ddec8e883..f29d3969f 100644 --- a/src/zentelemetry/include/zentelemetry/stats.h +++ b/src/zentelemetry/include/zentelemetry/stats.h @@ -300,11 +300,11 @@ private: /** Immutable snapshot of a Meter's state at a point in time. */ struct MeterSnapshot { - uint64_t Count; - double MeanRate; - double Rate1; - double Rate5; - double Rate15; + uint64_t Count = 0; + double MeanRate = 0.0; + double Rate1 = 0.0; + double Rate5 = 0.0; + double Rate15 = 0.0; }; /** Immutable snapshot of a Histogram's state at a point in time. @@ -315,14 +315,14 @@ struct MeterSnapshot */ struct HistogramSnapshot { - double Count; - double Avg; - double Min; - double Max; - double P75; - double P95; - double P99; - double P999; + double Count = 0.0; + double Avg = 0.0; + double Min = 0.0; + double Max = 0.0; + double P75 = 0.0; + double P95 = 0.0; + double P99 = 0.0; + double P999 = 0.0; }; /** Combined snapshot of a Meter and Histogram pair. */ diff --git a/src/zenutil/include/zenutil/logging.h b/src/zenutil/include/zenutil/logging.h index 6abf6a96f..94a45e46f 100644 --- a/src/zenutil/include/zenutil/logging.h +++ b/src/zenutil/include/zenutil/logging.h @@ -19,8 +19,9 @@ // namespace zen::logging { +class BacklogSink; class BroadcastSink; -} +} // namespace zen::logging namespace zen { @@ -46,4 +47,25 @@ void ShutdownLogging(); logging::SinkPtr GetFileSink(); Ref<logging::BroadcastSink> GetDefaultBroadcastSink(); +/// The default backlog sink, installed alongside the broadcast sink at +/// BeginInitializeLogging. Captures every log line until DisableLogBacklog() +/// is called. Use AttachSinkWithBacklogReplay() to add a sink that should +/// receive the backlog, or call Replay() directly on this sink for ad-hoc +/// replay into a target. May return nullptr before logging is initialized +/// or after the backlog is disabled. +Ref<logging::BacklogSink> GetLogBacklogSink(); + +/// Add a sink to the default broadcast and replay the captured backlog +/// into it before any new messages reach it. Use this for any sink that +/// should see the early-startup window — e.g. an in-proc session log +/// sink, a Sentry breadcrumb sink, an OTLP forwarder. After the backlog +/// has been disabled this is equivalent to a plain AddSink. +void AttachSinkWithBacklogReplay(logging::SinkPtr Sink); + +/// Stop capturing into the backlog and free its buffer. Idempotent. +/// Call this once the bootstrap window has closed — typically right +/// before the server enters its run loop or the CLI dispatches its +/// command. Subsequent log calls bypass the backlog cheaply. +void DisableLogBacklog(); + } // namespace zen diff --git a/src/zenutil/include/zenutil/sessionsclient.h b/src/zenutil/include/zenutil/sessionsclient.h index c144a9baa..ae2364279 100644 --- a/src/zenutil/include/zenutil/sessionsclient.h +++ b/src/zenutil/include/zenutil/sessionsclient.h @@ -24,12 +24,21 @@ class SessionsServiceClient public: struct Options { - std::string TargetUrl; // Base URL of the target zenserver (e.g. "http://localhost:8558") - std::string AppName; // Application name to register - std::string Mode; // Server mode (e.g. "Server", "Compute", "Proxy") - Oid SessionId = Oid::Zero; // Session ID to register under - Oid JobId = Oid::Zero; // Optional job ID - HttpClientSettings ClientSettings; // Optional; timeouts are overridden internally (e.g. for unix sockets) + std::string TargetUrl; // Base URL of the target zenserver (e.g. "http://localhost:8558") + std::string AppName; // Application name to register + std::string Mode; // Server mode (e.g. "Server", "Compute", "Proxy") + std::string Platform; // Client platform; empty = auto-detect via GetRuntimePlatformName() + // PID the server uses to track this client's liveness. 0 = don't + // report. Auto-filled to GetCurrentProcessId() when TargetUrl looks + // local (unix socket, localhost, 127.0.0.1); set explicitly to + // override or suppress. + uint32_t ClientPid = 0; + Oid SessionId = Oid::Zero; // Session ID to register under + Oid ParentSessionId = Oid::Zero; // Optional parent session ID + // Optional task/action identifier. Use this to associate the session with + // a specific unit of work while ParentSessionId links process/session ancestry. + Oid JobId = Oid::Zero; + HttpClientSettings ClientSettings; // Optional; timeouts are overridden internally (e.g. for unix sockets) }; /// Command sent to the background worker thread. diff --git a/src/zenutil/logging/logging.cpp b/src/zenutil/logging/logging.cpp index 936e3c4fd..06e8f920e 100644 --- a/src/zenutil/logging/logging.cpp +++ b/src/zenutil/logging/logging.cpp @@ -8,6 +8,7 @@ #include <zencore/logging.h> #include <zencore/logging/ansicolorsink.h> #include <zencore/logging/asyncsink.h> +#include <zencore/logging/backlogsink.h> #include <zencore/logging/broadcastsink.h> #include <zencore/logging/logger.h> #include <zencore/logging/msvcsink.h> @@ -27,6 +28,7 @@ namespace zen { static bool g_IsLoggingInitialized; logging::SinkPtr g_FileSink; Ref<logging::BroadcastSink> g_BroadcastSink; +Ref<logging::BacklogSink> g_BacklogSink; logging::SinkPtr GetFileSink() @@ -40,6 +42,83 @@ GetDefaultBroadcastSink() return g_BroadcastSink; } +Ref<logging::BacklogSink> +GetLogBacklogSink() +{ + return g_BacklogSink; +} + +void +AttachSinkWithBacklogReplay(logging::SinkPtr Sink) +{ + if (!Sink) + { + return; + } + // Drain any AsyncSink queue first so messages emitted just before this + // call have a chance to land in the backlog before we snapshot its + // cursor. Without this we'd still be correct (AsyncSink would deliver + // the queued lines to the new sink directly via the broadcast after + // AddSink runs), but the new sink would observe them out of timestamp + // order — replayed history first, then late drains interleaved with + // fresh post-attach messages. + zen::logging::FlushLogging(); + + if (!g_BroadcastSink) + { + return; + } + + // Subscribe Sink to the broadcast and snapshot the backlog cursor + // atomically. The broadcast's exclusive lock blocks fanout while it's + // held, so during the callback no thread can be inside backlog.Log() + // and the cursor we capture is exact w.r.t. the moment Sink became + // visible to fanout. + // + // After the callback returns and the lock is released, two things are + // true: + // - Any backlog entry at index < Cursor was added before Sink became + // a fanout target, so Sink did NOT receive it via Log() — Replay + // must deliver it. + // - Any backlog entry at index >= Cursor was added after Sink became + // a fanout target, so Sink already received it via Log() — Replay + // must skip it. + // This closes the prior race where a message arriving between Replay + // and AddSink would land in the backlog only and be lost on + // DisableLogBacklog. + size_t Cursor = 0; + g_BroadcastSink->AddSinkAtomic(Sink, [&]() { + if (g_BacklogSink && g_BacklogSink->IsEnabled()) + { + Cursor = g_BacklogSink->Size(); + } + }); + + if (Cursor > 0) + { + g_BacklogSink->Replay(*Sink, Cursor); + } +} + +void +DisableLogBacklog() +{ + if (!g_BacklogSink) + { + return; + } + // Remove from the broadcast first so subsequent log lines don't even + // fan out to a now-no-op sink — one fewer per-log overhead for the + // rest of the process lifetime. Disable() then frees the arena, and + // dropping our own Ref releases the object. + if (g_BroadcastSink) + { + g_BroadcastSink->RemoveSink(logging::SinkPtr(g_BacklogSink.Get())); + } + g_BacklogSink->Disable(); + g_BacklogSink = nullptr; +} + void InitializeLogging(const LoggingOptions& LogOptions) { @@ -129,6 +208,14 @@ BeginInitializeLogging(const LoggingOptions& LogOptions) // a child sink later is immediately visible to every logger. std::vector<logging::SinkPtr> BroadcastChildren; + // Install the backlog sink as the first broadcast child so it sees + // every line emitted from this point until the bootstrap window + // closes (DisableLogBacklog at server-run-loop / CLI-dispatch). Sinks + // attached later via AttachSinkWithBacklogReplay can replay the + // captured window into themselves so they don't miss the early logs. + g_BacklogSink = Ref<logging::BacklogSink>(new logging::BacklogSink()); + BroadcastChildren.push_back(logging::SinkPtr(g_BacklogSink.Get())); + if (LogOptions.NoConsoleOutput) { zen::logging::SuppressConsoleLog(); @@ -274,6 +361,7 @@ ShutdownLogging() g_FileSink = nullptr; g_BroadcastSink = nullptr; + g_BacklogSink = nullptr; } } // namespace zen diff --git a/src/zenutil/sessionsclient.cpp b/src/zenutil/sessionsclient.cpp index 6ba997a62..f8dab4fb9 100644 --- a/src/zenutil/sessionsclient.cpp +++ b/src/zenutil/sessionsclient.cpp @@ -6,6 +6,8 @@ #include <zencore/fmtutils.h> #include <zencore/iobuffer.h> #include <zencore/logging/logmsg.h> +#include <zencore/process.h> +#include <zencore/system.h> #include <zencore/thread.h> #include <vector> @@ -70,6 +72,27 @@ SessionsServiceClient::SessionsServiceClient(Options Opts) m_Options.TargetUrl.pop_back(); } + // Auto-detect the platform if the caller didn't set one explicitly. + if (m_Options.Platform.empty()) + { + m_Options.Platform = std::string(GetRuntimePlatformName()); + } + + // Auto-fill ClientPid when we can reasonably assume the target is on the + // same machine. The server ALSO defensively gates pid acceptance on + // IsLocalMachineRequest(), so sending a pid for a non-local URL doesn't + // cause false positives — this heuristic just avoids the redundant send. + if (m_Options.ClientPid == 0) + { + const bool IsUnixSocket = !m_Options.ClientSettings.UnixSocketPath.empty(); + const bool LooksLocal = IsUnixSocket || m_Options.TargetUrl.find("localhost") != std::string::npos || + m_Options.TargetUrl.find("127.0.0.1") != std::string::npos; + if (LooksLocal) + { + m_Options.ClientPid = static_cast<uint32_t>(GetCurrentProcessId()); + } + } + m_WorkerThread = std::thread([this]() { zen::SetCurrentThreadName("SessionIO"); WorkerLoop(); @@ -98,6 +121,18 @@ SessionsServiceClient::BuildRequestBody(CbObjectView Metadata) const { Writer << "mode" << m_Options.Mode; } + if (!m_Options.Platform.empty()) + { + Writer << "platform" << m_Options.Platform; + } + if (m_Options.ClientPid != 0) + { + Writer << "pid" << m_Options.ClientPid; + } + if (m_Options.ParentSessionId != Oid::Zero) + { + Writer << "parent_session_id" << m_Options.ParentSessionId; + } if (m_Options.JobId != Oid::Zero) { Writer << "jobid" << m_Options.JobId; diff --git a/src/zenutil/zenserverprocess.cpp b/src/zenutil/zenserverprocess.cpp index 2d4334ffa..16232333f 100644 --- a/src/zenutil/zenserverprocess.cpp +++ b/src/zenutil/zenserverprocess.cpp @@ -15,6 +15,7 @@ #include <zencore/timer.h> #include <atomic> +#include <cctype> #include <string> #include <gsl/gsl-lite.hpp> @@ -555,6 +556,28 @@ ZenServerState::ZenServerEntry::AddSponsorProcess(uint32_t PidToAdd, uint64_t Ti static constexpr size_t kInstanceInfoSize = 4096; +// Token-aware search for a CLI flag (e.g. "--parent-session") within an +// argument string. Avoids false positives like "--parent-session-foo" by +// requiring the match to start at the beginning or after whitespace, and to +// end at the end of the string, at '=', or at whitespace. +static bool +HasCliFlag(std::string_view Args, std::string_view Flag) +{ + size_t Pos = 0; + while ((Pos = Args.find(Flag, Pos)) != std::string_view::npos) + { + const bool LeftOk = (Pos == 0) || std::isspace(static_cast<unsigned char>(Args[Pos - 1])); + const size_t End = Pos + Flag.size(); + const bool RightOk = (End == Args.size()) || (Args[End] == '=') || std::isspace(static_cast<unsigned char>(Args[End])); + if (LeftOk && RightOk) + { + return true; + } + Pos = End; + } + return false; +} + ZenServerInstanceInfo::ZenServerInstanceInfo() = default; ZenServerInstanceInfo::~ZenServerInstanceInfo() @@ -1119,9 +1142,10 @@ ZenServerInstance::SpawnServerInternal(int ChildId, std::string_view ServerArgs, ExtendableStringBuilder<512> CommandLine; { - const std::string ExeUtf8 = PathToUtf8(Executable); + ExtendableStringBuilder<260> ExeUtf8; + PathToUtf8(Executable, ExeUtf8); constexpr AsciiSet QuoteChars = " \t\""; - if (AsciiSet::HasAny(ExeUtf8.c_str(), QuoteChars)) + if (AsciiSet::HasAny(ExeUtf8.ToView(), QuoteChars)) { CommandLine << '"' << ExeUtf8 << '"'; } @@ -1147,6 +1171,11 @@ ZenServerInstance::SpawnServerInternal(int ChildId, std::string_view ServerArgs, CommandLine << " --enable-execution-history=false"; } + if (!HasCliFlag(ServerArgs, "--parent-session")) + { + CommandLine << " --parent-session " << GetSessionIdString(); + } + if (!ServerArgs.empty()) { CommandLine << " " << ServerArgs; @@ -1246,7 +1275,7 @@ ZenServerInstance::SpawnServer(int BasePort, std::string_view AdditionalServerAr CommandLine << " --test --log-id " << m_Name; CommandLine << " --no-sentry"; - if (AdditionalServerArgs.find("--system-dir") == std::string_view::npos) + if (!HasCliFlag(AdditionalServerArgs, "--system-dir")) { CommandLine << " --system-dir "; PathToUtf8((m_Env.CreateNewTestDir() / "system-dir").c_str(), CommandLine); @@ -1665,10 +1694,11 @@ StartupZenServer(LoggerRef LogRef, const StartupZenServerOptions& Options) ZenServerInstance Server(ServerEnvironment, Options.Mode); Server.SetEnableExecutionHistory(Options.EnableExecutionHistory); - std::string ServerArguments(Options.ExtraArgs); - if ((Options.Port != 0) && (ServerArguments.find("--port") == std::string::npos)) + ExtendableStringBuilder<256> ServerArguments; + ServerArguments << Options.ExtraArgs; + if (Options.Port != 0 && !HasCliFlag(ServerArguments, "--port")) { - ServerArguments.append(fmt::format(" --port {}", Options.Port)); + ServerArguments << " --port " << Options.Port; } Server.SpawnServer(ServerArguments, Options.OpenConsole, /*WaitTimeoutMs*/ 0); |