use crate::logs::{GrepTelemetryRow, LOG_STORE, LogEntry, LogStore};
use crate::stats::{CallSource, LlmFailureRow, LlmRequestRecord};
use crate::util::UnwrapPoison;
use futures_util::FutureExt;
use std::collections::VecDeque;
use std::panic::AssertUnwindSafe;
use std::sync::OnceLock;
const MAX_QUEUED_ITEMS: usize = 4_096;
const MAX_QUEUED_BYTES: usize = 1 << 20;
const ROW_OVERHEAD: usize = 64;
pub(crate) struct ToolCallBatch {
pub source: CallSource,
pub agent_id: String,
pub role: String,
pub workspace: String,
pub recorded_at: String,
pub records: Vec<crate::ToolCallRecord>,
}
pub(crate) enum TelemetryRecord {
LlmRequest(LlmRequestRecord),
LlmFailure(LlmFailureRow),
GrepTelemetry(GrepTelemetryRow),
ToolCalls(ToolCallBatch),
StrayEvent(LogEntry),
}
impl TelemetryRecord {
fn estimated_bytes(&self) -> usize {
fn optional(text: Option<&String>) -> usize {
text.map_or(0, String::len)
}
fn json_len(value: &serde_json::Value) -> usize {
match value {
serde_json::Value::Null => 4,
serde_json::Value::Bool(_) | serde_json::Value::Number(_) => 8,
serde_json::Value::String(text) => text.len(),
serde_json::Value::Array(items) => items.iter().map(json_len).sum(),
serde_json::Value::Object(fields) => fields
.iter()
.map(|(key, value)| key.len() + json_len(value))
.sum(),
}
}
let text = match self {
Self::LlmRequest(rec) => {
rec.agent_id.len()
+ rec.role.len()
+ rec.workspace.len()
+ rec.model.len()
+ rec.routing.len()
+ rec.recorded_at.len()
+ rec.purpose.len()
+ optional(rec.ticket_id.as_ref())
+ optional(rec.cost_details.as_ref())
+ optional(rec.upstream_provider.as_ref())
+ optional(rec.system_fingerprint.as_ref())
+ optional(rec.finish_reason.as_ref())
+ optional(rec.failure_class.as_ref())
}
Self::LlmFailure(rec) => {
rec.operation_id.len()
+ rec.agent_id.len()
+ rec.role.len()
+ rec.workspace.len()
+ rec.model.len()
+ rec.routing.len()
+ rec.recorded_at.len()
+ rec.purpose.len()
+ rec.failure_class.len()
+ rec.error_chain.len()
+ optional(rec.ticket_id.as_ref())
+ optional(rec.finish_reason.as_ref())
}
Self::GrepTelemetry(row) => {
row.command.len()
+ row.reason.len()
+ row.flags.len()
+ row.mode.len()
+ row.workspace.len()
+ row.recorded_at.len()
}
Self::ToolCalls(batch) => {
batch.agent_id.len()
+ batch.role.len()
+ batch.workspace.len()
+ batch.recorded_at.len()
+ batch
.records
.iter()
.map(|call| {
call.tool_name.len()
+ call.arguments.len()
+ optional(call.error_message.as_ref())
+ ROW_OVERHEAD
})
.sum::<usize>()
}
Self::StrayEvent(entry) => {
entry.timestamp.len()
+ entry.level.len()
+ entry.target.len()
+ entry.message.len()
+ entry.agent_id.len()
+ entry.agent_role.len()
+ entry.workspace.len()
+ json_len(&entry.fields)
}
};
text + ROW_OVERHEAD
}
fn lost_rows(&self) -> u64 {
match self {
Self::ToolCalls(batch) => batch.records.len() as u64,
_ => 1,
}
}
}
struct Queued {
record: TelemetryRecord,
bytes: usize,
}
#[derive(Default)]
struct QueueState {
items: VecDeque<Queued>,
bytes: usize,
}
struct Queue {
state: std::sync::Mutex<QueueState>,
wake: tokio::sync::Notify,
}
static QUEUE: OnceLock<Queue> = OnceLock::new();
static DRAIN_STARTED: OnceLock<()> = OnceLock::new();
static WIPE_GATE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub(crate) fn start() {
let queue = QUEUE.get_or_init(Queue::new);
if DRAIN_STARTED.set(()).is_ok() {
tokio::spawn(drain(queue));
}
}
pub(crate) fn enqueue(record: TelemetryRecord) {
if let Some(queue) = sink() {
queue.push(record);
}
}
pub(crate) fn recording() -> bool {
sink().is_some()
}
fn sink() -> Option<&'static Queue> {
let queue = QUEUE.get()?;
has_store().then_some(queue)
}
pub(crate) async fn wipe<S, D>(strip: S, delete: D) -> anyhow::Result<u64>
where
S: FnMut(&mut TelemetryRecord) -> bool,
D: std::future::Future<Output = anyhow::Result<u64>>,
{
let _gate = WIPE_GATE.lock().await;
let deleted = delete.await?;
if let Some(queue) = QUEUE.get() {
queue.purge(strip);
}
Ok(deleted)
}
impl Queue {
fn new() -> Self {
Self {
state: std::sync::Mutex::new(QueueState::default()),
wake: tokio::sync::Notify::new(),
}
}
fn push(&self, record: TelemetryRecord) {
let bytes = record.estimated_bytes();
let mut state = self.state.lock().unwrap_poison();
if state.items.len() >= MAX_QUEUED_ITEMS || state.bytes + bytes > MAX_QUEUED_BYTES {
drop(state);
crate::logs::record_telemetry_drop(record.lost_rows());
self.wake.notify_one();
return;
}
state.bytes += bytes;
state.items.push_back(Queued { record, bytes });
drop(state);
self.wake.notify_one();
}
fn take(&self) -> Option<Queued> {
let mut state = self.state.lock().unwrap_poison();
let queued = state.items.pop_front()?;
state.bytes -= queued.bytes;
Some(queued)
}
#[cfg(test)]
fn clear(&self) {
*self.state.lock().unwrap_poison() = QueueState::default();
}
fn purge(&self, mut strip: impl FnMut(&mut TelemetryRecord) -> bool) {
let mut state = self.state.lock().unwrap_poison();
let mut freed = 0;
state.items.retain_mut(|queued| {
if !strip(&mut queued.record) {
freed += queued.bytes;
return false;
}
let bytes = queued.record.estimated_bytes().min(queued.bytes);
freed += queued.bytes - bytes;
queued.bytes = bytes;
true
});
state.bytes -= freed;
}
}
async fn drain(queue: &'static Queue) {
loop {
write_queued(queue).await;
queue.wake.notified().await;
}
}
async fn write_queued(queue: &Queue) -> bool {
let mut wrote = false;
loop {
crate::logs::report_telemetry_drops();
let _gate = WIPE_GATE.lock().await;
let Some(queued) = queue.take() else {
return wrote;
};
let Some(store) = store() else {
continue;
};
if let Err(payload) = AssertUnwindSafe(write(&store, queued.record))
.catch_unwind()
.await
{
crate::logs::record_telemetry_write_panic(&format!(
"telemetry record write panic: {}",
crate::util::panic_message(&*payload)
));
}
wrote = true;
}
}
async fn write(store: &LogStore, record: TelemetryRecord) {
let outcome = match &record {
TelemetryRecord::LlmRequest(rec) => store.record_llm_request(rec).await,
TelemetryRecord::LlmFailure(rec) => store.record_llm_failure_row(rec).await,
TelemetryRecord::GrepTelemetry(row) => store.record_grep_telemetry(row).await,
TelemetryRecord::ToolCalls(batch) => {
store
.flush_batch(
&batch.agent_id,
&batch.role,
&batch.workspace,
batch.source,
&batch.recorded_at,
&batch.records,
)
.await
}
TelemetryRecord::StrayEvent(entry) => store.insert_batch(std::slice::from_ref(entry)).await,
};
if let Err(error) = outcome {
note_write_failure(&record, &error);
}
}
fn note_write_failure(record: &TelemetryRecord, error: &anyhow::Error) {
match record {
TelemetryRecord::LlmRequest(_) => {
tracing::debug!(error = %error, "Failed to persist LLM request stat");
}
TelemetryRecord::LlmFailure(_) => {
tracing::debug!(error = %error, "Failed to persist LLM attempt failure row");
}
TelemetryRecord::GrepTelemetry(_) => {
tracing::debug!(error = %error, "Failed to persist grep telemetry");
}
TelemetryRecord::ToolCalls(batch) => match batch.source {
CallSource::Agent => tracing::warn!(
agent_id = %batch.agent_id,
role = %batch.role,
error = %error,
"Failed to flush tool usage stats"
),
CallSource::CommandLine => tracing::warn!(
error = %error,
tool = %batch.records.first().map_or("", |call| call.tool_name.as_str()),
"cli: failed to record the command-line operation"
),
},
TelemetryRecord::StrayEvent(entry) => tracing::debug!(
error = %error,
target = %entry.target,
"could not persist the leftover-process record"
),
}
}
fn has_store() -> bool {
#[cfg(test)]
if TEST_LOG_STORE.read().unwrap_poison().is_some() {
return true;
}
LOG_STORE.get().is_some()
}
fn store() -> Option<LogStore> {
#[cfg(test)]
if let Some(store) = TEST_LOG_STORE.read().unwrap_poison().as_ref() {
return Some(store.clone());
}
LOG_STORE.get().cloned()
}
#[cfg(test)]
static TEST_LOG_STORE: std::sync::RwLock<Option<LogStore>> = std::sync::RwLock::new(None);
#[cfg(test)]
pub(crate) fn swap_test_log_store(store: Option<LogStore>) -> Option<LogStore> {
QUEUE.get_or_init(Queue::new).clear();
let mut guard = TEST_LOG_STORE.write().unwrap_poison();
let previous = guard.take();
*guard = store;
previous
}
#[cfg(test)]
pub(crate) async fn drain_for_test() {
let Some(queue) = QUEUE.get() else {
return;
};
while write_queued(queue).await {}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::params;
const MARKER: &str = "telemetry-test";
fn telemetry_row(command: &str) -> GrepTelemetryRow {
GrepTelemetryRow {
command: command.to_string(),
served: true,
reason: String::new(),
recursive: true,
piped: false,
operand_count: 2,
flags: "-rn".to_string(),
mode: "Full".to_string(),
workspace: "/tmp/ws".to_string(),
grep_count: 1,
served_count: 1,
skipped_count: 0,
duration_ms: Some(3),
exit_code: Some(0),
recorded_at: crate::db::now(),
}
}
fn grep_row(command: &str) -> TelemetryRecord {
TelemetryRecord::GrepTelemetry(telemetry_row(command))
}
fn batch_of(calls: &[(&str, Option<&str>)]) -> TelemetryRecord {
TelemetryRecord::ToolCalls(ToolCallBatch {
source: CallSource::Agent,
agent_id: MARKER.to_string(),
role: "Engineer".to_string(),
workspace: "/tmp/ws".to_string(),
recorded_at: crate::db::now(),
records: calls
.iter()
.map(|(name, error)| crate::ToolCallRecord {
tool_name: (*name).to_string(),
arguments: "{}".to_string(),
duration_ms: 1,
success: error.is_none(),
error_message: error.map(str::to_string),
})
.collect(),
})
}
fn tool_call(name: &str, error: Option<&str>) -> TelemetryRecord {
batch_of(&[(name, error)])
}
fn log_row(level: &str, message: &str) -> TelemetryRecord {
TelemetryRecord::StrayEvent(LogEntry {
timestamp: crate::db::now(),
level: level.to_string(),
target: "shell".to_string(),
message: message.to_string(),
..LogEntry::default()
})
}
fn large_log_row(message_length: usize) -> TelemetryRecord {
log_row("WARN", &format!("{MARKER}{}", "x".repeat(message_length)))
}
async fn count_marked(store: &LogStore, table: &str, column: &str) -> i64 {
store
.conn
.query_row(
&format!("SELECT COUNT(*) FROM {table} WHERE {column} LIKE ?1"),
params![format!("{MARKER}%")],
|row| row.get::<i64>(0),
)
.await
.expect("count marked rows")
}
async fn marked_messages(store: &LogStore) -> Vec<String> {
store
.conn
.query(
"SELECT message FROM logs WHERE message LIKE ?1 ORDER BY id",
params![format!("{MARKER}%")],
)
.await
.expect("read the remaining rows")
.iter()
.map(|row| row.get::<String>(0).unwrap())
.collect()
}
#[tokio::test]
#[serial_test::serial(provider)] async fn the_task_started_for_the_product_writes_what_is_queued() {
let (store, _tmp) = crate::open_test_store!(crate::logs::LogStore, "log");
let _seam = crate::util::test::install_test_log_store(store.clone());
start();
enqueue(grep_row(&format!("{MARKER}-written-by-the-task")));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while count_marked(&store, "grep_telemetry", "command").await == 0 {
assert!(
std::time::Instant::now() < deadline,
"the drain task must write the queued record"
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
#[tokio::test]
#[serial_test::serial(provider)] async fn records_are_written_in_the_order_they_were_produced() {
let (store, _tmp) = crate::open_test_store!(crate::logs::LogStore, "log");
let _seam = crate::util::test::install_test_log_store(store.clone());
let queue = Queue::new();
for command in ["first", "second", "third"] {
queue.push(grep_row(&format!("{MARKER}-{command}")));
}
write_queued(&queue).await;
let rows = store
.conn
.query(
"SELECT command FROM grep_telemetry WHERE command LIKE ?1 ORDER BY id",
params![format!("{MARKER}%")],
)
.await
.expect("read the recorded runs");
let commands: Vec<String> = rows
.iter()
.map(|row| row.get::<String>(0).unwrap())
.collect();
let expected: Vec<String> = ["first", "second", "third"]
.iter()
.map(|command| format!("{MARKER}-{command}"))
.collect();
assert_eq!(commands, expected);
}
#[tokio::test]
#[serial_test::serial(provider, log_surface)]
async fn a_full_queue_drops_records_and_counts_them_apart_from_write_failures() {
let (store, _tmp) = crate::open_test_store!(crate::logs::LogStore, "log");
let _seam = crate::util::test::install_test_log_store(store.clone());
let queue = Queue::new();
let before = crate::logs::log_write_error_info();
for _ in 0..=5 {
queue.push(large_log_row(MAX_QUEUED_BYTES / 6));
}
let after = crate::logs::log_write_error_info();
assert!(
after.dropped > before.dropped,
"the record that did not fit must be counted as a drop"
);
assert_eq!(
after.count, before.count,
"a drop is the queue being full, not a store write failure"
);
write_queued(&queue).await;
assert_eq!(
count_marked(&store, "logs", "message").await,
5,
"the queue held only what its byte cap allows, and everything it held \
must still be written"
);
}
#[tokio::test]
#[serial_test::serial(provider)] async fn a_failed_write_is_noted_and_the_drain_keeps_going() {
let (store, _tmp) = crate::open_test_store!(crate::logs::LogStore, "log");
let _seam = crate::util::test::install_test_log_store(store.clone());
let queue = Queue::new();
store
.conn
.execute("DROP TABLE logs", ())
.await
.expect("drop the logs table for the failure test");
queue.push(large_log_row(8));
queue.push(grep_row(&format!("{MARKER}-after-the-failure")));
tokio::time::timeout(std::time::Duration::from_secs(30), write_queued(&queue))
.await
.expect("a failed write must not hold the drain");
assert_eq!(
count_marked(&store, "grep_telemetry", "command").await,
1,
"the next record must still be written"
);
}
#[tokio::test]
#[serial_test::serial(provider)] async fn producing_a_record_never_waits_for_a_stalled_store() {
let (store, _tmp) = crate::open_test_store!(crate::logs::LogStore, "log");
let _seam = crate::util::test::install_test_log_store(store.clone());
let held = store
.conn
.begin_tx()
.await
.expect("hold the store's connection");
assert!(
tokio::time::timeout(
std::time::Duration::from_millis(200),
store.record_grep_telemetry(&telemetry_row("wedged")),
)
.await
.is_err(),
"a store write must wait for the held connection"
);
let message = format!("{MARKER} stalled store");
let event = crate::logs::StrayEvent {
source: crate::logs::STRAY_SOURCE_SHELL,
message: &message,
recipe: "",
scope: "",
session: "",
workspace: "/tmp/ws",
duration_ms: Some(1),
};
tokio::time::timeout(std::time::Duration::from_secs(5), async {
crate::logs::record_stray_event(&event);
})
.await
.expect("producing a record must not wait for the store");
drop(held);
tokio::time::timeout(std::time::Duration::from_secs(10), drain_for_test())
.await
.expect("the drain must write the record once the store is free");
assert_eq!(
count_marked(&store, "logs", "message").await,
1,
"the record the wedged store could not take must land once it is free"
);
}
#[tokio::test]
#[serial_test::serial(provider)] async fn a_wipe_leaves_no_queued_record_of_its_class_behind() {
let (store, _tmp) = crate::open_test_store!(crate::logs::LogStore, "log");
let _seam = crate::util::test::install_test_log_store(store.clone());
enqueue(tool_call("read", Some("permission denied")));
drain_for_test().await;
enqueue(tool_call("edit", None));
enqueue(batch_of(&[
("write", Some("no space left")),
("glob", None),
]));
enqueue(tool_call("bash", Some("timed out")));
let deleted = store.clear_tool_errors().await.expect("wipe failed calls");
assert!(deleted >= 1, "the written failed call is there to delete");
drain_for_test().await;
let rows = store
.conn
.query(
"SELECT tool_name FROM tool_calls WHERE agent_id = ?1 ORDER BY tool_name",
params![MARKER],
)
.await
.expect("read the remaining calls");
let names: Vec<String> = rows
.iter()
.map(|row| row.get::<String>(0).unwrap())
.collect();
assert_eq!(
names,
["edit", "glob"],
"the queued failed calls must not land after the wipe, and the \
successful ones must still land"
);
}
#[tokio::test]
#[serial_test::serial(provider)] async fn a_wipe_leaves_no_queued_row_of_the_log_scope_behind() {
let (store, _tmp) = crate::open_test_store!(crate::logs::LogStore, "log");
let _seam = crate::util::test::install_test_log_store(store.clone());
enqueue(log_row("ERROR", &format!("{MARKER} written error")));
enqueue(log_row("INFO", &format!("{MARKER} written info")));
drain_for_test().await;
enqueue(log_row("ERROR", &format!("{MARKER} queued error")));
enqueue(log_row("WARN", &format!("{MARKER} queued warning")));
enqueue(log_row("INFO", &format!("{MARKER} queued info")));
let deleted = store
.clear_logs(Some("ERROR,WARN"))
.await
.expect("wipe the Issues scope");
assert!(deleted >= 1, "the written error row is there to delete");
drain_for_test().await;
assert_eq!(
marked_messages(&store).await,
vec![
format!("{MARKER} written info"),
format!("{MARKER} queued info"),
],
"the queued rows the scope covers must not land after the wipe, and the \
rows it leaves must"
);
}
}