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:
co-authored by
Claude Sonnet 4.6
parent
ac2178916e
commit
691f2beec2
+6
-17
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user