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

+ + +
ProfileClient TagRequestsFailuresSuccessTokensCostCost / RequestAvg LatencyModalitiesProviders
+
+

Client Breakdown

- +
ModalityProfileClient TagProviderLayerRequestsCostAvg LatencyModalityProfileClient TagProviderLayerRequestsFailuresSuccessTokensCostCost / RequestAvg 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()