Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 32 additions & 2 deletions foundrygate/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -1676,10 +1677,17 @@ def _dashboard_csp() -> str:
</tr></thead><tbody></tbody></table>
</div>

<div class="sect">
<h2>Client Totals</h2>
<table id="client-totals"><thead><tr>
<th>Profile</th><th>Client Tag</th><th>Requests</th><th>Failures</th><th>Success</th><th>Tokens</th><th>Cost</th><th>Cost / Request</th><th>Avg Latency</th><th>Modalities</th><th>Providers</th>
</tr></thead><tbody></tbody></table>
</div>

<div class="sect">
<h2>Client Breakdown</h2>
<table id="clients"><thead><tr>
<th>Modality</th><th>Profile</th><th>Client Tag</th><th>Provider</th><th>Layer</th><th>Requests</th><th>Cost</th><th>Avg Latency</th>
<th>Modality</th><th>Profile</th><th>Client Tag</th><th>Provider</th><th>Layer</th><th>Requests</th><th>Failures</th><th>Success</th><th>Tokens</th><th>Cost</th><th>Cost / Request</th><th>Avg Latency</th>
</tr></thead><tbody></tbody></table>
</div>

Expand Down Expand Up @@ -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 = `
<div class="card"><div class="label">Requests</div><div class="value">${fmtTok(totals.total_requests || 0)}</div></div>
<div class="card"><div class="label">Cost</div><div class="value cost">${fmtUsd(totals.total_cost_usd || 0)}</div></div>
Expand All @@ -1839,6 +1849,7 @@ def _dashboard_csp() -> str:
<div class="card"><div class="label">Healthy Providers</div><div class="value">${healthyProviders}/${providers.length}</div><div class="detail">${unhealthyProviders} unhealthy</div></div>
<div class="card"><div class="label">Capability Coverage</div><div class="value">${coverageEntries.length}</div><div class="detail">${coverageEntries.map(([name]) => name).slice(0,3).join(', ') || 'none'}</div></div>
<div class="card"><div class="label">Top Modality</div><div class="value">${esc(topModality)}</div><div class="detail">${modalityRows.length} modality groups</div></div>
<div class="card"><div class="label">Top Client</div><div class="value">${esc(topClient ? (topClient.client_tag || topClient.client_profile || 'generic') : '—')}</div><div class="detail">${topClient ? `${fmtTok(topClient.total_tokens || 0)} tokens / ${fmtUsd(topClient.cost_usd || 0)}` : 'No client traffic yet'}</div></div>
<div class="card"><div class="label">Release Status</div><div class="value ${(update.alert_level === 'critical' || update.alert_level === 'warning') ? 'err' : update.update_available ? 'cost' : ''}">${esc(update.latest_version || update.current_version || 'n/a')}</div><div class="detail">${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'}</div></div>
<div class="card"><div class="label">Operator Actions</div><div class="value">${fmtTok((operatorEvents.events || []).length)}</div><div class="detail">${latestOperatorEvent ? `${esc(latestOperatorEvent.action || 'update-check')} / ${esc(latestOperatorEvent.status || 'unknown')}` : 'No recent operator events'}</div></div>
`;
Expand Down Expand Up @@ -1866,17 +1877,36 @@ def _dashboard_csp() -> str:
</tr>`);
$('#coverage tbody').innerHTML = coverageRows.length ? coverageRows.join('') : emptyRow(5, 'No capability coverage data');

const clientTotalsRows = clientTotalRows.map(row => `<tr>
<td>${esc(row.client_profile || 'generic')}</td>
<td>${esc(row.client_tag || '—')}</td>
<td>${row.requests}</td>
<td>${row.failures || 0}</td>
<td class="mono">${fmt(row.success_pct || 0, 1)}%</td>
<td class="mono">${fmtTok(row.total_tokens || 0)}<div class="detail">${fmtTok(row.prompt_tokens || 0)} in / ${fmtTok(row.compl_tokens || 0)} out</div></td>
<td class="mono">${fmtUsd(row.cost_usd)}</td>
<td class="mono">${fmtUsd(row.cost_per_request_usd)}</td>
<td class="mono">${fmtMs(row.avg_latency_ms)}</td>
<td>${esc(row.modalities || '—')}</td>
<td class="mono">${esc(row.providers || '—')}</td>
</tr>`);
$('#client-totals tbody').innerHTML = clientTotalsRows.length ? clientTotalsRows.join('') : emptyRow(11, 'No client totals for the current filter set');

const clientRows = (stats.clients || []).map(row => `<tr>
<td><span class="pill">${esc(row.modality || 'chat')}</span></td>
<td>${esc(row.client_profile || 'generic')}</td>
<td>${esc(row.client_tag || '—')}</td>
<td>${esc(row.provider)}</td>
<td>${layerTag(row.layer)}</td>
<td>${row.requests}</td>
<td>${row.failures || 0}</td>
<td class="mono">${fmt(row.success_pct || 0, 1)}%</td>
<td class="mono">${fmtTok(row.total_tokens || 0)}<div class="detail">${fmtTok(row.prompt_tokens || 0)} in / ${fmtTok(row.compl_tokens || 0)} out</div></td>
<td class="mono">${fmtUsd(row.cost_usd)}</td>
<td class="mono">${fmtUsd(row.cost_per_request_usd)}</td>
<td class="mono">${fmtMs(row.avg_latency_ms)}</td>
</tr>`);
$('#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 => `<tr>
<td><span class="pill">${esc(row.modality || 'chat')}</span></td>
Expand Down
36 changes: 36 additions & 0 deletions foundrygate/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions tests/test_metrics_traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand All @@ -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()


Expand Down
Loading