package gateway import ( "net/http" "strconv" "time" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // Default list limits shared by the trace/audit endpoints. Compile-time constants // so no magic number appears in the run path (project rule ยง10). const ( defaultObsLimit = 50 maxObsLimit = 500 ) // observability bundles the gateway's live admin instrumentation: the request // metrics counter, the trace span ring buffer and the audit store. It is built in // New and is safe for concurrent use. type observability struct { metrics *MetricsCollector traceBuf *RingBuffer audit AuditStore } // newObservability builds the instrumentation, falling back to an in-memory audit // store when no store is injected. func newObservability(deps Deps) *observability { obs := &observability{ metrics: newMetricsCollector(), traceBuf: newRingBuffer(defaultTraceBufCap), } if deps.AuditStore != nil { obs.audit = deps.AuditStore } else { obs.audit = newMemoryAuditStore() } return obs } // ObserveSpan feeds a completed trace span into the gateway's ring buffer and the // LLM/subagent metric counters. It is the gateway's trace injection point: the // composition root wires the loop tracer here (e.g. through TraceSink) so the // admin panel can query spans without the gateway touching wire.go. func (s *Server) ObserveSpan(rec trace.SpanRecord) { if s.obs == nil { return } s.obs.traceBuf.Append(rec) s.obs.metrics.Observe(rec) } // TraceSink returns a trace.Writer that appends completed spans to the gateway's // trace ring buffer. Combine it with the existing JSONRecorder in the composition // root (e.g. trace.New(MultiTraceWriter{...})) so spans both hit stderr and the // admin panel. func (s *Server) TraceSink() trace.Writer { if s.obs == nil { return nil } return traceSink{s.obs.traceBuf} } // recordMetric counts one request for the observability panel. func (s *Server) recordMetric(method, path string, status int, durMs int64) { if s.obs == nil { return } s.obs.metrics.Record(method, path, status, durMs) } // adminObservabilityMetrics returns the current gateway metrics (admin read). func (s *Server) adminObservabilityMetrics(w http.ResponseWriter, r *http.Request) { if s.obs == nil { writeJSON(w, http.StatusOK, AdminMetrics{ PerMethod: map[string]int64{}, PerPath: map[string]int64{}, StatusCounts: map[string]int64{}, }) return } writeJSON(w, http.StatusOK, s.obs.metrics.Snapshot(time.Now())) } // adminObservabilityTrace returns the most recent traced spans (admin read). func (s *Server) adminObservabilityTrace(w http.ResponseWriter, r *http.Request) { if s.obs == nil { writeJSON(w, http.StatusOK, []AdminTraceSpan{}) return } spans := s.obs.traceBuf.Snapshot(parseLimit(r)) out := make([]AdminTraceSpan, 0, len(spans)) for _, sp := range spans { out = append(out, adminSpanOf(sp)) } writeJSON(w, http.StatusOK, out) } // adminObservabilityAudit returns the most recent audit entries (admin read). func (s *Server) adminObservabilityAudit(w http.ResponseWriter, r *http.Request) { if s.obs == nil || s.obs.audit == nil { writeJSON(w, http.StatusOK, []AuditRecord{}) return } records, err := s.obs.audit.List(r.Context(), parseLimit(r)) if err != nil { s.log.Error("list audit", "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "audit_failed", "failed to list audit records")) return } writeJSON(w, http.StatusOK, records) } // parseLimit parses the ?limit= query param, clamping to [1, maxObsLimit], and // selects defaultObsLimit when absent or invalid. func parseLimit(r *http.Request) int { n, err := strconv.Atoi(r.URL.Query().Get("limit")) if err != nil || n <= 0 { return defaultObsLimit } if n > maxObsLimit { return maxObsLimit } return n }