From ab797d3a57786b01f95bcc1ed3e4091a211dca66 Mon Sep 17 00:00:00 2001 From: Stefan Boberg Date: Fri, 13 Mar 2026 19:46:52 +0100 Subject: Add session log ingestion, dashboard visualization, and log forwarding sink - Add POST/GET endpoints for session logs (/sessions/{id}/log) supporting raw text (newline-split) and structured JSON/CbObject with batch "entries" array - Add in-memory log storage per session (capped at 10k entries) with pagination - Add log panel to sessions dashboard with polling, auto-scroll, and level coloring - Add SessionLogSink that batches log messages on a background thread and forwards them to the sessions endpoint via HTTP, integrated into ZenStorageServer when a remote sessions URL is configured --- src/zenserver/sessions/httpsessions.cpp | 161 ++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) (limited to 'src/zenserver/sessions/httpsessions.cpp') diff --git a/src/zenserver/sessions/httpsessions.cpp b/src/zenserver/sessions/httpsessions.cpp index 6996ce5b5..f6eb77c15 100644 --- a/src/zenserver/sessions/httpsessions.cpp +++ b/src/zenserver/sessions/httpsessions.cpp @@ -110,6 +110,11 @@ HttpSessionsService::Initialize() [this](HttpRouterRequest& Req) { SessionRequest(Req); }, HttpVerb::kGet | HttpVerb::kPost | HttpVerb::kPut | HttpVerb::kDelete); + m_Router.RegisterRoute( + "{session_id}/log", + [this](HttpRouterRequest& Req) { SessionLogRequest(Req); }, + HttpVerb::kGet | HttpVerb::kPost); + m_Router.RegisterRoute( "", [this](HttpRouterRequest& Req) { ListSessionsRequest(Req); }, @@ -313,6 +318,162 @@ HttpSessionsService::SessionRequest(HttpRouterRequest& Req) } } +////////////////////////////////////////////////////////////////////////// +// +// Session log +// + +static void +WriteLogEntry(CbWriter& Writer, const SessionsService::LogEntry& Entry) +{ + Writer << "timestamp" << Entry.Timestamp; + if (!Entry.Level.empty()) + { + Writer << "level" << Entry.Level; + } + if (!Entry.Message.empty()) + { + Writer << "message" << Entry.Message; + } + if (Entry.Data.GetSize() > 0) + { + Writer.AddObject("data"sv, Entry.Data); + } +} + +void +HttpSessionsService::SessionLogRequest(HttpRouterRequest& Req) +{ + HttpServerRequest& ServerRequest = Req.ServerRequest(); + + const Oid SessionId = Oid::TryFromHexString(Req.GetCapture(1)); + if (SessionId == Oid::Zero) + { + m_SessionsStats.BadRequestCount++; + return ServerRequest.WriteResponse(HttpResponseCode::BadRequest, + HttpContentType::kText, + fmt::format("Invalid session id '{}'", Req.GetCapture(1))); + } + + m_SessionsStats.RequestCount++; + + Ref Session = m_Sessions.GetSession(SessionId); + if (!Session) + { + return ServerRequest.WriteResponse(HttpResponseCode::NotFound, + HttpContentType::kText, + fmt::format("Session '{}' not found", SessionId)); + } + + if (ServerRequest.RequestVerb() == HttpVerb::kPost) + { + m_SessionsStats.SessionWriteCount++; + + if (ServerRequest.RequestContentType() == HttpContentType::kText) + { + // Raw text — split by newlines, one entry per line + IoBuffer Payload = ServerRequest.ReadPayload(); + std::string_view Text(reinterpret_cast(Payload.GetData()), Payload.GetSize()); + const DateTime Now = DateTime::Now(); + + size_t Pos = 0; + while (Pos < Text.size()) + { + size_t End = Text.find('\n', Pos); + if (End == std::string_view::npos) + { + End = Text.size(); + } + + std::string_view Line = Text.substr(Pos, End - Pos); + // Strip trailing \r + if (!Line.empty() && Line.back() == '\r') + { + Line.remove_suffix(1); + } + + if (!Line.empty()) + { + Session->AppendLog(SessionsService::LogEntry{ + .Timestamp = Now, + .Message = std::string(Line), + }); + } + + Pos = End + 1; + } + } + else + { + // Structured log (JSON or CbObject) + // Accepts a single record or an "entries" array of records + CbObject RequestObject = ServerRequest.ReadPayloadObject(); + const DateTime Now = DateTime::Now(); + + auto AppendFromObject = [&](CbObjectView Obj) { + std::string Level(Obj["level"sv].AsString()); + std::string Message(Obj["message"sv].AsString()); + CbObjectView DataView = Obj["data"sv].AsObjectView(); + + Session->AppendLog(SessionsService::LogEntry{ + .Timestamp = Now, + .Level = std::move(Level), + .Message = std::move(Message), + .Data = CbObject::Clone(DataView), + }); + }; + + CbFieldView EntriesField = RequestObject["entries"sv]; + if (EntriesField.IsArray()) + { + for (CbFieldView Entry : EntriesField) + { + AppendFromObject(Entry.AsObjectView()); + } + } + else + { + AppendFromObject(RequestObject); + } + } + + return ServerRequest.WriteResponse(HttpResponseCode::OK); + } + else + { + // GET - return log entries + m_SessionsStats.SessionReadCount++; + + HttpServerRequest::QueryParams Params = ServerRequest.GetQueryParams(); + uint32_t Limit = 0; + uint32_t Offset = 0; + + if (std::string_view LimitStr = Params.GetValue("limit"sv); !LimitStr.empty()) + { + Limit = uint32_t(std::strtoul(std::string(LimitStr).c_str(), nullptr, 10)); + } + if (std::string_view OffsetStr = Params.GetValue("offset"sv); !OffsetStr.empty()) + { + Offset = uint32_t(std::strtoul(std::string(OffsetStr).c_str(), nullptr, 10)); + } + + std::vector Entries = Session->GetLogEntries(Limit, Offset); + + CbObjectWriter Response; + Response << "total" << Session->GetLogCount(); + Response.BeginArray("entries"); + for (const SessionsService::LogEntry& Entry : Entries) + { + Response.BeginObject(); + WriteLogEntry(Response, Entry); + Response.EndObject(); + } + Response.EndArray(); + + return ServerRequest.WriteResponse(HttpResponseCode::OK, Response.Save()); + } +} + ////////////////////////////////////////////////////////////////////////// // // WebSocket push -- cgit v1.2.3