feat(ci): OTEL timing receiver for check-dagger

Adds ci/otelrecv/main.go — a minimal OTLP HTTP/JSON trace receiver that
listens on a random port (port 0) so parallel runs never collide.

The check-dagger Taskfile task now starts the receiver in the background,
passes the port via a mktemp file, runs dagger with OTEL env vars set,
then prints a per-span timing report on shutdown. Falls back to plain
dagger call when Go is not available (e.g. CI containers without Go).

First run will show raw attribute keys so we can learn Dagger's exact
telemetry format and refine the cached/live detection logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-20 10:27:57 +02:00
co-authored by Claude Sonnet 4.6
parent f23328fd1f
commit 3471e1fd2c
2 changed files with 235 additions and 2 deletions
+25 -2
View File
@@ -252,9 +252,32 @@ 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
desc: Run full check suite via Dagger (with OTEL timing report if Go is available)
cmds:
- dagger call --progress=plain -q -m ci --source=. check
- |
if ! command -v go >/dev/null 2>&1; then
dagger call --progress=plain -q -m ci --source=. check
exit $?
fi
PORTFILE=$(mktemp)
TIMINGFILE=$(mktemp)
(cd ci && go run ./otelrecv/ --port-file="$PORTFILE") > "$TIMINGFILE" &
RECV_PID=$!
cleanup() {
kill "$RECV_PID" 2>/dev/null
wait "$RECV_PID" 2>/dev/null
echo ""
cat "$TIMINGFILE"
rm -f "$PORTFILE" "$TIMINGFILE"
}
trap cleanup EXIT
until [ -s "$PORTFILE" ]; do sleep 0.05; done
PORT=$(cat "$PORTFILE")
RC=0
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
OTEL_EXPORTER_OTLP_PROTOCOL="http/json" \
dagger call --progress=plain -q -m ci --source=. check || RC=$?
exit $RC
integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
+210
View File
@@ -0,0 +1,210 @@
// 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:<port>
// 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] + "…"
}