ironclad-api 0.9.8

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Ironclad agent runtime
Documentation
pub async fn get_costs(
    State(state): State<AppState>,
    Query(pagination): Query<super::PaginationQuery>,
) -> impl IntoResponse {
    let (limit, offset) = pagination.resolve();
    let conn = state.db.conn();
    let mut stmt = conn
        .prepare(
            "SELECT id, model, provider, tokens_in, tokens_out, cost, tier, cached, created_at \
             FROM inference_costs ORDER BY created_at DESC LIMIT ?1 OFFSET ?2",
        )
        .map_err(|e| internal_err(&e))?;

    let rows = stmt
        .query_map(rusqlite::params![limit, offset], |row| {
            Ok(json!({
                "id": row.get::<_, String>(0)?,
                "model": row.get::<_, String>(1)?,
                "provider": row.get::<_, String>(2)?,
                "tokens_in": row.get::<_, i64>(3)?,
                "tokens_out": row.get::<_, i64>(4)?,
                "cost": row.get::<_, f64>(5)?,
                "tier": row.get::<_, Option<String>>(6)?,
                "cached": row.get::<_, i32>(7)? != 0,
                "created_at": row.get::<_, String>(8)?,
            }))
        })
        .map_err(|e| internal_err(&e))?;

    let costs: Vec<Value> = rows
        .filter_map(|r| {
            r.inspect_err(|e| tracing::warn!(error = %e, "skipping corrupted cost row"))
                .ok()
        })
        .collect();
    Ok::<_, JsonError>(axum::Json(json!({ "costs": costs })))
}

#[derive(Deserialize)]
pub struct TimeSeriesQuery {
    pub hours: Option<i64>,
}

pub async fn get_overview_timeseries(
    State(state): State<AppState>,
    Query(params): Query<TimeSeriesQuery>,
) -> impl IntoResponse {
    let hours = params.hours.unwrap_or(24).clamp(1, 168) as usize;
    let conn = state.db.conn();
    let now = chrono::Utc::now().naive_utc();
    let mut labels = Vec::with_capacity(hours);
    let mut cost_per_hour = vec![0.0f64; hours];
    let mut tokens_per_hour = vec![0.0f64; hours];
    let mut sessions_per_hour = vec![0i64; hours];
    let mut latency_samples: Vec<Vec<i64>> = (0..hours).map(|_| Vec::new()).collect();
    let mut cron_success = vec![0.0f64; hours];
    let mut cron_total = vec![0i64; hours];
    let mut cron_ok = vec![0i64; hours];

    for i in 0..hours {
        let hr = (now - chrono::Duration::hours((hours - 1 - i) as i64))
            .format("%H:00")
            .to_string();
        labels.push(hr);
    }

    let parse_ts = |s: &str| -> Option<chrono::NaiveDateTime> {
        chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").ok()
    };
    let bucket_for = |ts: chrono::NaiveDateTime| -> Option<usize> {
        let age = now - ts;
        let mins = age.num_minutes();
        if mins < 0 {
            return None;
        }
        let idx_from_end = (mins / 60) as usize;
        if idx_from_end >= hours {
            None
        } else {
            Some(hours - 1 - idx_from_end)
        }
    };

    match conn.prepare(
        "SELECT cost, tokens_in, tokens_out, created_at FROM inference_costs
         WHERE created_at >= datetime('now', ?1)",
    ) {
        Ok(mut stmt) => {
            let window = format!("-{} hours", hours);
            match stmt.query_map([window], |row| {
                Ok((
                    row.get::<_, f64>(0)?,
                    row.get::<_, i64>(1)?,
                    row.get::<_, i64>(2)?,
                    row.get::<_, String>(3)?,
                ))
            }) {
                Ok(rows) => {
                    for (cost, tin, tout, created_at) in rows.flatten() {
                        if let Some(ts) = parse_ts(&created_at)
                            && let Some(idx) = bucket_for(ts)
                        {
                            cost_per_hour[idx] += cost;
                            tokens_per_hour[idx] += (tin + tout) as f64;
                        }
                    }
                }
                Err(e) => tracing::warn!("failed to query inference_costs timeseries: {e}"),
            }
        }
        Err(e) => tracing::warn!("failed to prepare inference_costs timeseries query: {e}"),
    }

    match conn.prepare(
        "SELECT created_at FROM sessions
         WHERE created_at >= datetime('now', ?1)",
    ) {
        Ok(mut stmt) => {
            let window = format!("-{} hours", hours);
            match stmt.query_map([window], |row| row.get::<_, String>(0)) {
                Ok(rows) => {
                    for created_at in rows.flatten() {
                        if let Some(ts) = parse_ts(&created_at)
                            && let Some(idx) = bucket_for(ts)
                        {
                            sessions_per_hour[idx] += 1;
                        }
                    }
                }
                Err(e) => tracing::warn!("failed to query sessions timeseries: {e}"),
            }
        }
        Err(e) => tracing::warn!("failed to prepare sessions timeseries query: {e}"),
    }

    match conn.prepare(
        "SELECT duration_ms, created_at FROM tool_calls
         WHERE created_at >= datetime('now', ?1) AND duration_ms IS NOT NULL",
    ) {
        Ok(mut stmt) => {
            let window = format!("-{} hours", hours);
            match stmt.query_map([window], |row| {
                Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
            }) {
                Ok(rows) => {
                    for (dur, created_at) in rows.flatten() {
                        if let Some(ts) = parse_ts(&created_at)
                            && let Some(idx) = bucket_for(ts)
                        {
                            latency_samples[idx].push(dur);
                        }
                    }
                }
                Err(e) => tracing::warn!("failed to query tool_calls timeseries: {e}"),
            }
        }
        Err(e) => tracing::warn!("failed to prepare tool_calls timeseries query: {e}"),
    }

    let mut latency_p50 = vec![0.0f64; hours];
    for i in 0..hours {
        if latency_samples[i].is_empty() {
            continue;
        }
        latency_samples[i].sort_unstable();
        let n = latency_samples[i].len();
        latency_p50[i] = if n % 2 == 1 {
            latency_samples[i][n / 2] as f64
        } else {
            (latency_samples[i][n / 2 - 1] as f64 + latency_samples[i][n / 2] as f64) / 2.0
        };
    }

    match conn.prepare(
        "SELECT status, created_at FROM cron_runs
         WHERE created_at >= datetime('now', ?1)",
    ) {
        Ok(mut stmt) => {
            let window = format!("-{} hours", hours);
            match stmt.query_map([window], |row| {
                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
            }) {
                Ok(rows) => {
                    for (status, created_at) in rows.flatten() {
                        if let Some(ts) = parse_ts(&created_at)
                            && let Some(idx) = bucket_for(ts)
                        {
                            cron_total[idx] += 1;
                    if status == "success" {
                        cron_ok[idx] += 1;
                    }
                }
            }
                }
                Err(e) => tracing::warn!("failed to query cron_runs timeseries: {e}"),
            }
        }
        Err(e) => tracing::warn!("failed to prepare cron_runs timeseries query: {e}"),
    }
    for i in 0..hours {
        cron_success[i] = if cron_total[i] > 0 {
            cron_ok[i] as f64 / cron_total[i] as f64
        } else {
            1.0
        };
    }

    Ok::<_, JsonError>(axum::Json(json!({
        "hours": hours,
        "labels": labels,
        "series": {
            "cost_per_hour": cost_per_hour,
            "tokens_per_hour": tokens_per_hour,
            "sessions_per_hour": sessions_per_hour,
            "latency_p50_ms": latency_p50,
            "cron_success_rate": cron_success
        }
    })))
}

pub async fn get_transactions(
    State(state): State<AppState>,
    Query(params): Query<TransactionsQuery>,
) -> impl IntoResponse {
    let hours = params.hours.unwrap_or(24);
    match ironclad_db::metrics::query_transactions(&state.db, hours) {
        Ok(txs) => {
            let items: Vec<Value> = txs
                .into_iter()
                .map(|t| {
                    json!({
                        "id": t.id,
                        "tx_type": t.tx_type,
                        "amount": t.amount,
                        "currency": t.currency,
                        "counterparty": t.counterparty,
                        "tx_hash": t.tx_hash,
                        "metadata_json": t.metadata_json,
                        "created_at": t.created_at,
                    })
                })
                .collect();
            Ok(axum::Json(json!({ "transactions": items })))
        }
        Err(e) => Err(internal_err(&e)),
    }
}