langfuse: Go-side client + Phase 1c instrumentation

The Rust side has Langfuse tracing already (gateway/v1/langfuse_trace.rs);
this commit lands Go-side parity so the multi-coord stress harness can
emit traces visible at http://localhost:3001.

internal/langfuse/client.go:
- Minimal Trace + Span + Flush API mirroring what the Rust emitter
  uses. Auth: Basic over public_key:secret_key.
- Best-effort posture: errors are slog.Warn'd, never block calling
  paths. Same fail-open as observerd's persistor (ADR-005 Decision
  5.1) — observability is a witness, not a gate.
- Events buffered until 50, then auto-flushed; explicit Flush() at
  process exit.
- Each Trace/Span returns its id so callers can build hierarchies.

multi_coord_stress driver wiring:
- New --langfuse-env flag (default /etc/lakehouse/langfuse.env).
  Empty / missing / unparseable file → skip tracing with a logged
  warning; run still proceeds.
- Phase 1c (inbox burst) now emits one parent trace + 4 spans per
  inbox event:
    1. observerd.inbox.record  (post to /v1/observer/inbox)
    2. llm.parse_demand        (qwen2.5 → structured fields)
    3. matrix.search           (parsed query → top-K)
    4. llm.judge_top1          (rate top-1 vs original body)
  Each span carries input/output JSON + start/end times so the
  Langfuse UI shows a full waterfall per event.

Run #009 result:
  Trace landed: "multi_coord_stress phase 1c inbox burst"
  Observations attached: 24 (= 6 events × 4 spans)
  Tags: stress, phase-1c, inbox
  Browseable at http://localhost:3001 by tag query.

Other harness metrics: diversity 0.016, determinism 1.000,
verbatim handover 4/4, paraphrase handover 4/4 — all unchanged
by the tracing addition (best-effort post in parallel).

Phase 1c is the proof-of-concept; future commits can wrap other
phases (baseline / merge / handover / split) in traces too. Once
that's done, the entire stress run becomes scrubbable in Langfuse
without grepping the events JSON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-30 16:25:03 -05:00
co-authored by Claude Opus 4.7
parent ce940f4a14
commit 7e6431e4fd
3 changed files with 469 additions and 11 deletions
+170 -11
View File
@@ -23,6 +23,7 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -36,6 +37,8 @@ import (
"sort"
"strings"
"time"
"git.agentview.dev/profit/golangLAKEHOUSE/internal/langfuse"
)
// ── data shapes ──────────────────────────────────────────────────
@@ -184,6 +187,7 @@ func main() {
ollama = flag.String("ollama", "http://127.0.0.1:11434", "Ollama base URL (only used if --with-paraphrase-handover)")
judgeModel = flag.String("judge", "qwen2.5:latest", "Ollama model for paraphrase generation (only used if --with-paraphrase-handover)")
withParaphraseHandover = flag.Bool("with-paraphrase-handover", false, "after the verbatim handover phase, run a paraphrase handover phase: Bob runs paraphrased versions of Alice's queries against Alice's playbook")
langfuseEnv = flag.String("langfuse-env", "/etc/lakehouse/langfuse.env", "path to Langfuse credentials env file (empty = skip tracing)")
)
flag.Parse()
@@ -216,6 +220,24 @@ func main() {
ctx := context.Background()
_ = ctx
// Optional Langfuse client. Best-effort: missing env file or
// unreachable Langfuse just means traces don't go anywhere; the
// run still proceeds.
var lf *langfuse.Client
if *langfuseEnv != "" {
if creds, err := loadLangfuseEnv(*langfuseEnv); err == nil {
lf = langfuse.New(creds.URL, creds.PublicKey, creds.SecretKey, nil)
log.Printf("[stress] Langfuse client live → %s", creds.URL)
defer func() {
if err := lf.Flush(context.Background()); err != nil {
log.Printf("[stress] Langfuse final flush: %v", err)
}
}()
} else {
log.Printf("[stress] Langfuse skipped: %v", err)
}
}
output := Output{
Coordinators: []string{"alice", "bob", "carol"},
Contracts: []string{contracts[0].Name, contracts[1].Name, contracts[2].Name},
@@ -325,6 +347,19 @@ func main() {
// fire in their preferred order); this phase verifies the
// recording surface and the search-from-inbox flow work.
log.Printf("[stress] phase 1c: inbox burst (6 events, priority-ordered)")
var inboxTraceID string
if lf != nil {
inboxTraceID = lf.Trace(ctx, langfuse.TraceInput{
Name: "multi_coord_stress phase 1c inbox burst",
Tags: []string{"stress", "inbox", "phase-1c"},
Metadata: map[string]any{
"hour": 9,
"corpora": corpora,
"k": *k,
"event_count": 6,
},
})
}
type inboxEvent struct {
Priority string // "urgent" | "high" | "medium" | "low"
Type string // "email" | "sms"
@@ -376,40 +411,110 @@ func main() {
})
for _, ie := range inboxEvents {
// 1. Record inbox event at observerd
stepStart := time.Now()
if err := postInbox(hc, *gateway, ie.Type, ie.Sender, ie.Subject, ie.Body, ie.Priority, ie.Coord); err != nil {
log.Printf(" inbox record failed (%s): %v", ie.Priority, err)
continue
}
// 2. LLM parses the body into a structured demand. Real
// production: a small local model extracts {role, count,
// location, certs, skills, shift} from email/SMS bodies
// instead of a coordinator typing it into a form. Test
// captures both raw body and parsed structure for review.
if lf != nil && inboxTraceID != "" {
lf.Span(ctx, langfuse.SpanInput{
TraceID: inboxTraceID,
Name: "observerd.inbox.record",
Input: map[string]any{"type": ie.Type, "sender": ie.Sender, "priority": ie.Priority, "subject": ie.Subject, "body_chars": len(ie.Body)},
Output: map[string]any{"accepted": true},
StartTime: stepStart,
EndTime: time.Now(),
Metadata: map[string]any{"coordinator": ie.Coord},
})
}
// 2. LLM parses the body into a structured demand.
parseStart := time.Now()
parsed, perr := parseInboxDemand(hc, *ollama, *judgeModel, ie.Body)
parseEnd := time.Now()
if perr != nil {
if lf != nil && inboxTraceID != "" {
lf.Span(ctx, langfuse.SpanInput{
TraceID: inboxTraceID,
Name: "llm.parse_demand",
Input: map[string]any{"body": ie.Body, "model": *judgeModel},
Output: map[string]any{"error": perr.Error()},
StartTime: parseStart,
EndTime: parseEnd,
Level: "ERROR",
})
}
log.Printf(" inbox demand parse failed (%s): %v", ie.Priority, perr)
continue
}
if lf != nil && inboxTraceID != "" {
lf.Span(ctx, langfuse.SpanInput{
TraceID: inboxTraceID,
Name: "llm.parse_demand",
Input: map[string]any{"body": ie.Body, "model": *judgeModel},
Output: parsed,
StartTime: parseStart,
EndTime: parseEnd,
})
}
// 3. Build a query string from the parsed demand and search.
query := parsed.AsQuery()
coord := coordByName(coords, ie.Coord)
searchStart := time.Now()
resp, err := matrixSearch(hc, *gateway, query, corpora, *k, true, coord.PlaybookCorpus)
searchEnd := time.Now()
if err != nil {
log.Printf(" inbox-triggered search failed (%s): %v", ie.Priority, err)
continue
}
if lf != nil && inboxTraceID != "" {
topIDs := make([]string, 0, len(resp.Results))
for _, r := range resp.Results {
topIDs = append(topIDs, r.ID)
}
lf.Span(ctx, langfuse.SpanInput{
TraceID: inboxTraceID,
Name: "matrix.search",
Input: map[string]any{
"query": query,
"corpora": corpora,
"k": *k,
"playbook_corpus": coord.PlaybookCorpus,
},
Output: map[string]any{
"top_k_ids": topIDs,
"top1_distance": firstDistance(resp.Results),
"playbook_boosted": resp.PlaybookBoosted,
"playbook_injected": resp.PlaybookInjected,
},
StartTime: searchStart,
EndTime: searchEnd,
})
}
ev := captureEvent("inbox-triggered-search", 9, ie.Coord, "inbox-burst", ie.Subject, query, 1, true, coord.PlaybookCorpus, resp)
parsedJSON, _ := json.Marshal(parsed)
ev.Note = fmt.Sprintf("inbox %s/%s from %s · LLM-parsed demand: %s", ie.Type, ie.Priority, ie.Sender, string(parsedJSON))
// 4. Judge re-rates top-1 against the ORIGINAL body — not the
// parsed query. Catches the case where parsing dropped a
// constraint (or where the corpus has no real match for the
// asked specialist, e.g. "drone surveyor" against a corpus
// of warehouse workers — the closest semantic neighbor will
// have a tight distance but not actually fit).
// 4. Judge re-rates top-1 against the ORIGINAL body.
if len(resp.Results) > 0 {
judgeStart := time.Now()
rating := judgeInboxResult(hc, *ollama, *judgeModel, ie.Body, resp.Results[0])
ev.JudgeRating = rating
if lf != nil && inboxTraceID != "" {
lf.Span(ctx, langfuse.SpanInput{
TraceID: inboxTraceID,
Name: "llm.judge_top1",
Input: map[string]any{
"original_body": ie.Body,
"top1_id": resp.Results[0].ID,
"top1_corpus": resp.Results[0].Corpus,
},
Output: map[string]any{"rating": rating},
StartTime: judgeStart,
EndTime: time.Now(),
})
}
}
output.Events = append(output.Events, ev)
}
@@ -948,6 +1053,60 @@ func ingestFreshWorker(hc *http.Client, gw, id, text string, metadata map[string
return nil
}
type langfuseEnvCreds struct {
URL string
PublicKey string
SecretKey string
}
// loadLangfuseEnv parses a key=value env file (one assignment per
// non-comment line) and pulls LANGFUSE_URL, LANGFUSE_PUBLIC_KEY,
// LANGFUSE_SECRET_KEY. All three required; missing any returns an
// error so callers can skip-with-warning rather than silently
// run without tracing.
func loadLangfuseEnv(path string) (*langfuseEnvCreds, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
creds := &langfuseEnvCreds{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
k, v := strings.TrimSpace(line[:eq]), strings.TrimSpace(line[eq+1:])
switch k {
case "LANGFUSE_URL":
creds.URL = v
case "LANGFUSE_PUBLIC_KEY":
creds.PublicKey = v
case "LANGFUSE_SECRET_KEY":
creds.SecretKey = v
}
}
if err := sc.Err(); err != nil {
return nil, err
}
if creds.URL == "" || creds.PublicKey == "" || creds.SecretKey == "" {
return nil, fmt.Errorf("langfuse env missing one of URL/PUBLIC_KEY/SECRET_KEY")
}
return creds, nil
}
func firstDistance(results []matrixResult) float32 {
if len(results) == 0 {
return 0
}
return results[0].Distance
}
// parsedDemand is the LLM-extracted structure from an inbox message
// body — what a real coordinator would type into a search form.
// Empty fields are honest: the body didn't say.