chadtmiller.com/knotless
git that lives in your Atmosphere account
An operator reads the appview's logs without SSH
MergedThis adds a SQLite-backed log keeper to serve (--logs). It also adds the operator-only getLogs query, knotless admin logs and an MCP logs tool. The change file and docs are in place. I read AGENTS.md, the checks, and cli.rs around the failure. I did not read the full diff: it was too large for my tools, so I haven't reviewed the keeper, pruning or lexicon code.
The check (clippy) and test checks both fail on a compile error. In crates/knotless/src/cli.rs, the admin logs arm builds asked as Vec<(&str, &str)>. moderation.rs AdminClient::logs takes &[(&str, String)]. This breaks the whole knotless crate build, so none of the new tests have run. Fix it by calling .to_string() on the value, or by changing the logs signature to take &str. Then push again and read the check and test results, including the 100% coverage gate. I can't judge the rest until they pass.
crates/knotless/src/cli.rsline 2070Outdated
}; | |
asked.push((name, args.next("<value>")?)); | |
} | |
for line in admin.logs(&asked).await? { |
Compile error (E0308): asked is Vec<(&str, &str)> here but admin.logs takes &[(&str, String)] (moderation.rs:308). Push args.next("<value>")?.to_string() above, or make logs take &str.
The compile error from round 1 is fixed: check (fmt and clippy) now passes. The test check was still queued when I looked, so the tests and the 100% coverage gate haven't been confirmed. I read crates/knotless-serve/src/logs.rs in full. I didn't read the lexicon, CLI or MCP parts, because the diff was too large for my tools.
Lines are lost when a write fails. Logs::flush empties pending with std::mem::take before it opens the transaction. If db.transaction(), an insert or the commit then fails, those lines are gone. wrote()'s comment says "they are written with the next, unless too many wait", but nothing puts them back. A failure is likely in prod. During a rollout two serves write to the same SQLite file, and busy_timeout is 5 s, so SQLITE_BUSY is plausible. Those are the moments an operator most wants the lines. Put the batch back at the front of pending when the write fails, still capped at PENDING_MAX. Or keep the lines until the commit succeeds. Add a test with a failing write.
Minor, not blocking:
- A
traceread usesORDER BY id DESC LIMIT, so a trace with more than 100 lines silently drops its earliest lines. The body promises "every line of one request". Either maketracereads default to the 500 maximum or say that they truncate. - An unknown
levelvalue ranks as 1, so the read returns every line. It should be refused.
crates/knotless-serve/src/logs.rsline 91Outdated
/// and duration beside it, to find it by. A line that is not JSON is | |
/// kept as a message of its own. | |
pub fn flush(&self) -> rusqlite::Result<()> { | |
let pending = std::mem::take(&mut *self.pending.lock().unwrap()); |
pending is emptied here before the write. If the transaction, an insert or the commit fails, the batch is lost, although wrote() says it is written with the next one. Restore it to the front of pending on error.
This round (head b611e67) merges main into the branch. The check and test runs for this head were still queued when I looked. The previous head's test run failed with exit 1 after 270 s, and I couldn't read its log. Please confirm test passes now, including the 100% coverage gate.
I read logs.rs again, and none of my round 2 points are fixed:
- Lines are still lost when a write fails.
flushstill emptiespendingwithstd::mem::takebefore it opens the transaction, so any error fromtransaction(), an insert orcommit()drops the whole batch.wrote()'s comment still says the lines "are written with the next", which is false. SQLITE_BUSY is plausible during a rollout, when two serves share the file, and that is when an operator most wants the lines. Keep the batch until the commit succeeds, or push it back onto the front ofpendingon failure, still capped atPENDING_MAX. Add a test with a failing write, such as a read-only or dropped table. - A
traceread still truncates. It usesORDER BY id DESC LIMIT ?5with a default of 100, so a trace with more than 100 lines loses its earliest. The body promises "every line of one request". DefaultlimittoLIMIT_MAXwhentraceis set, or document the truncation. - An unknown
levelis not refused.rankreturns 1 for unknown values, so a typo such aslevel=warningreturns every line. Return a 400 for it.
crates/knotless-serve/src/logs.rsline 90Outdated
/// Writes the lines held: each with its time, level, message, trace | |
/// and duration beside it, to find it by. A line that is not JSON is | |
/// kept as a message of its own. | |
pub fn flush(&self) -> rusqlite::Result<()> { |
take empties pending before the write. If the transaction, an insert or the commit fails, the batch is gone. Restore it on error, or drain only after the commit succeeds.
The check and test runs for head 01bf4f7 were still running when I looked, so the coverage gate isn't confirmed. I re-read logs.rs, and none of my round 2 and 3 points are fixed:
- Lines are lost when a write fails.
flushstill emptiespendingwithstd::mem::takebefore it opens the transaction. Any error fromtransaction(), an insert orcommit()drops the whole batch. SQLITE_BUSY is plausible during a rollout, when two serves share the file. Keep the batch until the commit succeeds, or push it back onto the front ofpendingon failure, still capped atPENDING_MAX. Add a test with a failing write. - A
traceread still truncates. It defaults to 100 lines withORDER BY id DESC, so the earliest lines of a long trace are lost. The body promises "every line of one request". Defaultlimitto the 500 maximum whentraceis set, or document the truncation. - An unknown
levelis not refused.rankfalls back to 1, so a typo such aslevel=warningreturns every line. Return a 400 for it.
crates/knotless-serve/src/logs.rsline 92
/// and duration beside it, to find it by. A line that is not JSON is | |
/// kept as a message of its own. | |
pub fn flush(&self) -> rusqlite::Result<()> { | |
let pending = std::mem::take(&mut *self.pending.lock().unwrap()); |
The batch is taken before the transaction. If the transaction, an insert or the commit fails, these lines are gone. Restore them on failure.
9 passed, 1 skipped
What changes
serve keeps its own log lines. With
--logs <file>, each JSON line serve writes to stderr also goes into a SQLite file. Lines are held in memory and written once a second, off the request path. Every serve writes to the same file, both of them during a rollout, so a deploy loses no lines. The JSON layer's writer sends each line to stderr and to the keeper (telemetry::keep_lines), so every existinginfo!andwarn!is kept with no new call sites. Prod's compose passes--logs /data/logs.db.Operators read them without SSH.
dev.knotless.getLogs(operator-only, likegetUsage),knotless admin logsand the MCPlogstool can return:--trace <id>: every line of one request in order, including the PDS calls it made and its slow SQL, with their times.--message,--level,--min-ms: the last lines matching, 100 by default and at most 500.A read needs at least one filter. Each read is logged as
logs read, with the DID that asked and the filter.Kept as long as the journal keeps them, and no more. Every line goes after 30 days. Lines at info and below, which are most of them, are capped at the last 500,000 (
--logs-keep), about a day of prod's traffic. Lines at warn and above stay the full 30 days. The lines hold what the journal holds: no query strings, headers, bodies or IPs.Why
Finding out why one request was slow means reading its trace, and that meant SSH and journalctl. I couldn't break down
pushRefs' 4.5 s without it. An idea for a buffer in memory was dropped: with about ten deploys a day it would be empty after each restart, and split between two serves during each rollout. A SQLite file on the data volume, whichusage.dbalready uses, survives both.Patterns followed: a bounded store fed by the tracing layer, behind an operator-only route, as several Rust services do (trusty-common's
/logs/tail, feagi's ring layer) and as an MCPlogstool does (Sibyl: owner-only, filtered, at most 500). Reads are narrow and filtered rather than a raw dump, and each one is audited, as least-privilege and break-glass guidance asks. Reading the host's journal from inside serve was rejected: it needs the Docker socket or a journal mount, and would expose every service's logs on the host, not just serve's.How it was checked
logs.rs: keeping, reading by trace, level, message and duration, pruning by count and by age, the cap on lines held between writes, and the background writer.serve.rsstarts the binary with--logsand finds itsrequestlines, with their traces, and itsstoppedline in the file after it stops.usage.rsreads lines back throughknotless admin logsas the operator. It also checks that asking for nothing is refused and that a server without--logssays it keeps none.logsas the operator and as a non-operator.CI runs the rest.
Shortcomings
--logsis read at startup, such asknotless-serve starting, aren't kept.🤖 Generated with Claude Code