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:
1 parent
9cd1263080
commit
977bdb9ee0
6 files changed
+1027
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user