Adds comprehensive client-side telemetry capabilities for usage, errors, and performance metrics. This includes integrating OpenTelemetry standards into both the frontend (Svelte) and backend (Tauri/Rust) layers to capture events, spans, and system resource utilization. The implementation ensures that all collected data is privacy-filtered by design, explicitly excluding sensitive information like repository paths, credentials, source code, or email addresses from being logged. - Updates README with detailed SigNoz telemetry guide - Adds process metrics collection (CPU/Memory) in Rust backend - Exposes `setTelemetryEnabled` state management to the frontend
364 lines
12 KiB
Rust
364 lines
12 KiB
Rust
use std::{
|
|
sync::{
|
|
OnceLock,
|
|
atomic::{AtomicBool, Ordering},
|
|
mpsc::{SyncSender, sync_channel},
|
|
},
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
|
|
const DEFAULT_OTLP_LOGS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/logs";
|
|
const DEFAULT_OTLP_TRACES_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/traces";
|
|
const DEFAULT_OTLP_METRICS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/metrics";
|
|
const METRICS_INTERVAL: Duration = Duration::from_secs(30);
|
|
const MAX_BODY_BYTES: usize = 2_048;
|
|
|
|
static ENABLED: AtomicBool = AtomicBool::new(false);
|
|
static SENDER: OnceLock<SyncSender<TelemetrySignal>> = OnceLock::new();
|
|
|
|
#[derive(Debug)]
|
|
enum TelemetrySignal {
|
|
Log(TelemetryRecord),
|
|
Span(FrontendSpan),
|
|
Metrics(ProcessMetrics),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ProcessMetrics {
|
|
cpu_utilization: f64,
|
|
memory_bytes: u64,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct TelemetryRecord {
|
|
severity: &'static str,
|
|
severity_number: u8,
|
|
body: String,
|
|
event_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct FrontendLog {
|
|
level: String,
|
|
message: String,
|
|
event_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct FrontendSpan {
|
|
name: String,
|
|
trace_id: String,
|
|
span_id: String,
|
|
started_at_ms: u64,
|
|
duration_ms: f64,
|
|
success: bool,
|
|
}
|
|
|
|
pub fn init() {
|
|
let endpoint = std::env::var("GITTY_OTLP_LOGS_ENDPOINT")
|
|
.unwrap_or_else(|_| DEFAULT_OTLP_LOGS_ENDPOINT.to_string());
|
|
let traces_endpoint = std::env::var("GITTY_OTLP_TRACES_ENDPOINT")
|
|
.unwrap_or_else(|_| DEFAULT_OTLP_TRACES_ENDPOINT.to_string());
|
|
let metrics_endpoint = std::env::var("GITTY_OTLP_METRICS_ENDPOINT")
|
|
.unwrap_or_else(|_| DEFAULT_OTLP_METRICS_ENDPOINT.to_string());
|
|
let (sender, receiver) = sync_channel::<TelemetrySignal>(256);
|
|
if SENDER.set(sender.clone()).is_err() {
|
|
return;
|
|
}
|
|
|
|
start_process_metrics(sender);
|
|
|
|
std::thread::Builder::new()
|
|
.name("gitty-telemetry".into())
|
|
.spawn(move || {
|
|
let client = match reqwest::blocking::Client::builder()
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
{
|
|
Ok(client) => client,
|
|
Err(error) => {
|
|
eprintln!("[WARN] [gitty::telemetry] could not create OTLP client: {error}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let mut export_warning_shown = false;
|
|
while let Ok(signal) = receiver.recv() {
|
|
let (target_endpoint, payload) = match signal {
|
|
TelemetrySignal::Log(record) => (&endpoint, log_payload(record)),
|
|
TelemetrySignal::Span(span) => (&traces_endpoint, span_payload(span)),
|
|
TelemetrySignal::Metrics(metrics) => {
|
|
(&metrics_endpoint, metrics_payload(metrics))
|
|
}
|
|
};
|
|
export(
|
|
&client,
|
|
target_endpoint,
|
|
&payload,
|
|
&mut export_warning_shown,
|
|
);
|
|
}
|
|
})
|
|
.expect("failed to start telemetry worker");
|
|
}
|
|
|
|
fn start_process_metrics(sender: SyncSender<TelemetrySignal>) {
|
|
std::thread::Builder::new()
|
|
.name("gitty-process-metrics".into())
|
|
.spawn(move || {
|
|
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
|
|
|
|
let Ok(pid) = sysinfo::get_current_pid() else {
|
|
return;
|
|
};
|
|
let refresh_kind = ProcessRefreshKind::nothing().with_cpu().with_memory();
|
|
let cpu_count = std::thread::available_parallelism()
|
|
.map(|count| count.get())
|
|
.unwrap_or(1) as f64;
|
|
let mut system = System::new();
|
|
let mut primed = false;
|
|
|
|
loop {
|
|
std::thread::sleep(METRICS_INTERVAL);
|
|
if !ENABLED.load(Ordering::Relaxed) {
|
|
primed = false;
|
|
continue;
|
|
}
|
|
system.refresh_processes_specifics(
|
|
ProcessesToUpdate::Some(&[pid]),
|
|
true,
|
|
refresh_kind,
|
|
);
|
|
let Some(process) = system.process(pid) else {
|
|
continue;
|
|
};
|
|
if !primed {
|
|
primed = true;
|
|
continue;
|
|
}
|
|
let metrics = ProcessMetrics {
|
|
cpu_utilization: (f64::from(process.cpu_usage()) / (100.0 * cpu_count))
|
|
.clamp(0.0, 1.0),
|
|
memory_bytes: process.memory(),
|
|
};
|
|
let _ = sender.try_send(TelemetrySignal::Metrics(metrics));
|
|
}
|
|
})
|
|
.expect("failed to start process metrics worker");
|
|
}
|
|
|
|
fn resource_attributes() -> serde_json::Value {
|
|
json!([
|
|
{ "key": "service.name", "value": { "stringValue": "gitty-desktop" } },
|
|
{ "key": "service.version", "value": { "stringValue": env!("CARGO_PKG_VERSION") } },
|
|
{ "key": "deployment.environment.name", "value": { "stringValue": if cfg!(debug_assertions) { "development" } else { "production" } } },
|
|
{ "key": "os.type", "value": { "stringValue": std::env::consts::OS } },
|
|
{ "key": "host.arch", "value": { "stringValue": std::env::consts::ARCH } }
|
|
])
|
|
}
|
|
|
|
fn log_payload(record: TelemetryRecord) -> serde_json::Value {
|
|
let timestamp = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos()
|
|
.to_string();
|
|
let mut attributes = vec![json!({
|
|
"key": "telemetry.sdk.language",
|
|
"value": { "stringValue": "rust" }
|
|
})];
|
|
if let Some(event_name) = record.event_name {
|
|
attributes.push(json!({
|
|
"key": "event.name",
|
|
"value": { "stringValue": event_name }
|
|
}));
|
|
}
|
|
let payload = json!({
|
|
"resourceLogs": [{
|
|
"resource": { "attributes": resource_attributes() },
|
|
"scopeLogs": [{
|
|
"scope": { "name": "gitty.telemetry" },
|
|
"logRecords": [{
|
|
"timeUnixNano": timestamp,
|
|
"observedTimeUnixNano": timestamp,
|
|
"severityNumber": record.severity_number,
|
|
"severityText": record.severity,
|
|
"body": { "stringValue": record.body },
|
|
"attributes": attributes
|
|
}]
|
|
}]
|
|
}]
|
|
});
|
|
payload
|
|
}
|
|
|
|
fn span_payload(mut span: FrontendSpan) -> serde_json::Value {
|
|
if span.name.len() > 128 {
|
|
span.name.truncate(128);
|
|
}
|
|
let start = u128::from(span.started_at_ms) * 1_000_000;
|
|
let duration = (span.duration_ms.max(0.0) * 1_000_000.0) as u128;
|
|
let attributes = vec![
|
|
json!({ "key": "rpc.system", "value": { "stringValue": "tauri" } }),
|
|
json!({ "key": "rpc.method", "value": { "stringValue": span.name } }),
|
|
];
|
|
json!({
|
|
"resourceSpans": [{
|
|
"resource": { "attributes": resource_attributes() },
|
|
"scopeSpans": [{
|
|
"scope": { "name": "gitty.tauri", "version": env!("CARGO_PKG_VERSION") },
|
|
"spans": [{
|
|
"traceId": span.trace_id,
|
|
"spanId": span.span_id,
|
|
"name": format!("tauri.{}", span.name),
|
|
"kind": 1,
|
|
"startTimeUnixNano": start.to_string(),
|
|
"endTimeUnixNano": (start + duration).to_string(),
|
|
"attributes": attributes,
|
|
"status": {
|
|
"code": if span.success { 1 } else { 2 },
|
|
"message": if span.success { "" } else { "Tauri command failed" }
|
|
}
|
|
}]
|
|
}]
|
|
}]
|
|
})
|
|
}
|
|
|
|
fn metrics_payload(metrics: ProcessMetrics) -> serde_json::Value {
|
|
let timestamp = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos()
|
|
.to_string();
|
|
json!({
|
|
"resourceMetrics": [{
|
|
"resource": { "attributes": resource_attributes() },
|
|
"scopeMetrics": [{
|
|
"scope": { "name": "gitty.process", "version": env!("CARGO_PKG_VERSION") },
|
|
"metrics": [
|
|
{
|
|
"name": "process.cpu.utilization",
|
|
"description": "Normalized CPU utilization of the Gitty process",
|
|
"unit": "1",
|
|
"gauge": { "dataPoints": [{
|
|
"timeUnixNano": timestamp,
|
|
"asDouble": metrics.cpu_utilization,
|
|
"attributes": []
|
|
}] }
|
|
},
|
|
{
|
|
"name": "process.memory.usage",
|
|
"description": "Resident memory used by the Gitty process",
|
|
"unit": "By",
|
|
"gauge": { "dataPoints": [{
|
|
"timeUnixNano": timestamp,
|
|
"asInt": metrics.memory_bytes.to_string(),
|
|
"attributes": []
|
|
}] }
|
|
}
|
|
]
|
|
}]
|
|
}]
|
|
})
|
|
}
|
|
|
|
fn export(
|
|
client: &reqwest::blocking::Client,
|
|
endpoint: &str,
|
|
payload: &serde_json::Value,
|
|
export_warning_shown: &mut bool,
|
|
) {
|
|
match client.post(endpoint).json(payload).send() {
|
|
Ok(response) if response.status().is_success() => {
|
|
if response
|
|
.headers()
|
|
.get(reqwest::header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value.starts_with("text/html"))
|
|
&& !*export_warning_shown
|
|
{
|
|
eprintln!(
|
|
"[WARN] [gitty::telemetry] OTLP endpoint returned HTML; configure the collector endpoint with GITTY_OTLP_LOGS_ENDPOINT"
|
|
);
|
|
*export_warning_shown = true;
|
|
}
|
|
}
|
|
Ok(response) if !*export_warning_shown => {
|
|
eprintln!(
|
|
"[WARN] [gitty::telemetry] OTLP export failed with status {}",
|
|
response.status()
|
|
);
|
|
*export_warning_shown = true;
|
|
}
|
|
Err(error) if !*export_warning_shown => {
|
|
eprintln!("[WARN] [gitty::telemetry] OTLP export failed: {error}");
|
|
*export_warning_shown = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn emit(record: TelemetryRecord) {
|
|
if !ENABLED.load(Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
if let Some(sender) = SENDER.get() {
|
|
let _ = sender.try_send(TelemetrySignal::Log(record));
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_telemetry_enabled(enabled: bool) {
|
|
ENABLED.store(enabled, Ordering::Relaxed);
|
|
if enabled {
|
|
emit(TelemetryRecord {
|
|
severity: "INFO",
|
|
severity_number: 9,
|
|
body: "Telemetry enabled".into(),
|
|
event_name: Some("telemetry.enabled".into()),
|
|
});
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn emit_frontend_log(log: FrontendLog) {
|
|
let (severity, severity_number) = match log.level.as_str() {
|
|
"error" => ("ERROR", 17),
|
|
"warn" => ("WARN", 13),
|
|
_ => ("INFO", 9),
|
|
};
|
|
let mut body = log.message;
|
|
if body.len() > MAX_BODY_BYTES {
|
|
body.truncate(MAX_BODY_BYTES);
|
|
}
|
|
emit(TelemetryRecord {
|
|
severity,
|
|
severity_number,
|
|
body,
|
|
event_name: log.event_name,
|
|
});
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn emit_frontend_span(span: FrontendSpan) {
|
|
if !ENABLED.load(Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
if span.trace_id.len() != 32
|
|
|| span.span_id.len() != 16
|
|
|| !span.trace_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
|
|| !span.span_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
|
{
|
|
return;
|
|
}
|
|
if let Some(sender) = SENDER.get() {
|
|
let _ = sender.try_send(TelemetrySignal::Span(span));
|
|
}
|
|
}
|