package shared import ( "context" "net/http" "os" "time" "git.agentview.dev/profit/golangLAKEHOUSE/internal/langfuse" ) // TraceIDHeader propagates a Langfuse trace id across services. When // validatord makes a /v1/iterate call that internally calls chatd's // /v1/chat, validatord sends this header so both daemons' middleware // emit spans under the SAME trace tree (rather than two unrelated // traces). Closes the multi-call observability gap J flagged // 2026-05-02 ("we need to make sure they have the corpus of // information to complete the process and we want to spot errors"). const TraceIDHeader = "X-Lakehouse-Trace-Id" // traceIDCtxKey is the context value key for the per-request trace id. // Handlers downstream of langfuseMiddleware can pull it via TraceIDFromCtx // to attach child spans (e.g. iteration-attempt spans inside validatord). type traceIDCtxKey struct{} // TraceIDFromCtx returns the per-request Langfuse trace id, or "" if // the middleware didn't set one (Langfuse not configured / /health // bypass / no Client wired). func TraceIDFromCtx(ctx context.Context) string { if v, ok := ctx.Value(traceIDCtxKey{}).(string); ok { return v } return "" } // langfuseMiddleware emits one Langfuse trace per HTTP request, with // a single span carrying start/end timestamps + status code. Per // OPEN item #2 (closed by the wave that adds this file): production // traffic gets free trace visibility without per-handler wiring. // // nil client → returns a passthrough no-op middleware so callers // don't need a nil check in shared.Run. Same fail-open posture as // Langfuse's queue layer (per ADR-005 Decision 5.1: observability // is a witness, never a gate). // // /health bypasses tracing — operators don't want every LB probe // or monitor heartbeat polluting traces. Real traffic surfaces // only via the registered routes. func langfuseMiddleware(serviceName string, lf *langfuse.Client) func(http.Handler) http.Handler { if lf == nil { return func(next http.Handler) http.Handler { return next } } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // /health bypasses tracing — same exemption logic as // the auth middleware (see RequireAuth). if r.URL.Path == "/health" { next.ServeHTTP(w, r) return } start := time.Now() sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} // If the caller forwarded a trace id (cross-service parent // linkage) reuse it instead of starting a new trace. Spans // from this service then attach to the parent trace tree // so an /v1/iterate session shows as one trace with // children for each /v1/chat hop. traceID := r.Header.Get(TraceIDHeader) if traceID == "" { traceID = lf.Trace(r.Context(), langfuse.TraceInput{ Name: serviceName + " " + r.Method + " " + r.URL.Path, Tags: []string{serviceName, r.Method}, Metadata: map[string]any{ "path": r.URL.Path, "method": r.Method, "remote_addr": r.RemoteAddr, }, }) } // Stash the trace id on the request context so downstream // handlers can attach finer-grained spans (e.g. one per // iteration attempt inside validator.Iterate). r = r.WithContext(context.WithValue(r.Context(), traceIDCtxKey{}, traceID)) next.ServeHTTP(sw, r) level := "" if sw.status >= 500 { level = "ERROR" } else if sw.status >= 400 { level = "WARNING" } lf.Span(r.Context(), langfuse.SpanInput{ TraceID: traceID, Name: "http.request", Input: map[string]any{ "method": r.Method, "path": r.URL.Path, "remote_addr": r.RemoteAddr, }, Output: map[string]any{ "status": sw.status, "duration_ms": time.Since(start).Milliseconds(), }, StartTime: start, EndTime: time.Now(), StatusCode: sw.status, Level: level, }) }) } } // statusWriter is the standard "wrap http.ResponseWriter to capture // the status code" trick. WriteHeader is the only method that // changes status; any handler that doesn't call WriteHeader gets // the implicit 200 from our struct's default. type statusWriter struct { http.ResponseWriter status int } func (sw *statusWriter) WriteHeader(code int) { sw.status = code sw.ResponseWriter.WriteHeader(code) } // LoadLangfuseFromEnv builds a langfuse.Client from environment // variables. Returns nil if any of LANGFUSE_URL / LANGFUSE_PUBLIC_KEY // / LANGFUSE_SECRET_KEY is unset (best-effort: missing config means // no tracing, never a startup error). Same env names as the bare // /etc/lakehouse/langfuse.env file used by the multi_coord_stress // driver — operators ship one env file across every daemon. // // Exported 2026-05-02 so daemons that need to emit application-level // child spans (validatord's iterate-attempt spans) can hold their own // reference to the same client `shared.Run` is already wiring into // the middleware. func LoadLangfuseFromEnv() *langfuse.Client { url := os.Getenv("LANGFUSE_URL") pk := os.Getenv("LANGFUSE_PUBLIC_KEY") sk := os.Getenv("LANGFUSE_SECRET_KEY") if url == "" || pk == "" || sk == "" { return nil } return langfuse.New(url, pk, sk, nil) }