Files
synapbus/internal/api/traces_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

106 lines
2.6 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/smart-mcp-proxy/synapbus/internal/trace"
)
// TracesHandler handles REST API requests for trace data.
type TracesHandler struct {
store trace.TraceStore
}
// NewTracesHandler creates a new TracesHandler.
func NewTracesHandler(store trace.TraceStore) *TracesHandler {
return &TracesHandler{store: store}
}
// ListTraces handles GET /api/traces.
func (h *TracesHandler) ListTraces(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, expected ISO 8601 format"}`, 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, expected ISO 8601 format"}`, http.StatusBadRequest)
return
}
filter.Until = &t
}
if page := r.URL.Query().Get("page"); page != "" {
p, err := strconv.Atoi(page)
if err == nil {
filter.Page = p
}
}
if pageSize := r.URL.Query().Get("page_size"); pageSize != "" {
ps, err := strconv.Atoi(pageSize)
if err == nil {
filter.PageSize = ps
}
}
traces, total, err := h.store.Query(r.Context(), filter)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":"query failed: %s"}`, err), http.StatusInternalServerError)
return
}
filter.Normalize()
resp := map[string]any{
"traces": traces,
"total": total,
"page": filter.Page,
"page_size": filter.PageSize,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// TraceStats handles GET /api/traces/stats.
func (h *TracesHandler) TraceStats(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
counts, err := h.store.CountByAction(r.Context(), fmt.Sprintf("%d", ownerID))
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":"stats query failed: %s"}`, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"stats": counts,
})
}