Add Prometheus metrics, JSON logs and provider health checks

- GET /metrics (bearer server.metrics_token): build info, users, sessions,
  pending requests, per-model request/token/cost counters, provider_up
- CODECRAFT_LOG_FORMAT=json for one JSON object per log line
- Providers are probed every 5 minutes via their model list; status shown
  in the provider settings with a manual re-check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 00:57:02 +01:00
1 parent 10d4b64289
commit b5de531d2e
12 files changed
+244 -6

No files matched your search

Generated
+13
View File
@@ -4109,6 +4109,16 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
dependencies = [
"serde",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
@@ -4119,12 +4129,15 @@ dependencies = [
"nu-ansi-term",
"once_cell",
"regex-automata",
"serde",
"serde_json",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
"tracing-serde",
]
[[package]]
+1 -1
View File
@@ -37,7 +37,7 @@ toml = "1"
toml_edit = "0.25"
tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v4", "v7"] }
p256 = { version = "0.14", features = ["ecdh", "ecdsa"] }
hkdf = "0.13"
+12
View File
@@ -121,6 +121,7 @@ pub fn router(app: AppState) -> Router {
.route("/tools", get(crate::reverse::status))
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
.route("/providers/{name}/prices", get(provider_prices))
.route("/providers/status", get(provider_status).post(recheck_providers))
.route("/images", get(list_images).post(create_images))
.route("/images/config", get(images_config))
.route("/images/{id}", delete(delete_image))
@@ -162,6 +163,7 @@ pub fn router(app: AppState) -> Router {
Router::new()
.nest("/api", api)
.route("/hooks/{id}", post(incoming_webhook))
.route("/metrics", get(crate::metrics::metrics))
.route("/cli/install.sh", get(cli_install_script))
.route("/cli/download/{target}", get(cli_download))
.fallback_service(static_files)
@@ -1073,6 +1075,16 @@ async fn health(State(app): State<AppState>) -> Json<Value> {
Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
}
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
Json(json!(app.health.all()))
}
async fn recheck_providers(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
crate::health::check_all(&app).await;
Ok(Json(json!(app.health.all())))
}
// --- budget -------------------------------------------------------------------
/// Budgets concern the whole installation: admins only.
+3
View File
@@ -79,6 +79,8 @@ pub struct App {
pub terminals: crate::terminal::Terminals,
/// Newer release found by the update check (admins see it).
pub update: crate::update::UpdateState,
/// Provider reachability (checked every 5 minutes).
pub health: crate::health::Health,
}
/// What a user may do with a session.
@@ -118,6 +120,7 @@ impl App {
lsp: Default::default(),
push: crate::push::Push::new(&data),
terminals: Default::default(),
health: Default::default(),
update: Default::default(),
});
crate::auth::bootstrap(&app)?;
+4
View File
@@ -256,6 +256,9 @@ pub struct ServerConfig {
pub max_upload_mb: u64,
/// Where to look for new releases (Gitea/GitHub "latest release" API); empty = no check.
pub update_url: String,
/// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled.
#[serde(skip_serializing_if = "String::is_empty")]
pub metrics_token: String,
}
impl Default for ServerConfig {
@@ -275,6 +278,7 @@ impl Default for ServerConfig {
budget_action: "warn".into(),
max_upload_mb: 500,
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
metrics_token: String::new(),
}
}
}
+72
View File
@@ -0,0 +1,72 @@
//! Provider status: every 5 minutes each provider's model list is fetched
//! (free, no tokens) to see whether the host answers.
use crate::app::App;
use serde::Serialize;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Serialize)]
pub struct ProviderStatus {
pub up: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub latency_ms: u64,
pub checked_at: i64,
}
#[derive(Default)]
pub struct Health {
status: Mutex<BTreeMap<String, ProviderStatus>>,
}
impl Health {
pub fn snapshot(&self) -> Vec<(String, bool)> {
self.status.lock().unwrap().iter().map(|(k, v)| (k.clone(), v.up)).collect()
}
pub fn all(&self) -> BTreeMap<String, ProviderStatus> {
self.status.lock().unwrap().clone()
}
}
pub async fn check_all(app: &App) {
let registry = app.registry();
let mut found = BTreeMap::new();
let checks = registry.providers.iter().map(|(name, entry)| {
let provider = entry.provider.clone();
let name = name.clone();
async move {
let start = Instant::now();
let res = tokio::time::timeout(Duration::from_secs(15), provider.list_models()).await;
let error = match res {
Ok(Ok(_)) => None,
Ok(Err(e)) => Some(e.to_string().chars().take(300).collect()),
Err(_) => Some("timed out".to_string()),
};
(name, ProviderStatus { up: error.is_none(), error, latency_ms: start.elapsed().as_millis() as u64, checked_at: chrono_now() })
}
});
for (name, st) in futures::future::join_all(checks).await {
if !st.up {
tracing::warn!(provider = %name, error = st.error.as_deref().unwrap_or(""), "provider unreachable");
}
found.insert(name, st);
}
*app.health.status.lock().unwrap() = found;
}
fn chrono_now() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
}
pub fn spawn_checker(app: Arc<App>) {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(10)).await;
loop {
check_all(&app).await;
tokio::time::sleep(Duration::from_secs(300)).await;
}
});
}
+10 -3
View File
@@ -10,12 +10,14 @@ mod docs;
mod export;
mod files;
mod git;
mod health;
mod gitcreds;
mod hooks;
mod images;
mod jobs;
mod lsp;
mod mentions;
mod metrics;
mod mcp;
mod permission;
mod prompts;
@@ -36,9 +38,13 @@ use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,codecraft=info")))
.init();
// CODECRAFT_LOG_FORMAT=json: one JSON object per line (Loki, Elastic, …).
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,codecraft=info"));
if std::env::var("CODECRAFT_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json")) {
tracing_subscriber::fmt().json().flatten_event(true).with_current_span(false).with_env_filter(filter).init();
} else {
tracing_subscriber::fmt().with_env_filter(filter).init();
}
// In Docker, HOME lives on the data volume; make sure it exists for git/npm.
if let Ok(home) = std::env::var("HOME") {
@@ -57,6 +63,7 @@ async fn main() -> Result<()> {
terminal::spawn_reaper(app.clone());
jobs::spawn_scheduler(app.clone());
update::spawn_checker(app.clone());
health::spawn_checker(app.clone());
let router = api::router(app);
let listener = tokio::net::TcpListener::bind(&bind).await?;
tracing::info!("listening on http://{bind}");
+74
View File
@@ -0,0 +1,74 @@
//! Prometheus metrics (GET /metrics with the configured bearer token).
use crate::api::AppState;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use std::fmt::Write;
fn esc(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', " ")
}
pub async fn metrics(State(app): State<AppState>, headers: HeaderMap) -> Response {
let token = app.config().server.metrics_token.clone();
if token.is_empty() {
return (StatusCode::NOT_FOUND, "metrics are disabled (set server.metrics_token)").into_response();
}
let given = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()).and_then(|v| v.strip_prefix("Bearer ")).unwrap_or("");
if given.len() != token.len() || given.bytes().zip(token.bytes()).fold(0u8, |a, (x, y)| a | (x ^ y)) != 0 {
return (StatusCode::UNAUTHORIZED, "invalid metrics token").into_response();
}
let mut out = String::new();
let _ = writeln!(out, "# HELP codecraft_info Build information.\n# TYPE codecraft_info gauge");
let _ = writeln!(out, "codecraft_info{{version=\"{}\",commit=\"{}\"}} 1", crate::update::VERSION, crate::update::commit());
let busy = app.store.list_sessions(None).map(|l| l.iter().filter(|s| app.is_busy(&s.id)).count()).unwrap_or(0);
let gauges = [
("codecraft_users", "Registered users.", app.store.count("users") as f64),
("codecraft_sessions", "Chat sessions (incl. subagents).", app.store.count("sessions") as f64),
("codecraft_sessions_busy", "Sessions with a running agent.", busy as f64),
("codecraft_permissions_pending", "Open permission requests.", app.pending_events().iter().filter(|e| matches!(e, crate::types::Event::PermissionRequest { .. })).count() as f64),
("codecraft_questions_pending", "Open questions to users.", app.pending_events().iter().filter(|e| matches!(e, crate::types::Event::QuestionRequest { .. })).count() as f64),
("codecraft_automations", "Registered automations.", app.store.count("jobs") as f64),
("codecraft_push_subscriptions", "Devices with push notifications.", app.store.count("push_subs") as f64),
("codecraft_sandbox_available", "1 if bubblewrap works.", if crate::sandbox::available() { 1.0 } else { 0.0 }),
];
for (name, help, v) in gauges {
let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} gauge\n{name} {v}");
}
let totals = app.store.usage_totals().unwrap_or_default();
let series: [(&str, &str, &str); 9] = [
("codecraft_model_requests_total", "Model requests.", "counter"),
("codecraft_model_errors_total", "Failed model requests.", "counter"),
("codecraft_tokens_input_total", "Input tokens.", "counter"),
("codecraft_tokens_output_total", "Output tokens.", "counter"),
("codecraft_tokens_cache_read_total", "Cache-read tokens.", "counter"),
("codecraft_tokens_cache_write_total", "Cache-write tokens.", "counter"),
("codecraft_cost_total", "Recorded cost in the configured currency.", "counter"),
("codecraft_model_duration_seconds_total", "Time spent in model requests.", "counter"),
("codecraft_provider_up", "1 if the provider answered its last health check.", "gauge"),
];
for (i, (name, help, kind)) in series.iter().enumerate() {
let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}");
if i == 8 {
for (p, up) in app.health.snapshot() {
let _ = writeln!(out, "{name}{{provider=\"{}\"}} {}", esc(&p), if up { 1 } else { 0 });
}
continue;
}
for (p, m, req, err, inp, outp, cr, cw, cost, dur) in &totals {
let v = match i {
0 => *req as f64,
1 => *err as f64,
2 => *inp as f64,
3 => *outp as f64,
4 => *cr as f64,
5 => *cw as f64,
6 => *cost,
_ => *dur as f64 / 1000.0,
};
let _ = writeln!(out, "{name}{{provider=\"{}\",model=\"{}\"}} {v}", esc(p), esc(m));
}
}
([(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")], out).into_response()
}
+19
View File
@@ -964,6 +964,25 @@ impl Store {
// --- audit log & backup ----------------------------------------------------------
impl Store {
// --- metrics ---------------------------------------------------------------
/// All-time usage per provider/model: (provider, model, requests, errors, in, out, cache_read, cache_write, cost, duration_ms).
pub fn usage_totals(&self) -> Result<Vec<(String, String, i64, i64, i64, i64, i64, i64, f64, i64)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT provider, model, COUNT(*), COALESCE(SUM(error),0), COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
COALESCE(SUM(cache_read_tokens),0), COALESCE(SUM(cache_write_tokens),0), COALESCE(SUM(cost),0), COALESCE(SUM(duration_ms),0)
FROM usage GROUP BY provider, model",
)?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?, r.get(9)?)))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
pub fn count(&self, table: &str) -> i64 {
// Table names are fixed strings from the code, never user input.
self.conn.lock().unwrap().query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)).unwrap_or(0)
}
// --- scheduled jobs -------------------------------------------------------
fn job_row(r: &rusqlite::Row) -> rusqlite::Result<crate::jobs::Job> {
+28 -2
View File
@@ -371,7 +371,9 @@ function cleanModel(m: ModelRow) {
return out;
}
function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: () => void }) {
type ProviderStatus = { up: boolean; error?: string; latency_ms: number; checked_at: number };
function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; reload: () => void; status?: ProviderStatus }) {
const t = useT();
const [form, setForm] = useState({ ...p, api_key: p.api_key === MASK ? "" : p.api_key, admin_key: "" });
const [priceNote, setPriceNote] = useState<string>();
@@ -436,6 +438,15 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
{name}
<span className="chip">{form.kind}</span>
<span className="chip">{t("settings.modelCount", { n: models.length || discovered.length })}</span>
{status && (
<span
className={"chip " + (status.up ? "ok" : "warn")}
title={(status.error ?? "") + " · " + new Date(status.checked_at).toLocaleTimeString()}
>
<Icon name={status.up ? "check" : "warning"} size={12} />
{status.up ? t("settings.providerUp", { ms: status.latency_ms }) : t("settings.providerDown")}
</span>
)}
<span className="spacer" />
<button className="small" onClick={() => setOpen(!open)}>
{open ? t("common.collapse") : t("common.edit")}
@@ -584,6 +595,18 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
const [preset, setPreset] = useState("anthropic");
const { run, view } = useSaver(reload);
const providers = cfg.config.providers ?? {};
const [status, setStatus] = useState<Record<string, ProviderStatus>>({});
const [checking, setChecking] = useState(false);
useEffect(() => {
get<Record<string, ProviderStatus>>("/providers/status").then(setStatus).catch(() => {});
}, []);
const recheck = () => {
setChecking(true);
post<Record<string, ProviderStatus>>("/providers/status")
.then(setStatus)
.catch(() => {})
.finally(() => setChecking(false));
};
const add = () => {
const n = name.trim() || preset;
@@ -599,9 +622,12 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
<>
<div className="row" style={{ marginBottom: 12 }}>
<button onClick={() => post("/providers/reload").then(reload)}>{t("settings.reloadProviders")}</button>
<button onClick={recheck} disabled={checking}>
{checking ? t("settings.providerChecking") : t("settings.providerCheck")}
</button>
</div>
{Object.entries(providers).map(([n, p]) => (
<ProviderEditor key={n + JSON.stringify(p)} name={n} p={p} reload={reload} />
<ProviderEditor key={n + JSON.stringify(p)} name={n} p={p} reload={reload} status={status[n]} />
))}
<div className="panel">
<h3>{t("settings.addProvider")}</h3>
+4
View File
@@ -569,6 +569,10 @@ export const de: Messages = {
"settings.priceCacheWrite": "Cache schreiben",
"settings.addProvider": "Provider hinzufügen",
"settings.providerName": "Name (optional)",
"settings.providerUp": "erreichbar · {ms} ms",
"settings.providerDown": "nicht erreichbar",
"settings.providerCheck": "Provider prüfen",
"settings.providerChecking": "Prüfe…",
"settings.reloadProviders": "Provider & Modelle neu laden",
"settings.deleteProviderConfirm": "Provider „{name}“ löschen?",
"settings.invalidName": "Namen dürfen nur Buchstaben, Ziffern, - und _ enthalten.",
+4
View File
@@ -568,6 +568,10 @@ export const en = {
"settings.priceCacheWrite": "Cache write",
"settings.addProvider": "Add provider",
"settings.providerName": "Name (optional)",
"settings.providerUp": "reachable · {ms} ms",
"settings.providerDown": "unreachable",
"settings.providerCheck": "Check providers",
"settings.providerChecking": "Checking…",
"settings.reloadProviders": "Reload providers & models",
"settings.deleteProviderConfirm": "Delete provider \"{name}\"?",
"settings.invalidName": "Names may only contain letters, digits, - and _.",