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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user