client-core: the app's own log ring, and POST /client-log to get it off a phone

Iris tests iris builds on a phone with no adb, and Android forbids one
app reading another's logcat, so a `log::info!` in the app can only reach
her if the app carries its own copy and sends it somewhere.

`client_core::log_ring` is that copy: a bounded ring (2000 lines / 256
KiB, whichever bites first) behind a `log::Log` backend that forwards to
whichever real logger the platform installed, so `logcat` and the desktop
terminal see exactly what they saw before. Reading does not consume --
the report and the uploader are two readers of one ring.

`client_core::log_upload` drains it into ai-server's new `POST
/client-log`, which re-emits each line into the server's own tracing
output. Dev Updater already shows that as ai-server's runtime log, so
nothing new is built there. A failed batch is retried from the same
cursor, and nothing in the upload path calls `log!` -- it would land in
the ring it is draining.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-07 15:56:20 -04:00
1 parent 9cd1263080
commit 977bdb9ee0
6 files changed
+1027

No files matched your search

+109
View File
@@ -62,6 +62,9 @@
//! once the account's usage limit lifts
//! GET /notifications SSE: every session's attention-wanting
//! moments, live only (see `notifications`)
//! POST /client-log {source, lines} -- a client's own recent log,
//! re-emitted into this server's log (see
//! `client_log`; the phone has no logcat)
//! GET /defaults {effort} -- what a new session starts at
//! POST /defaults {effort} -- null for the CLI's own default
//! GET /usage cached usage windows per provider
@@ -158,6 +161,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
.route("/sessions/{id}/effort", post(set_effort))
.route("/defaults", get(defaults).post(set_defaults))
.route("/client-log", post(client_log))
.route("/sessions/{id}/notify", post(set_notify))
.route("/sessions/{id}/auto-resume", post(set_auto_resume))
.route("/notifications", get(notifications))
@@ -1407,6 +1411,111 @@ async fn defaults(State(manager): State<Arc<SessionManager>>) -> axum::Json<Defa
})
}
/// How many lines one `POST /client-log` may carry, matching
/// `client_core::log_upload::MAX_LINES_PER_BATCH`. A client that sends more
/// is refused rather than silently shortened: a log with a hole in it that
/// nothing mentions is worse than a rejected batch the client retries.
const CLIENT_LOG_MAX_LINES: usize = 500;
/// One line of a client's own log. `at` is that client's clock, not this
/// machine's -- see [`client_log`].
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ClientLogLine {
/// The client's ring sequence. Carried so a gap -- lines its bound
/// dropped -- is visible here rather than looking like a quiet client.
seq: u64,
/// Milliseconds since the unix epoch, from the client's own clock.
at: u64,
level: String,
target: String,
message: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ClientLogBody {
/// Which client this is -- an app name and build, not a device
/// identifier. It is what tells two phones' lines apart in the log.
source: String,
lines: Vec<ClientLogLine>,
}
/// Takes a client's own recent log lines and re-emits them into this
/// server's `tracing` output.
///
/// **Why a route rather than something on the phone**: Android forbids one
/// app reading another's `logcat`, and the phone this project is tested on
/// has no `adb` at all, so a `log::info!` in the app can only reach a
/// person if the app carries its own copy and sends it somewhere. This
/// server is the somewhere it already has a tunnel, a pinned CA and a
/// bearer token for -- and Dev Updater already shows this server's log as
/// its runtime log, so the line arrives where its reader is already
/// looking with nothing new built there. `docs/DECISIONS.md`, 2026-09-07.
///
/// Each line is emitted separately, at the level the client recorded it
/// at, with the client's own timestamp in the text -- the tracing
/// subscriber stamps the moment of *arrival*, which can be minutes later
/// or on the other side of a tunnel outage, and presenting that as when it
/// happened would be an inferred value shown as a measured one.
async fn client_log(axum::Json(body): axum::Json<ClientLogBody>) -> Result<StatusCode, ApiError> {
if body.lines.len() > CLIENT_LOG_MAX_LINES {
return Err(ApiError::BadRequest(format!(
"{} lines in one batch; the limit is {CLIENT_LOG_MAX_LINES}",
body.lines.len()
)));
}
for line in &body.lines {
let at = client_log_time(line.at);
let source = &body.source;
let target = &line.target;
let seq = line.seq;
let message = &line.message;
// The level is chosen here rather than passed, because a tracing
// macro's level is part of the callsite. Anything unrecognised is
// reported at INFO with the word it sent kept, so a client using a
// level this server has not heard of loses the level rather than
// the line.
match line.level.to_ascii_uppercase().as_str() {
"ERROR" => {
tracing::error!(target: "client_log", "[{source} {at} #{seq}] {target}: {message}")
}
"WARN" => {
tracing::warn!(target: "client_log", "[{source} {at} #{seq}] {target}: {message}")
}
"DEBUG" => {
tracing::debug!(target: "client_log", "[{source} {at} #{seq}] {target}: {message}")
}
"TRACE" => {
tracing::trace!(target: "client_log", "[{source} {at} #{seq}] {target}: {message}")
}
"INFO" => {
tracing::info!(target: "client_log", "[{source} {at} #{seq}] {target}: {message}")
}
other => {
tracing::info!(target: "client_log", "[{source} {at} #{seq}] {target}: <{other}> {message}")
}
}
}
Ok(StatusCode::NO_CONTENT)
}
/// `HH:MM:SS.mmm` UTC from the client's unix milliseconds -- the same
/// formatting `client_core::log_ring` uses, so a line read here and the
/// same line in the app's own copied report say the same time.
fn client_log_time(at_ms: u64) -> String {
let secs_of_day = (at_ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
at_ms % 1000
)
}
/// Sets what a new session's thinking level is. Applied when a session is
/// spawned, so nothing already running changes underneath anybody.
async fn set_defaults(