diff --git a/express/logging.ts b/express/logging.ts index bf76846..fc5a3a1 100644 --- a/express/logging.ts +++ b/express/logging.ts @@ -32,15 +32,39 @@ type FilterArgument = { match?: (string | RegExp)[]; }; -const log = (_message: Message) => { - // WRITEME - console.log( - `will POST a message to ${cli.logAddress.host}:${cli.logAddress.port}`, - ); +const loggerUrl = `http://${cli.logAddress.host}:${cli.logAddress.port}`; + +const log = (message: Message) => { + const payload = { + timestamp: message.timestamp ?? Date.now(), + source: message.source, + text: message.text, + }; + + fetch(`${loggerUrl}/log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch((err) => { + console.error("[logging] Failed to send log:", err.message); + }); }; -const getLogs = (filter: FilterArgument) => { - // WRITEME +const getLogs = async (filter: FilterArgument): Promise => { + const params = new URLSearchParams(); + if (filter.limit) { + params.set("limit", String(filter.limit)); + } + if (filter.before) { +- params.set("before", String(filter.before)); + } + if (filter.after) { + params.set("after", String(filter.after)); + } + + const url = `${loggerUrl}/logs?${params.toString()}`; + const response = await fetch(url); + return response.json(); }; // FIXME: there's scope for more specialized functions although they diff --git a/logger/.gitignore b/logger/.gitignore new file mode 100644 index 0000000..25304db --- /dev/null +++ b/logger/.gitignore @@ -0,0 +1 @@ +logger-bin diff --git a/logger/go.mod b/logger/go.mod new file mode 100644 index 0000000..dece3c0 --- /dev/null +++ b/logger/go.mod @@ -0,0 +1,3 @@ +module philologue.net/diachron/logger-bin + +go 1.23.3 diff --git a/logger/main.go b/logger/main.go new file mode 100644 index 0000000..f5ec2f1 --- /dev/null +++ b/logger/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "strconv" +) + +func main() { + port := flag.Int("port", 8085, "port to listen on") + capacity := flag.Int("capacity", 1000000, "max messages to store") + + flag.Parse() + + store := NewLogStore(*capacity) + + http.HandleFunc("POST /log", func(w http.ResponseWriter, r *http.Request) { + var msg Message + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + store.Add(msg) + w.WriteHeader(http.StatusCreated) + }) + + http.HandleFunc("GET /logs", func(w http.ResponseWriter, r *http.Request) { + params := FilterParams{} + + if limit := r.URL.Query().Get("limit"); limit != "" { + if n, err := strconv.Atoi(limit); err == nil { + params.Limit = n + } + } + if before := r.URL.Query().Get("before"); before != "" { + if ts, err := strconv.ParseInt(before, 10, 64); err == nil { + params.Before = ts + } + } + if after := r.URL.Query().Get("after"); after != "" { + if ts, err := strconv.ParseInt(after, 10, 64); err == nil { + params.After = ts + } + } + + messages := store.GetFiltered(params) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) + }) + + http.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) { + status := map[string]any{ + "count": store.Count(), + "capacity": *capacity, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) + }) + + listenAddr := fmt.Sprintf(":%d", *port) + log.Printf("[logger] Listening on %s (capacity: %d)", listenAddr, *capacity) + if err := http.ListenAndServe(listenAddr, nil); err != nil { + log.Fatalf("[logger] Failed to start: %v", err) + } +} diff --git a/logger/store.go b/logger/store.go new file mode 100644 index 0000000..3881549 --- /dev/null +++ b/logger/store.go @@ -0,0 +1,126 @@ +package main + +import ( + "sync" +) + +// Message represents a log entry from the express backend +type Message struct { + Timestamp int64 `json:"timestamp"` + Source string `json:"source"` // "logging" | "diagnostic" | "user" + Text []string `json:"text"` +} + +// LogStore is a thread-safe ring buffer for log messages +type LogStore struct { + mu sync.RWMutex + messages []Message + head int // next write position + full bool // whether buffer has wrapped + capacity int +} + +// NewLogStore creates a new log store with the given capacity +func NewLogStore(capacity int) *LogStore { + return &LogStore{ + messages: make([]Message, capacity), + capacity: capacity, + } +} + +// Add inserts a new message into the store +func (s *LogStore) Add(msg Message) { + s.mu.Lock() + defer s.mu.Unlock() + + s.messages[s.head] = msg + s.head++ + if s.head >= s.capacity { + s.head = 0 + s.full = true + } +} + +// Count returns the number of messages in the store +func (s *LogStore) Count() int { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.full { + return s.capacity + } + return s.head +} + +// GetRecent returns the most recent n messages, newest first +func (s *LogStore) GetRecent(n int) []Message { + s.mu.RLock() + defer s.mu.RUnlock() + + count := s.Count() + if n > count { + n = count + } + if n == 0 { + return nil + } + + result := make([]Message, n) + pos := s.head - 1 + for i := 0; i < n; i++ { + if pos < 0 { + pos = s.capacity - 1 + } + result[i] = s.messages[pos] + pos-- + } + return result +} + +// Filter parameters for retrieving logs +type FilterParams struct { + Limit int // max messages to return (0 = default 100) + Before int64 // only messages before this timestamp + After int64 // only messages after this timestamp +} + +// GetFiltered returns messages matching the filter criteria +func (s *LogStore) GetFiltered(params FilterParams) []Message { + s.mu.RLock() + defer s.mu.RUnlock() + + limit := params.Limit + if limit <= 0 { + limit = 100 + } + + count := s.Count() + if count == 0 { + return nil + } + + result := make([]Message, 0, limit) + pos := s.head - 1 + + for i := 0; i < count && len(result) < limit; i++ { + if pos < 0 { + pos = s.capacity - 1 + } + msg := s.messages[pos] + + // Apply filters + if params.Before > 0 && msg.Timestamp >= params.Before { + pos-- + continue + } + if params.After > 0 && msg.Timestamp <= params.After { + pos-- + continue + } + + result = append(result, msg) + pos-- + } + + return result +}