-
Notifications
You must be signed in to change notification settings - Fork 92
feat(#6458): export eval measurement scores via OTLP #6459
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7260ca8
17e3154
911b9bf
807faa1
766e64d
8278aee
c530e9d
b1405ff
2e029bf
d8a2eda
4adbaa2
9454c36
19687fb
581510d
a7fbd16
208fd8f
9a74198
b0b5ff6
e947baa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| // Command prove-otlp-scores scores a real run-telemetry.jsonl and asserts | ||
| // portable OTLP gen_ai.evaluation.result events arrive at a local sink. | ||
| package main | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "compress/gzip" | ||
| "context" | ||
| "encoding/hex" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "os" | ||
| "path/filepath" | ||
| "sync" | ||
|
|
||
| coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" | ||
| commonpb "go.opentelemetry.io/proto/otlp/common/v1" | ||
| "google.golang.org/protobuf/proto" | ||
|
|
||
| "github.com/fullsend-ai/fullsend/internal/evalmeasure" | ||
| ) | ||
|
|
||
| func main() { | ||
| if len(os.Args) < 3 { | ||
| fmt.Fprintf(os.Stderr, "usage: %s <run-telemetry.jsonl> <registry.yaml> [out-dir]\n", os.Args[0]) | ||
| fmt.Fprintf(os.Stderr, " out-dir defaults to a fresh temp dir (never the telemetry file's directory).\n") | ||
| os.Exit(2) | ||
| } | ||
| telem := os.Args[1] | ||
| reg := os.Args[2] | ||
| out := "" | ||
| if len(os.Args) > 3 { | ||
| out = os.Args[3] | ||
| } else { | ||
| tmp, err := os.MkdirTemp("", "prove-otlp-scores-*") | ||
| if err != nil { | ||
| fmt.Fprintf(os.Stderr, "temp out-dir: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
| out = tmp | ||
| fmt.Fprintf(os.Stderr, "using temp out-dir %s\n", out) | ||
| } | ||
| if err := os.MkdirAll(out, 0o755); err != nil { | ||
| fmt.Fprintf(os.Stderr, "out-dir: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
| if err := ensureCleanScoreOutput(out); err != nil { | ||
| fmt.Fprintf(os.Stderr, "out-dir: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
|
|
||
| var mu sync.Mutex | ||
| var reqs []*coltracepb.ExportTraceServiceRequest | ||
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| raw, err := io.ReadAll(r.Body) | ||
| if err != nil { | ||
| http.Error(w, err.Error(), http.StatusBadRequest) | ||
| return | ||
| } | ||
| if r.Header.Get("Content-Encoding") == "gzip" { | ||
| zr, err := gzip.NewReader(bytes.NewReader(raw)) | ||
| if err != nil { | ||
| http.Error(w, err.Error(), http.StatusBadRequest) | ||
| return | ||
| } | ||
| raw, err = io.ReadAll(zr) | ||
| _ = zr.Close() | ||
| if err != nil { | ||
| http.Error(w, err.Error(), http.StatusBadRequest) | ||
| return | ||
| } | ||
| } | ||
| var req coltracepb.ExportTraceServiceRequest | ||
| if err := proto.Unmarshal(raw, &req); err != nil { | ||
| http.Error(w, err.Error(), http.StatusBadRequest) | ||
| return | ||
| } | ||
| mu.Lock() | ||
| reqs = append(reqs, &req) | ||
| mu.Unlock() | ||
| resp, _ := proto.Marshal(&coltracepb.ExportTraceServiceResponse{}) | ||
| w.Header().Set("Content-Type", "application/x-protobuf") | ||
| _, _ = w.Write(resp) | ||
| })) | ||
| defer srv.Close() | ||
|
|
||
| _ = os.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", srv.URL) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM] The tool deliberately normalizes the OTLP environment before measuring — The suppression gate is now TraceID-scoped, so the false negative needs the ambient Suggestion: Add
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in c530e9d.
|
||
| _ = os.Unsetenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") | ||
| _ = os.Unsetenv("OTEL_SDK_DISABLED") | ||
| // Clear ambient W3C parents so an unsampled TRACEPARENT from a prior | ||
| // fullsend run in this shell cannot suppress every score and make the | ||
| // prove tool report a false FAIL. | ||
| _ = os.Unsetenv("TRACEPARENT") | ||
| _ = os.Unsetenv("TRACESTATE") | ||
|
|
||
| results, stats, err := evalmeasure.MeasureAndExport(context.Background(), telem, reg, out, "dev") | ||
| if err != nil { | ||
| fmt.Fprintf(os.Stderr, "measure failed: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
|
|
||
| mu.Lock() | ||
| reqsCopy := append([]*coltracepb.ExportTraceServiceRequest(nil), reqs...) | ||
| nReqs := len(reqs) | ||
| mu.Unlock() | ||
|
|
||
| events := extractEvents(reqsCopy) | ||
| report := map[string]any{ | ||
| "endpoint": srv.URL, | ||
| "out_dir": out, | ||
| "scores_written": len(results), | ||
| "remote_export_warning": stats.RemoteExportWarning, | ||
| "results": results, | ||
| "otlp_requests": nReqs, | ||
| "events": events, | ||
| } | ||
| enc := json.NewEncoder(os.Stdout) | ||
| enc.SetIndent("", " ") | ||
| _ = enc.Encode(report) | ||
|
|
||
| if len(results) == 0 { | ||
| fmt.Fprintf(os.Stderr, "FAIL: no scores written\n") | ||
| os.Exit(1) | ||
| } | ||
| if nReqs == 0 { | ||
| fmt.Fprintf(os.Stderr, "FAIL: no OTLP requests received\n") | ||
| os.Exit(1) | ||
| } | ||
| if len(events) == 0 { | ||
| fmt.Fprintf(os.Stderr, "FAIL: no gen_ai.evaluation.result events\n") | ||
| os.Exit(1) | ||
| } | ||
| fmt.Fprintf(os.Stderr, "PASS: %d score(s), %d OTLP event(s)\n", len(results), len(events)) | ||
| } | ||
|
|
||
| // ensureCleanScoreOutput rejects an existing score artifact rather than | ||
| // deleting it. The proof tool writes a fresh derived-data pair so it cannot | ||
| // erase or append duplicate measurements in an operator's live run directory. | ||
| func ensureCleanScoreOutput(out string) error { | ||
| for _, name := range []string{evalmeasure.LedgerFile, evalmeasure.MeasurementsFile} { | ||
| path := filepath.Join(out, name) | ||
| if _, err := os.Stat(path); err == nil { | ||
| return fmt.Errorf("%s already exists; choose a fresh out-dir", path) | ||
| } else if !os.IsNotExist(err) { | ||
| return fmt.Errorf("stat %s: %w", path, err) | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| type eventView struct { | ||
| SpanName string `json:"span_name"` | ||
| TraceID string `json:"trace_id"` | ||
| ParentID string `json:"parent_span_id"` | ||
| EventName string `json:"event_name"` | ||
| Attributes map[string]any `json:"attributes"` | ||
| } | ||
|
|
||
| func extractEvents(reqs []*coltracepb.ExportTraceServiceRequest) []eventView { | ||
| var out []eventView | ||
| for _, req := range reqs { | ||
| for _, rs := range req.GetResourceSpans() { | ||
| for _, ss := range rs.GetScopeSpans() { | ||
| for _, sp := range ss.GetSpans() { | ||
| for _, ev := range sp.GetEvents() { | ||
| if ev.GetName() != evalmeasure.EventGenAIEvaluationResult { | ||
| continue | ||
| } | ||
| attrs := map[string]any{} | ||
| for _, kv := range ev.GetAttributes() { | ||
| attrs[kv.GetKey()] = anyValue(kv.GetValue()) | ||
| } | ||
| out = append(out, eventView{ | ||
| SpanName: sp.GetName(), | ||
| TraceID: hex.EncodeToString(sp.GetTraceId()), | ||
| ParentID: hex.EncodeToString(sp.GetParentSpanId()), | ||
| EventName: ev.GetName(), | ||
| Attributes: attrs, | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| func anyValue(v *commonpb.AnyValue) any { | ||
| if v == nil { | ||
| return nil | ||
| } | ||
| switch x := v.GetValue().(type) { | ||
| case *commonpb.AnyValue_StringValue: | ||
| return x.StringValue | ||
| case *commonpb.AnyValue_DoubleValue: | ||
| return x.DoubleValue | ||
| case *commonpb.AnyValue_IntValue: | ||
| return x.IntValue | ||
| case *commonpb.AnyValue_BoolValue: | ||
| return x.BoolValue | ||
| default: | ||
| return v.String() | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.