From ac2178916e1ecc5c824b6eb16ae3bfa518c6e37c Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Wed, 20 May 2026 11:30:08 +0200 Subject: [PATCH] refactor(ci): replace Go OTEL receiver with Python (stdlib, no deps) python3 is pre-installed on ubuntu-latest so the timing report now also runs in CI, not just locally. Co-Authored-By: Claude Sonnet 4.6 --- Taskfile.yml | 6 +- ci/otelrecv.py | 153 ++++++++++++++++++++++++++++++++ ci/otelrecv/main.go | 210 -------------------------------------------- 3 files changed, 156 insertions(+), 213 deletions(-) create mode 100644 ci/otelrecv.py delete mode 100644 ci/otelrecv/main.go diff --git a/Taskfile.yml b/Taskfile.yml index d540b9e..e70c6d1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -252,15 +252,15 @@ tasks: - dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" check-dagger: - desc: Run full check suite via Dagger (with OTEL timing report if Go is available) + desc: Run full check suite via Dagger (with OTEL timing report if python3 is available) cmds: - | - if ! command -v go >/dev/null 2>&1; then + if ! command -v python3 >/dev/null 2>&1; then dagger call --progress=plain -q -m ci --source=. check exit $? fi PORTFILE=$(mktemp) - (cd ci && go run ./otelrecv/ --port-file="$PORTFILE") & + python3 ci/otelrecv.py --port-file="$PORTFILE" & RECV_PID=$! cleanup() { kill "$RECV_PID" 2>/dev/null diff --git a/ci/otelrecv.py b/ci/otelrecv.py new file mode 100644 index 0000000..e040a60 --- /dev/null +++ b/ci/otelrecv.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +Minimal OTLP HTTP/JSON trace receiver for Dagger CI timing. + +Usage: + python3 ci/otelrecv.py --port-file=/tmp/otel.port [--raw=/tmp/otel.json] + +Listens on a random port (written to --port-file), collects OTLP spans via +HTTP/JSON, and prints a timing report on SIGTERM/SIGINT. + +Caller sets: + OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1: + OTEL_EXPORTER_OTLP_PROTOCOL=http/json +""" + +import argparse +import json +import signal +import sys +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +_spans = [] +_lock = threading.Lock() +_raw_out = None + + +class _Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path != "/v1/traces": + self.send_response(404) + self.end_headers() + return + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + if _raw_out: + _raw_out.write(body) + _raw_out.write(b"\n") + _raw_out.flush() + try: + req = json.loads(body) + except json.JSONDecodeError as exc: + self.send_response(400) + self.end_headers() + self.wfile.write(str(exc).encode()) + return + with _lock: + for rs in req.get("resourceSpans", []): + for ss in rs.get("scopeSpans", []): + _spans.extend(ss.get("spans", [])) + self.send_response(200) + self.end_headers() + + def log_message(self, *_): + pass + + +def _kv_str(value): + if "stringValue" in value: + return str(value["stringValue"]) + if "boolValue" in value: + return str(value["boolValue"]).lower() + if "intValue" in value: + return str(value["intValue"]) + if "doubleValue" in value: + return str(value["doubleValue"]) + return "" + + +def _print_report(): + with _lock: + if not _spans: + print( + "otelrecv: no spans received" + " — check OTEL_EXPORTER_OTLP_PROTOCOL=http/json is supported", + file=sys.stderr, + ) + return + + rows = [] + for s in _spans: + start = int(s.get("startTimeUnixNano", 0)) + end = int(s.get("endTimeUnixNano", 0)) + dur = (end - start) / 1e9 + + cached = False + parts = [] + for attr in s.get("attributes", []): + key = attr["key"] + val_obj = attr.get("value", {}) + val = _kv_str(val_obj) + if "boolValue" in val_obj and "cached" in key.lower(): + cached = bool(val_obj["boolValue"]) + parts.append(f"{key}={val}") + + rows.append( + { + "name": s.get("name", ""), + "dur": dur, + "cached": cached, + "attrs": " ".join(parts), + } + ) + + rows.sort(key=lambda r: r["dur"], reverse=True) + + name_w, attr_w = 38, 60 + print(f'\n{"STATUS":<6} {"DURATION":>8} {"SPAN":<{name_w}} ATTRIBUTES') + print("─" * (6 + 2 + 8 + 2 + name_w + 2 + attr_w)) + for r in rows: + status = "CACHED" if r["cached"] else "LIVE" + attrs = r["attrs"] + if len(attrs) > attr_w: + attrs = attrs[: attr_w - 1] + "…" + name = r["name"] + if len(name) > name_w: + name = name[: name_w - 1] + "…" + print(f"{status:<6} {r['dur']:7.2f}s {name:<{name_w}} {attrs}") + print(f"\n{len(rows)} spans total") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--port-file", default="") + parser.add_argument("--raw", default="") + args = parser.parse_args() + + global _raw_out + if args.raw: + _raw_out = open(args.raw, "wb") + + server = HTTPServer(("127.0.0.1", 0), _Handler) + port = server.server_address[1] + + if args.port_file: + with open(args.port_file, "w") as f: + f.write(str(port)) + + def _shutdown(signum, frame): + threading.Thread(target=server.shutdown, daemon=True).start() + + signal.signal(signal.SIGTERM, _shutdown) + signal.signal(signal.SIGINT, _shutdown) + + server.serve_forever() + _print_report() + + if _raw_out: + _raw_out.close() + + +if __name__ == "__main__": + main() diff --git a/ci/otelrecv/main.go b/ci/otelrecv/main.go deleted file mode 100644 index 5e37c49..0000000 --- a/ci/otelrecv/main.go +++ /dev/null @@ -1,210 +0,0 @@ -// otelrecv is a minimal OTLP HTTP/JSON trace receiver for Dagger CI timing. -// -// Usage: -// -// go run ./ci/otelrecv/ --port-file=/tmp/otel.port [--raw=/tmp/otel.json] -// -// It listens on a random port (written to --port-file for the caller to read), -// collects spans sent by the Dagger CLI via OTLP HTTP/JSON, and prints a -// timing report on SIGTERM/SIGINT. -// -// Caller sets: -// -// OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1: -// OTEL_EXPORTER_OTLP_PROTOCOL=http/json -package main - -import ( - "encoding/json" - "flag" - "fmt" - "io" - "net" - "net/http" - "os" - "os/signal" - "sort" - "strconv" - "strings" - "sync" - "syscall" -) - -// OTLP HTTP/JSON payload — proto3 JSON mapping, minimal subset. -type exportRequest struct { - ResourceSpans []resourceSpan `json:"resourceSpans"` -} - -type resourceSpan struct { - ScopeSpans []scopeSpan `json:"scopeSpans"` -} - -type scopeSpan struct { - Spans []otlpSpan `json:"spans"` -} - -type otlpSpan struct { - Name string `json:"name"` - StartTimeUnixNano string `json:"startTimeUnixNano"` // decimal string (proto3 uint64 → JSON string) - EndTimeUnixNano string `json:"endTimeUnixNano"` - ParentSpanID string `json:"parentSpanId"` - Attributes []kv `json:"attributes"` -} - -type kv struct { - Key string `json:"key"` - Value kvValue `json:"value"` -} - -type kvValue struct { - StringValue *string `json:"stringValue,omitempty"` - BoolValue *bool `json:"boolValue,omitempty"` - IntValue *string `json:"intValue,omitempty"` - DoubleValue *float64 `json:"doubleValue,omitempty"` -} - -func (v kvValue) str() string { - switch { - case v.StringValue != nil: - return *v.StringValue - case v.BoolValue != nil: - return strconv.FormatBool(*v.BoolValue) - case v.IntValue != nil: - return *v.IntValue - case v.DoubleValue != nil: - return strconv.FormatFloat(*v.DoubleValue, 'f', -1, 64) - } - return "" -} - -var ( - mu sync.Mutex - spans []otlpSpan -) - -func main() { - portFile := flag.String("port-file", "", "write listening port to this file (required)") - rawFile := flag.String("raw", "", "write all received JSON bodies to this file for inspection") - flag.Parse() - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - fmt.Fprintf(os.Stderr, "otelrecv: listen: %v\n", err) - os.Exit(1) - } - - if *portFile != "" { - port := ln.Addr().(*net.TCPAddr).Port - if err := os.WriteFile(*portFile, []byte(strconv.Itoa(port)), 0o600); err != nil { - fmt.Fprintf(os.Stderr, "otelrecv: port-file: %v\n", err) - os.Exit(1) - } - } - - var rawOut *os.File - if *rawFile != "" { - f, err := os.Create(*rawFile) - if err != nil { - fmt.Fprintf(os.Stderr, "otelrecv: raw: %v\n", err) - os.Exit(1) - } - defer f.Close() - rawOut = f - } - - mux := http.NewServeMux() - mux.HandleFunc("/v1/traces", func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - if rawOut != nil { - rawOut.Write(body) - rawOut.WriteString("\n") - } - var req exportRequest - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - mu.Lock() - for _, rs := range req.ResourceSpans { - for _, ss := range rs.ScopeSpans { - spans = append(spans, ss.Spans...) - } - } - mu.Unlock() - w.WriteHeader(http.StatusOK) - }) - - srv := &http.Server{Handler: mux} - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) - go func() { <-ch; srv.Close() }() - - srv.Serve(ln) //nolint:errcheck // returns non-nil only on Close - printReport() -} - -func printReport() { - mu.Lock() - defer mu.Unlock() - - if len(spans) == 0 { - fmt.Fprintln(os.Stderr, "otelrecv: no spans received — check OTEL_EXPORTER_OTLP_PROTOCOL=http/json is supported") - return - } - - type row struct { - name string - dur float64 - cached bool - attrs string - } - - rows := make([]row, 0, len(spans)) - for _, s := range spans { - start, _ := strconv.ParseInt(s.StartTimeUnixNano, 10, 64) - end, _ := strconv.ParseInt(s.EndTimeUnixNano, 10, 64) - dur := float64(end-start) / 1e9 - - cached := false - var parts []string - for _, a := range s.Attributes { - val := a.Value.str() - if a.Value.BoolValue != nil && strings.Contains(strings.ToLower(a.Key), "cached") { - cached = *a.Value.BoolValue - } - parts = append(parts, a.Key+"="+val) - } - - rows = append(rows, row{ - name: s.Name, - dur: dur, - cached: cached, - attrs: strings.Join(parts, " "), - }) - } - - sort.Slice(rows, func(i, j int) bool { return rows[i].dur > rows[j].dur }) - - const nameW, attrW = 38, 60 - fmt.Printf("\n%-6s %8s %-*s %s\n", "STATUS", "DURATION", nameW, "SPAN", "ATTRIBUTES") - fmt.Println(strings.Repeat("─", 6+2+8+2+nameW+2+attrW)) - for _, r := range rows { - status := "LIVE" - if r.cached { - status = "CACHED" - } - attrs := r.attrs - if len(attrs) > attrW { - attrs = attrs[:attrW-1] + "…" - } - fmt.Printf("%-6s %7.2fs %-*s %s\n", status, r.dur, nameW, clip(r.name, nameW), attrs) - } - fmt.Printf("\n%d spans total\n", len(rows)) -} - -func clip(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n-1] + "…" -}