diff --git a/Cargo.toml b/Cargo.toml index cc5bb9b683..77bd378e41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,11 @@ version = "0.1.0" rust-version = "1.94" edition = "2024" +[workspace.lints.clippy] +# #[async_trait] generates methods returning Pin> with an +# implicit #[must_use]; newer clippy nightlies flag this as double_must_use. +double_must_use = "allow" + [profile.release] lto = true codegen-units = 1 diff --git a/crates/forge_api/Cargo.toml b/crates/forge_api/Cargo.toml index 9a567acfe6..566866dd44 100644 --- a/crates/forge_api/Cargo.toml +++ b/crates/forge_api/Cargo.toml @@ -31,3 +31,6 @@ forge_config.workspace = true tokio = { workspace = true } + +[lints] +workspace = true diff --git a/crates/forge_app/Cargo.toml b/crates/forge_app/Cargo.toml index 6fbd6df6d9..1189e4b847 100644 --- a/crates/forge_app/Cargo.toml +++ b/crates/forge_app/Cargo.toml @@ -58,3 +58,6 @@ insta.workspace = true tokio-stream.workspace = true fake = { version = "5.1.0", features = ["derive"] } forge_domain = { path = "../forge_domain" } + +[lints] +workspace = true diff --git a/crates/forge_ci/Cargo.toml b/crates/forge_ci/Cargo.toml index 03bd2eb0eb..73e6569fa1 100644 --- a/crates/forge_ci/Cargo.toml +++ b/crates/forge_ci/Cargo.toml @@ -13,3 +13,6 @@ derive_setters.workspace = true [dev-dependencies] + +[lints] +workspace = true diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml index b7a1822b27..bf14aaaa61 100644 --- a/crates/forge_config/Cargo.toml +++ b/crates/forge_config/Cargo.toml @@ -26,3 +26,6 @@ is_ci.workspace = true pretty_assertions.workspace = true serde_json.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } + +[lints] +workspace = true diff --git a/crates/forge_display/Cargo.toml b/crates/forge_display/Cargo.toml index 2a11aca5fa..2d20b11dec 100644 --- a/crates/forge_display/Cargo.toml +++ b/crates/forge_display/Cargo.toml @@ -20,3 +20,6 @@ two-face = "0.5.1" insta.workspace = true pretty_assertions.workspace = true strip-ansi-escapes.workspace = true + +[lints] +workspace = true diff --git a/crates/forge_domain/Cargo.toml b/crates/forge_domain/Cargo.toml index 966e2af9f6..1d1dfa9030 100644 --- a/crates/forge_domain/Cargo.toml +++ b/crates/forge_domain/Cargo.toml @@ -44,3 +44,6 @@ insta = { workspace = true, features = ["yaml"] } pretty_assertions.workspace = true is_ci.workspace = true fake = { version = "5.1.0", features = ["derive"] } + +[lints] +workspace = true diff --git a/crates/forge_embed/Cargo.toml b/crates/forge_embed/Cargo.toml index c221cc13d4..6b2056a058 100644 --- a/crates/forge_embed/Cargo.toml +++ b/crates/forge_embed/Cargo.toml @@ -8,3 +8,6 @@ rust-version.workspace = true include_dir.workspace = true handlebars.workspace = true anyhow.workspace = true + +[lints] +workspace = true diff --git a/crates/forge_eventsource/Cargo.toml b/crates/forge_eventsource/Cargo.toml index 4f1aad9d8f..e55f5e0672 100644 --- a/crates/forge_eventsource/Cargo.toml +++ b/crates/forge_eventsource/Cargo.toml @@ -20,3 +20,6 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] } futures-retry = "0.6" pin-utils = "0.1" rocket = "0.5.0" + +[lints] +workspace = true diff --git a/crates/forge_eventsource_stream/Cargo.toml b/crates/forge_eventsource_stream/Cargo.toml index 781dbd09f0..2c4d18dd50 100644 --- a/crates/forge_eventsource_stream/Cargo.toml +++ b/crates/forge_eventsource_stream/Cargo.toml @@ -19,3 +19,6 @@ http = "1.0" reqwest = { version = "0.11", features = ["stream"] } tokio = { version = "1.0", features = ["macros", "rt"] } url = "2.2" + +[lints] +workspace = true diff --git a/crates/forge_fs/Cargo.toml b/crates/forge_fs/Cargo.toml index 4232e439cc..eea2a60ff9 100644 --- a/crates/forge_fs/Cargo.toml +++ b/crates/forge_fs/Cargo.toml @@ -18,3 +18,6 @@ forge_domain.workspace = true [dev-dependencies] tempfile = "3.27.0" pretty_assertions = "1.4.0" + +[lints] +workspace = true diff --git a/crates/forge_infra/Cargo.toml b/crates/forge_infra/Cargo.toml index 88fed236bb..5ae3270208 100644 --- a/crates/forge_infra/Cargo.toml +++ b/crates/forge_infra/Cargo.toml @@ -58,3 +58,6 @@ serial_test = "4.0" fake = { version = "5.1.0", features = ["derive"] } pretty_assertions.workspace = true forge_domain = { path = "../forge_domain" } + +[lints] +workspace = true diff --git a/crates/forge_json_repair/Cargo.toml b/crates/forge_json_repair/Cargo.toml index b60e51292e..b9f4b5212f 100644 --- a/crates/forge_json_repair/Cargo.toml +++ b/crates/forge_json_repair/Cargo.toml @@ -13,4 +13,6 @@ schemars = { workspace = true } serde_json5 = "0.2.1" [dev-dependencies] -pretty_assertions = { workspace = true } \ No newline at end of file +pretty_assertions = { workspace = true } +[lints] +workspace = true diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index a27d7a3bd7..8552286e66 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -88,3 +88,6 @@ pretty_assertions.workspace = true serial_test = "4.0" fake = { version = "5.1.0", features = ["derive"] } forge_domain = { path = "../forge_domain" } + +[lints] +workspace = true diff --git a/crates/forge_main/src/input.rs b/crates/forge_main/src/input.rs index 3612f858f0..b13eabaa31 100644 --- a/crates/forge_main/src/input.rs +++ b/crates/forge_main/src/input.rs @@ -6,7 +6,6 @@ use forge_api::Environment; use crate::editor::{ForgeEditor, ReadResult}; use crate::model::{AppCommand, ForgeCommandManager}; use crate::prompt::ForgePrompt; -use crate::tracker; /// Console implementation for handling user input via command line. pub struct Console { @@ -38,7 +37,6 @@ impl Console { ReadResult::Exit => return Ok(AppCommand::Exit), ReadResult::Empty => continue, ReadResult::Success(text) => { - tracker::prompt(text.clone()); return self.command.parse(&text); } } diff --git a/crates/forge_main/src/tracker.rs b/crates/forge_main/src/tracker.rs index c033c4541d..ef1a060844 100644 --- a/crates/forge_main/src/tracker.rs +++ b/crates/forge_main/src/tracker.rs @@ -1,13 +1,7 @@ -use forge_tracker::{EventKind, ToolCallPayload}; +use forge_tracker::EventKind; use crate::TRACKER; -/// Helper functions to eliminate duplication of tokio::spawn + TRACKER patterns -/// Generic dispatcher for any event -fn dispatch(event: EventKind) { - tokio::spawn(TRACKER.dispatch(event)); -} - /// Dispatches an event blockingly /// This is useful for events that are not expected to be dispatched in the /// background @@ -18,30 +12,12 @@ fn dispatch_blocking(event: EventKind) { .ok(); } -/// For error events with Debug formatting -pub fn error(error: E) { - dispatch(EventKind::Error(format!("{error:?}"))); -} - +/// For error events with Debug formatting (used by the panic hook, where the +/// tracing pipeline may no longer be available) pub fn error_blocking(error: E) { dispatch_blocking(EventKind::Error(format!("{error:?}"))); } -/// For error events with string input -pub fn error_string(error: String) { - dispatch(EventKind::Error(error)); -} - -/// For tool call events -pub fn tool_call(payload: ToolCallPayload) { - dispatch(EventKind::ToolCall(payload)); -} - -/// For prompt events -pub fn prompt(text: String) { - dispatch(EventKind::Prompt(text)); -} - /// For model setting pub fn set_model(model: String) { tokio::spawn(TRACKER.set_model(model)); diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index a517907b24..40500d4d44 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -23,7 +23,6 @@ use forge_domain::{ use forge_fs::ForgeFS; use forge_select::{ForgeWidget, SelectRow}; use forge_spinner::SpinnerManager; -use forge_tracker::ToolCallPayload; use forge_walker::Walker; use futures::future; use strum::IntoEnumIterator; @@ -376,7 +375,6 @@ impl A + Send + Sync> UI // Handle direct prompt or piped input if provided (raw text messages) let input = self.cli.prompt.clone().or(self.cli.piped_input.clone()); if let Some(input) = input { - tracker::prompt(input.clone()); self.spinner.start(None)?; tokio::select! { _ = tokio::signal::ctrl_c() => { @@ -408,11 +406,6 @@ impl A + Send + Sync> UI match result { Ok(exit) => if exit {return Ok(())}, Err(error) => { - if let Some(conversation_id) = self.state.conversation_id.as_ref() - && let Some(conversation) = self.api.conversation(conversation_id).await.ok().flatten() { - TRACKER.set_conversation(conversation).await; - } - tracker::error(&error); tracing::error!(error = ?error); self.spinner.stop(None)?; self.writeln_to_stderr(TitleFormat::error(format!("{error:?}")).display().to_string())?; @@ -424,7 +417,6 @@ impl A + Send + Sync> UI self.spinner.stop(None)?; } Err(error) => { - tracker::error(&error); tracing::error!(error = ?error); self.spinner.stop(None)?; @@ -4167,19 +4159,7 @@ impl A + Send + Sync> UI // stdout from appearing before the tool name is printed. drop(_guard); } - ChatResponse::ToolCallEnd(toolcall_result) => { - // Only track toolcall name in case of success else track the error. - let payload = if toolcall_result.is_error() { - let mut r = ToolCallPayload::new(toolcall_result.name.to_string()); - if let Some(cause) = toolcall_result.output.as_str() { - r = r.with_cause(cause.to_string()); - } - r - } else { - ToolCallPayload::new(toolcall_result.name.to_string()) - }; - tracker::tool_call(payload); - + ChatResponse::ToolCallEnd(_toolcall_result) => { self.spinner.start(None)?; if !self.cli.verbose { return Ok(()); diff --git a/crates/forge_markdown_stream/Cargo.toml b/crates/forge_markdown_stream/Cargo.toml index 5449822e33..18653fc656 100644 --- a/crates/forge_markdown_stream/Cargo.toml +++ b/crates/forge_markdown_stream/Cargo.toml @@ -23,3 +23,6 @@ terminal-colorsaurus = "1.0.3" insta.workspace = true strip-ansi-escapes.workspace = true pretty_assertions.workspace = true + +[lints] +workspace = true diff --git a/crates/forge_repo/Cargo.toml b/crates/forge_repo/Cargo.toml index 964f92a021..137aa1d6f4 100644 --- a/crates/forge_repo/Cargo.toml +++ b/crates/forge_repo/Cargo.toml @@ -73,3 +73,6 @@ fake = { version = "5.1.0", features = ["derive"] } derive_setters.workspace = true mockito = { workspace = true } regex = { workspace = true } + +[lints] +workspace = true diff --git a/crates/forge_select/Cargo.toml b/crates/forge_select/Cargo.toml index 1494f9b142..1a5a51cfc0 100644 --- a/crates/forge_select/Cargo.toml +++ b/crates/forge_select/Cargo.toml @@ -18,3 +18,6 @@ tracing.workspace = true [dev-dependencies] pretty_assertions.workspace = true + +[lints] +workspace = true diff --git a/crates/forge_services/Cargo.toml b/crates/forge_services/Cargo.toml index 5e3be29337..d0ba05c5b9 100644 --- a/crates/forge_services/Cargo.toml +++ b/crates/forge_services/Cargo.toml @@ -63,3 +63,6 @@ fake = { version = "5.1.0", features = ["derive"] } forge_test_kit.workspace = true + +[lints] +workspace = true diff --git a/crates/forge_snaps/Cargo.toml b/crates/forge_snaps/Cargo.toml index 4997a3685b..951f16737c 100644 --- a/crates/forge_snaps/Cargo.toml +++ b/crates/forge_snaps/Cargo.toml @@ -17,4 +17,6 @@ forge_domain.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "time", "test-util"] } -tempfile.workspace = true \ No newline at end of file +tempfile.workspace = true +[lints] +workspace = true diff --git a/crates/forge_spinner/Cargo.toml b/crates/forge_spinner/Cargo.toml index 2076ff0429..c0021dc47a 100644 --- a/crates/forge_spinner/Cargo.toml +++ b/crates/forge_spinner/Cargo.toml @@ -18,3 +18,6 @@ rand = "0.10.0" [dev-dependencies] pretty_assertions.workspace = true tokio = { workspace = true, features = ["test-util"] } + +[lints] +workspace = true diff --git a/crates/forge_stream/Cargo.toml b/crates/forge_stream/Cargo.toml index e601537459..363a14b1f9 100644 --- a/crates/forge_stream/Cargo.toml +++ b/crates/forge_stream/Cargo.toml @@ -9,4 +9,6 @@ futures.workspace = true tokio.workspace = true [dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt", "time", "test-util"] } \ No newline at end of file +tokio = { workspace = true, features = ["macros", "rt", "time", "test-util"] } +[lints] +workspace = true diff --git a/crates/forge_template/Cargo.toml b/crates/forge_template/Cargo.toml index 8123d00d9a..9ee8a26dbc 100644 --- a/crates/forge_template/Cargo.toml +++ b/crates/forge_template/Cargo.toml @@ -10,3 +10,6 @@ html-escape = "0.2.13" [dev-dependencies] pretty_assertions.workspace = true + +[lints] +workspace = true diff --git a/crates/forge_test_kit/Cargo.toml b/crates/forge_test_kit/Cargo.toml index f169443335..aad5166568 100644 --- a/crates/forge_test_kit/Cargo.toml +++ b/crates/forge_test_kit/Cargo.toml @@ -15,3 +15,6 @@ json = ["serde", "serde_json"] [lib] doctest = false + +[lints] +workspace = true diff --git a/crates/forge_tool_macros/Cargo.toml b/crates/forge_tool_macros/Cargo.toml index 93e102ed34..1e27e2d6d4 100644 --- a/crates/forge_tool_macros/Cargo.toml +++ b/crates/forge_tool_macros/Cargo.toml @@ -10,4 +10,6 @@ proc-macro = true [dependencies] syn.workspace = true quote.workspace = true -proc-macro2.workspace = true \ No newline at end of file +proc-macro2.workspace = true +[lints] +workspace = true diff --git a/crates/forge_tracker/Cargo.toml b/crates/forge_tracker/Cargo.toml index e6d10b80a4..709523d135 100644 --- a/crates/forge_tracker/Cargo.toml +++ b/crates/forge_tracker/Cargo.toml @@ -39,3 +39,6 @@ uuid.workspace = true tokio = { workspace = true, features = ["macros", "rt", "time", "test-util"] } lazy_static.workspace = true pretty_assertions.workspace = true + +[lints] +workspace = true diff --git a/crates/forge_tracker/src/dispatch.rs b/crates/forge_tracker/src/dispatch.rs index bbec64e4f3..a087c5cc94 100644 --- a/crates/forge_tracker/src/dispatch.rs +++ b/crates/forge_tracker/src/dispatch.rs @@ -5,7 +5,6 @@ use std::sync::{Arc, LazyLock}; use bstr::ByteSlice; use chrono::{DateTime, Utc}; -use forge_domain::Conversation; use sysinfo::System; use tokio::process::Command; use tokio::sync::Mutex; @@ -56,7 +55,7 @@ static CACHED_ARGS: LazyLock> = LazyLock::new(|| std::env::args().sk /// This acts as a rate limiter to prevent runaway loops (e.g. when /// stdout/stderr is closed and every write error triggers another error event) /// while allowing normal tracking to continue for long-running sessions. -const MAX_EVENTS_PER_MINUTE: usize = 1_000; +const MAX_EVENTS_PER_MINUTE: usize = 60; #[derive(Clone)] pub struct Tracker { @@ -65,7 +64,6 @@ pub struct Tracker { start_time: DateTime, email: Arc>>>, model: Arc>>, - conversation: Arc>>, is_logged_in: Arc, rate_limiter: Arc>, } @@ -81,7 +79,6 @@ impl Default for Tracker { start_time, email: Arc::new(Mutex::new(None)), model: Arc::new(Mutex::new(None)), - conversation: Arc::new(Mutex::new(None)), is_logged_in: Arc::new(AtomicBool::new(false)), rate_limiter: Arc::new(Mutex::new(RateLimiter::new(MAX_EVENTS_PER_MINUTE))), } @@ -89,6 +86,21 @@ impl Default for Tracker { } impl Tracker { + /// Creates a tracker with custom collectors; tracking is always enabled. + /// Intended for tests. + #[cfg(test)] + pub(crate) fn with_collectors(collectors: Vec>) -> Self { + Self { + collectors: Arc::new(collectors), + can_track: true, + start_time: Utc::now(), + email: Arc::new(Mutex::new(Some(vec![]))), + model: Arc::new(Mutex::new(None)), + is_logged_in: Arc::new(AtomicBool::new(false)), + rate_limiter: Arc::new(Mutex::new(RateLimiter::new(MAX_EVENTS_PER_MINUTE))), + } + } + pub async fn set_model>(&'static self, model: S) { let mut guard = self.model.lock().await; *guard = Some(model.into()); @@ -131,7 +143,6 @@ impl Tracker { version: version(), email: email.clone(), model: self.model.lock().await.clone(), - conversation: self.conversation().await, identity: match event_kind { EventKind::Login(id) => Some(id), _ => None, @@ -152,16 +163,6 @@ impl Tracker { } guard.clone().unwrap_or_default() } - - async fn conversation(&self) -> Option { - let mut guard = self.conversation.lock().await; - let conversation = guard.clone(); - *guard = None; - conversation - } - pub async fn set_conversation(&self, conversation: Conversation) { - *self.conversation.lock().await = Some(conversation); - } } fn tracking_enabled() -> bool { @@ -320,10 +321,7 @@ mod tests { #[tokio::test] async fn test_tracker() { - if let Err(e) = TRACKER - .dispatch(EventKind::Prompt("ping".to_string())) - .await - { + if let Err(e) = TRACKER.dispatch(EventKind::Error("ping".to_string())).await { panic!("Tracker dispatch error: {e:?}"); } } diff --git a/crates/forge_tracker/src/event.rs b/crates/forge_tracker/src/event.rs index ed09c919d4..8745c259a8 100644 --- a/crates/forge_tracker/src/event.rs +++ b/crates/forge_tracker/src/event.rs @@ -1,9 +1,7 @@ use std::ops::Deref; -use bstr::ByteSlice; use chrono::{DateTime, Utc}; use convert_case::{Case, Casing}; -use forge_domain::Conversation; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -22,7 +20,6 @@ pub struct Event { pub version: String, pub email: Vec, pub model: Option, - pub conversation: Option, pub identity: Option, } @@ -52,31 +49,10 @@ impl From for String { } } -#[derive(Debug, Clone, Serialize)] -pub struct ToolCallPayload { - tool_name: String, - #[serde(skip_serializing_if = "Option::is_none")] - cause: Option, -} - -impl ToolCallPayload { - pub fn new(tool_name: String) -> Self { - Self { tool_name, cause: None } - } - - pub fn with_cause(mut self, cause: String) -> Self { - self.cause = Some(cause); - self - } -} - #[derive(Debug, Clone)] pub enum EventKind { Start, - ToolCall(ToolCallPayload), - Prompt(String), Error(String), - Trace(Vec), Login(Identity), } @@ -84,20 +60,14 @@ impl EventKind { pub fn name(&self) -> Name { match self { Self::Start => Name::from("start".to_string()), - Self::Prompt(_) => Name::from("prompt".to_string()), Self::Error(_) => Name::from("error".to_string()), - Self::ToolCall(_) => Name::from("tool_call".to_string()), - Self::Trace(_) => Name::from("trace".to_string()), Self::Login(_) => Name::from("login".to_string()), } } pub fn value(&self) -> String { match self { Self::Start => "".to_string(), - Self::Prompt(content) => content.to_string(), Self::Error(content) => content.to_string(), - Self::ToolCall(payload) => serde_json::to_string(&payload).unwrap_or_default(), - Self::Trace(trace) => trace.to_str_lossy().to_string(), Self::Login(id) => id.login.to_owned(), } } diff --git a/crates/forge_tracker/src/lib.rs b/crates/forge_tracker/src/lib.rs index e78d2bef67..f5bf3fbaca 100644 --- a/crates/forge_tracker/src/lib.rs +++ b/crates/forge_tracker/src/lib.rs @@ -9,5 +9,5 @@ mod rate_limit; pub use can_track::VERSION; pub use dispatch::Tracker; use error::Result; -pub use event::{Event, EventKind, ToolCallPayload}; +pub use event::{Event, EventKind}; pub use log::{Guard, init_tracing}; diff --git a/crates/forge_tracker/src/log.rs b/crates/forge_tracker/src/log.rs index df4cda283d..5fd0ca744f 100644 --- a/crates/forge_tracker/src/log.rs +++ b/crates/forge_tracker/src/log.rs @@ -11,9 +11,7 @@ use crate::can_track::can_track; pub fn init_tracing(log_path: PathBuf, tracker: Tracker) -> anyhow::Result { debug!(path = %log_path.display(), "Initializing logging system in JSON format"); - // If tracking is enabled, use PostHog for logging; otherwise, use a rolling - // file appender. - let (writer, guard, level) = prepare_writer(log_path, tracker); + let (writer, guard, level) = prepare_writer(log_path); // Create a filter that only allows logs from forge_ modules let filter = filter::filter_fn(|metadata| metadata.target().starts_with("forge_")); @@ -31,44 +29,44 @@ pub fn init_tracing(log_path: PathBuf, tracker: Tracker) -> anyhow::Result ( non_blocking::NonBlocking, WorkerGuard, tracing_subscriber::EnvFilter, ) { - let ((non_blocking, guard), env) = if can_track() { - let append = PostHogWriter::new(tracker); - ( - tracing_appender::non_blocking(append), - tracing_subscriber::EnvFilter::new("forge=info"), - ) + let append = tracing_appender::rolling::daily(log_path, "forge.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(append); + let env = if can_track() { + tracing_subscriber::EnvFilter::new("forge=info") } else { - let append = tracing_appender::rolling::daily(log_path, "forge.log"); - ( - tracing_appender::non_blocking(append), - tracing_subscriber::EnvFilter::new("forge=debug"), - ) + tracing_subscriber::EnvFilter::new("forge=debug") }; (non_blocking, guard, env) } pub struct Guard(#[allow(dead_code)] WorkerGuard); -struct PostHogWriter { +/// A tracing Layer that forwards error-level events from forge_ modules to the +/// tracker as EventKind::Error. This is the single pipeline through which +/// errors reach PostHog; lower-level crates just use `tracing::error!`. +struct PosthogErrorLayer { tracker: Tracker, runtime: tokio::runtime::Runtime, } -impl PostHogWriter { - pub fn new(tracker: Tracker) -> Self { +impl PosthogErrorLayer { + fn new(tracker: Tracker) -> Self { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .worker_threads(1) @@ -78,17 +76,141 @@ impl PostHogWriter { } } -impl std::io::Write for PostHogWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { +impl Layer for PosthogErrorLayer { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let metadata = event.metadata(); + if *metadata.level() != tracing::Level::ERROR || !metadata.target().starts_with("forge_") { + return; + } + + // Render the event's fields (message + structured fields) into a + // single string. + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + let mut message = visitor.0; + if let (Some(file), Some(line)) = (metadata.file(), metadata.line()) { + message = format!("{file}:{line} {message}"); + } + let tracker = self.tracker.clone(); - let event_kind = crate::EventKind::Trace(buf.to_vec()); self.runtime.spawn(async move { - let _ = tracker.dispatch(event_kind).await; + let _ = tracker.dispatch(crate::EventKind::Error(message)).await; }); - Ok(buf.len()) } +} + +#[derive(Default)] +struct MessageVisitor(String); + +impl tracing::field::Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if !self.0.is_empty() { + self.0.push(' '); + } + if field.name() == "message" { + self.0.push_str(&format!("{value:?}")); + } else { + self.0.push_str(&format!("{}={value:?}", field.name())); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::mpsc; + use std::time::Duration; + + use pretty_assertions::assert_eq; + use tracing_subscriber::prelude::*; + + use super::*; + use crate::collect::Collect; + use crate::{Event, Tracker}; + + struct ChannelCollector(std::sync::Mutex>); + + #[async_trait::async_trait] + impl Collect for ChannelCollector { + async fn collect(&self, event: Event) -> crate::Result<()> { + self.0.lock().unwrap().send(event).ok(); + Ok(()) + } + } + + /// Runs the given closure with a PosthogErrorLayer installed and returns + /// the `(name, value)` of every event it dispatched. + fn capture_events(f: impl FnOnce()) -> Vec<(String, String)> { + let (tx, rx) = mpsc::channel(); + let tracker = + Tracker::with_collectors(vec![Box::new(ChannelCollector(std::sync::Mutex::new(tx)))]); + let subscriber = tracing_subscriber::registry().with(PosthogErrorLayer::new(tracker)); + + // Keep the subscriber (and the layer's runtime) alive until all + // dispatched events have been drained. + let guard = tracing::subscriber::set_default(subscriber); + f(); + + let mut events = vec![]; + while let Ok(event) = rx.recv_timeout(Duration::from_millis(200)) { + events.push((event.event_name.to_string(), event.event_value)); + } + drop(guard); + events + } + + #[test] + fn test_error_event_is_dispatched_with_message_and_fields() { + let mut line = 0; + let actual = capture_events(|| { + tracing::error!(target: "forge_test", code = 42, "something failed"); + line = line!() - 1; // line of the error! call above + }); + + let expected = vec![( + "error".to_string(), + format!("{}:{line} something failed code=42", file!()), + )]; + assert_eq!(actual, expected); + } + + #[test] + fn test_non_error_levels_are_ignored() { + let actual = capture_events(|| { + tracing::warn!(target: "forge_test", "a warning"); + tracing::info!(target: "forge_test", "an info"); + tracing::debug!(target: "forge_test", "a debug"); + }); + + let expected: Vec<(String, String)> = vec![]; + assert_eq!(actual, expected); + } + + #[test] + fn test_non_forge_targets_are_ignored() { + let actual = capture_events(|| { + tracing::error!(target: "hyper::client", "external error"); + }); + + let expected: Vec<(String, String)> = vec![]; + assert_eq!(actual, expected); + } + + #[test] + fn test_multiple_errors_are_dispatched_in_order() { + let actual: Vec = capture_events(|| { + tracing::error!(target: "forge_test", "first"); + tracing::error!(target: "forge_test", "second"); + }) + .into_iter() + .map(|(_, value)| value) + .collect(); - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) + assert_eq!(actual.len(), 2); + assert!(actual[0].ends_with("first")); + assert!(actual[1].ends_with("second")); } } diff --git a/crates/forge_walker/Cargo.toml b/crates/forge_walker/Cargo.toml index 2aed0d7af0..cd55c4fce2 100644 --- a/crates/forge_walker/Cargo.toml +++ b/crates/forge_walker/Cargo.toml @@ -12,4 +12,6 @@ derive_setters.workspace = true [dev-dependencies] pretty_assertions.workspace = true -tempfile.workspace = true \ No newline at end of file +tempfile.workspace = true +[lints] +workspace = true