-
Notifications
You must be signed in to change notification settings - Fork 245
feat(logging): Add debug logging with tray menu level selector #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
865b983
6ae5f2b
a1ae335
4589541
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- File logging to ~/Library/Logs with 10MB max size - Tray menu "Debug Level" submenu (Error/Warn/Info/Debug/Trace), persisted - HTTP request/response logging with PII redaction (first4...last4) - Forward frontend console.error/warn to log file Co-authored-by: Cursor <cursoragent@cursor.com>
- Loading branch information
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ use std::sync::{Arc, Mutex}; | |
|
|
||
| use serde::Serialize; | ||
| use tauri::Emitter; | ||
| use tauri_plugin_log::{Target, TargetKind}; | ||
| use uuid::Uuid; | ||
|
|
||
| pub struct AppState { | ||
|
|
@@ -122,6 +123,12 @@ async fn start_probe_batch( | |
| .map(|plugin| plugin.manifest.id.clone()) | ||
| .collect(); | ||
|
|
||
| log::info!( | ||
| "probe batch {} starting: {:?}", | ||
| batch_id, | ||
| response_plugin_ids | ||
| ); | ||
|
|
||
| if selected_plugins.is_empty() { | ||
| let _ = app_handle.emit( | ||
| "probe:batch-complete", | ||
|
|
@@ -153,14 +160,23 @@ async fn start_probe_batch( | |
|
|
||
| match result { | ||
| Ok(output) => { | ||
| let has_error = output.lines.iter().any(|line| { | ||
| matches!(line, plugin_engine::runtime::MetricLine::Badge { label, .. } if label == "Error") | ||
| }); | ||
| if has_error { | ||
| log::warn!("probe {} completed with error", plugin_id); | ||
| } else { | ||
| log::info!("probe {} completed ok ({} lines)", plugin_id, output.lines.len()); | ||
| } | ||
| let _ = handle.emit("probe:result", ProbeResult { batch_id: bid, output }); | ||
| } | ||
| Err(_) => { | ||
| log::error!("Probe panicked for plugin {}", plugin_id); | ||
| log::error!("probe {} panicked", plugin_id); | ||
| } | ||
| } | ||
|
|
||
| if counter.fetch_sub(1, Ordering::SeqCst) == 1 { | ||
| log::info!("probe batch {} complete", completion_bid); | ||
| let _ = completion_handle.emit( | ||
| "probe:batch-complete", | ||
| ProbeBatchComplete { | ||
|
|
@@ -177,12 +193,23 @@ async fn start_probe_batch( | |
| }) | ||
| } | ||
|
|
||
| #[tauri::command] | ||
| fn get_log_path(app_handle: tauri::AppHandle) -> Result<String, String> { | ||
| // macOS log directory: ~/Library/Logs/{bundleIdentifier} | ||
| let home = dirs::home_dir().ok_or("no home dir")?; | ||
| let bundle_id = app_handle.config().identifier.clone(); | ||
| let log_dir = home.join("Library").join("Logs").join(&bundle_id); | ||
| let log_file = log_dir.join(format!("{}.log", app_handle.package_info().name)); | ||
|
Comment on lines
+198
to
+202
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This command hardcodes Useful? React with 👍 / 👎. |
||
| Ok(log_file.to_string_lossy().to_string()) | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| #[tauri::command] | ||
| fn list_plugins(state: tauri::State<'_, Mutex<AppState>>) -> Vec<PluginMeta> { | ||
| let plugins = { | ||
| let locked = state.lock().expect("plugin state poisoned"); | ||
| locked.plugins.clone() | ||
| }; | ||
| log::debug!("list_plugins: {} plugins", plugins.len()); | ||
|
|
||
| plugins | ||
| .into_iter() | ||
|
|
@@ -225,24 +252,40 @@ pub fn run() { | |
| .plugin(tauri_plugin_opener::init()) | ||
| .plugin(tauri_plugin_store::Builder::default().build()) | ||
| .plugin(tauri_nspanel::init()) | ||
| .plugin(tauri_plugin_log::Builder::new().level(log::LevelFilter::Info).build()) | ||
| .plugin( | ||
| tauri_plugin_log::Builder::new() | ||
| .targets([ | ||
| Target::new(TargetKind::Stdout), | ||
| Target::new(TargetKind::LogDir { file_name: None }), | ||
| ]) | ||
| .max_file_size(10_000_000) // 10 MB | ||
| .level(log::LevelFilter::Trace) // Allow all levels; runtime filter via tray menu | ||
| .level_for("hyper", log::LevelFilter::Warn) | ||
| .level_for("reqwest", log::LevelFilter::Warn) | ||
| .build(), | ||
| ) | ||
| .plugin(tauri_plugin_process::init()) | ||
| .invoke_handler(tauri::generate_handler![ | ||
| init_panel, | ||
| hide_panel, | ||
| start_probe_batch, | ||
| list_plugins | ||
| list_plugins, | ||
| get_log_path | ||
| ]) | ||
| .setup(|app| { | ||
| #[cfg(target_os = "macos")] | ||
| app.set_activation_policy(tauri::ActivationPolicy::Accessory); | ||
|
|
||
| use tauri::Manager; | ||
|
|
||
| let version = app.package_info().version.to_string(); | ||
| log::info!("OpenUsage v{} starting", version); | ||
|
|
||
| let _ = app.track_event("app_started", None); | ||
|
|
||
| let app_data_dir = app.path().app_data_dir().expect("no app data dir"); | ||
| let resource_dir = app.path().resource_dir().expect("no resource dir"); | ||
| log::debug!("app_data_dir: {:?}", app_data_dir); | ||
|
|
||
| let (_, plugins) = plugin_engine::initialize_plugins(&app_data_dir, &resource_dir); | ||
| app.manage(Mutex::new(AppState { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,94 @@ | ||
| use rquickjs::{Ctx, Exception, Function, Object}; | ||
| use std::path::PathBuf; | ||
|
|
||
| /// Redact sensitive value to first4...last4 format | ||
| fn redact_value(value: &str) -> String { | ||
| if value.len() <= 12 { | ||
| "[REDACTED]".to_string() | ||
| } else { | ||
| format!("{}...{}", &value[..4], &value[value.len() - 4..]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The redaction helper slices by byte index ( Useful? React with 👍 / 👎.
macroscopeapp[bot] marked this conversation as resolved.
Outdated
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| /// Redact sensitive query parameters in URL | ||
| fn redact_url(url: &str) -> String { | ||
| let sensitive_params = [ | ||
| "key", "api_key", "apikey", "token", "access_token", "secret", | ||
| "password", "auth", "authorization", "bearer", "credential", | ||
| ]; | ||
|
|
||
| if let Some(query_start) = url.find('?') { | ||
| let (base, query) = url.split_at(query_start + 1); | ||
| let redacted_params: Vec<String> = query | ||
|
Comment on lines
+23
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low
URL fragments (e.g., - if let Some(query_start) = url.find('?') {
- let (base, query) = url.split_at(query_start + 1);
+ if let Some(query_start) = url.find('?') {
+ let (base, rest) = url.split_at(query_start + 1);
+ let (query, fragment) = rest.split_once('#').map_or((rest, ""), |(q, f)| (q, f));
let redacted_params: Vec<String> = query
|
||
| .split('&') | ||
| .map(|param| { | ||
| if let Some(eq_pos) = param.find('=') { | ||
| let (name, value) = param.split_at(eq_pos); | ||
| let value = &value[1..]; // skip '=' | ||
| let name_lower = name.to_lowercase(); | ||
| if sensitive_params.iter().any(|s| name_lower.contains(s)) && !value.is_empty() { | ||
| format!("{}={}", name, redact_value(value)) | ||
| } else { | ||
| param.to_string() | ||
| } | ||
| } else { | ||
| param.to_string() | ||
| } | ||
| }) | ||
| .collect(); | ||
| format!("{}{}", base, redacted_params.join("&")) | ||
| } else { | ||
| url.to_string() | ||
| } | ||
| } | ||
|
|
||
| /// Redact sensitive patterns in response body for logging | ||
| fn redact_body(body: &str) -> String { | ||
| let mut result = body.to_string(); | ||
|
|
||
| // Redact JWTs (eyJ... pattern with dots) | ||
| let jwt_pattern = regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(); | ||
| result = jwt_pattern.replace_all(&result, |caps: ®ex_lite::Captures| { | ||
| let jwt = &caps[0]; | ||
| if jwt.len() > 12 { | ||
| format!("{}...{}", &jwt[..4], &jwt[jwt.len() - 4..]) | ||
| } else { | ||
| "[JWT]".to_string() | ||
| } | ||
| }).to_string(); | ||
|
|
||
| // Redact common API key patterns (sk-xxx, pk-xxx, api_xxx, etc.) | ||
| let api_key_pattern = regex_lite::Regex::new(r#"["']?(sk-|pk-|api_|key_|secret_)[A-Za-z0-9_-]{12,}["']?"#).unwrap(); | ||
| result = api_key_pattern.replace_all(&result, |caps: ®ex_lite::Captures| { | ||
| let key = caps[0].trim_matches(|c| c == '"' || c == '\''); | ||
| if key.len() > 12 { | ||
| format!("{}...{}", &key[..4], &key[key.len() - 4..]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low
The regex captures optional quotes, and - let api_key_pattern = regex_lite::Regex::new(r#"["']?(sk-|pk-|api_|key_|secret_)[A-Za-z0-9_-]{12,}["']?"#).unwrap();
- result = api_key_pattern.replace_all(&result, |caps: ®ex_lite::Captures| {
- let key = caps[0].trim_matches(|c| c == '"' || c == '\'');
- if key.len() > 12 {
- format!("{}...{}", &key[..4], &key[key.len() - 4..])
+ let api_key_pattern = regex_lite::Regex::new(r#"(["']?)(sk-|pk-|api_|key_|secret_)[A-Za-z0-9_-]{12,}(["']?)"#).unwrap();
+ result = api_key_pattern.replace_all(&result, |caps: ®ex_lite::Captures| {
+ let open_quote = &caps[1];
+ let close_quote = &caps[3];
+ let key = caps[0].trim_matches(|c| c == '"' || c == '\'');
+ if key.len() > 12 {
+ format!("{}{}...{}{}"open_quote, &key[..4], &key[key.len() - 4..], close_quote)
|
||
| } else { | ||
| "[KEY]".to_string() | ||
| } | ||
| }).to_string(); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| // Redact JSON values for sensitive keys | ||
| let sensitive_keys = [ | ||
| "password", "token", "access_token", "refresh_token", "secret", | ||
| "api_key", "apiKey", "authorization", "bearer", "credential", | ||
| "session_token", "sessionToken", "auth_token", "authToken", | ||
| "user_id", "account_id", "email", | ||
| ]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing camelCase token keys in PII redaction listLow Severity The |
||
| for key in sensitive_keys { | ||
| // Match "key": "value" or "key":"value" | ||
| let pattern = format!(r#""{}":\s*"([^"]+)""#, key); | ||
| if let Ok(re) = regex_lite::Regex::new(&pattern) { | ||
| result = re.replace_all(&result, |caps: ®ex_lite::Captures| { | ||
| let value = &caps[1]; | ||
| format!("\"{}\": \"{}\"", key, redact_value(value)) | ||
| }).to_string(); | ||
| } | ||
| } | ||
|
|
||
| result | ||
| } | ||
|
|
||
| pub fn inject_host_api<'js>( | ||
| ctx: &Ctx<'js>, | ||
| plugin_id: &str, | ||
|
|
@@ -33,7 +121,7 @@ pub fn inject_host_api<'js>( | |
| let host = Object::new(ctx.clone())?; | ||
| inject_log(ctx, &host, plugin_id)?; | ||
| inject_fs(ctx, &host)?; | ||
| inject_http(ctx, &host)?; | ||
| inject_http(ctx, &host, plugin_id)?; | ||
| inject_keychain(ctx, &host)?; | ||
| inject_sqlite(ctx, &host)?; | ||
|
|
||
|
|
@@ -119,8 +207,9 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { | |
| Ok(()) | ||
| } | ||
|
|
||
| fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { | ||
| fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquickjs::Result<()> { | ||
| let http_obj = Object::new(ctx.clone())?; | ||
| let pid = plugin_id.to_string(); | ||
|
|
||
| http_obj.set( | ||
| "_requestRaw", | ||
|
|
@@ -131,6 +220,10 @@ fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> | |
| Exception::throw_message(&ctx_inner, &format!("invalid request: {}", e)) | ||
| })?; | ||
|
|
||
| let method_str = req.method.as_deref().unwrap_or("GET"); | ||
| let redacted_url = redact_url(&req.url); | ||
| log::info!("[plugin:{}] HTTP {} {}", pid, method_str, redacted_url); | ||
|
|
||
| let mut header_map = reqwest::header::HeaderMap::new(); | ||
| if let Some(headers) = &req.headers { | ||
| for (key, val) in headers { | ||
|
|
@@ -190,6 +283,21 @@ fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> | |
| .text() | ||
| .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string()))?; | ||
|
|
||
| let body_preview = if body.len() > 500 { | ||
| format!("{}... ({} bytes total)", &body[..500], body.len()) | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| } else { | ||
| body.clone() | ||
| }; | ||
| let redacted_body = redact_body(&body_preview); | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| log::info!( | ||
| "[plugin:{}] HTTP {} {} -> {} | {}", | ||
| pid, | ||
| method_str, | ||
| redacted_url, | ||
| status, | ||
| redacted_body | ||
| ); | ||
|
|
||
| let resp = HttpRespParams { | ||
| status, | ||
| headers: resp_headers, | ||
|
|
@@ -786,4 +894,57 @@ mod tests { | |
| .expect("writeGenericPassword"); | ||
| }); | ||
| } | ||
|
|
||
| #[test] | ||
| fn redact_value_shows_first_and_last_four() { | ||
| assert_eq!(redact_value("sk-1234567890abcdef"), "sk-1...cdef"); | ||
| assert_eq!(redact_value("short"), "[REDACTED]"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn redact_url_redacts_api_key_param() { | ||
| let url = "https://api.example.com/v1?api_key=sk-1234567890abcdef&other=value"; | ||
| let redacted = redact_url(url); | ||
| assert!(redacted.contains("api_key=sk-1...cdef")); | ||
| assert!(redacted.contains("other=value")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn redact_url_preserves_non_sensitive_params() { | ||
| let url = "https://api.example.com/v1?limit=10&offset=20"; | ||
| assert_eq!(redact_url(url), url); | ||
| } | ||
|
|
||
| #[test] | ||
| fn redact_body_redacts_jwt() { | ||
| let body = r#"{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"}"#; | ||
| let redacted = redact_body(body); | ||
| // JWT gets redacted to first4...last4 format | ||
| assert!(!redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), "full JWT should be redacted, got: {}", redacted); | ||
| } | ||
|
|
||
| #[test] | ||
| fn redact_body_redacts_api_keys() { | ||
| let body = r#"{"key": "sk-1234567890abcdefghij"}"#; | ||
| let redacted = redact_body(body); | ||
| assert!(redacted.contains("sk-1...ghij")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn redact_body_redacts_json_password_field() { | ||
| let body = r#"{"password": "supersecretpassword123"}"#; | ||
| let redacted = redact_body(body); | ||
| assert!(!redacted.contains("supersecretpassword123"), "password should be redacted, got: {}", redacted); | ||
| } | ||
|
|
||
| #[test] | ||
| fn redact_body_redacts_user_id_and_email() { | ||
| let body = r#"{"user_id": "user-iupzZ7KFykMLrnzpkHSq7wjo", "email": "rob@sunstory.com"}"#; | ||
| let redacted = redact_body(body); | ||
| assert!(!redacted.contains("user-iupzZ7KFykMLrnzpkHSq7wjo"), "user_id should be redacted, got: {}", redacted); | ||
| assert!(!redacted.contains("rob@sunstory.com"), "email should be redacted, got: {}", redacted); | ||
| // Should show first4...last4 | ||
| assert!(redacted.contains("user...7wjo"), "user_id should show first4...last4, got: {}", redacted); | ||
| assert!(redacted.contains("rob@....com"), "email should show first4...last4, got: {}", redacted); | ||
| } | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟢 Low
src-tauri/src/lib.rs:197This hardcodes a macOS-specific path (
Library/Logs) but compiles on all platforms. Consider using#[cfg(target_os = "macos")]or returning an error on unsupported platforms.