diff --git a/docs/runbook.md b/docs/runbook.md index 23a4567..482772e 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -313,6 +313,11 @@ Container Insights, seven days for the match logs, thirty for the artifacts, ninety for CloudTrail. Run daily it keeps exact numbers; run monthly it keeps inferred ones. Nothing already in the ledger is downgraded by a later run. +Totals are coloured by how much they should worry you: amber at ten cents, +red at a dollar, against a match that finishes costing about a cent and a +half. `--yellow` and `--red` move the thresholds, `--color never` turns it +off, and so does piping the output anywhere or setting `NO_COLOR`. + The `src` column says where each match's billed window came from, as start/end — `ecs` is the task's own record, `trail/s3` is an exact RunTask and an end taken from the last artifact upload. `--explain ` prints one diff --git a/scripts/matches/cost.py b/scripts/matches/cost.py index 446090f..cae62dd 100755 --- a/scripts/matches/cost.py +++ b/scripts/matches/cost.py @@ -1274,18 +1274,21 @@ def report(args, _env): cutoff = (now() - dt.timedelta(days=args.days)).isoformat() selected = [r for r in all_rows if (r["started_at"] or "") >= cutoff] + paint = use_colour(args.color) + thresholds = (args.yellow, args.red) + if args.explain: - return explain(selected or all_rows, args.explain, prices, ledger) + return explain(selected or all_rows, args.explain, ledger, thresholds, paint) if args.format == "json": json.dump({"overhead_seconds": overhead, "matches": selected}, sys.stdout, indent=2) print() return if args.format == "csv": return write_csv(selected) - return write_table(selected, prices, ledger, overhead, args.days) + return write_table(selected, prices, ledger, overhead, args.days, thresholds, paint) -def write_table(selected, prices, ledger, overhead, days): +def write_table(selected, prices, ledger, overhead, days, thresholds, paint): if not selected: print(f"no matches in the last {days} days") return @@ -1301,12 +1304,12 @@ def write_table(selected, prices, ledger, overhead, days): _size(row), _duration(row["game_seconds"]), _duration(row["seconds"]), - _source_label(row), + _dim(_source_label(row), paint), _usd(cost["fargate_vcpu"] + cost["fargate_memory"]), _usd(cost["logs_ingest"] + cost["logs_storage"]), _usd(cost["s3_storage"] + cost["s3_requests"]), _usd(cost["public_ipv4"] + cost["container_insights"] + cost["data_transfer"]), - _usd(row["total"]), + _cost(row["total"], thresholds, paint), ]) _print_columns(header, body) @@ -1315,8 +1318,19 @@ def write_table(selected, prices, ledger, overhead, days): exact = sum(1 for r in selected if r["start_source"] in EXACT_SOURCES and r["end_source"] in EXACT_SOURCES) + yellow, red = thresholds + over_yellow = [r for r in selected if r["total"] >= yellow] + over_red = [r for r in selected if r["total"] >= red] print(f"\n{len(selected)} match(es) over {days} days: ${total:.4f}, " f"${total / len(selected):.4f} each, {_duration(billed)} billed") + if over_yellow: + # Doubles as the legend when the output is coloured, and stays worth + # printing when it is not: the counts are the point, not the paint. + amber = _paint(f"${yellow:.2f}", YELLOW, paint) + crimson = _paint(f"${red:.2f}", BOLD + RED, paint) + print(f"{len(over_yellow)} at or over {amber}, of which " + f"{len(over_red)} at or over {crimson} " + f"(${sum(r['total'] for r in over_red):.2f} between them)") inferred = len(selected) - exact print(f"{exact} of {len(selected)} billed from a task's own record" + (f"; {inferred} inferred from artifact timestamps or modelled at " @@ -1362,7 +1376,7 @@ def write_csv(selected): + [row["total"]]) -def explain(selected, wanted, prices, ledger): +def explain(selected, wanted, ledger, thresholds, paint): """One match, every line of it, and where each number came from.""" hits = [r for r in selected if r["match_id"].startswith(wanted.lower())] if not hits: @@ -1387,7 +1401,7 @@ def explain(selected, wanted, prices, ledger): print() lines = [[COMPONENT_LABELS[k], _usd(v), f"{100 * v / (row['total'] or 1):5.1f}%"] for k, v in row["cost"].items()] - lines.append(["total", _usd(row["total"]), ""]) + lines.append(["total", _cost(row["total"], thresholds, paint), ""]) _print_columns(["component", "usd", "share"], lines) if row["notes"]: @@ -1440,19 +1454,77 @@ def show_prices(args, _env): # --- formatting -------------------------------------------------------------- +# A match that finishes costs about a cent and a half, so ten cents is a match +# that ran long and a dollar is one that ran away. Both are overridable per +# run; they are about reading a table, not about pricing, which is why they +# are not in the price sheet's assumptions. +YELLOW_THRESH = 0.10 +RED_THRESH = 1.00 + +BOLD, YELLOW, RED, GREY, OFF = "\x1b[1m", "\x1b[33m", "\x1b[31m", "\x1b[90m", "\x1b[0m" + +ANSI = re.compile(r"\x1b\[[0-9;]*m") + + def _print_columns(header, body): - widths = [max(len(str(cell)) for cell in column) for column in zip(header, *body)] if body else \ + """Pad on what a terminal shows, not on what the string holds. + + A coloured cell carries escape sequences that occupy no columns, so + ljust() on the raw string over-counts and the table loses its alignment + exactly where the interesting rows are. + """ + def visible(cell): + return len(ANSI.sub("", str(cell))) + + def pad(cell, width): + return str(cell) + " " * (width - visible(cell)) + + widths = [max(visible(cell) for cell in column) for column in zip(header, *body)] if body else \ [len(h) for h in header] - line = " ".join(str(h).ljust(w) for h, w in zip(header, widths)) - print(line.rstrip()) + print(" ".join(pad(h, w) for h, w in zip(header, widths)).rstrip()) for row in body: - print(" ".join(str(c).ljust(w) for c, w in zip(row, widths)).rstrip()) + print(" ".join(pad(c, w) for c, w in zip(row, widths)).rstrip()) def _usd(value): return f"${value:.4f}" if value else "-" +def _cost(value, thresholds, paint): + """A dollar figure, loud in proportion to how much it should worry you.""" + text = _usd(value) + if not paint or not value: + return text + yellow, red = thresholds + if value >= red: + return _paint(text, BOLD + RED, True) + if value >= yellow: + return _paint(text, YELLOW, True) + return text + + +def _paint(text, code, paint): + return f"{code}{text}{OFF}" if paint else text + + +def _dim(text, paint): + return _paint(text, GREY, paint) if text != "-" else text + + +def use_colour(setting): + """auto means a terminal and no NO_COLOR; a pipe or a file gets none. + + Redirecting the table into a file or through `less` is how these get read + half the time, and escape sequences in a file are worse than no colour. + NO_COLOR is https://no-color.org. + """ + if setting == "always": + return True + if setting == "never": + return False + return sys.stdout.isatty() and not os.environ.get("NO_COLOR") + + def _duration(seconds): if not seconds: return "-" @@ -1525,6 +1597,12 @@ def main(argv): shown.add_argument("--days", type=float, default=30, help="window to report on (default 30)") shown.add_argument("--format", choices=("table", "csv", "json"), default="table") shown.add_argument("--explain", metavar="MATCH", help="one match, line by line") + shown.add_argument("--yellow", type=float, default=YELLOW_THRESH, metavar="USD", + help=f"colour a total this high amber (default {YELLOW_THRESH})") + shown.add_argument("--red", type=float, default=RED_THRESH, metavar="USD", + help=f"colour a total this high red (default {RED_THRESH})") + shown.add_argument("--color", choices=("auto", "always", "never"), default="auto", + help="auto means a terminal and no NO_COLOR set") shown.set_defaults(run=report, needs_env=False) priced = sub.add_parser("prices", help="show or refresh the price sheet")