mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-24 14:21:51 +00:00
- Add created_at display to trace summary and span detail - Add PreviewBlock component with copy button (top-right) and JSON syntax highlighting via highlight.js - Show span start/end time inline with timezone support - Use Badge for span duration and created_at on summary row - StatusBadge: icon-only on mobile, text on desktop - Double verbose trace preview limit from 100K to 200K chars - Double preview content height on desktop (20vh → 40vh) - formatDate() now accepts timezone param from useUiStore - Show seconds in all timestamp displays - Add i18n keys for all 3 locales (en, vi, zh)
311 lines
8.9 KiB
Go
311 lines
8.9 KiB
Go
package tracing
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
const (
|
|
defaultFlushInterval = 5 * time.Second
|
|
defaultBufferSize = 1000
|
|
previewMaxLen = 500
|
|
)
|
|
|
|
// SpanExporter is implemented by backends that receive span data alongside
|
|
// the PostgreSQL store (e.g. OpenTelemetry OTLP). Keeping this as an
|
|
// interface lets the OTel dependency live in a separate sub-package that can
|
|
// be swapped out by commenting one import line.
|
|
type SpanExporter interface {
|
|
ExportSpans(ctx context.Context, spans []store.SpanData)
|
|
Shutdown(ctx context.Context) error
|
|
}
|
|
|
|
// spanUpdate represents a deferred span field update, buffered alongside new
|
|
// spans and applied during the same flush cycle (after batch INSERT).
|
|
type spanUpdate struct {
|
|
SpanID uuid.UUID
|
|
TraceID uuid.UUID
|
|
Updates map[string]any
|
|
}
|
|
|
|
// Collector buffers spans in memory and periodically flushes them to the
|
|
// TracingStore in batches. Traces are created synchronously (one per run),
|
|
// while spans are buffered for async batch insert.
|
|
//
|
|
// When a SpanExporter is attached, spans are also exported to an
|
|
// external backend (Jaeger, Grafana Tempo, Datadog, etc.).
|
|
type Collector struct {
|
|
store store.TracingStore
|
|
|
|
spanCh chan store.SpanData
|
|
spanUpdateCh chan spanUpdate // deferred span updates (two-phase tracing)
|
|
stopCh chan struct{}
|
|
wg sync.WaitGroup
|
|
|
|
// traces that need aggregate updates on flush
|
|
dirtyTraces map[uuid.UUID]struct{}
|
|
dirtyTracesMu sync.Mutex
|
|
|
|
verbose bool // when true, LLM spans include full input messages
|
|
exporter SpanExporter // optional external exporter (nil = disabled)
|
|
|
|
// OnFlush is called after each flush cycle with the trace IDs that had
|
|
// their aggregates updated. Used to broadcast realtime trace events.
|
|
OnFlush func(traceIDs []uuid.UUID)
|
|
}
|
|
|
|
// NewCollector creates a new tracing collector backed by the given store.
|
|
// Set GOCLAW_TRACE_VERBOSE=1 to include full LLM input in spans.
|
|
func NewCollector(ts store.TracingStore) *Collector {
|
|
verbose := os.Getenv("GOCLAW_TRACE_VERBOSE") != ""
|
|
if verbose {
|
|
slog.Info("tracing: verbose mode enabled (GOCLAW_TRACE_VERBOSE)")
|
|
}
|
|
return &Collector{
|
|
store: ts,
|
|
spanCh: make(chan store.SpanData, defaultBufferSize),
|
|
spanUpdateCh: make(chan spanUpdate, defaultBufferSize),
|
|
stopCh: make(chan struct{}),
|
|
dirtyTraces: make(map[uuid.UUID]struct{}),
|
|
verbose: verbose,
|
|
}
|
|
}
|
|
|
|
// Verbose returns true if verbose tracing is enabled (full LLM input logging).
|
|
func (c *Collector) Verbose() bool { return c.verbose }
|
|
|
|
// PreviewMaxLen returns the max preview length: 200K when verbose, 500 otherwise.
|
|
func (c *Collector) PreviewMaxLen() int {
|
|
if c.verbose {
|
|
return 200_000
|
|
}
|
|
return previewMaxLen
|
|
}
|
|
|
|
// SetExporter attaches an external span exporter (e.g. OpenTelemetry OTLP).
|
|
// When set, spans are exported to the external backend during each flush cycle.
|
|
func (c *Collector) SetExporter(exp SpanExporter) {
|
|
c.exporter = exp
|
|
}
|
|
|
|
// Start begins the background flush loop.
|
|
func (c *Collector) Start() {
|
|
c.wg.Add(1)
|
|
go c.flushLoop()
|
|
slog.Info("tracing collector started")
|
|
}
|
|
|
|
// Stop gracefully shuts down the collector, flushing remaining spans.
|
|
func (c *Collector) Stop() {
|
|
close(c.stopCh)
|
|
c.wg.Wait()
|
|
|
|
// Shutdown external exporter (flushes remaining spans)
|
|
if c.exporter != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := c.exporter.Shutdown(ctx); err != nil {
|
|
slog.Warn("tracing: span exporter shutdown failed", "error", err)
|
|
}
|
|
}
|
|
|
|
slog.Info("tracing collector stopped")
|
|
}
|
|
|
|
// CreateTrace synchronously creates a trace record.
|
|
func (c *Collector) CreateTrace(ctx context.Context, trace *store.TraceData) error {
|
|
return c.store.CreateTrace(ctx, trace)
|
|
}
|
|
|
|
// UpdateTrace synchronously updates a trace record.
|
|
func (c *Collector) UpdateTrace(ctx context.Context, traceID uuid.UUID, updates map[string]any) error {
|
|
return c.store.UpdateTrace(ctx, traceID, updates)
|
|
}
|
|
|
|
// EmitSpan enqueues a span for async batch insertion.
|
|
// Non-blocking: drops the span if the buffer is full.
|
|
func (c *Collector) EmitSpan(span store.SpanData) {
|
|
if span.ID == uuid.Nil {
|
|
span.ID = store.GenNewID()
|
|
}
|
|
if span.CreatedAt.IsZero() {
|
|
span.CreatedAt = time.Now().UTC()
|
|
}
|
|
|
|
select {
|
|
case c.spanCh <- span:
|
|
c.markDirty(span.TraceID)
|
|
default:
|
|
slog.Warn("tracing: span buffer full, dropping span",
|
|
"span_type", span.SpanType, "name", span.Name)
|
|
}
|
|
}
|
|
|
|
// EmitSpanUpdate enqueues a deferred update for an existing span.
|
|
// Used by two-phase tracing: a "running" span is emitted via EmitSpan before
|
|
// execution starts, then updated via EmitSpanUpdate when execution completes.
|
|
// Non-blocking channel send — safe to call even after ctx cancellation.
|
|
func (c *Collector) EmitSpanUpdate(spanID, traceID uuid.UUID, updates map[string]any) {
|
|
select {
|
|
case c.spanUpdateCh <- spanUpdate{SpanID: spanID, TraceID: traceID, Updates: updates}:
|
|
c.markDirty(traceID)
|
|
default:
|
|
slog.Warn("tracing: span update buffer full, dropping update",
|
|
"span_id", spanID)
|
|
}
|
|
}
|
|
|
|
// SetTraceStatus updates only the trace status and marks it dirty for re-aggregation.
|
|
// Used by child trace runs (e.g. announce) to toggle the parent trace back to
|
|
// "running" while the child is active, then "completed" when done.
|
|
func (c *Collector) SetTraceStatus(ctx context.Context, traceID uuid.UUID, status string) {
|
|
if err := c.store.UpdateTrace(ctx, traceID, map[string]any{"status": status}); err != nil {
|
|
slog.Warn("tracing: failed to set trace status", "trace_id", traceID, "error", err)
|
|
}
|
|
c.markDirty(traceID)
|
|
}
|
|
|
|
// FinishTrace marks a trace as completed and schedules aggregate update.
|
|
func (c *Collector) FinishTrace(ctx context.Context, traceID uuid.UUID, status string, errMsg string, outputPreview string) {
|
|
now := time.Now().UTC()
|
|
updates := map[string]any{
|
|
"status": status,
|
|
"end_time": now,
|
|
}
|
|
if errMsg != "" {
|
|
updates["error"] = errMsg
|
|
}
|
|
if outputPreview != "" {
|
|
updates["output_preview"] = c.truncatePreviewStr(outputPreview)
|
|
}
|
|
if err := c.store.UpdateTrace(ctx, traceID, updates); err != nil {
|
|
slog.Warn("tracing: failed to finish trace", "trace_id", traceID, "error", err)
|
|
}
|
|
c.markDirty(traceID)
|
|
}
|
|
|
|
func (c *Collector) markDirty(traceID uuid.UUID) {
|
|
c.dirtyTracesMu.Lock()
|
|
c.dirtyTraces[traceID] = struct{}{}
|
|
c.dirtyTracesMu.Unlock()
|
|
}
|
|
|
|
func (c *Collector) flushLoop() {
|
|
defer c.wg.Done()
|
|
|
|
ticker := time.NewTicker(defaultFlushInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
c.flush()
|
|
case <-c.stopCh:
|
|
// Drain remaining spans
|
|
c.flush()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Collector) flush() {
|
|
// Drain span channel
|
|
var spans []store.SpanData
|
|
for {
|
|
select {
|
|
case span := <-c.spanCh:
|
|
spans = append(spans, span)
|
|
default:
|
|
goto done
|
|
}
|
|
}
|
|
done:
|
|
|
|
if len(spans) > 0 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := c.store.BatchCreateSpans(ctx, spans); err != nil {
|
|
slog.Warn("tracing: batch span insert failed", "count", len(spans), "error", err)
|
|
} else {
|
|
slog.Debug("tracing: flushed spans", "count", len(spans))
|
|
}
|
|
|
|
// Export to external backend (non-blocking — errors logged, not propagated)
|
|
if c.exporter != nil {
|
|
c.exporter.ExportSpans(ctx, spans)
|
|
}
|
|
}
|
|
|
|
// Drain and apply deferred span updates (two-phase tracing).
|
|
// Must run AFTER batch insert so that "running" spans exist before we UPDATE them.
|
|
var updates []spanUpdate
|
|
for {
|
|
select {
|
|
case u := <-c.spanUpdateCh:
|
|
updates = append(updates, u)
|
|
default:
|
|
goto doneUpdates
|
|
}
|
|
}
|
|
doneUpdates:
|
|
if len(updates) > 0 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
for _, u := range updates {
|
|
if err := c.store.UpdateSpan(ctx, u.SpanID, u.Updates); err != nil {
|
|
slog.Warn("tracing: span update failed", "span_id", u.SpanID, "error", err)
|
|
}
|
|
}
|
|
slog.Debug("tracing: applied span updates", "count", len(updates))
|
|
}
|
|
|
|
// Update aggregates for dirty traces
|
|
c.dirtyTracesMu.Lock()
|
|
dirty := c.dirtyTraces
|
|
c.dirtyTraces = make(map[uuid.UUID]struct{})
|
|
c.dirtyTracesMu.Unlock()
|
|
|
|
if len(dirty) > 0 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
for traceID := range dirty {
|
|
if err := c.store.BatchUpdateTraceAggregates(ctx, traceID); err != nil {
|
|
slog.Warn("tracing: aggregate update failed", "trace_id", traceID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Notify listeners about updated traces (realtime WS push).
|
|
if c.OnFlush != nil {
|
|
ids := make([]uuid.UUID, 0, len(dirty))
|
|
for id := range dirty {
|
|
ids = append(ids, id)
|
|
}
|
|
c.OnFlush(ids)
|
|
}
|
|
}
|
|
}
|
|
|
|
// truncatePreviewStr sanitizes and truncates a string based on verbose mode.
|
|
func (c *Collector) truncatePreviewStr(s string) string {
|
|
limit := c.PreviewMaxLen()
|
|
s = strings.ToValidUTF8(s, "")
|
|
if len(s) <= limit {
|
|
return s
|
|
}
|
|
for limit > 0 && !utf8.RuneStart(s[limit]) {
|
|
limit--
|
|
}
|
|
return s[:limit] + "..."
|
|
}
|