package main import ( "fmt" "os" "sync" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" ) // newAppTraceWriter builds the composition root's trace writer: the JSONL stderr // recorder (the local structured log of every span) plus room for the gateway's // observability sink, which can only be registered once the gateway exists (see // wireGatewayTraceSink). func newAppTraceWriter() *traceFanout { return newTraceFanout(trace.NewJSONRecorder(os.Stderr)) } // wireGatewayTraceSink registers the gateway's observability sink on the app's // trace fanout. It is called from wireFrontend — the first moment the gateway // exists — because the gateway is built after the loop that emits the spans. // Without it the admin trace ring buffer is never fed and // GET /api/v1/admin/observability/trace answers an empty list forever // (design/19 §2). It is a no-op on a boot without a tracer or gateway. func wireGatewayTraceSink(a *app, gw *gateway.Server) { if a.traceWriter == nil || gw == nil { return } a.traceWriter.Add(gatewaySpanSink{srv: gw}) a.log.Info("observability trace sink wired") } // traceFanout is the composition root's trace.Writer. It fans every completed // span out to the sinks installed at boot (the JSONL stderr recorder) plus any // sink registered later in the boot sequence: the gateway's observability buffer // only exists once wireFrontend has built the gateway, while the loop that emits // the spans is built earlier in wireRuntime. // // It is safe for concurrent use — the loop finishes spans from several // goroutines (per-step, per-tool-call, sub-agents). type traceFanout struct { mu sync.RWMutex writers []trace.Writer } // newTraceFanout builds a fanout over the given writers. Nil writers are dropped. func newTraceFanout(writers ...trace.Writer) *traceFanout { f := &traceFanout{} for _, w := range writers { f.Add(w) } return f } // Add installs one more sink. A nil writer is ignored. func (f *traceFanout) Add(w trace.Writer) { if w == nil { return } f.mu.Lock() defer f.mu.Unlock() f.writers = append(f.writers, w) } // WriteSpan writes rec to every sink and returns the first error. Every sink is // tried even after a failure, so one broken sink cannot hide a span from the // others; the tracer only logs the returned error (trace.Tracer.emit). func (f *traceFanout) WriteSpan(rec trace.SpanRecord) error { f.mu.RLock() defer f.mu.RUnlock() var firstErr error for _, w := range f.writers { if w == nil { continue } if err := w.WriteSpan(rec); err != nil && firstErr == nil { firstErr = fmt.Errorf("trace fanout: %w", err) } } return firstErr } // wireGatewayLogBuffer hands the composition root's log ring buffer to the // gateway, so GET /api/v1/admin/observability/logs serves the recent backend log // tail. The logger is built before the gateway (its records must be captured // from the first log line), so the buffer is injected here, the first moment the // gateway exists — like wireGatewayTraceSink does for spans. It is a no-op on a // boot without a gateway. func wireGatewayLogBuffer(a *app, buf *logger.RingBuffer) { if a == nil || a.gw == nil || buf == nil { return } a.gw.SetLogBuffer(buf) a.log.Info("observability log buffer wired", "capacity", buf.Len()) } // gatewaySpanSink adapts *gateway.Server to trace.Writer through its ObserveSpan // entry point, so an emitted span lands in the admin panel's trace ring buffer // AND in the LLM/subagent metric counters (ObserveSpan appends to the buffer and // observes the metrics; the buffer is what GET /api/v1/admin/observability/trace // serves). type gatewaySpanSink struct{ srv *gateway.Server } // WriteSpan feeds rec to the gateway. A missing server is a no-op. func (g gatewaySpanSink) WriteSpan(rec trace.SpanRecord) error { if g.srv == nil { return nil } g.srv.ObserveSpan(rec) return nil }