Refactor exporter into modules

This commit is contained in:
2026-07-09 10:21:37 +00:00
parent dc8aa26893
commit 43be027b5d
9 changed files with 821 additions and 720 deletions
+14
View File
@@ -108,6 +108,9 @@ Derived from `sessions.json`:
- `openclaw_exporter_files_scanned`
- `openclaw_exporter_lines_scanned`
- `openclaw_exporter_parse_errors`
- `openclaw_exporter_negative_cost_entries_skipped`
- `openclaw_exporter_negative_cost_fields_skipped{cost_field=...}`
- `openclaw_exporter_negative_cost_amount_skipped{cost_field=...}`
- `openclaw_exporter_history_days_retained`
- `openclaw_exporter_history_hours_retained`
- `openclaw_usage_cost_cache_files_tracked`
@@ -120,6 +123,17 @@ This exporter intentionally skips obvious revealing fields like message content,
Session ids are exported.
It also ignores invalid negative cost values found in some legacy/raw usage records and exposes exporter metrics for how many were skipped.
## Code layout
- `app.py` keeps the uvicorn entrypoint tiny
- `openclaw_usage_exporter/config.py` holds env-driven paths and retention settings
- `openclaw_usage_exporter/models.py` holds report dataclasses and cost handling
- `openclaw_usage_exporter/collectors.py` reads session files and cache/index data
- `openclaw_usage_exporter/metrics.py` renders Prometheus output
- `openclaw_usage_exporter/utils.py` keeps shared helpers small
## Run locally
```bash
+1 -720
View File
@@ -1,720 +1 @@
from __future__ import annotations
import gzip
import json
import os
import re
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Iterable
from zoneinfo import ZoneInfo
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
SESSIONS_DIR = Path(os.environ.get("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions"))
TIMEZONE = ZoneInfo(os.environ.get("OPENCLAW_EXPORTER_TZ", "Europe/Berlin"))
SESSIONS_INDEX_PATH = Path(os.environ.get("OPENCLAW_SESSIONS_INDEX", str(SESSIONS_DIR / "sessions.json")))
USAGE_COST_CACHE_PATH = Path(os.environ.get("OPENCLAW_USAGE_COST_CACHE", str(SESSIONS_DIR / ".usage-cost-cache.json")))
HISTORY_DAYS = max(1, int(os.environ.get("OPENCLAW_HISTORY_DAYS", "30")))
HISTORY_HOURS = max(1, int(os.environ.get("OPENCLAW_HISTORY_HOURS", "168")))
UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
app = FastAPI(title="openclaw-usage-exporter")
@dataclass
class Totals:
total: int = 0
input: int = 0
output: int = 0
cache_read: int = 0
cache_write: int = 0
reasoning: int = 0
entries: int = 0
cost_input: float = 0.0
cost_output: float = 0.0
cost_cache_read: float = 0.0
cost_cache_write: float = 0.0
cost_total: float = 0.0
def add_usage(self, usage: dict) -> tuple[int, int, int, int, int, int]:
total_tokens = _as_int(usage.get("totalTokens"))
input_tokens = _as_int(usage.get("input"))
output_tokens = _as_int(usage.get("output"))
cache_read_tokens = _as_int(usage.get("cacheRead"))
cache_write_tokens = _as_int(usage.get("cacheWrite"))
reasoning_tokens = _as_int(usage.get("reasoningTokens"))
self.entries += 1
self.total += total_tokens
self.input += input_tokens
self.output += output_tokens
self.cache_read += cache_read_tokens
self.cache_write += cache_write_tokens
self.reasoning += reasoning_tokens
cost = usage.get("cost") or {}
if isinstance(cost, dict):
self.cost_input += _as_float(cost.get("input"))
self.cost_output += _as_float(cost.get("output"))
self.cost_cache_read += _as_float(cost.get("cacheRead"))
self.cost_cache_write += _as_float(cost.get("cacheWrite"))
self.cost_total += _as_float(cost.get("total"))
return (
total_tokens,
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens,
reasoning_tokens,
)
@dataclass
class UsageReport:
today: Totals = field(default_factory=Totals)
all_time: Totals = field(default_factory=Totals)
files_scanned: int = 0
lines_scanned: int = 0
parse_errors: int = 0
message_records_today: int = 0
message_records_all_time: int = 0
non_message_records_today: int = 0
non_message_records_all_time: int = 0
unique_session_ids_today: set[str] = field(default_factory=set)
unique_models_today: set[str] = field(default_factory=set)
unique_providers_today: set[str] = field(default_factory=set)
role_counts_today: Counter[str] = field(default_factory=Counter)
stop_reason_counts_today: Counter[tuple[str, str, str]] = field(default_factory=Counter)
tool_call_counts_today: Counter[str] = field(default_factory=Counter)
error_type_counts_today: Counter[str] = field(default_factory=Counter)
session_message_counts_today: Counter[tuple[str, str]] = field(default_factory=Counter)
session_error_counts_today: Counter[str] = field(default_factory=Counter)
session_tool_call_counts_today: Counter[tuple[str, str]] = field(default_factory=Counter)
tokens_by_model_today: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
input_tokens_by_model_today: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
output_tokens_by_model_today: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
reasoning_tokens_by_model_today: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
session_tokens_today: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
session_input_tokens_today: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
session_output_tokens_today: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
tokens_by_model_all_time: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
input_tokens_by_model_all_time: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
output_tokens_by_model_all_time: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
session_tokens_all_time: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
day_buckets: defaultdict[str, Totals] = field(default_factory=lambda: defaultdict(Totals))
hour_buckets: defaultdict[str, Totals] = field(default_factory=lambda: defaultdict(Totals))
@dataclass
class SessionIndexReport:
visible_count: int = 0
total_count: int = 0
has_more: bool = False
sessions: list[dict] = field(default_factory=list)
@dataclass
class UsageCostCacheReport:
version: int | None = None
updated_at_ms: int | None = None
files_tracked: int = 0
def _as_int(value: object) -> int:
if value is None:
return 0
if isinstance(value, bool):
return int(value)
if isinstance(value, (int, float)):
return int(value)
return 0
def _as_float(value: object) -> float:
if value is None:
return 0.0
if isinstance(value, bool):
return float(value)
if isinstance(value, (int, float)):
return float(value)
return 0.0
def _today_in_tz() -> date:
return datetime.now(TIMEZONE).date()
def _now_in_tz() -> datetime:
return datetime.now(TIMEZONE)
def _sanitize_label(value: object, default: str = "unknown") -> str:
if value is None:
return default
text = str(value).strip()
return text or default
def _prom_value(value: str) -> str:
return value.replace("\\", r"\\").replace("\n", r"\n").replace('"', r'\"')
def _metric_line(name: str, value: object, **labels: object) -> str:
if labels:
rendered = ",".join(f'{key}="{_prom_value(_sanitize_label(val))}"' for key, val in sorted(labels.items()))
return f"{name}{{{rendered}}} {value}"
return f"{name} {value}"
def _open_session_file(path: Path):
if path.suffix == ".gz":
return gzip.open(path, "rt", encoding="utf-8")
return path.open("r", encoding="utf-8")
def _session_id_from_path(path: Path) -> str | None:
match = UUID_RE.search(path.name)
if match:
return match.group(0).lower()
return None
def _iter_session_paths() -> Iterable[Path]:
for path in sorted(SESSIONS_DIR.glob("*.jsonl*")):
if ".trajectory." in path.name:
continue
yield path
def collect_usage() -> UsageReport:
report = UsageReport()
now = _now_in_tz()
today = now.date()
day_cutoff = today - timedelta(days=HISTORY_DAYS - 1)
hour_cutoff = now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=HISTORY_HOURS - 1)
for path in _iter_session_paths():
report.files_scanned += 1
session_id_from_path = _session_id_from_path(path)
try:
with _open_session_file(path) as handle:
for line in handle:
report.lines_scanned += 1
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
report.parse_errors += 1
continue
message = record.get("message") or {}
timestamp_ms = message.get("timestamp", record.get("timestamp"))
if not isinstance(timestamp_ms, (int, float)):
continue
dt = datetime.fromtimestamp(timestamp_ms / 1000, TIMEZONE)
usage_date = dt.date()
is_today = usage_date == today
if record.get("type") != "message":
report.non_message_records_all_time += 1
if is_today:
report.non_message_records_today += 1
continue
report.message_records_all_time += 1
if is_today:
report.message_records_today += 1
role = _sanitize_label(message.get("role"))
model = _sanitize_label(message.get("model"))
provider = _sanitize_label(message.get("provider"))
stop_reason = _sanitize_label(message.get("stopReason"))
tool_name = _sanitize_label(message.get("toolName"))
error_type = _sanitize_label(message.get("errorType"))
is_error = bool(message.get("isError"))
usage = message.get("usage")
session_id = session_id_from_path
if is_today:
report.role_counts_today[role] += 1
if model != "unknown":
report.unique_models_today.add(model)
if provider != "unknown":
report.unique_providers_today.add(provider)
if session_id:
report.unique_session_ids_today.add(session_id)
report.session_message_counts_today[(session_id, role)] += 1
if stop_reason != "unknown":
report.stop_reason_counts_today[(stop_reason, model, provider)] += 1
if tool_name != "unknown":
report.tool_call_counts_today[tool_name] += 1
if session_id:
report.session_tool_call_counts_today[(session_id, tool_name)] += 1
if is_error:
report.error_type_counts_today[error_type] += 1
if session_id:
report.session_error_counts_today[session_id] += 1
if not isinstance(usage, dict):
continue
total_tokens, input_tokens, output_tokens, _, _, reasoning_tokens = report.all_time.add_usage(usage)
report.tokens_by_model_all_time[(model, provider)] += total_tokens
report.input_tokens_by_model_all_time[(model, provider)] += input_tokens
report.output_tokens_by_model_all_time[(model, provider)] += output_tokens
if session_id:
report.session_tokens_all_time[session_id] += total_tokens
if usage_date >= day_cutoff:
report.day_buckets[usage_date.isoformat()].add_usage(usage)
hour_bucket = dt.replace(minute=0, second=0, microsecond=0)
if hour_bucket >= hour_cutoff:
report.hour_buckets[hour_bucket.isoformat()].add_usage(usage)
if is_today:
report.today.add_usage(usage)
report.tokens_by_model_today[(model, provider)] += total_tokens
report.input_tokens_by_model_today[(model, provider)] += input_tokens
report.output_tokens_by_model_today[(model, provider)] += output_tokens
report.reasoning_tokens_by_model_today[(model, provider)] += reasoning_tokens
if session_id:
report.session_tokens_today[session_id] += total_tokens
report.session_input_tokens_today[session_id] += input_tokens
report.session_output_tokens_today[session_id] += output_tokens
except OSError:
report.parse_errors += 1
continue
return report
def collect_session_index() -> SessionIndexReport:
report = SessionIndexReport()
try:
data = json.loads(SESSIONS_INDEX_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return report
if not isinstance(data, dict):
return report
if isinstance(data.get("sessions"), list):
sessions = [session for session in data.get("sessions", []) if isinstance(session, dict)]
report.visible_count = _as_int(data.get("count")) or len(sessions)
report.total_count = _as_int(data.get("totalCount")) or len(sessions)
report.has_more = bool(data.get("hasMore"))
report.sessions = sessions
return report
sessions = [session for session in data.values() if isinstance(session, dict)]
report.visible_count = len(sessions)
report.total_count = len(sessions)
report.has_more = False
report.sessions = sessions
return report
def collect_usage_cost_cache() -> UsageCostCacheReport:
report = UsageCostCacheReport()
try:
data = json.loads(USAGE_COST_CACHE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return report
if not isinstance(data, dict):
return report
files = data.get("files")
report.version = _as_int(data.get("version")) if data.get("version") is not None else None
report.updated_at_ms = _as_int(data.get("updatedAt")) if data.get("updatedAt") is not None else None
report.files_tracked = len(files) if isinstance(files, dict) else 0
return report
def _append_current_day_metrics(lines: list[str], usage: UsageReport, today: str, tz_name: str) -> None:
lines.extend([
"# HELP openclaw_daily_total_tokens Total OpenClaw tokens recorded today.",
"# TYPE openclaw_daily_total_tokens gauge",
_metric_line("openclaw_daily_total_tokens", usage.today.total, date=today, tz=tz_name),
"# HELP openclaw_daily_input_tokens OpenClaw input tokens recorded today.",
"# TYPE openclaw_daily_input_tokens gauge",
_metric_line("openclaw_daily_input_tokens", usage.today.input, date=today, tz=tz_name),
"# HELP openclaw_daily_output_tokens OpenClaw output tokens recorded today.",
"# TYPE openclaw_daily_output_tokens gauge",
_metric_line("openclaw_daily_output_tokens", usage.today.output, date=today, tz=tz_name),
"# HELP openclaw_daily_cache_read_tokens OpenClaw cache-read tokens recorded today.",
"# TYPE openclaw_daily_cache_read_tokens gauge",
_metric_line("openclaw_daily_cache_read_tokens", usage.today.cache_read, date=today, tz=tz_name),
"# HELP openclaw_daily_cache_write_tokens OpenClaw cache-write tokens recorded today.",
"# TYPE openclaw_daily_cache_write_tokens gauge",
_metric_line("openclaw_daily_cache_write_tokens", usage.today.cache_write, date=today, tz=tz_name),
"# HELP openclaw_daily_reasoning_tokens OpenClaw reasoning tokens recorded today.",
"# TYPE openclaw_daily_reasoning_tokens gauge",
_metric_line("openclaw_daily_reasoning_tokens", usage.today.reasoning, date=today, tz=tz_name),
"# HELP openclaw_daily_usage_entries OpenClaw usage entries recorded today.",
"# TYPE openclaw_daily_usage_entries gauge",
_metric_line("openclaw_daily_usage_entries", usage.today.entries, date=today, tz=tz_name),
"# HELP openclaw_daily_cost_input OpenClaw input cost recorded today.",
"# TYPE openclaw_daily_cost_input gauge",
_metric_line("openclaw_daily_cost_input", f"{usage.today.cost_input:.12g}", date=today, tz=tz_name),
"# HELP openclaw_daily_cost_output OpenClaw output cost recorded today.",
"# TYPE openclaw_daily_cost_output gauge",
_metric_line("openclaw_daily_cost_output", f"{usage.today.cost_output:.12g}", date=today, tz=tz_name),
"# HELP openclaw_daily_cost_cache_read OpenClaw cache-read cost recorded today.",
"# TYPE openclaw_daily_cost_cache_read gauge",
_metric_line("openclaw_daily_cost_cache_read", f"{usage.today.cost_cache_read:.12g}", date=today, tz=tz_name),
"# HELP openclaw_daily_cost_cache_write OpenClaw cache-write cost recorded today.",
"# TYPE openclaw_daily_cost_cache_write gauge",
_metric_line("openclaw_daily_cost_cache_write", f"{usage.today.cost_cache_write:.12g}", date=today, tz=tz_name),
"# HELP openclaw_daily_cost_total OpenClaw total cost recorded today.",
"# TYPE openclaw_daily_cost_total gauge",
_metric_line("openclaw_daily_cost_total", f"{usage.today.cost_total:.12g}", date=today, tz=tz_name),
"# HELP openclaw_daily_message_records Total message records seen today.",
"# TYPE openclaw_daily_message_records gauge",
_metric_line("openclaw_daily_message_records", usage.message_records_today, date=today, tz=tz_name),
"# HELP openclaw_daily_non_message_records Total non-message records seen today.",
"# TYPE openclaw_daily_non_message_records gauge",
_metric_line("openclaw_daily_non_message_records", usage.non_message_records_today, date=today, tz=tz_name),
"# HELP openclaw_daily_unique_sessions Number of unique session ids seen today.",
"# TYPE openclaw_daily_unique_sessions gauge",
_metric_line("openclaw_daily_unique_sessions", len(usage.unique_session_ids_today), date=today, tz=tz_name),
"# HELP openclaw_daily_unique_models Number of unique models seen today.",
"# TYPE openclaw_daily_unique_models gauge",
_metric_line("openclaw_daily_unique_models", len(usage.unique_models_today), date=today, tz=tz_name),
"# HELP openclaw_daily_unique_providers Number of unique providers seen today.",
"# TYPE openclaw_daily_unique_providers gauge",
_metric_line("openclaw_daily_unique_providers", len(usage.unique_providers_today), date=today, tz=tz_name),
])
def _append_counter_metrics(lines: list[str], usage: UsageReport) -> None:
lines.extend([
"# HELP openclaw_total_tokens_total All-time OpenClaw tokens from session usage logs.",
"# TYPE openclaw_total_tokens_total counter",
_metric_line("openclaw_total_tokens_total", usage.all_time.total),
"# HELP openclaw_input_tokens_total All-time OpenClaw input tokens from session usage logs.",
"# TYPE openclaw_input_tokens_total counter",
_metric_line("openclaw_input_tokens_total", usage.all_time.input),
"# HELP openclaw_output_tokens_total All-time OpenClaw output tokens from session usage logs.",
"# TYPE openclaw_output_tokens_total counter",
_metric_line("openclaw_output_tokens_total", usage.all_time.output),
"# HELP openclaw_cache_read_tokens_total All-time OpenClaw cache-read tokens from session usage logs.",
"# TYPE openclaw_cache_read_tokens_total counter",
_metric_line("openclaw_cache_read_tokens_total", usage.all_time.cache_read),
"# HELP openclaw_cache_write_tokens_total All-time OpenClaw cache-write tokens from session usage logs.",
"# TYPE openclaw_cache_write_tokens_total counter",
_metric_line("openclaw_cache_write_tokens_total", usage.all_time.cache_write),
"# HELP openclaw_reasoning_tokens_total All-time OpenClaw reasoning tokens from session usage logs.",
"# TYPE openclaw_reasoning_tokens_total counter",
_metric_line("openclaw_reasoning_tokens_total", usage.all_time.reasoning),
"# HELP openclaw_usage_entries_total All-time OpenClaw usage entries from session usage logs.",
"# TYPE openclaw_usage_entries_total counter",
_metric_line("openclaw_usage_entries_total", usage.all_time.entries),
"# HELP openclaw_cost_input_usd_total All-time OpenClaw input cost in USD from session usage logs.",
"# TYPE openclaw_cost_input_usd_total counter",
_metric_line("openclaw_cost_input_usd_total", f"{usage.all_time.cost_input:.12g}"),
"# HELP openclaw_cost_output_usd_total All-time OpenClaw output cost in USD from session usage logs.",
"# TYPE openclaw_cost_output_usd_total counter",
_metric_line("openclaw_cost_output_usd_total", f"{usage.all_time.cost_output:.12g}"),
"# HELP openclaw_cost_cache_read_usd_total All-time OpenClaw cache-read cost in USD from session usage logs.",
"# TYPE openclaw_cost_cache_read_usd_total counter",
_metric_line("openclaw_cost_cache_read_usd_total", f"{usage.all_time.cost_cache_read:.12g}"),
"# HELP openclaw_cost_cache_write_usd_total All-time OpenClaw cache-write cost in USD from session usage logs.",
"# TYPE openclaw_cost_cache_write_usd_total counter",
_metric_line("openclaw_cost_cache_write_usd_total", f"{usage.all_time.cost_cache_write:.12g}"),
"# HELP openclaw_cost_total_usd_total All-time OpenClaw total cost in USD from session usage logs.",
"# TYPE openclaw_cost_total_usd_total counter",
_metric_line("openclaw_cost_total_usd_total", f"{usage.all_time.cost_total:.12g}"),
"# HELP openclaw_message_records_total All-time message records seen in session logs.",
"# TYPE openclaw_message_records_total counter",
_metric_line("openclaw_message_records_total", usage.message_records_all_time),
"# HELP openclaw_non_message_records_total All-time non-message records seen in session logs.",
"# TYPE openclaw_non_message_records_total counter",
_metric_line("openclaw_non_message_records_total", usage.non_message_records_all_time),
])
def _append_bucket_metrics(lines: list[str], usage: UsageReport, tz_name: str) -> None:
lines.extend([
"# HELP openclaw_day_total_tokens Daily token totals for recent retained days.",
"# TYPE openclaw_day_total_tokens gauge",
"# HELP openclaw_day_cost_total Daily cost totals for recent retained days.",
"# TYPE openclaw_day_cost_total gauge",
"# HELP openclaw_day_usage_entries Daily usage-entry totals for recent retained days.",
"# TYPE openclaw_day_usage_entries gauge",
])
for bucket_date, totals in sorted(usage.day_buckets.items()):
labels = {"date": bucket_date, "tz": tz_name}
lines.append(_metric_line("openclaw_day_total_tokens", totals.total, **labels))
lines.append(_metric_line("openclaw_day_cost_total", f"{totals.cost_total:.12g}", **labels))
lines.append(_metric_line("openclaw_day_usage_entries", totals.entries, **labels))
lines.extend([
"# HELP openclaw_hour_total_tokens Hourly token totals for recent retained hours.",
"# TYPE openclaw_hour_total_tokens gauge",
"# HELP openclaw_hour_cost_total Hourly cost totals for recent retained hours.",
"# TYPE openclaw_hour_cost_total gauge",
"# HELP openclaw_hour_usage_entries Hourly usage-entry totals for recent retained hours.",
"# TYPE openclaw_hour_usage_entries gauge",
])
for bucket_hour, totals in sorted(usage.hour_buckets.items()):
labels = {"hour": bucket_hour, "tz": tz_name}
lines.append(_metric_line("openclaw_hour_total_tokens", totals.total, **labels))
lines.append(_metric_line("openclaw_hour_cost_total", f"{totals.cost_total:.12g}", **labels))
lines.append(_metric_line("openclaw_hour_usage_entries", totals.entries, **labels))
def _append_breakdown_metrics(lines: list[str], usage: UsageReport, today: str, tz_name: str) -> None:
lines.extend([
"# HELP openclaw_daily_messages_total Daily message count by role.",
"# TYPE openclaw_daily_messages_total gauge",
])
for role, count in sorted(usage.role_counts_today.items()):
lines.append(_metric_line("openclaw_daily_messages_total", count, date=today, tz=tz_name, role=role))
lines.extend([
"# HELP openclaw_daily_stop_reasons_total Daily assistant stop reasons by model/provider.",
"# TYPE openclaw_daily_stop_reasons_total gauge",
])
for (stop_reason, model, provider), count in sorted(usage.stop_reason_counts_today.items()):
lines.append(_metric_line("openclaw_daily_stop_reasons_total", count, date=today, tz=tz_name, stop_reason=stop_reason, model=model, provider=provider))
lines.extend([
"# HELP openclaw_daily_tool_calls_total Daily tool call count by tool name.",
"# TYPE openclaw_daily_tool_calls_total gauge",
])
for tool_name, count in sorted(usage.tool_call_counts_today.items()):
lines.append(_metric_line("openclaw_daily_tool_calls_total", count, date=today, tz=tz_name, tool_name=tool_name))
lines.extend([
"# HELP openclaw_daily_errors_total Daily message error count by error type.",
"# TYPE openclaw_daily_errors_total gauge",
])
for error_type, count in sorted(usage.error_type_counts_today.items()):
lines.append(_metric_line("openclaw_daily_errors_total", count, date=today, tz=tz_name, error_type=error_type))
lines.extend([
"# HELP openclaw_daily_model_total_tokens Daily total tokens by model/provider.",
"# TYPE openclaw_daily_model_total_tokens gauge",
])
for (model, provider), count in sorted(usage.tokens_by_model_today.items()):
lines.append(_metric_line("openclaw_daily_model_total_tokens", count, date=today, tz=tz_name, model=model, provider=provider))
lines.extend([
"# HELP openclaw_daily_model_input_tokens Daily input tokens by model/provider.",
"# TYPE openclaw_daily_model_input_tokens gauge",
])
for (model, provider), count in sorted(usage.input_tokens_by_model_today.items()):
lines.append(_metric_line("openclaw_daily_model_input_tokens", count, date=today, tz=tz_name, model=model, provider=provider))
lines.extend([
"# HELP openclaw_daily_model_output_tokens Daily output tokens by model/provider.",
"# TYPE openclaw_daily_model_output_tokens gauge",
])
for (model, provider), count in sorted(usage.output_tokens_by_model_today.items()):
lines.append(_metric_line("openclaw_daily_model_output_tokens", count, date=today, tz=tz_name, model=model, provider=provider))
lines.extend([
"# HELP openclaw_daily_model_reasoning_tokens Daily reasoning tokens by model/provider.",
"# TYPE openclaw_daily_model_reasoning_tokens gauge",
])
for (model, provider), count in sorted(usage.reasoning_tokens_by_model_today.items()):
lines.append(_metric_line("openclaw_daily_model_reasoning_tokens", count, date=today, tz=tz_name, model=model, provider=provider))
lines.extend([
"# HELP openclaw_model_total_tokens_total All-time total tokens by model/provider.",
"# TYPE openclaw_model_total_tokens_total counter",
])
for (model, provider), count in sorted(usage.tokens_by_model_all_time.items()):
lines.append(_metric_line("openclaw_model_total_tokens_total", count, model=model, provider=provider))
lines.extend([
"# HELP openclaw_model_input_tokens_total All-time input tokens by model/provider.",
"# TYPE openclaw_model_input_tokens_total counter",
])
for (model, provider), count in sorted(usage.input_tokens_by_model_all_time.items()):
lines.append(_metric_line("openclaw_model_input_tokens_total", count, model=model, provider=provider))
lines.extend([
"# HELP openclaw_model_output_tokens_total All-time output tokens by model/provider.",
"# TYPE openclaw_model_output_tokens_total counter",
])
for (model, provider), count in sorted(usage.output_tokens_by_model_all_time.items()):
lines.append(_metric_line("openclaw_model_output_tokens_total", count, model=model, provider=provider))
lines.extend([
"# HELP openclaw_daily_session_total_tokens Daily total tokens by session id.",
"# TYPE openclaw_daily_session_total_tokens gauge",
])
for session_id, count in sorted(usage.session_tokens_today.items()):
lines.append(_metric_line("openclaw_daily_session_total_tokens", count, date=today, tz=tz_name, session_id=session_id))
lines.extend([
"# HELP openclaw_daily_session_input_tokens Daily input tokens by session id.",
"# TYPE openclaw_daily_session_input_tokens gauge",
])
for session_id, count in sorted(usage.session_input_tokens_today.items()):
lines.append(_metric_line("openclaw_daily_session_input_tokens", count, date=today, tz=tz_name, session_id=session_id))
lines.extend([
"# HELP openclaw_daily_session_output_tokens Daily output tokens by session id.",
"# TYPE openclaw_daily_session_output_tokens gauge",
])
for session_id, count in sorted(usage.session_output_tokens_today.items()):
lines.append(_metric_line("openclaw_daily_session_output_tokens", count, date=today, tz=tz_name, session_id=session_id))
lines.extend([
"# HELP openclaw_session_total_tokens_total All-time total tokens by session id.",
"# TYPE openclaw_session_total_tokens_total counter",
])
for session_id, count in sorted(usage.session_tokens_all_time.items()):
lines.append(_metric_line("openclaw_session_total_tokens_total", count, session_id=session_id))
lines.extend([
"# HELP openclaw_daily_session_messages_total Daily message count by session id and role.",
"# TYPE openclaw_daily_session_messages_total gauge",
])
for (session_id, role), count in sorted(usage.session_message_counts_today.items()):
lines.append(_metric_line("openclaw_daily_session_messages_total", count, date=today, tz=tz_name, session_id=session_id, role=role))
lines.extend([
"# HELP openclaw_daily_session_errors_total Daily error count by session id.",
"# TYPE openclaw_daily_session_errors_total gauge",
])
for session_id, count in sorted(usage.session_error_counts_today.items()):
lines.append(_metric_line("openclaw_daily_session_errors_total", count, date=today, tz=tz_name, session_id=session_id))
lines.extend([
"# HELP openclaw_daily_session_tool_calls_total Daily tool call count by session id and tool name.",
"# TYPE openclaw_daily_session_tool_calls_total gauge",
])
for (session_id, tool_name), count in sorted(usage.session_tool_call_counts_today.items()):
lines.append(_metric_line("openclaw_daily_session_tool_calls_total", count, date=today, tz=tz_name, session_id=session_id, tool_name=tool_name))
def _append_exporter_metrics(lines: list[str], usage: UsageReport, session_index: SessionIndexReport, usage_cost_cache: UsageCostCacheReport, today: str, tz_name: str) -> None:
lines.extend([
"# HELP openclaw_exporter_files_scanned Session files scanned for metrics generation.",
"# TYPE openclaw_exporter_files_scanned gauge",
_metric_line("openclaw_exporter_files_scanned", usage.files_scanned, date=today, tz=tz_name),
"# HELP openclaw_exporter_lines_scanned Session lines scanned for metrics generation.",
"# TYPE openclaw_exporter_lines_scanned gauge",
_metric_line("openclaw_exporter_lines_scanned", usage.lines_scanned, date=today, tz=tz_name),
"# HELP openclaw_exporter_parse_errors Parse or read errors while generating metrics.",
"# TYPE openclaw_exporter_parse_errors gauge",
_metric_line("openclaw_exporter_parse_errors", usage.parse_errors, date=today, tz=tz_name),
"# HELP openclaw_exporter_history_days_retained Retained day-bucket history window.",
"# TYPE openclaw_exporter_history_days_retained gauge",
_metric_line("openclaw_exporter_history_days_retained", HISTORY_DAYS),
"# HELP openclaw_exporter_history_hours_retained Retained hour-bucket history window.",
"# TYPE openclaw_exporter_history_hours_retained gauge",
_metric_line("openclaw_exporter_history_hours_retained", HISTORY_HOURS),
"# HELP openclaw_sessions_visible_count Session entries currently present in sessions.json output.",
"# TYPE openclaw_sessions_visible_count gauge",
_metric_line("openclaw_sessions_visible_count", session_index.visible_count),
"# HELP openclaw_sessions_total_count Total stored sessions reported by sessions.json.",
"# TYPE openclaw_sessions_total_count gauge",
_metric_line("openclaw_sessions_total_count", session_index.total_count),
"# HELP openclaw_sessions_has_more Whether sessions.json was truncated when written.",
"# TYPE openclaw_sessions_has_more gauge",
_metric_line("openclaw_sessions_has_more", int(session_index.has_more)),
"# HELP openclaw_usage_cost_cache_files_tracked Number of files tracked in .usage-cost-cache.json.",
"# TYPE openclaw_usage_cost_cache_files_tracked gauge",
_metric_line("openclaw_usage_cost_cache_files_tracked", usage_cost_cache.files_tracked),
])
if usage_cost_cache.version is not None:
lines.extend([
"# HELP openclaw_usage_cost_cache_version Version field from .usage-cost-cache.json.",
"# TYPE openclaw_usage_cost_cache_version gauge",
_metric_line("openclaw_usage_cost_cache_version", usage_cost_cache.version),
])
if usage_cost_cache.updated_at_ms is not None:
lines.extend([
"# HELP openclaw_usage_cost_cache_updated_timestamp_seconds Last update time of .usage-cost-cache.json.",
"# TYPE openclaw_usage_cost_cache_updated_timestamp_seconds gauge",
_metric_line("openclaw_usage_cost_cache_updated_timestamp_seconds", f"{usage_cost_cache.updated_at_ms / 1000:.3f}"),
])
def _append_session_inventory_metrics(lines: list[str], session_index: SessionIndexReport) -> None:
lines.extend([
"# HELP openclaw_session_info Non-sensitive session metadata from sessions.json. Session key is intentionally omitted.",
"# TYPE openclaw_session_info gauge",
"# HELP openclaw_session_age_seconds Current age of a stored session from sessions.json.",
"# TYPE openclaw_session_age_seconds gauge",
"# HELP openclaw_session_updated_timestamp_seconds Last update time of a stored session from sessions.json.",
"# TYPE openclaw_session_updated_timestamp_seconds gauge",
"# HELP openclaw_session_started_timestamp_seconds Session start time from sessions.json.",
"# TYPE openclaw_session_started_timestamp_seconds gauge",
"# HELP openclaw_session_last_interaction_timestamp_seconds Last interaction time from sessions.json.",
"# TYPE openclaw_session_last_interaction_timestamp_seconds gauge",
"# HELP openclaw_session_total_tokens Stored session totalTokens from sessions.json.",
"# TYPE openclaw_session_total_tokens gauge",
"# HELP openclaw_session_context_tokens Stored session contextTokens from sessions.json.",
"# TYPE openclaw_session_context_tokens gauge",
"# HELP openclaw_session_runtime_seconds Stored runtimeMs from sessions.json.",
"# TYPE openclaw_session_runtime_seconds gauge",
"# HELP openclaw_session_estimated_cost_usd Stored estimatedCostUsd from sessions.json.",
"# TYPE openclaw_session_estimated_cost_usd gauge",
"# HELP openclaw_session_compaction_count Stored compactionCount from sessions.json.",
"# TYPE openclaw_session_compaction_count gauge",
"# HELP openclaw_session_usage_family_session_count Stored usageFamilySessionIds count from sessions.json.",
"# TYPE openclaw_session_usage_family_session_count gauge",
])
for session in sorted(session_index.sessions, key=lambda item: _sanitize_label(item.get("sessionId"))):
session_id = _sanitize_label(session.get("sessionId"))
usage_family_session_ids = session.get("usageFamilySessionIds")
labels = {
"session_id": session_id,
"status": _sanitize_label(session.get("status")),
"chat_type": _sanitize_label(session.get("chatType")),
"last_channel": _sanitize_label(session.get("lastChannel")),
"model": _sanitize_label(session.get("model")),
"provider": _sanitize_label(session.get("modelProvider")),
"auth_profile_override": _sanitize_label(session.get("authProfileOverride")),
"system_sent": str(bool(session.get("systemSent"))).lower(),
"aborted_last_run": str(bool(session.get("abortedLastRun"))).lower(),
"total_tokens_fresh": str(bool(session.get("totalTokensFresh"))).lower(),
}
lines.append(_metric_line("openclaw_session_info", 1, **labels))
lines.append(_metric_line("openclaw_session_age_seconds", f"{_as_int(session.get('ageMs')) / 1000:.3f}", session_id=session_id))
lines.append(_metric_line("openclaw_session_updated_timestamp_seconds", f"{_as_int(session.get('updatedAt')) / 1000:.3f}", session_id=session_id))
lines.append(_metric_line("openclaw_session_started_timestamp_seconds", f"{_as_int(session.get('sessionStartedAt')) / 1000:.3f}", session_id=session_id))
lines.append(_metric_line("openclaw_session_last_interaction_timestamp_seconds", f"{_as_int(session.get('lastInteractionAt')) / 1000:.3f}", session_id=session_id))
lines.append(_metric_line("openclaw_session_total_tokens", _as_int(session.get("totalTokens")), session_id=session_id))
lines.append(_metric_line("openclaw_session_context_tokens", _as_int(session.get("contextTokens")), session_id=session_id))
lines.append(_metric_line("openclaw_session_runtime_seconds", f"{_as_int(session.get('runtimeMs')) / 1000:.3f}", session_id=session_id))
lines.append(_metric_line("openclaw_session_estimated_cost_usd", f"{_as_float(session.get('estimatedCostUsd')):.12g}", session_id=session_id))
lines.append(_metric_line("openclaw_session_compaction_count", _as_int(session.get("compactionCount")), session_id=session_id))
lines.append(_metric_line("openclaw_session_usage_family_session_count", len(usage_family_session_ids) if isinstance(usage_family_session_ids, list) else 0, session_id=session_id))
@app.get("/metrics", response_class=PlainTextResponse)
def metrics() -> str:
usage = collect_usage()
session_index = collect_session_index()
usage_cost_cache = collect_usage_cost_cache()
today = _today_in_tz().isoformat()
tz_name = TIMEZONE.key
lines: list[str] = []
_append_current_day_metrics(lines, usage, today, tz_name)
_append_counter_metrics(lines, usage)
_append_bucket_metrics(lines, usage, tz_name)
_append_breakdown_metrics(lines, usage, today, tz_name)
_append_exporter_metrics(lines, usage, session_index, usage_cost_cache, today, tz_name)
_append_session_inventory_metrics(lines, session_index)
lines.append("")
return "\n".join(lines)
from openclaw_usage_exporter.api import app
+3
View File
@@ -0,0 +1,3 @@
from .api import app
__all__ = ["app"]
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
from .metrics import render_metrics
app = FastAPI(title="openclaw-usage-exporter")
@app.get("/metrics", response_class=PlainTextResponse)
def metrics() -> str:
return render_metrics()
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Iterable
from .config import HISTORY_DAYS, HISTORY_HOURS, SESSIONS_DIR, SESSIONS_INDEX_PATH, TIMEZONE, USAGE_COST_CACHE_PATH
from .models import SessionIndexReport, UsageCostCacheReport, UsageReport
from .utils import as_int, open_session_file, sanitize_label, now_in_tz, session_id_from_path
def iter_session_paths() -> Iterable[Path]:
for path in sorted(SESSIONS_DIR.glob("*.jsonl*")):
if ".trajectory." in path.name:
continue
yield path
def collect_usage() -> UsageReport:
report = UsageReport()
now = now_in_tz()
today = now.date()
day_cutoff = today - timedelta(days=HISTORY_DAYS - 1)
hour_cutoff = now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=HISTORY_HOURS - 1)
for path in iter_session_paths():
report.files_scanned += 1
session_id = session_id_from_path(path)
try:
with open_session_file(path) as handle:
for line in handle:
report.lines_scanned += 1
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
report.parse_errors += 1
continue
message = record.get("message") or {}
timestamp_ms = message.get("timestamp", record.get("timestamp"))
if not isinstance(timestamp_ms, (int, float)):
continue
dt = datetime.fromtimestamp(timestamp_ms / 1000, TIMEZONE)
usage_date = dt.date()
is_today = usage_date == today
if record.get("type") != "message":
report.non_message_records_all_time += 1
if is_today:
report.non_message_records_today += 1
continue
report.message_records_all_time += 1
if is_today:
report.message_records_today += 1
role = sanitize_label(message.get("role"))
model = sanitize_label(message.get("model"))
provider = sanitize_label(message.get("provider"))
stop_reason = sanitize_label(message.get("stopReason"))
tool_name = sanitize_label(message.get("toolName"))
error_type = sanitize_label(message.get("errorType"))
is_error = bool(message.get("isError"))
usage = message.get("usage")
if is_today:
report.role_counts_today[role] += 1
if model != "unknown":
report.unique_models_today.add(model)
if provider != "unknown":
report.unique_providers_today.add(provider)
if session_id:
report.unique_session_ids_today.add(session_id)
report.session_message_counts_today[(session_id, role)] += 1
if stop_reason != "unknown":
report.stop_reason_counts_today[(stop_reason, model, provider)] += 1
if tool_name != "unknown":
report.tool_call_counts_today[tool_name] += 1
if session_id:
report.session_tool_call_counts_today[(session_id, tool_name)] += 1
if is_error:
report.error_type_counts_today[error_type] += 1
if session_id:
report.session_error_counts_today[session_id] += 1
if not isinstance(usage, dict):
continue
total_tokens, input_tokens, output_tokens, _, _, reasoning_tokens, skipped_negative_costs = report.all_time.add_usage(usage)
if skipped_negative_costs:
report.negative_cost_entries_skipped += 1
for cost_field, raw_value in skipped_negative_costs.items():
report.negative_cost_fields_skipped[cost_field] += 1
report.negative_cost_amounts_skipped[cost_field] += abs(raw_value)
report.tokens_by_model_all_time[(model, provider)] += total_tokens
report.input_tokens_by_model_all_time[(model, provider)] += input_tokens
report.output_tokens_by_model_all_time[(model, provider)] += output_tokens
if session_id:
report.session_tokens_all_time[session_id] += total_tokens
if usage_date >= day_cutoff:
report.day_buckets[usage_date.isoformat()].add_usage(usage)
hour_bucket = dt.replace(minute=0, second=0, microsecond=0)
if hour_bucket >= hour_cutoff:
report.hour_buckets[hour_bucket.isoformat()].add_usage(usage)
if is_today:
report.today.add_usage(usage)
report.tokens_by_model_today[(model, provider)] += total_tokens
report.input_tokens_by_model_today[(model, provider)] += input_tokens
report.output_tokens_by_model_today[(model, provider)] += output_tokens
report.reasoning_tokens_by_model_today[(model, provider)] += reasoning_tokens
if session_id:
report.session_tokens_today[session_id] += total_tokens
report.session_input_tokens_today[session_id] += input_tokens
report.session_output_tokens_today[session_id] += output_tokens
except OSError:
report.parse_errors += 1
continue
return report
def collect_session_index() -> SessionIndexReport:
report = SessionIndexReport()
try:
data = json.loads(SESSIONS_INDEX_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return report
if not isinstance(data, dict):
return report
if isinstance(data.get("sessions"), list):
sessions = [session for session in data.get("sessions", []) if isinstance(session, dict)]
report.visible_count = as_int(data.get("count")) or len(sessions)
report.total_count = as_int(data.get("totalCount")) or len(sessions)
report.has_more = bool(data.get("hasMore"))
report.sessions = sessions
return report
sessions = [session for session in data.values() if isinstance(session, dict)]
report.visible_count = len(sessions)
report.total_count = len(sessions)
report.has_more = False
report.sessions = sessions
return report
def collect_usage_cost_cache() -> UsageCostCacheReport:
report = UsageCostCacheReport()
try:
data = json.loads(USAGE_COST_CACHE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return report
if not isinstance(data, dict):
return report
files = data.get("files")
report.version = as_int(data.get("version")) if data.get("version") is not None else None
report.updated_at_ms = as_int(data.get("updatedAt")) if data.get("updatedAt") is not None else None
report.files_tracked = len(files) if isinstance(files, dict) else 0
return report
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
import os
from pathlib import Path
from zoneinfo import ZoneInfo
SESSIONS_DIR = Path(os.environ.get("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions"))
TIMEZONE = ZoneInfo(os.environ.get("OPENCLAW_EXPORTER_TZ", "Europe/Berlin"))
SESSIONS_INDEX_PATH = Path(os.environ.get("OPENCLAW_SESSIONS_INDEX", str(SESSIONS_DIR / "sessions.json")))
USAGE_COST_CACHE_PATH = Path(os.environ.get("OPENCLAW_USAGE_COST_CACHE", str(SESSIONS_DIR / ".usage-cost-cache.json")))
HISTORY_DAYS = max(1, int(os.environ.get("OPENCLAW_HISTORY_DAYS", "30")))
HISTORY_HOURS = max(1, int(os.environ.get("OPENCLAW_HISTORY_HOURS", "168")))
+403
View File
@@ -0,0 +1,403 @@
from __future__ import annotations
from .collectors import collect_session_index, collect_usage, collect_usage_cost_cache
from .config import HISTORY_DAYS, HISTORY_HOURS, TIMEZONE
from .models import COST_FIELDS, SessionIndexReport, UsageCostCacheReport, UsageReport
from .utils import as_float, as_int, metric_line, sanitize_label, today_in_tz
def append_current_day_metrics(lines: list[str], usage: UsageReport, today: str, tz_name: str) -> None:
lines.extend([
"# HELP openclaw_daily_total_tokens Total OpenClaw tokens recorded today.",
"# TYPE openclaw_daily_total_tokens gauge",
metric_line("openclaw_daily_total_tokens", usage.today.total, date=today, tz=tz_name),
"# HELP openclaw_daily_input_tokens OpenClaw input tokens recorded today.",
"# TYPE openclaw_daily_input_tokens gauge",
metric_line("openclaw_daily_input_tokens", usage.today.input, date=today, tz=tz_name),
"# HELP openclaw_daily_output_tokens OpenClaw output tokens recorded today.",
"# TYPE openclaw_daily_output_tokens gauge",
metric_line("openclaw_daily_output_tokens", usage.today.output, date=today, tz=tz_name),
"# HELP openclaw_daily_cache_read_tokens OpenClaw cache-read tokens recorded today.",
"# TYPE openclaw_daily_cache_read_tokens gauge",
metric_line("openclaw_daily_cache_read_tokens", usage.today.cache_read, date=today, tz=tz_name),
"# HELP openclaw_daily_cache_write_tokens OpenClaw cache-write tokens recorded today.",
"# TYPE openclaw_daily_cache_write_tokens gauge",
metric_line("openclaw_daily_cache_write_tokens", usage.today.cache_write, date=today, tz=tz_name),
"# HELP openclaw_daily_reasoning_tokens OpenClaw reasoning tokens recorded today.",
"# TYPE openclaw_daily_reasoning_tokens gauge",
metric_line("openclaw_daily_reasoning_tokens", usage.today.reasoning, date=today, tz=tz_name),
"# HELP openclaw_daily_usage_entries OpenClaw usage entries recorded today.",
"# TYPE openclaw_daily_usage_entries gauge",
metric_line("openclaw_daily_usage_entries", usage.today.entries, date=today, tz=tz_name),
"# HELP openclaw_daily_cost_input OpenClaw input cost recorded today.",
"# TYPE openclaw_daily_cost_input gauge",
metric_line("openclaw_daily_cost_input", f"{usage.today.cost_input:.12g}", date=today, tz=tz_name),
"# HELP openclaw_daily_cost_output OpenClaw output cost recorded today.",
"# TYPE openclaw_daily_cost_output gauge",
metric_line("openclaw_daily_cost_output", f"{usage.today.cost_output:.12g}", date=today, tz=tz_name),
"# HELP openclaw_daily_cost_cache_read OpenClaw cache-read cost recorded today.",
"# TYPE openclaw_daily_cost_cache_read gauge",
metric_line("openclaw_daily_cost_cache_read", f"{usage.today.cost_cache_read:.12g}", date=today, tz=tz_name),
"# HELP openclaw_daily_cost_cache_write OpenClaw cache-write cost recorded today.",
"# TYPE openclaw_daily_cost_cache_write gauge",
metric_line("openclaw_daily_cost_cache_write", f"{usage.today.cost_cache_write:.12g}", date=today, tz=tz_name),
"# HELP openclaw_daily_cost_total OpenClaw total cost recorded today.",
"# TYPE openclaw_daily_cost_total gauge",
metric_line("openclaw_daily_cost_total", f"{usage.today.cost_total:.12g}", date=today, tz=tz_name),
"# HELP openclaw_daily_message_records Total message records seen today.",
"# TYPE openclaw_daily_message_records gauge",
metric_line("openclaw_daily_message_records", usage.message_records_today, date=today, tz=tz_name),
"# HELP openclaw_daily_non_message_records Total non-message records seen today.",
"# TYPE openclaw_daily_non_message_records gauge",
metric_line("openclaw_daily_non_message_records", usage.non_message_records_today, date=today, tz=tz_name),
"# HELP openclaw_daily_unique_sessions Number of unique session ids seen today.",
"# TYPE openclaw_daily_unique_sessions gauge",
metric_line("openclaw_daily_unique_sessions", len(usage.unique_session_ids_today), date=today, tz=tz_name),
"# HELP openclaw_daily_unique_models Number of unique models seen today.",
"# TYPE openclaw_daily_unique_models gauge",
metric_line("openclaw_daily_unique_models", len(usage.unique_models_today), date=today, tz=tz_name),
"# HELP openclaw_daily_unique_providers Number of unique providers seen today.",
"# TYPE openclaw_daily_unique_providers gauge",
metric_line("openclaw_daily_unique_providers", len(usage.unique_providers_today), date=today, tz=tz_name),
])
def append_counter_metrics(lines: list[str], usage: UsageReport) -> None:
lines.extend([
"# HELP openclaw_total_tokens_total All-time OpenClaw tokens from session usage logs.",
"# TYPE openclaw_total_tokens_total counter",
metric_line("openclaw_total_tokens_total", usage.all_time.total),
"# HELP openclaw_input_tokens_total All-time OpenClaw input tokens from session usage logs.",
"# TYPE openclaw_input_tokens_total counter",
metric_line("openclaw_input_tokens_total", usage.all_time.input),
"# HELP openclaw_output_tokens_total All-time OpenClaw output tokens from session usage logs.",
"# TYPE openclaw_output_tokens_total counter",
metric_line("openclaw_output_tokens_total", usage.all_time.output),
"# HELP openclaw_cache_read_tokens_total All-time OpenClaw cache-read tokens from session usage logs.",
"# TYPE openclaw_cache_read_tokens_total counter",
metric_line("openclaw_cache_read_tokens_total", usage.all_time.cache_read),
"# HELP openclaw_cache_write_tokens_total All-time OpenClaw cache-write tokens from session usage logs.",
"# TYPE openclaw_cache_write_tokens_total counter",
metric_line("openclaw_cache_write_tokens_total", usage.all_time.cache_write),
"# HELP openclaw_reasoning_tokens_total All-time OpenClaw reasoning tokens from session usage logs.",
"# TYPE openclaw_reasoning_tokens_total counter",
metric_line("openclaw_reasoning_tokens_total", usage.all_time.reasoning),
"# HELP openclaw_usage_entries_total All-time OpenClaw usage entries from session usage logs.",
"# TYPE openclaw_usage_entries_total counter",
metric_line("openclaw_usage_entries_total", usage.all_time.entries),
"# HELP openclaw_cost_input_usd_total All-time OpenClaw input cost in USD from session usage logs.",
"# TYPE openclaw_cost_input_usd_total counter",
metric_line("openclaw_cost_input_usd_total", f"{usage.all_time.cost_input:.12g}"),
"# HELP openclaw_cost_output_usd_total All-time OpenClaw output cost in USD from session usage logs.",
"# TYPE openclaw_cost_output_usd_total counter",
metric_line("openclaw_cost_output_usd_total", f"{usage.all_time.cost_output:.12g}"),
"# HELP openclaw_cost_cache_read_usd_total All-time OpenClaw cache-read cost in USD from session usage logs.",
"# TYPE openclaw_cost_cache_read_usd_total counter",
metric_line("openclaw_cost_cache_read_usd_total", f"{usage.all_time.cost_cache_read:.12g}"),
"# HELP openclaw_cost_cache_write_usd_total All-time OpenClaw cache-write cost in USD from session usage logs.",
"# TYPE openclaw_cost_cache_write_usd_total counter",
metric_line("openclaw_cost_cache_write_usd_total", f"{usage.all_time.cost_cache_write:.12g}"),
"# HELP openclaw_cost_total_usd_total All-time OpenClaw total cost in USD from session usage logs.",
"# TYPE openclaw_cost_total_usd_total counter",
metric_line("openclaw_cost_total_usd_total", f"{usage.all_time.cost_total:.12g}"),
"# HELP openclaw_message_records_total All-time message records seen in session logs.",
"# TYPE openclaw_message_records_total counter",
metric_line("openclaw_message_records_total", usage.message_records_all_time),
"# HELP openclaw_non_message_records_total All-time non-message records seen in session logs.",
"# TYPE openclaw_non_message_records_total counter",
metric_line("openclaw_non_message_records_total", usage.non_message_records_all_time),
])
def append_bucket_metrics(lines: list[str], usage: UsageReport, tz_name: str) -> None:
lines.extend([
"# HELP openclaw_day_total_tokens Daily token totals for recent retained days.",
"# TYPE openclaw_day_total_tokens gauge",
"# HELP openclaw_day_cost_total Daily cost totals for recent retained days.",
"# TYPE openclaw_day_cost_total gauge",
"# HELP openclaw_day_usage_entries Daily usage-entry totals for recent retained days.",
"# TYPE openclaw_day_usage_entries gauge",
])
for bucket_date, totals in sorted(usage.day_buckets.items()):
labels = {"date": bucket_date, "tz": tz_name}
lines.append(metric_line("openclaw_day_total_tokens", totals.total, **labels))
lines.append(metric_line("openclaw_day_cost_total", f"{totals.cost_total:.12g}", **labels))
lines.append(metric_line("openclaw_day_usage_entries", totals.entries, **labels))
lines.extend([
"# HELP openclaw_hour_total_tokens Hourly token totals for recent retained hours.",
"# TYPE openclaw_hour_total_tokens gauge",
"# HELP openclaw_hour_cost_total Hourly cost totals for recent retained hours.",
"# TYPE openclaw_hour_cost_total gauge",
"# HELP openclaw_hour_usage_entries Hourly usage-entry totals for recent retained hours.",
"# TYPE openclaw_hour_usage_entries gauge",
])
for bucket_hour, totals in sorted(usage.hour_buckets.items()):
labels = {"hour": bucket_hour, "tz": tz_name}
lines.append(metric_line("openclaw_hour_total_tokens", totals.total, **labels))
lines.append(metric_line("openclaw_hour_cost_total", f"{totals.cost_total:.12g}", **labels))
lines.append(metric_line("openclaw_hour_usage_entries", totals.entries, **labels))
def append_breakdown_metrics(lines: list[str], usage: UsageReport, today: str, tz_name: str) -> None:
lines.extend([
"# HELP openclaw_daily_messages_total Daily message count by role.",
"# TYPE openclaw_daily_messages_total gauge",
])
for role, count in sorted(usage.role_counts_today.items()):
lines.append(metric_line("openclaw_daily_messages_total", count, date=today, tz=tz_name, role=role))
lines.extend([
"# HELP openclaw_daily_stop_reasons_total Daily assistant stop reasons by model/provider.",
"# TYPE openclaw_daily_stop_reasons_total gauge",
])
for (stop_reason, model, provider), count in sorted(usage.stop_reason_counts_today.items()):
lines.append(metric_line("openclaw_daily_stop_reasons_total", count, date=today, tz=tz_name, stop_reason=stop_reason, model=model, provider=provider))
lines.extend([
"# HELP openclaw_daily_tool_calls_total Daily tool call count by tool name.",
"# TYPE openclaw_daily_tool_calls_total gauge",
])
for tool_name, count in sorted(usage.tool_call_counts_today.items()):
lines.append(metric_line("openclaw_daily_tool_calls_total", count, date=today, tz=tz_name, tool_name=tool_name))
lines.extend([
"# HELP openclaw_daily_errors_total Daily message error count by error type.",
"# TYPE openclaw_daily_errors_total gauge",
])
for error_type, count in sorted(usage.error_type_counts_today.items()):
lines.append(metric_line("openclaw_daily_errors_total", count, date=today, tz=tz_name, error_type=error_type))
lines.extend([
"# HELP openclaw_daily_model_total_tokens Daily total tokens by model/provider.",
"# TYPE openclaw_daily_model_total_tokens gauge",
])
for (model, provider), count in sorted(usage.tokens_by_model_today.items()):
lines.append(metric_line("openclaw_daily_model_total_tokens", count, date=today, tz=tz_name, model=model, provider=provider))
lines.extend([
"# HELP openclaw_daily_model_input_tokens Daily input tokens by model/provider.",
"# TYPE openclaw_daily_model_input_tokens gauge",
])
for (model, provider), count in sorted(usage.input_tokens_by_model_today.items()):
lines.append(metric_line("openclaw_daily_model_input_tokens", count, date=today, tz=tz_name, model=model, provider=provider))
lines.extend([
"# HELP openclaw_daily_model_output_tokens Daily output tokens by model/provider.",
"# TYPE openclaw_daily_model_output_tokens gauge",
])
for (model, provider), count in sorted(usage.output_tokens_by_model_today.items()):
lines.append(metric_line("openclaw_daily_model_output_tokens", count, date=today, tz=tz_name, model=model, provider=provider))
lines.extend([
"# HELP openclaw_daily_model_reasoning_tokens Daily reasoning tokens by model/provider.",
"# TYPE openclaw_daily_model_reasoning_tokens gauge",
])
for (model, provider), count in sorted(usage.reasoning_tokens_by_model_today.items()):
lines.append(metric_line("openclaw_daily_model_reasoning_tokens", count, date=today, tz=tz_name, model=model, provider=provider))
lines.extend([
"# HELP openclaw_model_total_tokens_total All-time total tokens by model/provider.",
"# TYPE openclaw_model_total_tokens_total counter",
])
for (model, provider), count in sorted(usage.tokens_by_model_all_time.items()):
lines.append(metric_line("openclaw_model_total_tokens_total", count, model=model, provider=provider))
lines.extend([
"# HELP openclaw_model_input_tokens_total All-time input tokens by model/provider.",
"# TYPE openclaw_model_input_tokens_total counter",
])
for (model, provider), count in sorted(usage.input_tokens_by_model_all_time.items()):
lines.append(metric_line("openclaw_model_input_tokens_total", count, model=model, provider=provider))
lines.extend([
"# HELP openclaw_model_output_tokens_total All-time output tokens by model/provider.",
"# TYPE openclaw_model_output_tokens_total counter",
])
for (model, provider), count in sorted(usage.output_tokens_by_model_all_time.items()):
lines.append(metric_line("openclaw_model_output_tokens_total", count, model=model, provider=provider))
lines.extend([
"# HELP openclaw_daily_session_total_tokens Daily total tokens by session id.",
"# TYPE openclaw_daily_session_total_tokens gauge",
])
for session_id, count in sorted(usage.session_tokens_today.items()):
lines.append(metric_line("openclaw_daily_session_total_tokens", count, date=today, tz=tz_name, session_id=session_id))
lines.extend([
"# HELP openclaw_daily_session_input_tokens Daily input tokens by session id.",
"# TYPE openclaw_daily_session_input_tokens gauge",
])
for session_id, count in sorted(usage.session_input_tokens_today.items()):
lines.append(metric_line("openclaw_daily_session_input_tokens", count, date=today, tz=tz_name, session_id=session_id))
lines.extend([
"# HELP openclaw_daily_session_output_tokens Daily output tokens by session id.",
"# TYPE openclaw_daily_session_output_tokens gauge",
])
for session_id, count in sorted(usage.session_output_tokens_today.items()):
lines.append(metric_line("openclaw_daily_session_output_tokens", count, date=today, tz=tz_name, session_id=session_id))
lines.extend([
"# HELP openclaw_session_total_tokens_total All-time total tokens by session id.",
"# TYPE openclaw_session_total_tokens_total counter",
])
for session_id, count in sorted(usage.session_tokens_all_time.items()):
lines.append(metric_line("openclaw_session_total_tokens_total", count, session_id=session_id))
lines.extend([
"# HELP openclaw_daily_session_messages_total Daily message count by session id and role.",
"# TYPE openclaw_daily_session_messages_total gauge",
])
for (session_id, role), count in sorted(usage.session_message_counts_today.items()):
lines.append(metric_line("openclaw_daily_session_messages_total", count, date=today, tz=tz_name, session_id=session_id, role=role))
lines.extend([
"# HELP openclaw_daily_session_errors_total Daily error count by session id.",
"# TYPE openclaw_daily_session_errors_total gauge",
])
for session_id, count in sorted(usage.session_error_counts_today.items()):
lines.append(metric_line("openclaw_daily_session_errors_total", count, date=today, tz=tz_name, session_id=session_id))
lines.extend([
"# HELP openclaw_daily_session_tool_calls_total Daily tool call count by session id and tool name.",
"# TYPE openclaw_daily_session_tool_calls_total gauge",
])
for (session_id, tool_name), count in sorted(usage.session_tool_call_counts_today.items()):
lines.append(metric_line("openclaw_daily_session_tool_calls_total", count, date=today, tz=tz_name, session_id=session_id, tool_name=tool_name))
def append_exporter_metrics(lines: list[str], usage: UsageReport, session_index: SessionIndexReport, usage_cost_cache: UsageCostCacheReport, today: str, tz_name: str) -> None:
lines.extend([
"# HELP openclaw_exporter_files_scanned Session files scanned for metrics generation.",
"# TYPE openclaw_exporter_files_scanned gauge",
metric_line("openclaw_exporter_files_scanned", usage.files_scanned, date=today, tz=tz_name),
"# HELP openclaw_exporter_lines_scanned Session lines scanned for metrics generation.",
"# TYPE openclaw_exporter_lines_scanned gauge",
metric_line("openclaw_exporter_lines_scanned", usage.lines_scanned, date=today, tz=tz_name),
"# HELP openclaw_exporter_parse_errors Parse or read errors while generating metrics.",
"# TYPE openclaw_exporter_parse_errors gauge",
metric_line("openclaw_exporter_parse_errors", usage.parse_errors, date=today, tz=tz_name),
"# HELP openclaw_exporter_negative_cost_entries_skipped Usage entries whose negative cost values were ignored as invalid.",
"# TYPE openclaw_exporter_negative_cost_entries_skipped gauge",
metric_line("openclaw_exporter_negative_cost_entries_skipped", usage.negative_cost_entries_skipped),
"# HELP openclaw_exporter_history_days_retained Retained day-bucket history window.",
"# TYPE openclaw_exporter_history_days_retained gauge",
metric_line("openclaw_exporter_history_days_retained", HISTORY_DAYS),
"# HELP openclaw_exporter_history_hours_retained Retained hour-bucket history window.",
"# TYPE openclaw_exporter_history_hours_retained gauge",
metric_line("openclaw_exporter_history_hours_retained", HISTORY_HOURS),
"# HELP openclaw_sessions_visible_count Session entries currently present in sessions.json output.",
"# TYPE openclaw_sessions_visible_count gauge",
metric_line("openclaw_sessions_visible_count", session_index.visible_count),
"# HELP openclaw_sessions_total_count Total stored sessions reported by sessions.json.",
"# TYPE openclaw_sessions_total_count gauge",
metric_line("openclaw_sessions_total_count", session_index.total_count),
"# HELP openclaw_sessions_has_more Whether sessions.json was truncated when written.",
"# TYPE openclaw_sessions_has_more gauge",
metric_line("openclaw_sessions_has_more", int(session_index.has_more)),
"# HELP openclaw_usage_cost_cache_files_tracked Number of files tracked in .usage-cost-cache.json.",
"# TYPE openclaw_usage_cost_cache_files_tracked gauge",
metric_line("openclaw_usage_cost_cache_files_tracked", usage_cost_cache.files_tracked),
])
lines.extend([
"# HELP openclaw_exporter_negative_cost_fields_skipped Invalid negative cost fields ignored by field name.",
"# TYPE openclaw_exporter_negative_cost_fields_skipped gauge",
])
for cost_field in COST_FIELDS:
if usage.negative_cost_fields_skipped.get(cost_field):
lines.append(metric_line("openclaw_exporter_negative_cost_fields_skipped", usage.negative_cost_fields_skipped[cost_field], cost_field=cost_field))
lines.extend([
"# HELP openclaw_exporter_negative_cost_amount_skipped Total absolute invalid negative cost amount ignored by field name.",
"# TYPE openclaw_exporter_negative_cost_amount_skipped gauge",
])
for cost_field in COST_FIELDS:
if usage.negative_cost_amounts_skipped.get(cost_field):
lines.append(metric_line("openclaw_exporter_negative_cost_amount_skipped", f"{usage.negative_cost_amounts_skipped[cost_field]:.12g}", cost_field=cost_field))
if usage_cost_cache.version is not None:
lines.extend([
"# HELP openclaw_usage_cost_cache_version Version field from .usage-cost-cache.json.",
"# TYPE openclaw_usage_cost_cache_version gauge",
metric_line("openclaw_usage_cost_cache_version", usage_cost_cache.version),
])
if usage_cost_cache.updated_at_ms is not None:
lines.extend([
"# HELP openclaw_usage_cost_cache_updated_timestamp_seconds Last update time of .usage-cost-cache.json.",
"# TYPE openclaw_usage_cost_cache_updated_timestamp_seconds gauge",
metric_line("openclaw_usage_cost_cache_updated_timestamp_seconds", f"{usage_cost_cache.updated_at_ms / 1000:.3f}"),
])
def append_session_inventory_metrics(lines: list[str], session_index: SessionIndexReport) -> None:
lines.extend([
"# HELP openclaw_session_info Non-sensitive session metadata from sessions.json. Session key is intentionally omitted.",
"# TYPE openclaw_session_info gauge",
"# HELP openclaw_session_age_seconds Current age of a stored session from sessions.json.",
"# TYPE openclaw_session_age_seconds gauge",
"# HELP openclaw_session_updated_timestamp_seconds Last update time of a stored session from sessions.json.",
"# TYPE openclaw_session_updated_timestamp_seconds gauge",
"# HELP openclaw_session_started_timestamp_seconds Session start time from sessions.json.",
"# TYPE openclaw_session_started_timestamp_seconds gauge",
"# HELP openclaw_session_last_interaction_timestamp_seconds Last interaction time from sessions.json.",
"# TYPE openclaw_session_last_interaction_timestamp_seconds gauge",
"# HELP openclaw_session_total_tokens Stored session totalTokens from sessions.json.",
"# TYPE openclaw_session_total_tokens gauge",
"# HELP openclaw_session_context_tokens Stored session contextTokens from sessions.json.",
"# TYPE openclaw_session_context_tokens gauge",
"# HELP openclaw_session_runtime_seconds Stored runtimeMs from sessions.json.",
"# TYPE openclaw_session_runtime_seconds gauge",
"# HELP openclaw_session_estimated_cost_usd Stored estimatedCostUsd from sessions.json.",
"# TYPE openclaw_session_estimated_cost_usd gauge",
"# HELP openclaw_session_compaction_count Stored compactionCount from sessions.json.",
"# TYPE openclaw_session_compaction_count gauge",
"# HELP openclaw_session_usage_family_session_count Stored usageFamilySessionIds count from sessions.json.",
"# TYPE openclaw_session_usage_family_session_count gauge",
])
for session in sorted(session_index.sessions, key=lambda item: sanitize_label(item.get("sessionId"))):
session_id = sanitize_label(session.get("sessionId"))
usage_family_session_ids = session.get("usageFamilySessionIds")
labels = {
"session_id": session_id,
"status": sanitize_label(session.get("status")),
"chat_type": sanitize_label(session.get("chatType")),
"last_channel": sanitize_label(session.get("lastChannel")),
"model": sanitize_label(session.get("model")),
"provider": sanitize_label(session.get("modelProvider")),
"auth_profile_override": sanitize_label(session.get("authProfileOverride")),
"system_sent": str(bool(session.get("systemSent"))).lower(),
"aborted_last_run": str(bool(session.get("abortedLastRun"))).lower(),
"total_tokens_fresh": str(bool(session.get("totalTokensFresh"))).lower(),
}
lines.append(metric_line("openclaw_session_info", 1, **labels))
lines.append(metric_line("openclaw_session_age_seconds", f"{as_int(session.get('ageMs')) / 1000:.3f}", session_id=session_id))
lines.append(metric_line("openclaw_session_updated_timestamp_seconds", f"{as_int(session.get('updatedAt')) / 1000:.3f}", session_id=session_id))
lines.append(metric_line("openclaw_session_started_timestamp_seconds", f"{as_int(session.get('sessionStartedAt')) / 1000:.3f}", session_id=session_id))
lines.append(metric_line("openclaw_session_last_interaction_timestamp_seconds", f"{as_int(session.get('lastInteractionAt')) / 1000:.3f}", session_id=session_id))
lines.append(metric_line("openclaw_session_total_tokens", as_int(session.get("totalTokens")), session_id=session_id))
lines.append(metric_line("openclaw_session_context_tokens", as_int(session.get("contextTokens")), session_id=session_id))
lines.append(metric_line("openclaw_session_runtime_seconds", f"{as_int(session.get('runtimeMs')) / 1000:.3f}", session_id=session_id))
lines.append(metric_line("openclaw_session_estimated_cost_usd", f"{as_float(session.get('estimatedCostUsd')):.12g}", session_id=session_id))
lines.append(metric_line("openclaw_session_compaction_count", as_int(session.get("compactionCount")), session_id=session_id))
lines.append(metric_line("openclaw_session_usage_family_session_count", len(usage_family_session_ids) if isinstance(usage_family_session_ids, list) else 0, session_id=session_id))
def render_metrics() -> str:
usage = collect_usage()
session_index = collect_session_index()
usage_cost_cache = collect_usage_cost_cache()
today = today_in_tz().isoformat()
tz_name = TIMEZONE.key
lines: list[str] = []
append_current_day_metrics(lines, usage, today, tz_name)
append_counter_metrics(lines, usage)
append_bucket_metrics(lines, usage, tz_name)
append_breakdown_metrics(lines, usage, today, tz_name)
append_exporter_metrics(lines, usage, session_index, usage_cost_cache, today, tz_name)
append_session_inventory_metrics(lines, session_index)
lines.append("")
return "\n".join(lines)
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from .utils import as_float, as_int
COST_FIELDS = ("input", "output", "cacheRead", "cacheWrite", "total")
@dataclass
class Totals:
total: int = 0
input: int = 0
output: int = 0
cache_read: int = 0
cache_write: int = 0
reasoning: int = 0
entries: int = 0
cost_input: float = 0.0
cost_output: float = 0.0
cost_cache_read: float = 0.0
cost_cache_write: float = 0.0
cost_total: float = 0.0
def add_usage(self, usage: dict) -> tuple[int, int, int, int, int, int, dict[str, float]]:
total_tokens = as_int(usage.get("totalTokens"))
input_tokens = as_int(usage.get("input"))
output_tokens = as_int(usage.get("output"))
cache_read_tokens = as_int(usage.get("cacheRead"))
cache_write_tokens = as_int(usage.get("cacheWrite"))
reasoning_tokens = as_int(usage.get("reasoningTokens"))
self.entries += 1
self.total += total_tokens
self.input += input_tokens
self.output += output_tokens
self.cache_read += cache_read_tokens
self.cache_write += cache_write_tokens
self.reasoning += reasoning_tokens
skipped_negative_costs: dict[str, float] = {}
cost = usage.get("cost") or {}
if isinstance(cost, dict):
input_cost = as_float(cost.get("input"))
output_cost = as_float(cost.get("output"))
cache_read_cost = as_float(cost.get("cacheRead"))
cache_write_cost = as_float(cost.get("cacheWrite"))
total_cost = as_float(cost.get("total"))
if input_cost < 0:
skipped_negative_costs["input"] = input_cost
input_cost = 0.0
if output_cost < 0:
skipped_negative_costs["output"] = output_cost
output_cost = 0.0
if cache_read_cost < 0:
skipped_negative_costs["cacheRead"] = cache_read_cost
cache_read_cost = 0.0
if cache_write_cost < 0:
skipped_negative_costs["cacheWrite"] = cache_write_cost
cache_write_cost = 0.0
if total_cost < 0:
skipped_negative_costs["total"] = total_cost
total_cost = 0.0
self.cost_input += input_cost
self.cost_output += output_cost
self.cost_cache_read += cache_read_cost
self.cost_cache_write += cache_write_cost
self.cost_total += total_cost
return (
total_tokens,
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens,
reasoning_tokens,
skipped_negative_costs,
)
@dataclass
class UsageReport:
today: Totals = field(default_factory=Totals)
all_time: Totals = field(default_factory=Totals)
files_scanned: int = 0
lines_scanned: int = 0
parse_errors: int = 0
negative_cost_entries_skipped: int = 0
negative_cost_fields_skipped: Counter[str] = field(default_factory=Counter)
negative_cost_amounts_skipped: defaultdict[str, float] = field(default_factory=lambda: defaultdict(float))
message_records_today: int = 0
message_records_all_time: int = 0
non_message_records_today: int = 0
non_message_records_all_time: int = 0
unique_session_ids_today: set[str] = field(default_factory=set)
unique_models_today: set[str] = field(default_factory=set)
unique_providers_today: set[str] = field(default_factory=set)
role_counts_today: Counter[str] = field(default_factory=Counter)
stop_reason_counts_today: Counter[tuple[str, str, str]] = field(default_factory=Counter)
tool_call_counts_today: Counter[str] = field(default_factory=Counter)
error_type_counts_today: Counter[str] = field(default_factory=Counter)
session_message_counts_today: Counter[tuple[str, str]] = field(default_factory=Counter)
session_error_counts_today: Counter[str] = field(default_factory=Counter)
session_tool_call_counts_today: Counter[tuple[str, str]] = field(default_factory=Counter)
tokens_by_model_today: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
input_tokens_by_model_today: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
output_tokens_by_model_today: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
reasoning_tokens_by_model_today: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
session_tokens_today: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
session_input_tokens_today: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
session_output_tokens_today: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
tokens_by_model_all_time: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
input_tokens_by_model_all_time: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
output_tokens_by_model_all_time: defaultdict[tuple[str, str], int] = field(default_factory=lambda: defaultdict(int))
session_tokens_all_time: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
day_buckets: defaultdict[str, Totals] = field(default_factory=lambda: defaultdict(Totals))
hour_buckets: defaultdict[str, Totals] = field(default_factory=lambda: defaultdict(Totals))
@dataclass
class SessionIndexReport:
visible_count: int = 0
total_count: int = 0
has_more: bool = False
sessions: list[dict] = field(default_factory=list)
@dataclass
class UsageCostCacheReport:
version: int | None = None
updated_at_ms: int | None = None
files_tracked: int = 0
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import gzip
import re
from datetime import date, datetime
from pathlib import Path
from .config import TIMEZONE
UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
def as_int(value: object) -> int:
if value is None:
return 0
if isinstance(value, bool):
return int(value)
if isinstance(value, (int, float)):
return int(value)
return 0
def as_float(value: object) -> float:
if value is None:
return 0.0
if isinstance(value, bool):
return float(value)
if isinstance(value, (int, float)):
return float(value)
return 0.0
def today_in_tz() -> date:
return datetime.now(TIMEZONE).date()
def now_in_tz() -> datetime:
return datetime.now(TIMEZONE)
def sanitize_label(value: object, default: str = "unknown") -> str:
if value is None:
return default
text = str(value).strip()
return text or default
def prom_value(value: str) -> str:
return value.replace("\\", r"\\").replace("\n", r"\n").replace('"', r'\"')
def metric_line(name: str, value: object, **labels: object) -> str:
if labels:
rendered = ",".join(f'{key}="{prom_value(sanitize_label(val))}"' for key, val in sorted(labels.items()))
return f"{name}{{{rendered}}} {value}"
return f"{name} {value}"
def open_session_file(path: Path):
if path.suffix == ".gz":
return gzip.open(path, "rt", encoding="utf-8")
return path.open("r", encoding="utf-8")
def session_id_from_path(path: Path) -> str | None:
match = UUID_RE.search(path.name)
if match:
return match.group(0).lower()
return None