Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(logging): Add debug logging with tray menu level selector
- 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
robinebers and cursoragent committed Feb 3, 2026
commit 865b983bda8a6f8a3927661e5457123ba2deedc3
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2.4.2",
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ tauri-plugin-aptabase = { git = "https://github.com/aptabase/tauri-plugin-aptaba
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
regex-lite = "0.1.9"
49 changes: 46 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand All @@ -177,12 +193,23 @@ async fn start_probe_batch(
})
}

#[tauri::command]
fn get_log_path(app_handle: tauri::AppHandle) -> Result<String, String> {
Copy link
Copy Markdown

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:197

This 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.

🚀 Want me to fix this? Reply ex: "fix it for me".

// 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return platform log dir instead of macOS-only path

This command hardcodes ~/Library/Logs/{bundle} and always builds a macOS path. The app bundles for all targets (tauri.conf.json has bundle.targets: "all"), so on Windows/Linux this returns a path that does not exist and does not match tauri_plugin_log’s log directory, breaking any UI that opens logs. Prefer app_handle.path().app_log_dir() or the log plugin’s path helpers.

Useful? React with 👍 / 👎.

Ok(log_file.to_string_lossy().to_string())
}
Comment thread
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()
Expand Down Expand Up @@ -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 {
Expand Down
165 changes: 163 additions & 2 deletions src-tauri/src/plugin_engine/host_api.rs
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..])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid UTF-8 slicing panic in redaction

The redaction helper slices by byte index (&value[..4], value.len() - 4), which panics on non-ASCII UTF-8 boundaries. This path is used when logging HTTP URLs/bodies, so any plugin response or query parameter containing multibyte characters (e.g., localized IDs, emails with Unicode) can crash the app during logging. Use chars()/graphemes or byte-safe truncation to avoid runtime panics.

Useful? React with 👍 / 👎.

Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}
}
Comment thread
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

src-tauri/src/plugin_engine/host_api.rs:20

URL fragments (e.g., #anchor) get included in the last parameter's value. Consider splitting off the fragment before processing query parameters, then re-appending it.

-    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

🚀 Want me to fix this? Reply ex: "fix it for me".

.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: &regex_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: &regex_lite::Captures| {
let key = caps[0].trim_matches(|c| c == '"' || c == '\'');
if key.len() > 12 {
format!("{}...{}", &key[..4], &key[key.len() - 4..])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

src-tauri/src/plugin_engine/host_api.rs:61

The regex captures optional quotes, and trim_matches removes them, but the replacement doesn't re-add them. This produces invalid JSON like {"key": sk-1...ghij}. Consider preserving the original quotes in the replacement.

-    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)

🚀 Want me to fix this? Reply ex: "fix it for me".

} else {
"[KEY]".to_string()
}
}).to_string();
Comment thread
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",
];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing camelCase token keys in PII redaction list

Low Severity

The sensitive_keys list includes some camelCase variants (apiKey, sessionToken, authToken) but is missing common ones like accessToken, refreshToken, userId, and accountId. If any API responses contain these camelCase key names, the associated values won't be redacted by the JSON key pattern. The JWT and API key prefix patterns provide partial defense-in-depth, but non-JWT tokens using camelCase keys could leak in logs.

Fix in Cursor Fix in Web

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: &regex_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,
Expand Down Expand Up @@ -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)?;

Expand Down Expand Up @@ -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",
Expand All @@ -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 {
Expand Down Expand Up @@ -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())
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
} else {
body.clone()
};
let redacted_body = redact_body(&body_preview);
Comment thread
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,
Expand Down Expand Up @@ -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);
}
}
Loading