Improve agent-facing history endpoints (Phase 1)
- Fix /api/history/{type} to search the full persistent store instead of
only the bounded in-memory ring buffer, so filtered history isn't
silently truncated.
- Add structured type/since/until filters to /api/history/search and
/api/history/wait, alongside the existing free-text query.
- Add GET /api/traces/{traceId} to fetch all entries for a trace in one
call instead of relying on substring search.
- Rate-limit /api/history/wait per client IP.
- Add docs/PLAN.md tracking the phased improvement plan; update README
and agents_example.md for the new/changed endpoints.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ed1cb4355
commit
f77b89a779
13 files changed
+473
-88
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-otel/internal/storage"
|
||||
"git.tornberg.me/go-otel/internal/websocket"
|
||||
"go.opentelemetry.io/collector/pdata/plog"
|
||||
"go.opentelemetry.io/collector/pdata/pmetric"
|
||||
@@ -146,23 +147,52 @@ func (c *Collector) HandleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleHistory returns the telemetry history as JSON.
|
||||
// parseSearchOptions builds a storage.SearchOptions from common query
|
||||
// parameters shared by the search/wait/history endpoints: q, type, since, until.
|
||||
func parseSearchOptions(r *http.Request) storage.SearchOptions {
|
||||
q := r.URL.Query()
|
||||
opts := storage.SearchOptions{
|
||||
Query: q.Get("q"),
|
||||
Type: q.Get("type"),
|
||||
}
|
||||
if since := q.Get("since"); since != "" {
|
||||
if v, err := strconv.ParseInt(since, 10, 64); err == nil {
|
||||
opts.Since = v
|
||||
}
|
||||
}
|
||||
if until := q.Get("until"); until != "" {
|
||||
if v, err := strconv.ParseInt(until, 10, 64); err == nil {
|
||||
opts.Until = v
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func parseLimit(r *http.Request, def int) int {
|
||||
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// HandleHistory returns the telemetry history as JSON. It searches the full
|
||||
// persistent store when one is configured, so results are not bounded by the
|
||||
// in-memory ring buffer size.
|
||||
func (c *Collector) HandleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 100
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil {
|
||||
limit = l
|
||||
}
|
||||
history, err := c.SearchHistory(r.Context(), storage.SearchOptions{}, parseLimit(r, 100))
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch history: %v", err)
|
||||
http.Error(w, "failed to fetch history", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
history := c.GetHistory(limit)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(history); err != nil {
|
||||
log.Printf("failed to encode history: %v", err)
|
||||
@@ -171,23 +201,16 @@ func (c *Collector) HandleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSearch searches the telemetry history.
|
||||
// HandleSearch searches the telemetry history. Supports the free-text 'q'
|
||||
// parameter plus structured filters: type, since, until (unix nanoseconds).
|
||||
func (c *Collector) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 100
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
history, err := c.SearchHistory(r.Context(), query, limit)
|
||||
opts := parseSearchOptions(r)
|
||||
history, err := c.SearchHistory(r.Context(), opts, parseLimit(r, 100))
|
||||
if err != nil {
|
||||
log.Printf("failed to search history: %v", err)
|
||||
http.Error(w, "failed to search history", http.StatusInternalServerError)
|
||||
@@ -202,14 +225,20 @@ func (c *Collector) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWait waits for a specific telemetry signal.
|
||||
// HandleWait waits for a specific telemetry signal, optionally restricted by
|
||||
// the same structured filters as HandleSearch (type, since, until).
|
||||
func (c *Collector) HandleWait(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
if !c.waitLimiter.Allow(clientIP(r)) {
|
||||
http.Error(w, "too many wait requests, slow down", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
opts := parseSearchOptions(r)
|
||||
timeoutSec := 30
|
||||
if t := r.URL.Query().Get("timeout"); t != "" {
|
||||
if ts, err := strconv.Atoi(t); err == nil {
|
||||
@@ -221,7 +250,7 @@ func (c *Collector) HandleWait(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
|
||||
// First, check if result already exists in recent history
|
||||
matches, err := c.SearchHistory(ctx, query, 1)
|
||||
matches, err := c.SearchHistory(ctx, opts, 1)
|
||||
if err == nil && len(matches) > 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(matches[0]); err != nil {
|
||||
@@ -231,13 +260,15 @@ func (c *Collector) HandleWait(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Otherwise, subscribe and wait
|
||||
lowerQuery := strings.ToLower(query)
|
||||
lowerQuery := strings.ToLower(opts.Query)
|
||||
ch, cleanup := c.Subscribe(func(entry websocket.HistoryEntry) bool {
|
||||
if query == "" {
|
||||
if opts.Type != "" && entry.Type != opts.Type {
|
||||
return false
|
||||
}
|
||||
if opts.Query == "" {
|
||||
return true
|
||||
}
|
||||
data, _ := json.Marshal(entry.Data)
|
||||
return strings.Contains(strings.ToLower(string(data)), lowerQuery)
|
||||
return strings.Contains(strings.ToLower(string(entry.Data)), lowerQuery)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -252,7 +283,8 @@ func (c *Collector) HandleWait(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleHistoryByType returns telemetry history filtered by type.
|
||||
// HandleHistoryByType returns telemetry history filtered by type, searching
|
||||
// the full persistent store when one is configured.
|
||||
func (c *Collector) HandleHistoryByType(dataType string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -260,33 +292,16 @@ func (c *Collector) HandleHistoryByType(dataType string) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 100
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch a larger set to allow for filtering, then slice
|
||||
// Retrieve up to 1000 from memory then filter
|
||||
history := c.GetHistory(5000)
|
||||
|
||||
// Filter by type
|
||||
var filteredHistory []websocket.HistoryEntry
|
||||
for _, entry := range history {
|
||||
if string(entry.Type) == dataType {
|
||||
filteredHistory = append(filteredHistory, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the results
|
||||
if len(filteredHistory) > limit {
|
||||
filteredHistory = filteredHistory[len(filteredHistory)-limit:]
|
||||
opts := storage.SearchOptions{Type: dataType}
|
||||
history, err := c.SearchHistory(r.Context(), opts, parseLimit(r, 100))
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch history for type %s: %v", dataType, err)
|
||||
http.Error(w, "failed to fetch history", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(filteredHistory); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(history); err != nil {
|
||||
log.Printf("failed to encode filtered history: %v", err)
|
||||
http.Error(w, "failed to encode history", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -294,6 +309,36 @@ func (c *Collector) HandleHistoryByType(dataType string) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTraceByID returns all stored entries referencing the given trace id,
|
||||
// so agents don't have to rely on the trace id happening to substring-match
|
||||
// inside a search result.
|
||||
func (c *Collector) HandleTraceByID(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
traceID := r.PathValue("traceId")
|
||||
if traceID == "" {
|
||||
http.Error(w, "traceId is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := c.SearchHistory(r.Context(), storage.SearchOptions{Query: traceID}, parseLimit(r, 1000))
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch trace %s: %v", traceID, err)
|
||||
http.Error(w, "failed to fetch trace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(entries); err != nil {
|
||||
log.Printf("failed to encode trace: %v", err)
|
||||
http.Error(w, "failed to encode trace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleHealth returns the health status of the collector.
|
||||
func (c *Collector) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
Reference in new issue
Block a user