|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Summarizes the OpenTelemetry spans an e2e run exported, as markdown. |
| 3 | +
|
| 4 | +usage: trace_summary.py SPANS_JSONL [--json OUT] |
| 5 | +
|
| 6 | +SPANS_JSONL is the OTLP/JSON file a collector's file exporter writes, one export per line. |
| 7 | +--json also writes the aggregates, for comparing runs. |
| 8 | +""" |
| 9 | + |
| 10 | +import argparse |
| 11 | +import collections |
| 12 | +import json |
| 13 | +from urllib.parse import urlparse |
| 14 | + |
| 15 | +STAGES = [ |
| 16 | + "updatecli.prepare", |
| 17 | + "updatecli.load_configurations", |
| 18 | + "updatecli.init_scm", |
| 19 | + "updatecli.autodiscovery", |
| 20 | + "updatecli.run", |
| 21 | + "updatecli.push_commits", |
| 22 | + "updatecli.run_actions", |
| 23 | + "updatecli.prune_scm_branches", |
| 24 | +] |
| 25 | + |
| 26 | + |
| 27 | +def load(path: str) -> list[dict]: |
| 28 | + spans = [] |
| 29 | + with open(path) as f: |
| 30 | + for line in f: |
| 31 | + for resource in json.loads(line)["resourceSpans"]: |
| 32 | + for scope in resource["scopeSpans"]: |
| 33 | + for span in scope["spans"]: |
| 34 | + span["attrs"] = {a["key"]: next(iter(a["value"].values()), None) for a in span.get("attributes", [])} |
| 35 | + span["seconds"] = (int(span["endTimeUnixNano"]) - int(span["startTimeUnixNano"])) / 1e9 |
| 36 | + spans.append(span) |
| 37 | + return spans |
| 38 | + |
| 39 | + |
| 40 | +def aggregate(spans: list[dict]) -> dict: |
| 41 | + def group(selected, key): |
| 42 | + groups = collections.defaultdict(list) |
| 43 | + for span in selected: |
| 44 | + groups[key(span)].append(span["seconds"]) |
| 45 | + return { |
| 46 | + name: {"count": len(v), "total_s": round(sum(v), 1), "avg_s": round(sum(v) / len(v), 3), "max_s": round(max(v), 1)} |
| 47 | + for name, v in sorted(groups.items(), key=lambda item: -sum(item[1])) |
| 48 | + } |
| 49 | + |
| 50 | + def http_key(span): |
| 51 | + url = urlparse(str(span["attrs"].get("url.full", ""))) |
| 52 | + return f"{span['attrs'].get('http.request.method', '?')} {url.netloc}{'/graphql' if url.path.endswith('/graphql') else ''}" |
| 53 | + |
| 54 | + roots = [s for s in spans if s["name"] == "updatecli"] |
| 55 | + pipelines = [s for s in spans if s["name"] == "updatecli.pipeline"] |
| 56 | + return { |
| 57 | + "processes": len(roots), |
| 58 | + "wall_s": round(sum(s["seconds"] for s in roots), 1), |
| 59 | + "pipelines": len(pipelines), |
| 60 | + "pipeline_results": dict(collections.Counter(str(s["attrs"].get("updatecli.pipeline.result")) for s in pipelines)), |
| 61 | + "stages": group([s for s in spans if s["name"] in STAGES], lambda s: s["name"]), |
| 62 | + "resources": group( |
| 63 | + [s for s in spans if s["name"] == "updatecli.resource"], |
| 64 | + lambda s: f"{s['attrs'].get('updatecli.resource.category')}/{s['attrs'].get('updatecli.resource.kind')}", |
| 65 | + ), |
| 66 | + "http": group([s for s in spans if s["name"].startswith("HTTP ")], http_key), |
| 67 | + "slowest_pipelines": [ |
| 68 | + {"name": s["attrs"].get("updatecli.pipeline.name"), "result": s["attrs"].get("updatecli.pipeline.result"), "seconds": round(s["seconds"], 1)} |
| 69 | + for s in sorted(pipelines, key=lambda s: -s["seconds"])[:10] |
| 70 | + ], |
| 71 | + } |
| 72 | + |
| 73 | + |
| 74 | +def table(title: str, groups: dict, limit: int = 15) -> str: |
| 75 | + rows = [f"### {title}", "", "| | count | total s | avg s | max s |", "|---|---:|---:|---:|---:|"] |
| 76 | + rows += [f"| `{name}` | {g['count']} | {g['total_s']} | {g['avg_s']} | {g['max_s']} |" for name, g in list(groups.items())[:limit]] |
| 77 | + return "\n".join(rows) + "\n" |
| 78 | + |
| 79 | + |
| 80 | +def markdown(summary: dict) -> str: |
| 81 | + out = [ |
| 82 | + "## Updatecli e2e trace summary", |
| 83 | + "", |
| 84 | + f"{summary['processes']} updatecli process(es), {summary['wall_s']}s in total, {summary['pipelines']} pipelines " |
| 85 | + f"({', '.join(f'{k}: {v}' for k, v in sorted(summary['pipeline_results'].items()))}).", |
| 86 | + "", |
| 87 | + "Resources of a pipeline run concurrently, so resource totals can exceed the wall time.", |
| 88 | + "", |
| 89 | + table("Stages", summary["stages"]), |
| 90 | + table("Resources by category/kind", summary["resources"]), |
| 91 | + table("HTTP requests by method and host", summary["http"]), |
| 92 | + "### Slowest pipelines", |
| 93 | + "", |
| 94 | + "| pipeline | result | s |", |
| 95 | + "|---|---|---:|", |
| 96 | + ] |
| 97 | + out += [f"| {p['name']} | {p['result']} | {p['seconds']} |" for p in summary["slowest_pipelines"]] |
| 98 | + return "\n".join(out) + "\n" |
| 99 | + |
| 100 | + |
| 101 | +def main() -> None: |
| 102 | + parser = argparse.ArgumentParser(description="Summarizes the OpenTelemetry spans an e2e run exported.") |
| 103 | + parser.add_argument("spans") |
| 104 | + parser.add_argument("--json") |
| 105 | + args = parser.parse_args() |
| 106 | + |
| 107 | + summary = aggregate(load(args.spans)) |
| 108 | + if args.json: |
| 109 | + with open(args.json, "w") as f: |
| 110 | + json.dump(summary, f, indent=2) |
| 111 | + print(markdown(summary), end="") |
| 112 | + |
| 113 | + |
| 114 | +if __name__ == "__main__": |
| 115 | + main() |
0 commit comments