~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

Usage counts slow statements, why actions failed, and crawlers by agent

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/usage-detail
.changeset/usage-detail.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: patch
---
# `knotless admin stats` says which queries were slow, why actions failed, and which crawlers came
An operator's `knotless admin stats`, and the MCP `stats` tool, now list the index's slow statements with how often each ran slowly, their mean and their longest; why MCP tool calls failed, with like errors counted together; and the requests each crawler or script made, by its user agent. Each is kept for 30 days.
crates/knotless/src/telemetry.rs+8−7
@@ -339,13 +339,7 @@ pub fn git(command: &str, dir: &std::path::Path, took: Duration) {
/// parameters unbound, so nothing it was given is logged; its whitespace
/// is closed up, and it is cut at 500 characters.
pub fn sql(sql: &str, took: Duration) {
let text: String = sql
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(500)
.collect();
let text = sql_text(sql);
let f = Fields {
query: Some(&text),
duration_ms: Some(millis(took)),
@@ -354,6 +348,13 @@ pub fn sql(sql: &str, took: Duration) {
note(Level::WARN, "sql", &f);
}
/// The statement `sql` as [`sql`] logs it: its whitespace closed up,
/// and cut at 500 characters.
pub fn sql_text(sql: &str) -> String {
let words = sql.split_whitespace().collect::<Vec<_>>();
words.join(" ").chars().take(500).collect()
}
/// What kind of failure `err` is, and why, without the URL it names.
fn error_type(err: &reqwest::Error) -> String {
let kind = match () {
crates/knotless/src/usage.rs+64−0
@@ -43,6 +43,19 @@ pub struct Report {
/// slow answers came, to read the logs of.
#[serde(default)]
pub hours: Vec<Hour>,
/// The index's statements that took a tenth of a slow request or
/// more, as written, without their values: the most time first, and
/// at most the last 30 days.
#[serde(default)]
pub statements: Vec<Statement>,
/// Why what the handlers saw failed, as they said, most often first,
/// and at most the last 30 days.
#[serde(default)]
pub failures: Vec<Failure>,
/// The requests each crawler or script made, by its `User-Agent`,
/// most first, and at most the last 30 days: who the `bot` client is.
#[serde(default)]
pub agents: Vec<Count>,
}
/// What `dev.knotless.getStats` shows anyone: the network and its days,
@@ -184,6 +197,30 @@ pub struct Route {
pub over_two: u64,
}
/// A statement the index ran slowly, and how often.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct Statement {
/// As written, its whitespace closed up and cut at 500 characters,
/// with no value it was given.
pub sql: String,
/// Times it ran slowly.
pub calls: u64,
pub mean_ms: u64,
pub max_ms: u64,
}
/// Why something the handlers saw failed, and how often.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
pub struct Failure {
/// The event's name and subject, as in [`Event`].
pub name: String,
pub subject: String,
/// What it answered, cut at 300 characters.
pub error: String,
pub n: u64,
}
/// Something, and how many times.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
pub struct Count {
@@ -248,6 +285,16 @@ pub fn render(r: &Report, out: &mut dyn Write) -> std::io::Result<()> {
h.hour, h.requests, h.errors, h.over_two
)
}));
lines.extend(r.statements.iter().map(|s| {
format!(
"sql\t{}\t{} slow, {}ms mean, {}ms max",
s.sql, s.calls, s.mean_ms, s.max_ms
)
}));
lines.extend(r.failures.iter().map(|f| {
let error = f.error.split_whitespace().collect::<Vec<_>>().join(" ");
format!("failure\t{}\t{}\t{}\t{error}", f.name, f.subject, f.n)
}));
lines.extend(r.funnels.iter().map(|f| {
format!(
"funnel\t{} -> {}\t{} started, {} reached",
@@ -258,6 +305,7 @@ pub fn render(r: &Report, out: &mut dyn Write) -> std::io::Result<()> {
("page", &r.pages),
("referrer", &r.referrers),
("client", &r.clients),
("agent", &r.agents),
] {
lines.extend(
counts
@@ -340,6 +388,19 @@ mod tests {
over_two: 1,
},
],
statements: vec![Statement {
sql: "SELECT 1".into(),
calls: 3,
mean_ms: 150,
max_ms: 400,
}],
failures: vec![Failure {
name: "mcp.tool".into(),
subject: "read_file".into(),
error: "no such file:\n a.txt".into(),
n: 1,
}],
agents: vec![count("Googlebot/2.1", 20)],
};
let mut out = Vec::new();
render(&report, &mut out).unwrap();
@@ -351,11 +412,14 @@ day\tvisitors\tviews\trequests\terrors\trepos\tpushes\tpulls\tissues\tcomments\t
route\tapi\tGET /api/{authority}/{repo}/tree\t12 requests, 1 failed, 30ms mean, 2 over 0.5s, 1 over 2s
event\tmcp.tool\tread_file\t9 calls, 1 failed, 120ms mean, 2 accounts
hour\t2026-10-23T19\t28 requests, 1 failed, 1 over 2s
sql\tSELECT 1\t3 slow, 150ms mean, 400ms max
failure\tmcp.tool\tread_file\t1\tno such file: a.txt
funnel\tmcp.signin -> mcp.tool\t3 started, 2 reached
page\t/[authority]/[repo]\t6
referrer\tbsky.app\t2
client\tbrowser\t30
client\tgit\t4
agent\tGooglebot/2.1\t20
"
);
}
crates/knotless-mcp/src/http.rs+4−0
@@ -152,6 +152,8 @@ pub struct Event<'a> {
pub ok: bool,
pub took: Duration,
pub did: Option<&'a str>,
/// Why it failed, as it answered, if it did and said.
pub error: Option<&'a str>,
}
/// Where events go.
@@ -664,6 +666,7 @@ impl ServerHandler for Remote {
ok: failure.is_none(),
took,
did: Some(&did),
error: failure.as_deref(),
});
res
}
@@ -917,6 +920,7 @@ async fn callback(
ok: did.is_some(),
took: started.elapsed(),
did,
error: None,
});
let to = match finished {
Ok((code, _)) => mcp.back(&redirect, state.as_deref(), &[("code", &code)]),
crates/knotless-mcp/src/lib.rs+1−1
@@ -2770,7 +2770,7 @@ impl Server {
}
#[tool(
description = "As the appview's operator, how it is used, day by day: visitors and page views, requests by route and kind of client, MCP tool calls, clones by repository, CLI versions, sign-up funnels, and what the network wrote (repositories, pushes, pulls, issues, comments, checks, active accounts)."
description = "As the appview's operator, how it is used, day by day: visitors and page views, requests by route and kind of client, MCP tool calls and why those that failed did, the index's slow statements, crawlers by user agent, clones by repository, CLI versions, sign-up funnels, and what the network wrote (repositories, pushes, pulls, issues, comments, checks, active accounts)."
)]
async fn stats(&self, Parameters(p): Parameters<Stats>) -> Result<CallToolResult, McpError> {
answer(
crates/knotless-serve/src/lib.rs+2−0
@@ -1136,6 +1136,8 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> {
Some(path) => Some(Arc::new(usage::Usage::open(path)?)),
None => None,
};
// The index's slow statements are counted in it.
usage.iter().for_each(usage::sink);
// MCP over HTTP, for any account not taken down, made once
// the address it reads this server's index at is known.
let mcp_ctx = ctx.clone();
crates/knotless-serve/src/telemetry.rs+2−1
@@ -112,13 +112,14 @@ fn traced(event: TraceEvent<'_>) {
}
}
/// Logs the statement `sql`, which took `took`, if that is a tenth
/// Logs the statement `sql`, and counts it in usage, which took `took`, if that is a tenth
/// of a slow request or more (`KNOTLESS_SLOW_MS`, a second unless set):
/// a request seldom runs one statement alone, so one that slow is what
/// made it slow, and the line, in its trace, says which.
fn profiled(took: Duration, sql: &str) {
if telemetry::slow(took.saturating_mul(10)) {
telemetry::sql(sql, took);
crate::usage::slow_statement(sql, took);
}
}
crates/knotless-serve/src/usage.rs+369−27
@@ -23,6 +23,11 @@
//! 30th day after it (UTC), so no DID is kept longer than 30 days.
//! Before they go, each funnel's day is kept as two counts with no one
//! in them: how many accounts started it, and how many of those went on.
//! - What tells an operator why, by the hour, and for those same 30
//! days: each of the index's statements slow enough to log (its SQL as
//! written, no value it was given), why an event failed (what it
//! answered, which can name a repository or an account), and the
//! `User-Agent` of each crawler or script, never a browser's.
//!
//! Requests are counted in memory and written now and then
//! ([`Usage::flush`]); the salt is in the database, so every serve
@@ -38,7 +43,9 @@ use axum::http::{HeaderMap, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use knotless::telemetry::{self, Fields, Level};
use knotless::usage::{Count, Day, Event, Funnel, Hour, Network, Report, Route, Stats};
use knotless::usage::{
Count, Day, Event, Failure, Funnel, Hour, Network, Report, Route, Statement, Stats,
};
use rand_core::{OsRng, RngCore};
use rusqlite::{Connection, params};
use serde::Deserialize;
@@ -46,7 +53,7 @@ use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::net::IpAddr;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, RwLock, Weak};
use std::time::{Duration, Instant, SystemTime};
/// How often the counted requests are written.
@@ -63,6 +70,17 @@ pub const ACCOUNT_DAYS: u64 = 30;
/// The longest route or referrer a page may send.
const NAME_MAX: usize = 200;
/// The most of a failure's error, or a crawler's `User-Agent`, kept.
const ERROR_MAX: usize = 300;
const AGENT_MAX: usize = 200;
/// The most failures, or crawlers, kept apart in an hour; past it, the
/// rest are counted together as [`OVERFLOW`], so a crawler naming itself
/// anew each time, or a statement built to its input, cannot grow the
/// table without end.
const KEPT_PER_HOUR: i64 = 500;
const OVERFLOW: &str = "(others)";
pub struct Usage {
db: Mutex<Connection>,
/// Requests counted since the last flush.
@@ -72,6 +90,41 @@ pub struct Usage {
/// The accounts seen in each, by day, in order: an account's first
/// day of an event is written before its next.
actors: Mutex<BTreeSet<(String, &'static str, String)>>,
/// Slow statements counted since the last flush, by hour and SQL.
statements: Mutex<HashMap<(String, String), Slow>>,
/// Failures counted since the last flush.
failures: Mutex<HashMap<FailureKey, u64>>,
/// Crawlers' requests counted since the last flush, by hour and agent.
agents: Mutex<HashMap<(String, String), u64>>,
}
/// A failure's hour, event name, subject and error.
type FailureKey = (String, &'static str, String, String);
/// A statement's slow runs: how many, their milliseconds, and the most.
#[derive(Default, Debug, PartialEq)]
struct Slow {
n: u64,
ms: u64,
max: u64,
}
/// The usage slow statements are counted in: the index's statements are
/// watched by a function SQLite calls, which can take no state.
static SINK: RwLock<Weak<Usage>> = RwLock::new(Weak::new());
/// Has the index's slow statements counted in `usage`.
pub fn sink(usage: &Arc<Usage>) {
*SINK.write().unwrap() = Arc::downgrade(usage);
}
/// Counts the slow statement `sql`, which took `took`, in the usage
/// [`sink`] named, if it named one.
pub fn slow_statement(sql: &str, took: Duration) {
let usage = SINK.read().unwrap().upgrade();
if let Some(usage) = usage {
usage.statement(sql, took);
}
}
/// The funnels the report follows: of the accounts that first did the one
@@ -138,6 +191,17 @@ impl Usage {
PRIMARY KEY (day, start, goal)) WITHOUT ROWID;
-- Kept before as `actor`, for good: moved, and folded at the
-- next flush.
CREATE TABLE IF NOT EXISTS statement (
hour TEXT NOT NULL, sql TEXT NOT NULL, n INTEGER NOT NULL,
ms INTEGER NOT NULL, max INTEGER NOT NULL,
PRIMARY KEY (hour, sql)) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS failure (
hour TEXT NOT NULL, name TEXT NOT NULL, subject TEXT NOT NULL,
error TEXT NOT NULL, n INTEGER NOT NULL,
PRIMARY KEY (hour, name, subject, error)) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS agent (
hour TEXT NOT NULL, agent TEXT NOT NULL, n INTEGER NOT NULL,
PRIMARY KEY (hour, agent)) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS actor (
day TEXT NOT NULL, name TEXT NOT NULL, did TEXT NOT NULL,
PRIMARY KEY (day, name, did)) WITHOUT ROWID;
@@ -153,9 +217,32 @@ impl Usage {
pending: Mutex::default(),
events: Mutex::default(),
actors: Mutex::default(),
statements: Mutex::default(),
failures: Mutex::default(),
agents: Mutex::default(),
})
}
/// Counts the index's statement `sql`, which took `took`: one slow
/// enough to log.
pub fn statement(&self, sql: &str, took: Duration) {
let key = (hour(SystemTime::now()), telemetry::sql_text(sql));
let ms = took.as_millis() as u64;
let mut statements = self.statements.lock().unwrap();
let slow = statements.entry(key).or_default();
slow.n += 1;
slow.ms += ms;
slow.max = slow.max.max(ms);
}
/// Counts why the event `name`, of `subject`, failed: `error`, what it
/// answered.
pub fn failed(&self, name: &'static str, subject: &str, error: &str) {
let error: String = fingerprint(error).chars().take(ERROR_MAX).collect();
let key = (hour(SystemTime::now()), name, subject.to_string(), error);
*self.failures.lock().unwrap().entry(key).or_default() += 1;
}
/// Counts an event a handler saw: `name`, of `subject` (a tool, a
/// repository, a version, or nothing), whether it worked, how long it
/// took, and the account, if signed in.
@@ -193,12 +280,18 @@ impl Usage {
path: &str,
method: &str,
status: u16,
client: &'static str,
agent: &str,
took: Duration,
) {
let Some((surface, route)) = surface(route, path) else {
return;
};
let client = client(agent);
if client == "bot" {
let agent: String = agent.chars().take(AGENT_MAX).collect();
let key = (hour(SystemTime::now()), agent);
*self.agents.lock().unwrap().entry(key).or_default() += 1;
}
// A long poll's wait is what it is for, not what anyone waited on.
let slow = u64::from(!crate::telemetry::waits(Some(route.as_str())));
let key = Key {
@@ -282,6 +375,7 @@ impl Usage {
}
drop(stmt);
tx.execute("DELETE FROM account WHERE day < ?1", [&kept])?;
self.flush_detail(&tx, &kept)?;
tx.execute(
"INSERT INTO visitors SELECT day, count(*) FROM visitor WHERE day < ?1 GROUP BY day
ON CONFLICT DO UPDATE SET n = n + excluded.n",
@@ -292,6 +386,57 @@ impl Usage {
tx.commit()
}
/// Writes the statements, failures and crawlers counted, and deletes
/// those of the days before `kept`, as the accounts are.
fn flush_detail(&self, tx: &rusqlite::Transaction, kept: &str) -> rusqlite::Result<()> {
// A new statement, failure or crawler past the hour's
// [`KEPT_PER_HOUR`] is counted as [`OVERFLOW`]; one already kept
// goes on being. Statements are as written, so few, but one built
// to its input, as an `IN` list, is new at each size.
let statements = std::mem::take(&mut *self.statements.lock().unwrap());
let mut stmt = tx.prepare_cached(
"INSERT INTO statement SELECT ?1,
CASE WHEN EXISTS (SELECT 1 FROM statement WHERE hour = ?1 AND sql = ?2)
OR (SELECT count(*) FROM statement WHERE hour = ?1) < ?6
THEN ?2 ELSE ?7 END, ?3, ?4, ?5
ON CONFLICT DO UPDATE SET n = n + excluded.n, ms = ms + excluded.ms,
max = max(max, excluded.max)",
)?;
for ((hour, sql), s) in &statements {
let p = params![hour, sql, s.n, s.ms, s.max, KEPT_PER_HOUR, OVERFLOW];
stmt.execute(p)?;
}
let failures = std::mem::take(&mut *self.failures.lock().unwrap());
let mut stmt = tx.prepare_cached(
"INSERT INTO failure SELECT ?1, ?2, ?3,
CASE WHEN EXISTS (SELECT 1 FROM failure
WHERE hour = ?1 AND name = ?2 AND subject = ?3 AND error = ?4)
OR (SELECT count(*) FROM failure WHERE hour = ?1) < ?6
THEN ?4 ELSE ?7 END, ?5
ON CONFLICT DO UPDATE SET n = n + excluded.n",
)?;
for ((hour, name, subject, error), n) in &failures {
let p = params![hour, name, subject, error, n, KEPT_PER_HOUR, OVERFLOW];
stmt.execute(p)?;
}
let agents = std::mem::take(&mut *self.agents.lock().unwrap());
let mut stmt = tx.prepare_cached(
"INSERT INTO agent SELECT ?1,
CASE WHEN EXISTS (SELECT 1 FROM agent WHERE hour = ?1 AND agent = ?2)
OR (SELECT count(*) FROM agent WHERE hour = ?1) < ?4
THEN ?2 ELSE ?5 END, ?3
ON CONFLICT DO UPDATE SET n = n + excluded.n",
)?;
for ((hour, agent), n) in &agents {
stmt.execute(params![hour, agent, n, KEPT_PER_HOUR, OVERFLOW])?;
}
// An hour, `YYYY-MM-DDTHH`, sorts after its day and before the next.
for table in ["statement", "failure", "agent"] {
tx.execute(&format!("DELETE FROM {table} WHERE hour < ?1"), [kept])?;
}
Ok(())
}
/// Counts a page drawn in a browser: the route that drew it, the host
/// that sent the visitor there, and the visitor, by `ip` and `agent`.
fn view(
@@ -435,10 +580,71 @@ impl Usage {
let funnel = stmt.query_row(params![from, to, since], funnel)?;
report.funnels.push(funnel);
}
let mut stmt = db.prepare(
"SELECT sql, sum(n), sum(ms) / sum(n), max(max) FROM statement WHERE hour >= ?1
GROUP BY 1 ORDER BY sum(ms) DESC, 1 LIMIT 50",
)?;
report.statements = stmt
.query_map([&since], statement)?
.collect::<rusqlite::Result<_>>()?;
let mut stmt = db.prepare(
"SELECT name, subject, error, sum(n) FROM failure WHERE hour >= ?1
GROUP BY 1, 2, 3 ORDER BY 4 DESC, 1, 2, 3 LIMIT 100",
)?;
report.failures = stmt
.query_map([&since], failure)?
.collect::<rusqlite::Result<_>>()?;
let mut stmt = db.prepare(
"SELECT agent, sum(n) FROM agent WHERE hour >= ?1 GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 20",
)?;
report.agents = stmt
.query_map([&since], count)?
.collect::<rusqlite::Result<_>>()?;
Ok(())
}
}
/// `error` with what differs from one failure to the next put as what
/// it is, so like failures count together: a DID as
/// `<did>`, an `at://` URI as `<uri>`, a hash or other hex of seven
/// digits or more as `<hex>`, and a number as `<n>`.
fn fingerprint(error: &str) -> String {
let words = error.split_whitespace().map(|word| {
let edge = |c: char| !c.is_alphanumeric() && !matches!(c, ':' | '/' | '.' | '_' | '-');
let core = word.trim_matches(edge).trim_end_matches(['.', ':']);
let is = |pred: fn(char) -> bool| !core.is_empty() && core.chars().all(pred);
let kind = match () {
_ if core.starts_with("did:") => "<did>",
_ if core.starts_with("at://") => "<uri>",
_ if core.len() >= 7 && is(|c| c.is_ascii_hexdigit()) && !is(char::is_alphabetic) => {
"<hex>"
}
_ if is(|c| c.is_ascii_digit()) => "<n>",
_ => return word.to_string(),
};
word.replacen(core, kind, 1)
});
words.collect::<Vec<_>>().join(" ")
}
fn statement(row: &rusqlite::Row) -> rusqlite::Result<Statement> {
Ok(Statement {
sql: row.get(0)?,
calls: row.get(1)?,
mean_ms: row.get(2)?,
max_ms: row.get(3)?,
})
}
fn failure(row: &rusqlite::Row) -> rusqlite::Result<Failure> {
Ok(Failure {
name: row.get(0)?,
subject: row.get(1)?,
error: row.get(2)?,
n: row.get(3)?,
})
}
fn counts(row: &rusqlite::Row) -> rusqlite::Result<(String, u64, u64)> {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
}
@@ -613,8 +819,7 @@ pub async fn count_requests(usage: Arc<Usage>, req: Request, next: Next) -> Resp
.map(|m| m.as_str().to_string());
let path = req.uri().path().to_string();
let method = req.method().to_string();
let agent = agent(req.headers());
let client = client(agent);
let agent = agent(req.headers()).to_string();
// The CLI's version, which says how far each release has reached.
let version = agent
.strip_prefix("knotless/")
@@ -628,7 +833,7 @@ pub async fn count_requests(usage: Arc<Usage>, req: Request, next: Next) -> Resp
&path,
&method,
status,
client,
&agent,
started.elapsed(),
);
res
@@ -678,9 +883,14 @@ impl App {
}
}
/// MCP's sign-ins and tool calls, counted.
/// MCP's sign-ins and tool calls, counted, and why those that failed did.
pub fn mcp_events(usage: Arc<Usage>) -> knotless_mcp::http::Events {
Arc::new(move |e| usage.event(e.name, e.subject, e.ok, e.took, e.did))
Arc::new(move |e| {
usage.event(e.name, e.subject, e.ok, e.took, e.did);
if let Some(error) = e.error {
usage.failed(e.name, e.subject, error);
}
})
}
/// A page drawn, as the page tells of it.
@@ -883,6 +1093,82 @@ mod tests {
assert_eq!(host(""), "");
}
/// The index's slow statements reach the usage named, and none when
/// it is gone.
#[test]
fn sinks_slow_statements() {
let dir = tempfile::tempdir().unwrap();
let usage = Arc::new(Usage::open(&dir.path().join("usage.db")).unwrap());
sink(&usage);
let sql = "SELECT 'sinks_slow_statements'";
slow_statement(sql, Duration::from_millis(150));
let counted = usage
.statements
.lock()
.unwrap()
.keys()
.any(|(_, s)| s == sql);
assert!(counted);
drop(usage);
slow_statement(sql, Duration::from_millis(150));
}
#[test]
fn fingerprints() {
for (error, print) in [
(
"did:plc:abc is not a member of at://did:plc:x/dev.knotless.repo/r.",
"<did> is not a member of <uri>.",
),
("no commit 5bf7b54f503f (yet)", "no commit <hex> (yet)"),
("took 1500 ms, (3) times", "took <n> ms, (<n>) times"),
("deadbeef and facade", "deadbeef and facade"),
("", ""),
] {
assert_eq!(fingerprint(error), print, "{error}");
}
}
/// Past an hour's kept failures and crawlers, the new ones count
/// together; one already kept goes on being.
#[test]
fn caps_each_hour() {
let dir = tempfile::tempdir().unwrap();
let usage = Usage::open(&dir.path().join("usage.db")).unwrap();
let fast = Duration::ZERO;
for i in 0..KEPT_PER_HOUR {
let [a, b] = [i / 26, i % 26].map(|d| char::from(b'a' + d as u8));
usage.failed("mcp.tool", "x", &format!("failed as {a}{b}"));
usage.tally(None, "/", "GET", 200, &format!("bot{i}"), fast);
usage.statement(&format!("SELECT {a}{b}"), Duration::from_millis(100));
}
usage.flush().unwrap();
usage.failed("mcp.tool", "x", " failed as aa ");
usage.failed("mcp.tool", "x", "new");
usage.failed("mcp.tool", "x", "newer");
usage.tally(None, "/", "GET", 200, "bot0", fast);
usage.tally(None, "/", "GET", 200, "botnew", fast);
usage.statement("SELECT aa", Duration::from_millis(300));
usage.statement("SELECT new", Duration::from_millis(200));
usage.flush().unwrap();
let db = usage.db.lock().unwrap();
let n = |sql: &str| db.query_row(sql, [], |r| r.get::<_, i64>(0)).unwrap();
assert_eq!(n("SELECT count(*) FROM failure"), KEPT_PER_HOUR + 1);
assert_eq!(n("SELECT n FROM failure WHERE error = '(others)'"), 2);
assert_eq!(n("SELECT n FROM failure WHERE error = 'failed as aa'"), 2);
assert_eq!(n("SELECT count(*) FROM agent"), KEPT_PER_HOUR + 1);
assert_eq!(n("SELECT n FROM agent WHERE agent = '(others)'"), 1);
assert_eq!(n("SELECT n FROM agent WHERE agent = 'bot0'"), 2);
assert_eq!(n("SELECT count(*) FROM statement"), KEPT_PER_HOUR + 1);
let row = |sql: &str| {
let q = "SELECT n, ms, max FROM statement WHERE sql = ?1";
db.query_row(q, [sql], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
};
assert_eq!(row("SELECT aa"), (2i64, 400i64, 300i64));
assert_eq!(row("(others)"), (1, 200, 200));
}
#[test]
fn says_what_was_not_written() {
wrote(Ok(()));
@@ -921,6 +1207,25 @@ mod tests {
// Opened again, as each deploy does: nothing moves twice.
drop(usage);
let usage = Usage::open(&path).unwrap();
// What tells why, past the month and at its edge.
{
let db = usage.db.lock().unwrap();
for d in [ago(30), ago(29)] {
let h = format!("{d}T23");
db.execute(
"INSERT INTO statement VALUES (?1, 'SELECT 1', 1, 100, 100)",
[&h],
)
.unwrap();
db.execute(
"INSERT INTO failure VALUES (?1, 'mcp.tool', 'x', 'no', 1)",
[&h],
)
.unwrap();
db.execute("INSERT INTO agent VALUES (?1, 'curl/8', 1)", [&h])
.unwrap();
}
}
usage.event("gateway.served", "", true, Duration::ZERO, Some("did:c"));
usage.event("gateway.delegated", "", true, Duration::ZERO, Some("did:c"));
usage.event("gateway.delegated", "", true, Duration::ZERO, Some("did:d"));
@@ -941,6 +1246,12 @@ mod tests {
.query_row("SELECT min(day) FROM account", [], |r| r.get(0))
.unwrap();
assert_eq!(oldest, ago(29));
for table in ["statement", "failure", "agent"] {
let oldest: String = db
.query_row(&format!("SELECT min(hour) FROM {table}"), [], |r| r.get(0))
.unwrap();
assert_eq!(oldest, format!("{}T23", ago(29)), "{table}");
}
// Their funnels, counted: a and b started 40 days ago and a went
// on; a again 35 days ago was not a start.
let mut stmt = db
@@ -1036,7 +1347,7 @@ mod tests {
let usage = Usage::open(&dir.path().join("usage.db")).unwrap();
let route = "/xrpc/dev.knotless.awaitSpaceWrites";
let waited = Duration::from_secs(25);
usage.tally(Some(route), route, "GET", 200, "cli", waited);
usage.tally(Some(route), route, "GET", 200, "knotless/0.4.0", waited);
let pending = usage.pending.lock().unwrap();
let tally = pending.values().next().unwrap();
assert_eq!((tally.n, tally.over_half, tally.over_two), (1, 0, 0));
@@ -1052,7 +1363,7 @@ mod tests {
"/api/a/r/tree",
"GET",
200,
"cli",
"knotless/0.4.0",
fast,
);
usage.tally(
@@ -1060,22 +1371,37 @@ mod tests {
"/api/b/r/tree",
"GET",
502,
"cli",
"knotless/0.4.0",
Duration::from_secs(3),
);
usage.tally(Some("/healthz"), "/healthz", "GET", 200, "other", fast);
usage.tally(Some("/healthz"), "/healthz", "GET", 200, "curl/8.0", fast);
usage.tally(
None,
"/a.test",
"GET",
200,
"browser",
"Mozilla/5.0",
Duration::from_millis(600),
);
// A crawler, by its agent; the health check's was not counted.
let crawler = format!("Googlebot/2.1 {}", "x".repeat(300));
usage.tally(None, "/b.test", "GET", 200, &crawler, fast);
usage.tally(None, "/b.test", "GET", 200, &crawler, fast);
// Slow statements, as written; and why a tool failed.
usage.statement("SELECT a\n FROM b", Duration::from_millis(100));
usage.statement("SELECT a FROM b", Duration::from_millis(500));
usage.statement("SELECT c", Duration::from_millis(200));
usage.failed(
"mcp.tool",
"read_file",
&format!(" no such file {}", "y".repeat(400)),
);
usage.failed("mcp.tool", "rerun", "not the owner");
usage.failed("mcp.tool", "rerun", "not the owner");
{
let pending = usage.pending.lock().unwrap();
// Apart by the status's class.
assert_eq!(pending.len(), 3);
// Apart by the status's class, and by client.
assert_eq!(pending.len(), 4);
let failed = pending.iter().find(|(k, _)| k.class == 5).unwrap().1;
assert_eq!(
*failed,
@@ -1151,24 +1477,20 @@ mod tests {
let now = &days[&today];
assert_eq!(
(now.visitors, now.views, now.requests, now.errors),
(2, 3, 3, 1)
);
assert_eq!(report.routes[0].route, "/api/{authority}/{repo}/tree");
assert_eq!(
(
report.routes[0].requests,
report.routes[0].errors,
report.routes[0].mean_ms
),
(2, 1, 1501)
(2, 3, 5, 1)
);
// The pages, a browser's and the crawler's, then the API.
assert_eq!(report.routes[0].route, "*");
let api = &report.routes[1];
assert_eq!(api.route, "/api/{authority}/{repo}/tree");
assert_eq!((api.requests, api.errors, api.mean_ms), (2, 1, 1501));
// All in this hour: the one that failed took three seconds.
let hours: Vec<_> = report
.hours
.iter()
.map(|h| (h.requests, h.errors, h.over_two))
.collect();
assert_eq!(hours, [(3, 1, 1)]);
assert_eq!(hours, [(5, 1, 1)]);
assert!(report.hours[0].hour.starts_with(&today));
let names = |c: &[Count]| c.iter().map(|c| (c.name.clone(), c.n)).collect::<Vec<_>>();
assert_eq!(
@@ -1181,8 +1503,28 @@ mod tests {
assert_eq!(names(&report.referrers), [("bsky.app".into(), 1)]);
assert_eq!(
names(&report.clients),
[("cli".into(), 2), ("browser".into(), 1)]
[("bot".into(), 2), ("cli".into(), 2), ("browser".into(), 1)]
);
let agent = &report.agents[0];
assert_eq!((agent.name.len(), agent.n), (AGENT_MAX, 2));
assert!(agent.name.starts_with("Googlebot/2.1 x"));
assert_eq!(report.agents.len(), 1);
let statements: Vec<_> = report
.statements
.iter()
.map(|s| (s.sql.as_str(), s.calls, s.mean_ms, s.max_ms))
.collect();
assert_eq!(
statements,
[("SELECT a FROM b", 2, 300, 500), ("SELECT c", 1, 200, 200)]
);
let failures: Vec<_> = report
.failures
.iter()
.map(|f| (f.subject.as_str(), f.error.len(), f.n))
.collect();
assert_eq!(failures, [("rerun", 13, 2), ("read_file", ERROR_MAX, 1)]);
assert!(report.failures[1].error.starts_with("no such file y"));
assert_eq!(hour(SystemTime::UNIX_EPOCH), "1970-01-01T00");
}
}
crates/knotless-serve/tests/usage.rs+33−9
@@ -142,19 +142,26 @@ fn hits_requests_and_the_network() {
// call a tool, twice; one delegates to the gateway and pushes.
let mcp = mcp_events(app.usage.clone().unwrap());
let ms = Duration::from_millis;
for (name, subject, ok, took, did) in [
("mcp.signin", "", true, ms(9), "did:x"),
("mcp.signin", "", true, ms(9), "did:y"),
("mcp.tool", "read_file", true, ms(100), "did:x"),
("mcp.tool", "read_file", false, ms(300), "did:x"),
for (name, subject, error, took, did) in [
("mcp.signin", "", None, ms(9), "did:x"),
("mcp.signin", "", None, ms(9), "did:y"),
("mcp.tool", "read_file", None, ms(100), "did:x"),
(
"mcp.tool",
"read_file",
Some("no such file"),
ms(300),
"did:x",
),
] {
let did = Some(did);
mcp(Event {
name,
subject,
ok,
ok: error.is_none(),
took,
did,
error,
});
}
app.event("gateway.delegated", "", true, Duration::ZERO, Some("did:x"));
@@ -200,6 +207,17 @@ fn hits_requests_and_the_network() {
res.unwrap().status().as_u16()
});
assert_eq!(listed, 200);
// A crawler, counted by its agent.
let crawled = rt.block_on(async {
let url = format!("{base}/xrpc/dev.knotless.listRepos");
let res = http
.get(url)
.header("user-agent", "curl/8.7.1")
.send()
.await;
res.unwrap().status().as_u16()
});
assert_eq!(crawled, 200);
let unauthorized = rt.block_on(async {
http.get(format!("{base}/xrpc/dev.knotless.getUsage"))
.send()
@@ -222,7 +240,7 @@ fn hits_requests_and_the_network() {
assert_eq!(today.day, now[..10]);
assert_eq!(
(today.visitors, today.views, today.requests, today.errors),
(3, 4, 2, 0)
(3, 4, 3, 0)
);
assert_eq!(
(
@@ -254,7 +272,7 @@ fn hits_requests_and_the_network() {
.map(|r| (r.surface.as_str(), r.route.as_str(), r.requests))
.collect();
assert!(
routes.contains(&("xrpc", "/xrpc/dev.knotless.listRepos", 1)),
routes.contains(&("xrpc", "/xrpc/dev.knotless.listRepos", 2)),
"{routes:?}"
);
assert!(
@@ -277,6 +295,12 @@ fn hits_requests_and_the_network() {
(2, 1, 200, 1)
);
assert_eq!(event("mcp.signin", "").accounts, 2);
let failure = &report.failures[0];
assert_eq!(
(failure.subject.as_str(), failure.error.as_str(), failure.n),
("read_file", "no such file", 1)
);
assert_eq!(report.agents[0].name, "curl/8.7.1");
assert!(event("cli.version", env!("CARGO_PKG_VERSION")).calls >= 1);
let funnels: Vec<(&str, &str, u64, u64)> = report
.funnels
@@ -320,7 +344,7 @@ fn hits_requests_and_the_network() {
"{text}"
);
assert!(
text.contains("route\txrpc\tGET /xrpc/dev.knotless.listRepos\t1 requests"),
text.contains("route\txrpc\tGET /xrpc/dev.knotless.listRepos\t2 requests"),
"{text}"
);
assert_ne!(
deploy/README.md+18−2
@@ -215,13 +215,29 @@ KNOTLESS_APPVIEW=https://knotless.dev knotless admin stats
- **Hours**: each hour's requests, how many failed and how many took over
two seconds, to know when to read the [logs](#logs) of. The lines show
only the hours with either; `--json` has every hour.
- **Statements** (`sql`): the index's statements that took 100 ms or
more, as the `sql` log line has them (the SQL as written, no value it
was given), with how often, their mean and their longest, the most
time first: the fifty that cost most.
- **Failures**: why an event failed, as it answered, most often first:
so far an MCP tool's error. A DID, an `at://` URI, a hash and a number
in it are put as `<did>`, `<uri>`, `<hex>` and `<n>`, so like failures
count together. A handle or a repository's name in it is kept as
written.
- **Agents**: requests by the `User-Agent` of each crawler or script,
the `bot` client, most first: never a browser's.
Statements, failures and agents keep at most 500 of each a hour apart;
past that, the new ones count together as `(others)`.
What is kept, in `usage.db`, is counts by the hour, and no address; and,
for the pages' logins, MCP and the gateway, where people are signed in
already, which accounts (by DID) did what each day, for the events'
accounts and the funnels. Those are kept 30 days, today the last: on the
accounts and the funnels; and the statements, failures and crawlers'
agents above, by the hour. Those are kept 30 days, today the last: on the
30th day after, serve folds each day into its funnels' counts, how many
started and how many went on, with no one in them, and deletes them. A
started and how many went on, with no one in them, and deletes them, and
the hours of the rest. A
page, once drawn in a browser, sends `/_hit` its route and the site the
visitor came from. Each visitor is counted once a day by a hash of their
address, less its last byte (its last 80 bits for IPv6), and their
docs/reference/api.md+1−1
lexicons/dev/knotless/getUsage.json+84−0
web/src/routes/privacy/+page.svelte+2−2