diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b4f83d..b7d1156 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ All notable changes to FoundryGate should be documented here.
The format is intentionally lightweight and human-readable. Group entries by release and focus on user-visible behavior, operational changes, and compatibility notes.
+## Unreleased
+
+### Added
+
+- Added richer client usage reporting in `GET /api/stats` and the dashboard, including per-client tokens, failures, success rate, and aggregate client totals
+
## v1.0.0 - 2026-03-15
### Added
diff --git a/docs/API.md b/docs/API.md
index 4539457..4ca1098 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -91,7 +91,7 @@ curl -fsS 'http://127.0.0.1:8090/api/providers?capability=image_generation'
### `GET /api/stats`
-Returns aggregate request counters, cost data, and operator-action summaries.
+Returns aggregate request counters, token usage, per-client breakdowns, aggregate client totals, cost data, and operator-action summaries.
```bash
curl -fsS http://127.0.0.1:8090/api/stats
diff --git a/foundrygate/main.py b/foundrygate/main.py
index 7588af1..b3e753c 100644
--- a/foundrygate/main.py
+++ b/foundrygate/main.py
@@ -913,6 +913,7 @@ async def stats(
"modalities": _metrics.get_modality_breakdown(**filters),
"routing": _metrics.get_routing_breakdown(**filters),
"clients": _metrics.get_client_breakdown(**filters),
+ "client_totals": _metrics.get_client_totals(**filters),
"operator_actions": _metrics.get_operator_breakdown(**operator_filters),
"hourly": _metrics.get_hourly_series(24),
"daily": _metrics.get_daily_totals(30),
@@ -1676,10 +1677,17 @@ def _dashboard_csp() -> str:
+
+
Client Totals
+
+ | Profile | Client Tag | Requests | Failures | Success | Tokens | Cost | Cost / Request | Avg Latency | Modalities | Providers |
+
+
+
Client Breakdown
- | Modality | Profile | Client Tag | Provider | Layer | Requests | Cost | Avg Latency |
+ Modality | Profile | Client Tag | Provider | Layer | Requests | Failures | Success | Tokens | Cost | Cost / Request | Avg Latency |
@@ -1828,7 +1836,9 @@ def _dashboard_csp() -> str:
$('#ago').textContent = ago(totals.last_request);
const operatorRows = stats.operator_actions || [];
+ const clientTotalRows = stats.client_totals || [];
const latestOperatorEvent = (operatorEvents.events || [])[0] || null;
+ const topClient = clientTotalRows.length ? clientTotalRows[0] : null;
$('#cards').innerHTML = `
Requests
${fmtTok(totals.total_requests || 0)}
Cost
${fmtUsd(totals.total_cost_usd || 0)}
@@ -1839,6 +1849,7 @@ def _dashboard_csp() -> str:
Healthy Providers
${healthyProviders}/${providers.length}
${unhealthyProviders} unhealthy
Capability Coverage
${coverageEntries.length}
${coverageEntries.map(([name]) => name).slice(0,3).join(', ') || 'none'}
Top Modality
${esc(topModality)}
${modalityRows.length} modality groups
+ Top Client
${esc(topClient ? (topClient.client_tag || topClient.client_profile || 'generic') : '—')}
${topClient ? `${fmtTok(topClient.total_tokens || 0)} tokens / ${fmtUsd(topClient.cost_usd || 0)}` : 'No client traffic yet'}
Release Status
${esc(update.latest_version || update.current_version || 'n/a')}
${update.enabled ? (update.status === 'ok' ? `${esc(update.release_channel || 'stable')} / ${esc(update.update_type || 'current')} / ${esc(update.recommended_action || (update.update_available ? 'Upgrade recommended' : 'No action needed'))}${update.auto_update && update.auto_update.enabled ? ` / ring: ${esc(update.auto_update.rollout_ring || 'early')} / auto: ${esc(update.auto_update.eligible ? 'eligible' : (update.auto_update.blocked_reason || 'blocked'))}` : ''}` : esc(update.recommended_action || 'Update check unavailable')) : 'Update checks disabled'}
Operator Actions
${fmtTok((operatorEvents.events || []).length)}
${latestOperatorEvent ? `${esc(latestOperatorEvent.action || 'update-check')} / ${esc(latestOperatorEvent.status || 'unknown')}` : 'No recent operator events'}
`;
@@ -1866,6 +1877,21 @@ def _dashboard_csp() -> str:
`);
$('#coverage tbody').innerHTML = coverageRows.length ? coverageRows.join('') : emptyRow(5, 'No capability coverage data');
+ const clientTotalsRows = clientTotalRows.map(row => `
+ | ${esc(row.client_profile || 'generic')} |
+ ${esc(row.client_tag || '—')} |
+ ${row.requests} |
+ ${row.failures || 0} |
+ ${fmt(row.success_pct || 0, 1)}% |
+ ${fmtTok(row.total_tokens || 0)} ${fmtTok(row.prompt_tokens || 0)} in / ${fmtTok(row.compl_tokens || 0)} out |
+ ${fmtUsd(row.cost_usd)} |
+ ${fmtUsd(row.cost_per_request_usd)} |
+ ${fmtMs(row.avg_latency_ms)} |
+ ${esc(row.modalities || '—')} |
+ ${esc(row.providers || '—')} |
+
`);
+ $('#client-totals tbody').innerHTML = clientTotalsRows.length ? clientTotalsRows.join('') : emptyRow(11, 'No client totals for the current filter set');
+
const clientRows = (stats.clients || []).map(row => `
| ${esc(row.modality || 'chat')} |
${esc(row.client_profile || 'generic')} |
@@ -1873,10 +1899,14 @@ def _dashboard_csp() -> str:
${esc(row.provider)} |
${layerTag(row.layer)} |
${row.requests} |
+ ${row.failures || 0} |
+ ${fmt(row.success_pct || 0, 1)}% |
+ ${fmtTok(row.total_tokens || 0)} ${fmtTok(row.prompt_tokens || 0)} in / ${fmtTok(row.compl_tokens || 0)} out |
${fmtUsd(row.cost_usd)} |
+ ${fmtUsd(row.cost_per_request_usd)} |
${fmtMs(row.avg_latency_ms)} |
`);
- $('#clients tbody').innerHTML = clientRows.length ? clientRows.join('') : emptyRow(8, 'No client rows for the current filter set');
+ $('#clients tbody').innerHTML = clientRows.length ? clientRows.join('') : emptyRow(12, 'No client rows for the current filter set');
const modalityRowsHtml = modalityRows.map(row => `
| ${esc(row.modality || 'chat')} |
diff --git a/foundrygate/metrics.py b/foundrygate/metrics.py
index 63031d5..8027bd9 100644
--- a/foundrygate/metrics.py
+++ b/foundrygate/metrics.py
@@ -270,7 +270,16 @@ def get_client_breakdown(self, **filters: Any) -> list[dict]:
provider,
layer,
COUNT(*) AS requests,
+ SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS failures,
+ ROUND(CASE WHEN COUNT(*)>0
+ THEN (COUNT(*) - SUM(CASE WHEN success=0 THEN 1 ELSE 0 END))*100.0/COUNT(*)
+ ELSE 0 END, 1) AS success_pct,
+ SUM(prompt_tok) AS prompt_tokens,
+ SUM(compl_tok) AS compl_tokens,
+ SUM(prompt_tok+compl_tok) AS total_tokens,
ROUND(SUM(cost_usd),6) AS cost_usd,
+ ROUND(CASE WHEN COUNT(*)>0 THEN SUM(cost_usd)/COUNT(*) ELSE 0 END, 6)
+ AS cost_per_request_usd,
ROUND(AVG(latency_ms),1) AS avg_latency_ms
FROM requests{where_sql}
GROUP BY modality, client_profile, client_tag, provider, layer
@@ -279,6 +288,33 @@ def get_client_breakdown(self, **filters: Any) -> list[dict]:
params,
)
+ def get_client_totals(self, **filters: Any) -> list[dict]:
+ where_sql, params = self._build_where_clause(filters)
+ return self._q(
+ f"""
+ SELECT client_profile,
+ client_tag,
+ COUNT(*) AS requests,
+ SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS failures,
+ ROUND(CASE WHEN COUNT(*)>0
+ THEN (COUNT(*) - SUM(CASE WHEN success=0 THEN 1 ELSE 0 END))*100.0/COUNT(*)
+ ELSE 0 END, 1) AS success_pct,
+ SUM(prompt_tok) AS prompt_tokens,
+ SUM(compl_tok) AS compl_tokens,
+ SUM(prompt_tok+compl_tok) AS total_tokens,
+ ROUND(SUM(cost_usd),6) AS cost_usd,
+ ROUND(CASE WHEN COUNT(*)>0 THEN SUM(cost_usd)/COUNT(*) ELSE 0 END, 6)
+ AS cost_per_request_usd,
+ ROUND(AVG(latency_ms),1) AS avg_latency_ms,
+ GROUP_CONCAT(DISTINCT modality) AS modalities,
+ GROUP_CONCAT(DISTINCT provider) AS providers
+ FROM requests{where_sql}
+ GROUP BY client_profile, client_tag
+ ORDER BY requests DESC, client_profile ASC, client_tag ASC
+ """,
+ params,
+ )
+
def get_modality_breakdown(self, **filters: Any) -> list[dict]:
where_sql, params = self._build_where_clause(filters)
return self._q(
diff --git a/tests/test_metrics_traces.py b/tests/test_metrics_traces.py
index a270668..0eace8a 100644
--- a/tests/test_metrics_traces.py
+++ b/tests/test_metrics_traces.py
@@ -45,6 +45,16 @@ def test_metrics_store_persists_trace_fields(tmp_path):
assert client_rows[0]["provider"] == "local-worker"
assert client_rows[0]["layer"] == "profile"
assert client_rows[0]["requests"] == 1
+ assert client_rows[0]["prompt_tokens"] == 120
+ assert client_rows[0]["compl_tokens"] == 24
+ assert client_rows[0]["total_tokens"] == 144
+ assert client_rows[0]["failures"] == 0
+ assert client_rows[0]["success_pct"] == 100.0
+
+ client_totals = metrics.get_client_totals()
+ assert client_totals[0]["client_profile"] == "local-only"
+ assert client_totals[0]["client_tag"] == "n8n"
+ assert client_totals[0]["total_tokens"] == 144
metrics.close()
@@ -132,6 +142,7 @@ def test_metrics_store_filters_recent_and_breakdowns(tmp_path):
assert client_rows[0]["modality"] == "image_generation"
assert client_rows[0]["client_tag"] == "codex"
assert client_rows[0]["provider"] == "local-worker"
+ assert client_rows[0]["success_pct"] == 100.0
modality_rows = metrics.get_modality_breakdown(modality="image_generation")
assert len(modality_rows) == 1
@@ -146,6 +157,10 @@ def test_metrics_store_filters_recent_and_breakdowns(tmp_path):
assert totals["total_requests"] == 1
assert totals["total_failures"] == 1
+ client_totals = metrics.get_client_totals()
+ assert len(client_totals) == 2
+ assert client_totals[0]["requests"] >= client_totals[1]["requests"]
+
metrics.close()