fix(ci): switch timing from OTEL receiver to --progress=plain pipe filter

Dagger v0.20.8 only supports 'grpc' and 'http/protobuf' OTLP protocols;
'http/json' triggers a WARN and exports nothing.  The new approach pipes
dagger's --progress=plain output through a Python script that echoes it
in real-time and prints a timing table at EOF.  No HTTP server, no port
files, no protocol issues — works locally and in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-20 11:43:26 +02:00
co-authored by Claude Sonnet 4.6
parent ac2178916e
commit 691f2beec2
2 changed files with 38 additions and 152 deletions
+6 -17
View File
@@ -252,29 +252,18 @@ 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 python3 is available)
desc: Run full check suite via Dagger (with timing report if python3 is available)
cmds:
- |
if ! command -v python3 >/dev/null 2>&1; then
dagger call --progress=plain -q -m ci --source=. check
exit $?
fi
PORTFILE=$(mktemp)
python3 ci/otelrecv.py --port-file="$PORTFILE" &
RECV_PID=$!
cleanup() {
kill "$RECV_PID" 2>/dev/null
wait "$RECV_PID" 2>/dev/null
rm -f "$PORTFILE"
}
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
RC_FILE=$(mktemp)
(dagger call --progress=plain -q -m ci --source=. check; echo $? >"$RC_FILE") 2>&1 | python3 ci/otelrecv.py
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
rm -f "$RC_FILE"
exit "$RC"
integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
+32 -135
View File
@@ -1,153 +1,50 @@
#!/usr/bin/env python3
"""
Minimal OTLP HTTP/JSON trace receiver for Dagger CI timing.
Pipe filter for dagger --progress=plain timing analysis.
Usage:
python3 ci/otelrecv.py --port-file=/tmp/otel.port [--raw=/tmp/otel.json]
dagger call --progress=plain ... 2>&1 | python3 ci/otelrecv.py
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:<port>
OTEL_EXPORTER_OTLP_PROTOCOL=http/json
Echoes every line to stdout in real-time, then prints a timing table sorted
by duration when stdin closes.
"""
import argparse
import json
import signal
import re
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}")
_ANSI = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
# Matches completed span lines: "Container.withExec CACHED [1.23s]"
_TIMING = re.compile(r"([\w.]+)\s+(CACHED\s+)?\[(\d+\.?\d*)s\]$")
rows = []
for line in sys.stdin:
sys.stdout.write(line)
sys.stdout.flush()
clean = _ANSI.sub("", line).rstrip()
m = _TIMING.search(clean)
if m:
dur = float(m.group(3))
if dur > 0:
rows.append(
{
"name": s.get("name", ""),
"name": m.group(1),
"cached": bool(m.group(2)),
"dur": dur,
"cached": cached,
"attrs": " ".join(parts),
}
)
rows.sort(key=lambda r: r["dur"], reverse=True)
if not rows:
sys.exit(0)
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")
rows.sort(key=lambda r: r["dur"], reverse=True)
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()
NAME_W, ATTR_W = 38, 6
print(f'\n{"STATUS":<6} {"DURATION":>8} SPAN')
print("" * (6 + 2 + 8 + 2 + NAME_W + 20))
for r in rows:
status = "CACHED" if r["cached"] else "LIVE"
name = r["name"]
if len(name) > NAME_W:
name = name[: NAME_W - 1] + ""
print(f'{status:<6} {r["dur"]:7.2f}s {name}')
print(f"\n{len(rows)} spans total")