package collector import ( "crypto/rand" "encoding/hex" "encoding/json" "log" "net/http" "strconv" "strings" "time" "git.tornberg.me/go-otel/internal/todos" ) const ( maxTodoBody = 16 << 10 maxTodoText = 4000 maxTodoMeta = 500 ) // SetTodoStore enables the todo endpoints. func (c *Collector) SetTodoStore(s todos.Store) { c.todoStore = s } func clipTo(s string, n int) string { s = strings.TrimSpace(s) if len(s) > n { return s[:n] } return s } type todoPayload struct { Project string `json:"project"` Text string `json:"text"` Component string `json:"component"` Path string `json:"path"` Hash string `json:"hash"` URL string `json:"url"` UserAgent string `json:"ua"` Session string `json:"session"` } // HandleTodoCreate ingests a todo posted by otel-todo.js. It is guarded by // the same ingest key and rate limit as the other RUM endpoints. func (c *Collector) HandleTodoCreate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if c.todoStore == nil { http.Error(w, "todos not enabled", http.StatusServiceUnavailable) return } if !c.checkIngest(w, r) { return } var p todoPayload if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxTodoBody)).Decode(&p); err != nil { http.Error(w, "invalid todo payload", http.StatusBadRequest) return } text := clipTo(p.Text, maxTodoText) if text == "" { http.Error(w, "text is required", http.StatusBadRequest) return } project := clipTo(p.Project, maxTodoMeta) if project == "" { project = "frontend" } id := make([]byte, 8) _, _ = rand.Read(id) t := todos.Todo{ ID: hex.EncodeToString(id), Project: project, Text: text, Component: clipTo(p.Component, maxTodoMeta), Path: clipTo(p.Path, maxTodoMeta), Hash: clipTo(p.Hash, maxTodoMeta), URL: clipTo(p.URL, maxTodoMeta), UserAgent: clipTo(p.UserAgent, maxTodoMeta), Session: clipTo(p.Session, maxTodoMeta), CreatedAt: time.Now().UTC(), } if err := c.todoStore.Add(r.Context(), t); err != nil { log.Printf("failed to store todo: %v", err) http.Error(w, "failed to store todo", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(t) } // HandleTodoList returns todos, newest first. ?project= filters by project // (omit for all); ?limit= defaults to 100, max 1000. Registered behind auth. func (c *Collector) HandleTodoList(w http.ResponseWriter, r *http.Request) { if c.todoStore == nil { http.Error(w, "todos not enabled", http.StatusServiceUnavailable) return } limit := 100 if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 { limit = min(v, 1000) } items, err := c.todoStore.List(r.Context(), r.URL.Query().Get("project"), limit) if err != nil { log.Printf("failed to list todos: %v", err) http.Error(w, "failed to list todos", http.StatusInternalServerError) return } if items == nil { items = []todos.Todo{} } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(items) }