← Files SavvyARCHIVED FILE

skills/savvy/scripts/savant_api/admin_usage.py

7.59 KB · Oct 2, 2026 · 00:28 UTC

↓ Download file

#!/usr/bin/env python3
"""Read Savant Admin > Usage Log data through the internal app API.

This helper reuses the user's authenticated browser session. It is intentionally
read-only and narrow because these are internal app APIs, not a public SDK.
"""

from __future__ import annotations

import argparse
import csv
import json
import sys
from pathlib import Path
from typing import Any

SCRIPT_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from savant_api.cli import (  # noqa: E402
    DEFAULT_ORIGIN,
    SavantAppApiError,
    SavantSessionContext,
    discover_session,
    poll_promise,
    request,
    save_json,
)
from savant_api.fileio import workspace_tmp  # noqa: E402


USAGE_EVENT_TYPE = "bot_run"
USAGE_EVENT_FIELDS = [
    "executionId",
    "recipeName",
    "recipeOwner",
    "runType",
    "startedAt",
    "workspace",
    "srcConnectors",
    "dstConnectors",
    "processedRecords",
    "inputTokens",
    "outputTokens",
    "totalTokens",
]


def _promise_id(response: Any, endpoint: str) -> str:
    if not isinstance(response, dict):
        raise SavantAppApiError(f"{endpoint} did not return a JSON object.")
    promise_id = response.get("promiseId") or response.get("id")
    if not isinstance(promise_id, str) or not promise_id:
        raise SavantAppApiError(f"{endpoint} did not return a promise id.")
    return promise_id


def _usage_payload(from_date: str, to_date: str) -> dict[str, Any]:
    return {
        "timeRange": {
            "from": from_date,
            "to": to_date,
        },
        "eventType": USAGE_EVENT_TYPE,
    }


def _result_from_promise(promise: dict[str, Any]) -> Any:
    if "exception" in promise and promise["exception"]:
        raise SavantAppApiError(f"Usage promise failed: {promise['exception']}")
    return promise.get("result", promise)


def _normalize_tokens(row: Any) -> Any:
    if not isinstance(row, dict):
        return row
    input_tokens = row.get("inputTokens") if isinstance(row.get("inputTokens"), (int, float)) else 0
    output_tokens = row.get("outputTokens") if isinstance(row.get("outputTokens"), (int, float)) else 0
    return {
        **row,
        "totalTokens": row.get("totalTokens") if isinstance(row.get("totalTokens"), (int, float)) else input_tokens + output_tokens,
    }


def _normalize_result(result: Any) -> Any:
    if isinstance(result, list):
        return [_normalize_tokens(row) for row in result]
    if isinstance(result, dict):
        return _normalize_tokens(result)
    return result


def fetch_usage(context: SavantSessionContext, endpoint: str, from_date: str, to_date: str) -> Any:
    response = request(context, endpoint, method="POST", body=_usage_payload(from_date, to_date))
    promise = poll_promise(context, _promise_id(response, endpoint), timeout_seconds=120, interval_seconds=1.0)
    return _normalize_result(_result_from_promise(promise))


def _csv_value(value: Any) -> str | int | float:
    if isinstance(value, list):
        return "; ".join(str(item) for item in value)
    if value is None:
        return ""
    if isinstance(value, (str, int, float)):
        return value
    return json.dumps(value, sort_keys=True)


def save_usage_csv(rows: Any, output_path: Path) -> None:
    if not isinstance(rows, list):
        raise SavantAppApiError("CSV output expected usage events as an array.")
    output_path.parent.mkdir(parents=True, exist_ok=True)
    keys = list(USAGE_EVENT_FIELDS)
    extras = sorted({key for row in rows if isinstance(row, dict) for key in row.keys()} - set(keys))
    with output_path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=[*keys, *extras])
        writer.writeheader()
        for row in rows:
            if not isinstance(row, dict):
                continue
            writer.writerow({key: _csv_value(row.get(key)) for key in [*keys, *extras]})


def _safe_label(value: str) -> str:
    return "".join(char if char.isalnum() or char in {"-", "_"} else "-" for char in value).strip("-") or "usage"


def default_output_paths(from_date: str, to_date: str) -> dict[str, Path]:
    label = _safe_label(f"{from_date}_to_{to_date}")
    root = workspace_tmp("admin-usage", label)
    return {
        "events": root / "events.json",
        "metrics": root / "metrics.json",
        "csv": root / "usage_log.csv",
    }


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Read Savant Admin Usage Log data through the internal app API.")
    parser.add_argument("--from", dest="from_date", required=True, help="Start date, usually YYYY-MM-DD.")
    parser.add_argument("--to", dest="to_date", required=True, help="End date, usually YYYY-MM-DD.")
    parser.add_argument("--namespace", help="Optional Savant namespace/rns to match a specific browser tab session.")
    parser.add_argument("--origin", default=DEFAULT_ORIGIN, help="Savant app origin. Defaults to https://app.savantlabs.io.")
    parser.add_argument("--events-output", type=Path, help="Where to write detailed usage events JSON.")
    parser.add_argument("--metrics-output", type=Path, help="Where to write aggregate usage metrics JSON.")
    parser.add_argument("--csv-output", type=Path, help="Where to write usage_log.csv from CSV-export event rows.")
    parser.epilog = (
        "If none of the output flags are supplied, all three outputs are written under "
        "tmp/admin-usage/{from}_to_{to}/."
    )
    parser.add_argument("--quiet", action="store_true", help="Only print written output paths.")
    args = parser.parse_args(argv)

    context = discover_session(args.namespace, origin=args.origin)
    written: list[Path] = []
    defaults = default_output_paths(args.from_date, args.to_date)
    write_all_defaults = not (args.events_output or args.metrics_output or args.csv_output)
    events_output = args.events_output or (defaults["events"] if write_all_defaults else None)
    metrics_output = args.metrics_output or (defaults["metrics"] if write_all_defaults else None)
    csv_output = args.csv_output or (defaults["csv"] if write_all_defaults else None)

    if events_output:
        events = fetch_usage(context, "/api/usage-events/events/async", args.from_date, args.to_date)
        save_json(
            {
                "namespace": context.namespace,
                "from": args.from_date,
                "to": args.to_date,
                "eventType": USAGE_EVENT_TYPE,
                "events": events,
            },
            events_output,
        )
        written.append(events_output)

    if metrics_output:
        metrics = fetch_usage(context, "/api/usage-events/metrics/async", args.from_date, args.to_date)
        save_json(
            {
                "namespace": context.namespace,
                "from": args.from_date,
                "to": args.to_date,
                "eventType": USAGE_EVENT_TYPE,
                "metrics": metrics,
            },
            metrics_output,
        )
        written.append(metrics_output)

    if csv_output:
        rows = fetch_usage(context, "/api/usage-events/csv/async", args.from_date, args.to_date)
        save_usage_csv(rows, csv_output)
        written.append(csv_output)

    if not written:
        raise SavantAppApiError("No usage outputs were written.")

    if args.quiet:
        for path in written:
            print(path)
    else:
        print("Wrote:")
        for path in written:
            print(f"- {path}")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except SavantAppApiError as exc:
        print(f"error: {exc}", file=sys.stderr)
        raise SystemExit(1)

SHA-256: d8bc939db6f28b2a732f3d9192fa4b9af7b8927406a2aa681e17c47eb47b5dc8