diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 366f6ae..3daadd9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -118,7 +118,11 @@ "Bash(node -e \"const fs=require\\('fs'\\); const version='2026.8.1'; const pkgbuild=fs.readFileSync\\('PKGBUILD','utf8'\\).replace\\(/^pkgver=.*\\\\$/m, 'pkgver='+version\\).replace\\(/^pkgrel=.*\\\\$/m, 'pkgrel=1'\\); fs.writeFileSync\\('PKGBUILD', pkgbuild\\);\")", "Bash(rm PKGBUILD)", "Bash(git -C /mnt/data/Development/GitLite stash)", - "Bash(git -C /mnt/data/Development/GitLite stash pop)" + "Bash(git -C /mnt/data/Development/GitLite stash pop)", + "Bash(cargo clean *)", + "Bash(cargo test *)", + "Bash(sed -i \"s/TIMESTAMP/$\\(date +%s%N\\)/\" otlp-test.json)", + "Bash(curl -sS -w '\\\\nHTTP %{http_code}\\\\n' -X POST https://telemetry.cbsk-tech.de/v1/logs -H 'Content-Type: application/json' --data @otlp-test.json --max-time 15)" ] } } diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 0000000..9c006a8 --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,33 @@ +# SigNoz-Telemetrie + +Gitty meldet Abstürze, Fehler und Ressourcenverbrauch an eine SigNoz-Instanz +(OTLP/HTTP, JSON). Implementierung: `src-tauri/src/telemetry.rs` plus +`src/lib/telemetry.ts` für Frontend-Fehler. + +## Konfiguration + +Der Collector-Endpoint ist als Konstante `OTLP_ENDPOINT` in +`src-tauri/src/telemetry.rs` hinterlegt (`http://telemetry.cbsk-tech.de`, +analog zum fest eingetragenen Aptabase-Host in `main.rs`). Telemetrie ist +damit in jedem Build aktiv — auch im Dev-Modus. + +## Was gemeldet wird + +- **Abstürze**: Ein Panic-Hook schickt Panics als `FATAL`-Log mit Backtrace und + Quellposition — synchron, bevor der Prozess stirbt (SigNoz: *Logs*, Filter + `error.kind=panic`). +- **Rust-Fehler/-Warnungen**: Alles, was über das `log`-Crate mit `warn!`/ + `error!` geloggt wird, geht zusätzlich zur Konsole als Log-Record raus. +- **Frontend-Fehler**: Uncaught Exceptions und unbehandelte Promise-Rejections + aus dem WebView (Attribut `component=frontend`), pro Sitzung dedupliziert + und auf 25 Meldungen gedeckelt. +- **Ressourcen** (alle 60 s als Gauges): `process.memory.usage` (Bytes), + `process.cpu.utilization` (0–1, über alle Kerne normalisiert), + `system.memory.utilization` (0–1). + +Alle Daten tragen die Resource-Attribute `service.name=gitty`, +`service.version` (App-Version aus `tauri.conf.json`), `os.type` und +`host.arch`. Es werden keine Repository-Inhalte, Pfade oder Nutzerdaten +übertragen — nur Fehlermeldungstexte, Stacktraces und Prozessmetriken. +Fehlgeschlagene Exporte werden verworfen; Telemetrie darf den Git-Workflow +nie stören. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9e300b4..3386aee 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2538,8 +2538,10 @@ dependencies = [ "commit_ai", "keyring", "log", + "reqwest 0.12.28", "serde", "serde_json", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-aptabase", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f81922f..67df54e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,10 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "async- commit_ai = { path = "crates/commit_ai" } tokio = "1.52.3" log = "0.4" +# SigNoz telemetry (see src/telemetry.rs). Both crates are already in the +# dependency tree via commit_ai/mistralrs, so this costs little binary size. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] } +sysinfo = { version = "0.36", default-features = false, features = ["system"] } [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8e0d866..83d0497 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,6 +2,7 @@ mod badge; mod git; +mod telemetry; use badge::set_sync_badge; use git::{ @@ -40,6 +41,7 @@ impl log::Log for ConsoleLogger { record.target(), record.args() ); + telemetry::forward_log_record(record); } } @@ -86,6 +88,15 @@ async fn main() { return; } + let context = tauri::generate_context!(); + telemetry::init( + context + .config() + .version + .clone() + .unwrap_or_else(|| "unknown".to_string()), + ); + let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _, _| { #[cfg(desktop)] @@ -193,8 +204,9 @@ async fn main() { cred_save, cred_delete, set_sync_badge, - close_splashscreen + close_splashscreen, + telemetry::report_frontend_error ]) - .run(tauri::generate_context!()) + .run(context) .expect("error while running tauri application"); } diff --git a/src-tauri/src/telemetry.rs b/src-tauri/src/telemetry.rs new file mode 100644 index 0000000..bbc13b1 --- /dev/null +++ b/src-tauri/src/telemetry.rs @@ -0,0 +1,364 @@ +//! Crash, error, and resource reporting to the self-hosted SigNoz instance +//! via OTLP/HTTP (JSON). +//! +//! A hand-rolled OTLP JSON payload keeps the binary small: the official +//! `opentelemetry` crates would pull in tonic/prost for functionality this app +//! doesn't need beyond logs and a handful of gauges. + +use serde_json::{Value, json}; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::mpsc; + +/// Self-hosted SigNoz OTLP/HTTP collector (same infrastructure as the +/// Aptabase host configured in main.rs). +const OTLP_ENDPOINT: &str = "https://telemetry.cbsk-tech.de"; + +const LOG_BATCH_SIZE: usize = 20; +const LOG_FLUSH_INTERVAL: Duration = Duration::from_secs(5); +const METRICS_INTERVAL: Duration = Duration::from_secs(60); +/// Bodies and stack traces are truncated so a pathological error message can't +/// produce megabyte-sized export payloads. +const MAX_TEXT_LEN: usize = 8_000; + +pub const SEVERITY_WARN: (u8, &str) = (13, "WARN"); +pub const SEVERITY_ERROR: (u8, &str) = (17, "ERROR"); +const SEVERITY_FATAL: (u8, &str) = (21, "FATAL"); + +pub struct LogEvent { + time_unix_nano: u128, + severity: (u8, &'static str), + body: String, + attributes: Vec<(String, String)>, +} + +impl LogEvent { + pub fn new(severity: (u8, &'static str), body: impl Into) -> Self { + Self { + time_unix_nano: now_unix_nanos(), + severity, + body: truncate(body.into()), + attributes: Vec::new(), + } + } + + pub fn attribute(mut self, key: &str, value: impl Into) -> Self { + self.attributes.push((key.to_string(), truncate(value.into()))); + self + } +} + +struct Config { + logs_url: String, + metrics_url: String, + app_version: String, +} + +static CONFIG: OnceLock = OnceLock::new(); +static SENDER: OnceLock> = OnceLock::new(); + +fn now_unix_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) +} + +fn truncate(mut text: String) -> String { + if text.len() > MAX_TEXT_LEN { + let mut cut = MAX_TEXT_LEN; + while !text.is_char_boundary(cut) { + cut -= 1; + } + text.truncate(cut); + text.push_str(" …[truncated]"); + } + text +} + +/// Initializes telemetry export. Must run inside the tokio runtime. +pub fn init(app_version: String) { + if CONFIG + .set(Config { + logs_url: format!("{OTLP_ENDPOINT}/v1/logs"), + metrics_url: format!("{OTLP_ENDPOINT}/v1/metrics"), + app_version, + }) + .is_err() + { + return; + } + + let (tx, rx) = mpsc::unbounded_channel(); + let _ = SENDER.set(tx); + + install_panic_hook(); + tokio::spawn(run_log_exporter(rx)); + tokio::spawn(run_metrics_exporter()); +} + +/// Queues a log record for export. Safe to call from any thread; a no-op +/// before `init`. +pub fn emit(event: LogEvent) { + if let Some(tx) = SENDER.get() { + let _ = tx.send(event); + } +} + +/// Forwards a `log` crate record. Filters out the HTTP stack used by the +/// exporter itself so a failing export can never loop back into the queue. +pub fn forward_log_record(record: &log::Record<'_>) { + let target = record.target(); + if ["reqwest", "hyper", "rustls", "h2"] + .iter() + .any(|noisy| target.starts_with(noisy)) + { + return; + } + let severity = match record.level() { + log::Level::Error => SEVERITY_ERROR, + log::Level::Warn => SEVERITY_WARN, + _ => return, + }; + emit(LogEvent::new(severity, record.args().to_string()).attribute("log.target", target)); +} + +/// Receives uncaught frontend errors (window `error` / `unhandledrejection`). +#[tauri::command] +pub fn report_frontend_error(message: String, stack: Option, source: Option) { + let mut event = + LogEvent::new(SEVERITY_ERROR, message).attribute("component", "frontend"); + if let Some(stack) = stack { + event = event.attribute("exception.stacktrace", stack); + } + if let Some(source) = source { + event = event.attribute("code.filepath", source); + } + emit(event); +} + +fn install_panic_hook() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + previous(info); + + let message = info + .payload() + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| info.payload().downcast_ref::().cloned()) + .unwrap_or_else(|| "panic with non-string payload".to_string()); + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + .unwrap_or_else(|| "unknown".to_string()); + let backtrace = std::backtrace::Backtrace::force_capture().to_string(); + + let event = LogEvent::new(SEVERITY_FATAL, format!("panic at {location}: {message}")) + .attribute("error.kind", "panic") + .attribute("exception.stacktrace", backtrace) + .attribute("code.filepath", location); + + // The async exporter may never get to run again, so ship the crash on + // a dedicated thread with a blocking client and wait for it. + let payload = logs_payload(std::iter::once(&event)); + let _ = std::thread::spawn(move || post_blocking(&payload)).join(); + })); +} + +fn post_blocking(payload: &Value) { + let Some(cfg) = CONFIG.get() else { return }; + let Ok(client) = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + else { + return; + }; + let _ = client.post(&cfg.logs_url).json(payload).send(); +} + +async fn run_log_exporter(mut rx: mpsc::UnboundedReceiver) { + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + { + Ok(client) => client, + Err(_) => return, + }; + let mut batch: Vec = Vec::new(); + let mut ticker = tokio::time::interval(LOG_FLUSH_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + received = rx.recv() => match received { + Some(event) => { + batch.push(event); + if batch.len() >= LOG_BATCH_SIZE { + flush_logs(&client, &mut batch).await; + } + } + None => { + flush_logs(&client, &mut batch).await; + return; + } + }, + _ = ticker.tick() => flush_logs(&client, &mut batch).await, + } + } +} + +async fn flush_logs(client: &reqwest::Client, batch: &mut Vec) { + if batch.is_empty() { + return; + } + let payload = logs_payload(batch.iter()); + batch.clear(); + post(client, &CONFIG.get().expect("config set before export").logs_url, &payload).await; +} + +async fn post(client: &reqwest::Client, url: &str, payload: &Value) { + // Telemetry must never disturb the app; a lost batch is acceptable. + let _ = client.post(url).json(payload).send().await; +} + +async fn run_metrics_exporter() { + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + { + Ok(client) => client, + Err(_) => return, + }; + let Ok(pid) = sysinfo::get_current_pid() else { + return; + }; + let cpu_count = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1) as f64; + let mut sys = sysinfo::System::new(); + let mut ticker = tokio::time::interval(METRICS_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // The first tick fires immediately and only primes the CPU counters — + // sysinfo needs two samples before cpu_usage() is meaningful. + sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true); + ticker.tick().await; + + loop { + ticker.tick().await; + sys.refresh_memory(); + sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true); + let Some(process) = sys.process(pid) else { + continue; + }; + + let time = now_unix_nanos().to_string(); + let metrics = json!([ + gauge("process.memory.usage", "By", json!({"asInt": process.memory().to_string()}), &time), + gauge("process.cpu.utilization", "1", json!({"asDouble": f64::from(process.cpu_usage()) / (100.0 * cpu_count)}), &time), + gauge("system.memory.utilization", "1", json!({"asDouble": sys.used_memory() as f64 / sys.total_memory().max(1) as f64}), &time), + ]); + let payload = json!({ + "resourceMetrics": [{ + "resource": resource(), + "scopeMetrics": [{ "scope": {"name": "gitty"}, "metrics": metrics }] + }] + }); + post(&client, &CONFIG.get().expect("config set before export").metrics_url, &payload).await; + } +} + +fn gauge(name: &str, unit: &str, value: Value, time_unix_nano: &str) -> Value { + let mut point = json!({"timeUnixNano": time_unix_nano}); + point + .as_object_mut() + .expect("point is an object") + .extend(value.as_object().cloned().unwrap_or_default()); + json!({"name": name, "unit": unit, "gauge": {"dataPoints": [point]}}) +} + +fn logs_payload<'a>(events: impl Iterator) -> Value { + let records: Vec = events + .map(|event| { + let attributes: Vec = event + .attributes + .iter() + .map(|(key, value)| string_attribute(key, value)) + .collect(); + json!({ + "timeUnixNano": event.time_unix_nano.to_string(), + "severityNumber": event.severity.0, + "severityText": event.severity.1, + "body": {"stringValue": event.body}, + "attributes": attributes, + }) + }) + .collect(); + json!({ + "resourceLogs": [{ + "resource": resource(), + "scopeLogs": [{ "scope": {"name": "gitty"}, "logRecords": records }] + }] + }) +} + +fn resource() -> Value { + let version = CONFIG + .get() + .map(|c| c.app_version.as_str()) + .unwrap_or("unknown"); + json!({ + "attributes": [ + string_attribute("service.name", "gitty"), + string_attribute("service.version", version), + string_attribute("os.type", std::env::consts::OS), + string_attribute("host.arch", std::env::consts::ARCH), + ] + }) +} + +fn string_attribute(key: &str, value: &str) -> Value { + json!({"key": key, "value": {"stringValue": value}}) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn logs_payload_matches_otlp_shape() { + let event = LogEvent::new(SEVERITY_ERROR, "boom").attribute("log.target", "gitty::git"); + let payload = logs_payload(std::iter::once(&event)); + + let record = &payload["resourceLogs"][0]["scopeLogs"][0]["logRecords"][0]; + assert_eq!(record["severityNumber"], 17); + assert_eq!(record["severityText"], "ERROR"); + assert_eq!(record["body"]["stringValue"], "boom"); + assert_eq!(record["attributes"][0]["key"], "log.target"); + assert_eq!(record["attributes"][0]["value"]["stringValue"], "gitty::git"); + // OTLP/JSON requires uint64 nanos as a string. + assert!(record["timeUnixNano"].is_string()); + + let resource_attrs = &payload["resourceLogs"][0]["resource"]["attributes"]; + assert_eq!(resource_attrs[0]["key"], "service.name"); + assert_eq!(resource_attrs[0]["value"]["stringValue"], "gitty"); + } + + #[test] + fn gauge_merges_value_into_data_point() { + let metric = gauge("process.memory.usage", "By", json!({"asInt": "1024"}), "42"); + assert_eq!(metric["name"], "process.memory.usage"); + assert_eq!(metric["unit"], "By"); + let point = &metric["gauge"]["dataPoints"][0]; + assert_eq!(point["timeUnixNano"], "42"); + assert_eq!(point["asInt"], "1024"); + } + + #[test] + fn oversized_bodies_are_truncated_on_char_boundary() { + let body = "ä".repeat(MAX_TEXT_LEN); + let event = LogEvent::new(SEVERITY_WARN, body); + assert!(event.body.len() <= MAX_TEXT_LEN + " …[truncated]".len()); + assert!(event.body.ends_with("…[truncated]")); + } +} diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts new file mode 100644 index 0000000..c5e74f9 --- /dev/null +++ b/src/lib/telemetry.ts @@ -0,0 +1,37 @@ +import { invoke } from "@tauri-apps/api/core"; + +// A rendering bug firing on every frame must not flood the collector, so +// duplicate messages are sent once and the session is capped overall. +const MAX_REPORTS_PER_SESSION = 25; +const seen = new Set(); + +function report(message: string, stack?: string, source?: string) { + if (seen.size >= MAX_REPORTS_PER_SESSION || seen.has(message)) { + return; + } + seen.add(message); + void invoke("report_frontend_error", { message, stack, source }).catch(() => { + // Telemetry must never interrupt the Git workflow. + }); +} + +/** Forwards uncaught errors and unhandled promise rejections to SigNoz. */ +export function installErrorReporting() { + window.addEventListener("error", (event) => { + const error: unknown = event.error; + report( + event.message || String(error), + error instanceof Error ? error.stack : undefined, + event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : undefined, + ); + }); + + window.addEventListener("unhandledrejection", (event) => { + const reason: unknown = event.reason; + if (reason instanceof Error) { + report(`Unhandled rejection: ${reason.message}`, reason.stack); + } else { + report(`Unhandled rejection: ${String(reason)}`); + } + }); +} diff --git a/src/main.ts b/src/main.ts index 0fd5c7c..ab5e8f2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,9 @@ import { mount } from "svelte"; import App from "./App.svelte"; import "./app.css"; +import { installErrorReporting } from "./lib/telemetry"; + +installErrorReporting(); const THEME_KEY = "gitlite.theme.v1"; const target = document.getElementById("app");