Files
synapbus/internal/api/traces_export_handler.go
T
Algis DumbrisandClaude Opus 4.6 be098081b4 feat: implement trace logging, REST API, and observability
Add comprehensive trace logging and observability features:

- Enhanced trace store with owner-scoped queries, filtering (agent, action,
  time range), pagination, streaming export, and retention cleanup
- REST API endpoints: GET /api/traces (list with filters), GET /api/traces/export
  (streaming JSON/CSV), GET /api/traces/stats (action counts)
- Owner isolation enforced at every layer (store, API, tests)
- Hand-rolled Prometheus metrics (pure Go, zero CGO): traces_total,
  traces_by_action, errors_total, active_agents at GET /metrics
- Configurable slog JSON handler with --log-level flag
- Request ID middleware for cross-referencing logs and traces
- Batch trace writing (64 entries or 100ms flush interval)
- Background retention cleanup via --trace-retention flag
- SQL migration 002 adds owner_id column and composite indexes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:57:05 +02:00

121 lines
2.8 KiB
Go

package api
import (
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/smart-mcp-proxy/synapbus/internal/trace"
)
// ExportTraces handles GET /api/traces/export.
func (h *TracesHandler) ExportTraces(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
filter := trace.TraceFilter{
OwnerID: fmt.Sprintf("%d", ownerID),
AgentName: r.URL.Query().Get("agent_name"),
Action: r.URL.Query().Get("action"),
}
if since := r.URL.Query().Get("since"); since != "" {
t, err := time.Parse(time.RFC3339, since)
if err != nil {
http.Error(w, `{"error":"invalid 'since' parameter"}`, http.StatusBadRequest)
return
}
filter.Since = &t
}
if until := r.URL.Query().Get("until"); until != "" {
t, err := time.Parse(time.RFC3339, until)
if err != nil {
http.Error(w, `{"error":"invalid 'until' parameter"}`, http.StatusBadRequest)
return
}
filter.Until = &t
}
// Determine format
format := r.URL.Query().Get("format")
if format == "" {
accept := r.Header.Get("Accept")
switch accept {
case "text/csv":
format = "csv"
default:
format = "json"
}
}
dateStr := time.Now().Format("2006-01-02")
switch format {
case "csv":
h.exportCSV(w, r, filter, dateStr)
default:
h.exportJSON(w, r, filter, dateStr)
}
}
func (h *TracesHandler) exportJSON(w http.ResponseWriter, r *http.Request, filter trace.TraceFilter, dateStr string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="traces-%s.json"`, dateStr))
w.Header().Set("Transfer-Encoding", "chunked")
// Write opening bracket
w.Write([]byte("["))
first := true
err := h.store.QueryStream(r.Context(), filter, func(t trace.Trace) error {
if !first {
w.Write([]byte(","))
}
first = false
b, err := json.Marshal(t)
if err != nil {
return err
}
_, err = w.Write(b)
return err
})
if err != nil {
// Best effort — headers already sent
w.Write([]byte(fmt.Sprintf(`]`)))
return
}
w.Write([]byte("]"))
}
func (h *TracesHandler) exportCSV(w http.ResponseWriter, r *http.Request, filter trace.TraceFilter, dateStr string) {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="traces-%s.csv"`, dateStr))
w.Header().Set("Transfer-Encoding", "chunked")
cw := csv.NewWriter(w)
defer cw.Flush()
// Write header
cw.Write([]string{"id", "agent_name", "action", "details", "timestamp"})
h.store.QueryStream(r.Context(), filter, func(t trace.Trace) error {
return cw.Write([]string{
strconv.FormatInt(t.ID, 10),
t.AgentName,
t.Action,
string(t.Details),
t.Timestamp.Format(time.RFC3339),
})
})
}