Files
GitLite/src-tauri/src/telemetry.rs
T
Christoph Brandau cae8390b54 feat(telemetry): Implement structured telemetry logging and reporting
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
2026-07-21 23:40:39 +02:00

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