Files
db69ccf882 tracing: nest tool calls under LLM spans, emit hook spans, fix usage_events FK (#1415)
- Reparent tool_call spans under their producing llm_call span via
  RunState.CurrentLLMSpanID so a model turn's trace visibly contains the
  tool calls it triggered.
- Wire internal/hooks EmitHookSpan into the dispatcher writeExec so every
  hook execution (pre/post tool use, etc.) produces a trace span with input,
  console output, decision, error, and duration for agent troubleshooting.
- Fix usage_events_span_id_fkey violations: usage events were inserted
  synchronously referencing a span flushed ~5s later, silently dropping
  token/cost data. Route them through the collector so they flush after
  spans in the same cycle. No schema migration; FK preserved.

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-10 03:54:42 +07:00

611 lines
20 KiB
Go

package tracing
import (
"context"
"log/slog"
"os"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const (
defaultFlushInterval = 5 * time.Second
defaultBufferSize = 1000
previewMaxLen = 40_000
traceRetention = 7 * 24 * time.Hour // auto-delete traces older than 7 days
pruneInterval = 8 * time.Hour
// staleThreshold: how long a "running" trace must be stuck before the recovery
// worker marks it as "error". 10min is conservative — the primary sub-second
// stop visibility is delivered by the trace.status WS event (Phase 4). Stale
// recovery is a safety net for crashed/orphaned traces. Lowering this further
// requires a `last_span_at` column so we don't sweep legitimate long-running
// agents (see plan's Phase 3 unresolved question).
staleThreshold = 10 * time.Minute
staleRecoveryPeriod = 30 * time.Second // new: run periodically instead of once on startup
retryQueueCap = 1000
retryWorkerPeriod = 5 * time.Second
retryMaxTries = 10
)
// TraceStatusPayload is the payload for EventTraceStatusChanged WS events.
// Sent immediately after a status write succeeds (not buffered by flush interval).
type TraceStatusPayload struct {
TraceID string `json:"traceId"`
Status string `json:"status"`
EndedAt *time.Time `json:"endedAt,omitempty"`
}
// StatusBroadcaster is a callback invoked after each successful trace status write.
// Implementations broadcast the payload to connected WS clients in the given tenant.
type StatusBroadcaster func(payload TraceStatusPayload, tenantID uuid.UUID)
// 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
}
// pendingUpdate holds a trace update that failed to persist and is queued for retry.
type pendingUpdate struct {
TraceID uuid.UUID
TenantID uuid.UUID // captured from caller ctx at enqueue time for retryWorker broadcast
Updates map[string]any
Tries int
LastTry time.Time
}
// 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
// usageStore flushes usage events AFTER spans in the same flush cycle,
// so a usage event's referenced span row exists before the FK is checked.
usageStore store.UsageEventStore
spanCh chan store.SpanData
spanUpdateCh chan spanUpdate // deferred span updates (two-phase tracing)
usageEventCh chan store.UsageEvent
stopCh chan struct{}
wg sync.WaitGroup
// retryCh buffers trace updates that failed all inline retries.
// Bounded at retryQueueCap — drop-oldest under sustained DB outage.
retryCh chan pendingUpdate
// 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)
// broadcastStatus is an optional callback invoked immediately after each
// successful trace status write (SetTraceStatus / FinishTrace). Fires
// before the 5s OnFlush tick for low-latency status delivery.
broadcastStatus StatusBroadcaster
}
// NewCollector creates a new tracing collector backed by the given store.
// Set GOCLAW_TRACE_VERBOSE=1 to include full LLM input in spans.
//
// The optional usageStore flushes usage events AFTER spans within the same
// flush cycle, preserving the usage_events→spans FK without a schema change.
// When omitted (nil), EmitUsageEvent is a no-op and callers fall back to a
// direct best-effort insert. This keeps callers that only need tracing
// (e.g. unit/integration tests) compiling unchanged.
func NewCollector(ts store.TracingStore, usageStore ...store.UsageEventStore) *Collector {
verbose := os.Getenv("GOCLAW_TRACE_VERBOSE") != ""
if verbose {
slog.Info("tracing: verbose mode enabled (GOCLAW_TRACE_VERBOSE)")
}
var us store.UsageEventStore
if len(usageStore) > 0 {
us = usageStore[0]
}
return &Collector{
store: ts,
usageStore: us,
spanCh: make(chan store.SpanData, defaultBufferSize),
spanUpdateCh: make(chan spanUpdate, defaultBufferSize),
usageEventCh: make(chan store.UsageEvent, defaultBufferSize),
stopCh: make(chan struct{}),
retryCh: make(chan pendingUpdate, retryQueueCap),
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
}
// SetStatusBroadcaster wires a callback that is invoked immediately after each
// successful trace status write. Called from SetTraceStatus and FinishTrace
// on success, bypassing the 5s OnFlush buffer for low-latency status events.
func (c *Collector) SetStatusBroadcaster(b StatusBroadcaster) {
c.broadcastStatus = b
}
// Start begins the background flush loop and retry worker.
//
// NOTE: staleRecoveryLoop is intentionally NOT started. The current implementation
// sweeps traces by `start_time`, which would kill legitimate long-running agent
// runs (research chains, large code generation, long shell commands routinely
// exceed 10 minutes). Re-enable only after adding a `last_span_at` column so
// recovery can gate on "no activity for N minutes" instead of "started > N min
// ago". Until then, crashed/orphaned traces may remain `running` in DB — the
// primary abort path (router 2-phase + trace.status WS event) handles the
// common case; this is a safety-net gap we accept over false kills.
func (c *Collector) Start() {
c.wg.Add(2) // flushLoop + retryWorker (staleRecoveryLoop disabled — see note above)
go c.flushLoop()
go c.retryWorker()
// go c.staleRecoveryLoop() // disabled: would kill healthy long runs. See Start() godoc.
slog.Info("tracing collector started")
}
// keep staleRecoveryLoop reachable to silence "unused" linter; re-enabled in
// Start() once last_span_at-based recovery lands.
var _ = (*Collector).staleRecoveryLoop
// 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)
}
}
// EmitUsageEvent buffers a usage event to be flushed AFTER spans in the same
// flush cycle, so its referenced span row exists before the FK is checked.
// Non-blocking: drops the event if the buffer is full. No-op when no usage
// store is configured (e.g. tracing-only contexts).
func (c *Collector) EmitUsageEvent(event store.UsageEvent) {
if c == nil || c.usageStore == nil {
return
}
select {
case c.usageEventCh <- event:
default:
slog.Debug("tracing.usage_event_dropped", "resource", event.ResourceName)
}
}
// SetTraceStatus updates only the trace status and marks it dirty for re-aggregation.
// Uses a detached context with retries to survive caller context cancellation.
func (c *Collector) SetTraceStatus(ctx context.Context, traceID uuid.UUID, status string) {
updates := map[string]any{"status": status}
if c.updateTraceWithRetry(ctx, traceID, updates) {
c.markDirty(traceID)
}
}
// FinishTrace marks a trace as completed and schedules aggregate update.
// Uses a detached context with retries to survive caller context cancellation (e.g. abort path).
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 c.updateTraceWithRetry(ctx, traceID, updates) {
c.markDirty(traceID)
}
}
// updateTraceWithRetry persists trace updates with a detached ctx + 3 inline retries.
// Uses context.WithoutCancel to preserve tenant/locale values while detaching cancellation.
// On final failure, enqueues to the retry queue. Returns true if persisted inline.
func (c *Collector) updateTraceWithRetry(ctx context.Context, traceID uuid.UUID, updates map[string]any) bool {
detached := context.WithoutCancel(ctx)
backoffs := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
for attempt := 0; attempt <= len(backoffs); attempt++ {
opCtx, cancel := context.WithTimeout(detached, 5*time.Second)
err := c.store.UpdateTrace(opCtx, traceID, updates)
cancel()
if err == nil {
c.emitStatusBroadcast(ctx, traceID, updates)
return true
}
slog.Warn("tracing: UpdateTrace attempt failed",
"trace_id", traceID, "attempt", attempt+1, "error", err)
if attempt < len(backoffs) {
time.Sleep(backoffs[attempt])
}
}
// All inline retries failed — enqueue for background worker
c.enqueueRetry(ctx, traceID, updates)
return false
}
// enqueueRetry pushes a failed update to the bounded retry channel.
// ctx is used to capture the tenant ID so the retryWorker can broadcast with correct tenant.
// Drop-oldest behavior when the queue is full to prevent unbounded growth.
func (c *Collector) enqueueRetry(ctx context.Context, traceID uuid.UUID, updates map[string]any) {
item := pendingUpdate{
TraceID: traceID,
TenantID: store.TenantIDFromContext(ctx),
Updates: updates,
Tries: 0,
}
select {
case c.retryCh <- item:
default:
// queue full — drop oldest, push new
select {
case dropped := <-c.retryCh:
slog.Warn("tracing: retry queue full, dropped oldest",
"dropped_trace_id", dropped.TraceID, "new_trace_id", traceID)
default:
}
c.retryCh <- item
}
}
// retryWorker drains the retry channel every 5 seconds and re-attempts
// failed trace updates. Items exceeding retryMaxTries are dropped with an error log.
func (c *Collector) retryWorker() {
defer c.wg.Done()
ticker := time.NewTicker(retryWorkerPeriod)
defer ticker.Stop()
var pending []pendingUpdate
for {
select {
case <-c.stopCh:
return
case item := <-c.retryCh:
pending = append(pending, item)
case <-ticker.C:
// drain any newly arrived items
draining := true
for draining {
select {
case item := <-c.retryCh:
pending = append(pending, item)
default:
draining = false
}
}
// attempt all pending updates
kept := pending[:0]
for _, p := range pending {
// Restore tenant scope so UpdateTrace and broadcast use correct tenant.
baseCtx := context.Background()
if p.TenantID != uuid.Nil {
baseCtx = store.WithTenantID(baseCtx, p.TenantID)
}
opCtx, cancel := context.WithTimeout(baseCtx, 5*time.Second)
err := c.store.UpdateTrace(opCtx, p.TraceID, p.Updates)
cancel()
if err == nil {
c.markDirty(p.TraceID)
c.emitStatusBroadcast(baseCtx, p.TraceID, p.Updates)
continue
}
p.Tries++
p.LastTry = time.Now()
if p.Tries < retryMaxTries {
kept = append(kept, p)
} else {
slog.Error("tracing: retry exhausted, dropping update",
"trace_id", p.TraceID, "tries", p.Tries)
}
}
pending = kept
}
}
}
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()
pruneTicker := time.NewTicker(pruneInterval)
defer pruneTicker.Stop()
// Run initial prune shortly after startup.
go c.pruneOldTraces()
for {
select {
case <-ticker.C:
c.flush()
case <-pruneTicker.C:
c.pruneOldTraces()
case <-c.stopCh:
c.flush()
return
}
}
}
// staleRecoveryLoop runs recoverStaleOnce immediately on startup, then every
// staleRecoveryPeriod (30s). Replaces the one-shot recoverStaleTraces call.
func (c *Collector) staleRecoveryLoop() {
defer c.wg.Done()
ticker := time.NewTicker(staleRecoveryPeriod)
defer ticker.Stop()
// run immediately on startup too
c.recoverStaleOnce()
for {
select {
case <-c.stopCh:
return
case <-ticker.C:
c.recoverStaleOnce()
}
}
}
// recoverStaleOnce marks "running" traces whose start_time is older than
// staleThreshold (10 min) as "error". Also recovers stuck spans.
//
// NOTE: Both PG and SQLite implementations use start_time < cutoff, not last
// activity time. Follow-up: gate on "no spans in last N min" instead (requires
// a last_span_at schema column). Tracked as an open question.
func (c *Collector) recoverStaleOnce() {
cutoff := time.Now().UTC().Add(-staleThreshold)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
recovered, err := c.store.RecoverStaleRunningTraces(ctx, cutoff)
if err != nil {
slog.Warn("tracing: stale recovery failed", "error", err)
return
}
if recovered > 0 {
slog.Info("tracing: recovered stale running traces",
"count", recovered, "older_than", cutoff.Format(time.RFC3339))
}
}
// pruneOldTraces deletes traces and spans older than traceRetention.
func (c *Collector) pruneOldTraces() {
cutoff := time.Now().UTC().Add(-traceRetention)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
deleted, err := c.store.DeleteTracesOlderThan(ctx, cutoff)
if err != nil {
slog.Warn("tracing: prune old traces failed", "error", err)
return
}
if deleted > 0 {
slog.Info("tracing: pruned old traces", "deleted", deleted, "older_than", cutoff.Format(time.RFC3339))
}
}
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))
}
// Drain and insert buffered usage events AFTER spans have been written in
// this same flush cycle. Each event carries its own TenantID (the store
// reads tenant from the row, not ctx), so the batch insert stays
// tenant-correct. Ordering here is the root-cause fix: a usage event's
// referenced span row now exists before the usage_events_span_id_fkey is
// checked, so the INSERT no longer fails and silently drops the row.
var usageEvents []store.UsageEvent
for {
select {
case ev := <-c.usageEventCh:
usageEvents = append(usageEvents, ev)
default:
goto doneUsage
}
}
doneUsage:
if len(usageEvents) > 0 {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := c.usageStore.InsertEvents(ctx, usageEvents); err != nil {
slog.Warn("tracing: usage event insert failed", "count", len(usageEvents), "error", err)
} else {
slog.Debug("tracing: flushed usage events", "count", len(usageEvents))
}
}
// 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)
}
}
}
// emitStatusBroadcast fires broadcastStatus if set and the updates map contains a "status" key.
// Extracts optional "end_time" from updates for the EndedAt field.
func (c *Collector) emitStatusBroadcast(ctx context.Context, traceID uuid.UUID, updates map[string]any) {
if c.broadcastStatus == nil {
return
}
status, _ := updates["status"].(string)
if status == "" {
return
}
var endedAt *time.Time
if t, ok := updates["end_time"].(time.Time); ok {
endedAt = &t
}
c.broadcastStatus(TraceStatusPayload{
TraceID: traceID.String(),
Status: status,
EndedAt: endedAt,
}, store.TenantIDFromContext(ctx))
}
// truncatePreviewStr sanitizes and truncates a string by removing the middle.
func (c *Collector) truncatePreviewStr(s string) string {
return TruncateMid(s, c.PreviewMaxLen())
}