Skip to content

Commit c132f00

Browse files
authored
feat: add /admin/stats endpoint with persistent runtime counters (#106)
Exposes GET /admin/stats returning running totals for requests served, upstream prompt/completion/total tokens, emails hidden by the sender policy, and a per-address breakdown of filtered senders.
1 parent 4b7c956 commit c132f00

28 files changed

Lines changed: 668 additions & 62 deletions

‎README.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,21 @@ ClawShell supports OAuth-based authentication as an alternative to static API ke
4949
- **Automatic Token Refresh**: Access tokens are refreshed transparently before they expire.
5050
- **Request Translation**: Automatically translates OpenAI Chat Completions API requests to the ChatGPT Responses API format when using Codex OAuth.
5151

52-
### 5. Seamless Integration
52+
### 5. Runtime Statistics
53+
54+
ClawShell exposes running counters at `GET /admin/stats` so operators can audit proxy activity since startup and across restarts.
55+
56+
- **What's Counted**: Total requests served, total upstream `prompt_tokens` / `completion_tokens` / `total_tokens` (parsed from non-streaming JSON responses — SSE streams are not counted), number of emails hidden by the sender policy, and a per-address breakdown of filtered senders.
57+
- **Loopback-Only**: The endpoint is reachable without a virtual key but only from `127.0.0.1` / `::1` peers; non-loopback clients receive `403`.
58+
- **Persistent**: Counters are flushed to disk every 30 seconds and on graceful shutdown. The location is a required config field — set `[stats] persist_path = "..."` in `clawshell.toml` (typically `/var/lib/clawshell/stats.json` under the hardened systemd unit, since `/etc/clawshell` is read-only there).
59+
- **Bounded**: The filtered-address map is capped at 10,000 unique entries; further unique addresses are aggregated under an `<overflow>` bucket so memory stays bounded.
60+
61+
### 6. Seamless Integration
5362

5463
- **Drop-in Sidecar**: The `clawshell onboard` wizard configures exactly one downstream LLM client per run — either OpenClaw or [Hermes Agent](https://github.com/NousResearch/hermes-agent) — to route all requests through ClawShell's proxy. See [Agent Target (pick one)](#agent-target-pick-one).
5564
- **No External Dependencies**: Uses Unix file system permissions to protect secrets. No IdP, Vault, or external key management service required.
5665

57-
### 6. Ultra Lightweight and Scalable
66+
### 7. Ultra Lightweight and Scalable
5867

5968
- Runs in under 10MB of memory.
6069
- Written in Rust with Tokio.

‎clawshell.example.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ patterns = [
5353
{ name = "amex_card", regex = '\b3[47][0-9]{13}\b', action = "redact" },
5454
]
5555

56+
# Runtime statistics persistence
57+
# ClawShell counts total requests served, upstream prompt/completion/total
58+
# tokens (from non-streaming responses), and per-sender email-filter
59+
# activity, and exposes them at GET /admin/stats (loopback-only).
60+
[stats]
61+
persist_path = "/etc/clawshell/stats.json"
62+
5663
# Email read endpoint
5764
# If enabled, set exactly one mode:
5865
# - mode = "allowlist" with non-empty allow_senders and empty deny_senders

‎src/app.rs‎

Lines changed: 88 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ use crate::email::{
88
use crate::keys::{KeyManager, KeySource, ResolvedKey};
99
use crate::oauth::OAuthRegistry;
1010
use crate::proxy::ProxyClient;
11+
use crate::stats::Stats;
1112

1213
use axum::Router;
1314
use axum::body::Body;
14-
use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
15+
use axum::extract::{ConnectInfo, DefaultBodyLimit, Path, Query, Request, State};
1516
use axum::http::{HeaderMap, Method, StatusCode, Uri};
1617
use axum::middleware::Next;
1718
use axum::response::{IntoResponse, Response};
@@ -20,6 +21,7 @@ use bytes::Bytes;
2021
use http_body_util::BodyExt;
2122
use serde::{Deserialize, Serialize};
2223
use std::collections::BTreeMap;
24+
use std::net::SocketAddr;
2325
use std::sync::Arc;
2426
use std::time::Instant;
2527
use tracing::{debug, error, info, trace, warn};
@@ -34,6 +36,7 @@ pub struct AppState {
3436
pub email_policy: Option<EmailPolicy>,
3537
pub email_accounts: Arc<BTreeMap<String, EmailAccountCredentials>>,
3638
pub email_service: Arc<EmailService>,
39+
pub stats: Arc<Stats>,
3740
}
3841

3942
impl AppState {
@@ -138,6 +141,7 @@ impl AppState {
138141
email_policy,
139142
email_accounts: Arc::new(email_accounts),
140143
email_service: Arc::new(EmailService::Imap(ImapEmailService::default())),
144+
stats: Arc::new(Stats::new(Some(config.stats.persist_path.clone()))),
141145
})
142146
}
143147
}
@@ -149,20 +153,30 @@ pub fn build_router(state: AppState) -> Router {
149153
Router::new()
150154
.route("/v1/email/messages", get(handle_email_secure_messages))
151155
.route("/v1/email/messages/{id}", get(handle_email_message_content))
156+
.route("/admin/stats", get(handle_stats))
152157
.route("/", any(handle_request))
153158
.route("/{*path}", any(handle_request))
154159
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
155-
.layer(axum::middleware::from_fn(log_request_completion))
160+
.layer(axum::middleware::from_fn_with_state(
161+
state.clone(),
162+
log_request_completion,
163+
))
156164
.with_state(state)
157165
}
158166

159-
async fn log_request_completion(request: Request, next: Next) -> Response {
167+
async fn log_request_completion(
168+
State(state): State<AppState>,
169+
request: Request,
170+
next: Next,
171+
) -> Response {
160172
let start = Instant::now();
161173
let method = request.method().clone();
162174
let path = request.uri().path().to_string();
163175
let query = request.uri().query().map(|q| q.to_string());
164176
let response = next.run(request).await;
165177

178+
state.stats.record_request();
179+
166180
info!(
167181
method = %method,
168182
path = %path,
@@ -317,6 +331,7 @@ async fn handle_email_secure_messages(
317331
continue;
318332
};
319333
if !policy.sender_visible(from_header) {
334+
state.stats.record_email_filtered(from_header);
320335
continue;
321336
}
322337
visible_messages.push(EmailSecureMessage {
@@ -447,6 +462,9 @@ async fn handle_email_message_content(
447462
.as_deref()
448463
.or_else(|| content.headers.get("from").map(String::as_str));
449464
if from_header.is_none_or(|from| !policy.sender_visible(from)) {
465+
if let Some(from) = from_header {
466+
state.stats.record_email_filtered(from);
467+
}
450468
return Err(error_response(
451469
StatusCode::NOT_FOUND,
452470
"Email message not found",
@@ -469,6 +487,27 @@ async fn handle_email_message_content(
469487
Ok(axum::Json(response).into_response())
470488
}
471489

490+
/// Management endpoint that returns running counters for total requests,
491+
/// upstream token usage, and per-sender email-filter activity. Only
492+
/// reachable from loopback peers; streaming (SSE) responses are not
493+
/// included in token totals (see `handle_request`).
494+
async fn handle_stats(
495+
State(state): State<AppState>,
496+
ConnectInfo(peer): ConnectInfo<SocketAddr>,
497+
) -> Result<Response, Response> {
498+
if !peer.ip().is_loopback() {
499+
warn!(
500+
peer = %peer,
501+
"Non-loopback client tried to hit /admin/stats"
502+
);
503+
return Err(error_response(
504+
StatusCode::FORBIDDEN,
505+
"stats endpoint is loopback-only",
506+
));
507+
}
508+
Ok(axum::Json(state.stats.snapshot()).into_response())
509+
}
510+
472511
async fn handle_request(
473512
State(state): State<AppState>,
474513
request: Request,
@@ -630,31 +669,52 @@ async fn handle_request(
630669
})?,
631670
};
632671

633-
// 5. DLP scan on response body (redact all PII before returning to client)
634-
let response = if state.dlp_scanner.scan_responses() {
635-
trace!("Response DLP scanning enabled, checking response body");
636-
let is_streaming = response
637-
.headers()
638-
.get("content-type")
639-
.and_then(|v| v.to_str().ok())
640-
.is_some_and(|ct| ct.contains("text/event-stream"));
672+
// 5. Response processing.
673+
// Non-streaming responses are buffered so we can (a) record upstream
674+
// token usage for stats and (b) optionally DLP-redact before sending.
675+
// SSE streams are handled by the existing DLP SSE wrapper; we do not
676+
// count tokens from SSE usage events.
677+
let is_streaming = response
678+
.headers()
679+
.get("content-type")
680+
.and_then(|v| v.to_str().ok())
681+
.is_some_and(|ct| ct.contains("text/event-stream"));
641682

642-
if !is_streaming {
643-
trace!("Non-streaming response, scanning body for PII");
683+
let response = if is_streaming {
684+
if state.dlp_scanner.scan_responses() {
685+
debug!(
686+
method = %method,
687+
path = %path,
688+
virtual_key = %virtual_key,
689+
"Streaming response (SSE) — wrapping with DLP SSE scanner"
690+
);
644691
let (parts, body) = response.into_parts();
645-
let body = body
646-
.collect()
647-
.await
648-
.map_err(|e| {
649-
error!(error = %e, "Failed to read response body for DLP scan");
650-
error_response(
651-
StatusCode::INTERNAL_SERVER_ERROR,
652-
"Failed to process response",
653-
)
654-
})?
655-
.to_bytes();
656-
657-
let (redacted, redacted_names) = state.dlp_scanner.redact_all(&body);
692+
let dlp_body =
693+
crate::translate::wrap_body_with_dlp_sse_stream(body, state.dlp_scanner.clone());
694+
Response::from_parts(parts, dlp_body)
695+
} else {
696+
trace!("Streaming response, DLP response scanning disabled");
697+
response
698+
}
699+
} else {
700+
let (parts, body) = response.into_parts();
701+
let body_bytes = body
702+
.collect()
703+
.await
704+
.map_err(|e| {
705+
error!(error = %e, "Failed to read response body");
706+
error_response(
707+
StatusCode::INTERNAL_SERVER_ERROR,
708+
"Failed to process response",
709+
)
710+
})?
711+
.to_bytes();
712+
713+
// Count tokens from the upstream `usage` block before any DLP mutation.
714+
state.stats.record_tokens_from_usage(&body_bytes);
715+
716+
if state.dlp_scanner.scan_responses() {
717+
let (redacted, redacted_names) = state.dlp_scanner.redact_all(&body_bytes);
658718
if !redacted_names.is_empty() {
659719
warn!(
660720
method = %method,
@@ -669,23 +729,11 @@ async fn handle_request(
669729
parts.headers.remove("content-length");
670730
Response::from_parts(parts, Body::from(redacted_bytes))
671731
} else {
672-
Response::from_parts(parts, Body::from(body))
732+
Response::from_parts(parts, Body::from(body_bytes))
673733
}
674734
} else {
675-
debug!(
676-
method = %method,
677-
path = %path,
678-
virtual_key = %virtual_key,
679-
"Streaming response (SSE) — wrapping with DLP SSE scanner"
680-
);
681-
let (parts, body) = response.into_parts();
682-
let dlp_body =
683-
crate::translate::wrap_body_with_dlp_sse_stream(body, state.dlp_scanner.clone());
684-
Response::from_parts(parts, dlp_body)
735+
Response::from_parts(parts, Body::from(body_bytes))
685736
}
686-
} else {
687-
trace!("Response DLP scanning disabled");
688-
response
689737
};
690738

691739
Ok(response)

‎src/app/tests.rs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use crate::email::{
1919
use crate::keys::{KeyManager, KeySource, ResolvedKey};
2020
use crate::oauth::OAuthRegistry;
2121
use crate::proxy::ProxyClient;
22+
use crate::stats::Stats;
2223

2324
fn make_app(upstream_url: &str) -> axum::Router {
2425
let mut key_map = BTreeMap::new();
@@ -75,6 +76,7 @@ fn make_app(upstream_url: &str) -> axum::Router {
7576
email_policy: None,
7677
email_accounts: Arc::new(BTreeMap::new()),
7778
email_service: Arc::new(EmailService::mock_disabled()),
79+
stats: Arc::new(Stats::new(None)),
7880
};
7981

8082
build_router(state)
@@ -117,6 +119,7 @@ fn make_app_with_anthropic(upstream_url: &str) -> axum::Router {
117119
email_policy: None,
118120
email_accounts: Arc::new(BTreeMap::new()),
119121
email_service: Arc::new(EmailService::mock_disabled()),
122+
stats: Arc::new(Stats::new(None)),
120123
};
121124

122125
build_router(state)
@@ -568,6 +571,8 @@ openai_base_url = "https://api.openai.com"
568571
[[keys]]
569572
virtual_key = "vk-1"
570573
real_key = "sk-real-1"
574+
[stats]
575+
persist_path = "/etc/clawshell/stats.json"
571576
"#;
572577
let config = Config::parse(toml_str).unwrap();
573578
let state = AppState::from_config(&config).unwrap();
@@ -597,6 +602,8 @@ provider = "openai"
597602
virtual_key = "vk-ant"
598603
real_key = "sk-ant-key"
599604
provider = "anthropic"
605+
[stats]
606+
persist_path = "/etc/clawshell/stats.json"
600607
"#;
601608
let config = Config::parse(toml_str).unwrap();
602609
let state = AppState::from_config(&config).unwrap();
@@ -635,6 +642,9 @@ email = "bot@gmail.com"
635642
app_password = "abcd efgh ijkl mnop"
636643
imap_host = "imap.gmail.com"
637644
imap_port = 993
645+
646+
[stats]
647+
persist_path = "/etc/clawshell/stats.json"
638648
"#;
639649
let config = Config::parse(toml_str).unwrap();
640650
let state = AppState::from_config(&config).unwrap();
@@ -665,6 +675,9 @@ allow_senders = ["alice@example.com"]
665675
virtual_key = "vk-email"
666676
email = "bot@gmail.com"
667677
app_password = "abcd efgh ijkl mnop"
678+
679+
[stats]
680+
persist_path = "/etc/clawshell/stats.json"
668681
"#;
669682
let config = Config::parse(toml_str).unwrap();
670683
let state = AppState::from_config(&config).unwrap();
@@ -708,6 +721,7 @@ async fn test_proxy_error_on_unreachable_upstream() {
708721
email_policy: None,
709722
email_accounts: Arc::new(BTreeMap::new()),
710723
email_service: Arc::new(EmailService::mock_disabled()),
724+
stats: Arc::new(Stats::new(None)),
711725
};
712726

713727
let app = build_router(state);
@@ -855,6 +869,7 @@ async fn test_anthropic_dlp_blocks_sensitive_data() {
855869
email_policy: None,
856870
email_accounts: Arc::new(BTreeMap::new()),
857871
email_service: Arc::new(EmailService::mock_disabled()),
872+
stats: Arc::new(Stats::new(None)),
858873
};
859874
let app = build_router(state);
860875

@@ -957,6 +972,7 @@ async fn test_openai_and_openrouter_keys_map_to_distinct_real_keys() {
957972
email_policy: None,
958973
email_accounts: Arc::new(BTreeMap::new()),
959974
email_service: Arc::new(EmailService::mock_disabled()),
975+
stats: Arc::new(Stats::new(None)),
960976
});
961977

962978
let openai_req = Request::builder()
@@ -1028,6 +1044,7 @@ fn make_app_with_redact(upstream_url: &str) -> axum::Router {
10281044
email_policy: None,
10291045
email_accounts: Arc::new(BTreeMap::new()),
10301046
email_service: Arc::new(EmailService::mock_disabled()),
1047+
stats: Arc::new(Stats::new(None)),
10311048
};
10321049

10331050
build_router(state)
@@ -1235,6 +1252,7 @@ async fn test_response_dlp_disabled() {
12351252
email_policy: None,
12361253
email_accounts: Arc::new(BTreeMap::new()),
12371254
email_service: Arc::new(EmailService::mock_disabled()),
1255+
stats: Arc::new(Stats::new(None)),
12381256
};
12391257
let app = build_router(state);
12401258

@@ -1598,6 +1616,7 @@ fn make_email_app(
15981616
email_policy: Some(policy),
15991617
email_accounts: Arc::new(email_accounts),
16001618
email_service,
1619+
stats: Arc::new(Stats::new(None)),
16011620
};
16021621

16031622
build_router(state)

0 commit comments

Comments
 (0)