package gateway import ( "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // defaultTraceBufCap is how many completed spans the ring buffer holds before the // oldest is evicted. Compile-time constant. const defaultTraceBufCap = 512 // AdminTraceSpan is the camelCase wire shape of one traced span. It decouples the // frontend from the trace package's snake_case SpanRecord. type AdminTraceSpan struct { ID string `json:"id"` ParentID string `json:"parentId,omitempty"` Name string `json:"name"` Kind string `json:"kind"` Status string `json:"status"` StartedAt string `json:"startedAt"` EndedAt string `json:"endedAt,omitempty"` DurationNs int64 `json:"durationNs"` Props map[string]any `json:"props,omitempty"` Events []trace.Event `json:"events,omitempty"` } // RingBuffer is a concurrency-safe bounded buffer of completed trace spans. The // oldest span is evicted when the buffer is full, so the admin panel always sees // the most recent work without unbounded growth. type RingBuffer struct { mu sync.RWMutex buf []trace.SpanRecord next int full bool cap int } // newRingBuffer builds a ring buffer holding up to cap spans (default when cap<=0). func newRingBuffer(cap int) *RingBuffer { if cap <= 0 { cap = defaultTraceBufCap } return &RingBuffer{buf: make([]trace.SpanRecord, cap), cap: cap} } // Append stores rec, evicting the oldest when the buffer is full. func (r *RingBuffer) Append(rec trace.SpanRecord) { r.mu.Lock() defer r.mu.Unlock() r.buf[r.next] = rec r.next = (r.next + 1) % r.cap if r.next == 0 { r.full = true } } // Snapshot returns up to n most-recent spans, newest first. A non-positive n // selects defaultObsLimit; n is clamped to maxObsLimit. func (r *RingBuffer) Snapshot(n int) []trace.SpanRecord { if n <= 0 { n = defaultObsLimit } if n > maxObsLimit { n = maxObsLimit } r.mu.RLock() defer r.mu.RUnlock() count := r.next if r.full { count = r.cap } if count > n { count = n } out := make([]trace.SpanRecord, 0, count) for i := 0; i < count; i++ { idx := (r.next - 1 - i + r.cap*2) % r.cap out = append(out, r.buf[idx]) } return out } // traceSink adapts a *RingBuffer to trace.Writer so a completed span is appended // to the gateway's trace buffer. type traceSink struct{ b *RingBuffer } // WriteSpan appends rec to the ring buffer. func (s traceSink) WriteSpan(rec trace.SpanRecord) error { if s.b == nil { return nil } s.b.Append(rec) return nil } // MultiTraceWriter fans a completed span out to several trace.Writers, so a tracer // can record to both the gateway's ring buffer and a JSONL file in one pass. A nil // Writer is skipped. It is the composition-root helper the main session uses to // route the loop tracer's output into the gateway without dropping stderr. type MultiTraceWriter struct { // Writers are the sinks a span is written to. A nil Writer is skipped. Writers []trace.Writer } // WriteSpan writes rec to every configured Writer. func (m MultiTraceWriter) WriteSpan(rec trace.SpanRecord) error { for _, w := range m.Writers { if w == nil { continue } if err := w.WriteSpan(rec); err != nil { return err } } return nil } // adminSpanOf maps a trace.SpanRecord to the camelCase admin wire shape. func adminSpanOf(rec trace.SpanRecord) AdminTraceSpan { sp := AdminTraceSpan{ ID: rec.ID, ParentID: rec.ParentID, Name: rec.Name, Kind: string(rec.Kind), Status: string(rec.Status), StartedAt: rec.StartedAt.UTC().Format(time.RFC3339), DurationNs: rec.Duration.Nanoseconds(), Props: rec.Props, Events: rec.Events, } if !rec.EndedAt.IsZero() { sp.EndedAt = rec.EndedAt.UTC().Format(time.RFC3339) } return sp }