diff --git a/docker/tempo/docker-compose.yaml b/docker/tempo/docker-compose.yaml new file mode 100644 index 0000000..95857b3 --- /dev/null +++ b/docker/tempo/docker-compose.yaml @@ -0,0 +1,27 @@ +services: + tempo: + image: grafana/tempo:latest + command: ["-config.file=/etc/tempo.yaml"] + volumes: + - ./tempo.yaml:/etc/tempo.yaml:ro + - tempo-data:/var/tempo + ports: + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + - "3200:3200" # Tempo query API + + grafana: + image: grafana/grafana:latest + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_DISABLE_LOGIN_FORM=true + volumes: + - ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro + ports: + - "9715:3000" # Grafana UI (project port convention) + depends_on: + - tempo + +volumes: + tempo-data: diff --git a/docker/tempo/grafana-datasources.yaml b/docker/tempo/grafana-datasources.yaml new file mode 100644 index 0000000..c5ceb4f --- /dev/null +++ b/docker/tempo/grafana-datasources.yaml @@ -0,0 +1,11 @@ +apiVersion: 1 + +datasources: + - name: Tempo + type: tempo + access: proxy + url: http://tempo:3200 + isDefault: true + jsonData: + tracesToLogsV2: + datasourceUid: '' diff --git a/docker/tempo/tempo.yaml b/docker/tempo/tempo.yaml new file mode 100644 index 0000000..2eb3414 --- /dev/null +++ b/docker/tempo/tempo.yaml @@ -0,0 +1,19 @@ +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +storage: + trace: + backend: local + local: + path: /var/tempo/traces + wal: + path: /var/tempo/wal diff --git a/v2/Cargo.lock b/v2/Cargo.lock index fff5963..dcf71d3 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -152,6 +152,17 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atk" version = "0.18.2" @@ -297,6 +308,9 @@ dependencies = [ "futures-util", "log", "notify", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "rfd", "rusqlite", "serde", @@ -309,6 +323,9 @@ dependencies = [ "tempfile", "tokio", "tokio-tungstenite", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", "uuid", ] @@ -935,6 +952,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "3.0.6" @@ -1194,6 +1217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1973,6 +1997,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -2259,6 +2292,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matches" version = "0.1.10" @@ -2447,6 +2489,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2685,6 +2736,86 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "opentelemetry" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236e667b670a5cdf90c258f5a55794ec5ac5027e960c224bff8367a59e1e6426" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8863faf2910030d139fb48715ad5ff2f35029fc5f244f6d5f689ddcf4d26253" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest 0.12.28", + "tracing", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bef114c6d41bea83d6dc60eb41720eedd0261a67af57b66dd2b84ac46c01d91" +dependencies = [ + "async-trait", + "futures-core", + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest 0.12.28", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f8870d3024727e99212eb3bb1762ec16e255e3e6f58eeb3dc8db1aa226746d" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84dfad6042089c7fc1f6118b7040dc2eb4ab520abbf410b79dc481032af39570" +dependencies = [ + "async-trait", + "futures-channel", + "futures-executor", + "futures-util", + "glob", + "opentelemetry", + "percent-encoding", + "rand 0.8.5", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2893,6 +3024,26 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3087,6 +3238,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -3328,6 +3502,40 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -3565,6 +3773,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3792,6 +4006,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.17.0" @@ -3919,6 +4145,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shared_library" version = "0.1.9" @@ -4256,7 +4491,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -4435,7 +4670,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.2", "rustls", "semver", "serde", @@ -4625,6 +4860,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -4731,6 +4975,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.21.0" @@ -4863,6 +5118,27 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "http", + "http-body", + "http-body-util", + "percent-encoding", + "pin-project", + "prost", + "tokio-stream", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -4915,9 +5191,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4925,6 +5213,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721f2d2569dce9f3dfbbddee5906941e953bfcdf736a62da3377f5751650cc36" +dependencies = [ + "js-sys", + "once_cell", + "opentelemetry", + "opentelemetry_sdk", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] @@ -5113,6 +5462,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "value-bag" version = "1.12.0" @@ -5322,6 +5677,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webkit2gtk" version = "2.0.2" diff --git a/v2/src-tauri/Cargo.toml b/v2/src-tauri/Cargo.toml index 0764454..a702493 100644 --- a/v2/src-tauri/Cargo.toml +++ b/v2/src-tauri/Cargo.toml @@ -33,6 +33,12 @@ uuid = { version = "1", features = ["v4"] } tokio-tungstenite = { version = "0.21", features = ["native-tls"] } tokio = { version = "1", features = ["full"] } futures-util = "0.3" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +opentelemetry = "0.28" +opentelemetry_sdk = { version = "0.28", features = ["rt-tokio"] } +opentelemetry-otlp = { version = "0.28", features = ["http-proto", "reqwest-client"] } +tracing-opentelemetry = "0.29" [dev-dependencies] tempfile = "3" diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 1c4d1d7..e264344 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod pty; mod remote; mod sidecar; mod session; +mod telemetry; mod watcher; use ctx::CtxDb; @@ -25,11 +26,13 @@ struct AppState { file_watcher: Arc, ctx_db: Arc, remote_manager: Arc, + _telemetry: telemetry::TelemetryGuard, } // --- PTY commands --- #[tauri::command] +#[tracing::instrument(skip(state), fields(shell = ?options.shell))] fn pty_spawn( state: State<'_, AppState>, options: PtyOptions, @@ -53,6 +56,7 @@ fn pty_resize( } #[tauri::command] +#[tracing::instrument(skip(state))] fn pty_kill(state: State<'_, AppState>, id: String) -> Result<(), String> { state.pty_manager.kill(&id) } @@ -60,6 +64,7 @@ fn pty_kill(state: State<'_, AppState>, id: String) -> Result<(), String> { // --- Agent/sidecar commands --- #[tauri::command] +#[tracing::instrument(skip(state, options), fields(session_id = %options.session_id))] fn agent_query( state: State<'_, AppState>, options: AgentQueryOptions, @@ -68,6 +73,7 @@ fn agent_query( } #[tauri::command] +#[tracing::instrument(skip(state))] fn agent_stop(state: State<'_, AppState>, session_id: String) -> Result<(), String> { state.sidecar_manager.stop_session(&session_id) } @@ -78,6 +84,7 @@ fn agent_ready(state: State<'_, AppState>) -> bool { } #[tauri::command] +#[tracing::instrument(skip(state))] fn agent_restart(state: State<'_, AppState>) -> Result<(), String> { state.sidecar_manager.restart() } @@ -470,6 +477,19 @@ fn cli_get_group() -> Option { None } +// --- Frontend telemetry bridge --- + +#[tauri::command] +fn frontend_log(level: String, message: String, context: Option) { + match level.as_str() { + "error" => tracing::error!(source = "frontend", ?context, "{message}"), + "warn" => tracing::warn!(source = "frontend", ?context, "{message}"), + "info" => tracing::info!(source = "frontend", ?context, "{message}"), + "debug" => tracing::debug!(source = "frontend", ?context, "{message}"), + _ => tracing::trace!(source = "frontend", ?context, "{message}"), + } +} + // --- Remote machine commands --- #[tauri::command] @@ -488,26 +508,31 @@ async fn remote_remove(state: State<'_, AppState>, machine_id: String) -> Result } #[tauri::command] +#[tracing::instrument(skip(app, state))] async fn remote_connect(app: tauri::AppHandle, state: State<'_, AppState>, machine_id: String) -> Result<(), String> { state.remote_manager.connect(&app, &machine_id).await } #[tauri::command] +#[tracing::instrument(skip(state))] async fn remote_disconnect(state: State<'_, AppState>, machine_id: String) -> Result<(), String> { state.remote_manager.disconnect(&machine_id).await } #[tauri::command] +#[tracing::instrument(skip(state, options), fields(session_id = %options.session_id))] async fn remote_agent_query(state: State<'_, AppState>, machine_id: String, options: AgentQueryOptions) -> Result<(), String> { state.remote_manager.agent_query(&machine_id, &options).await } #[tauri::command] +#[tracing::instrument(skip(state))] async fn remote_agent_stop(state: State<'_, AppState>, machine_id: String, session_id: String) -> Result<(), String> { state.remote_manager.agent_stop(&machine_id, &session_id).await } #[tauri::command] +#[tracing::instrument(skip(state), fields(shell = ?options.shell))] async fn remote_pty_spawn(state: State<'_, AppState>, machine_id: String, options: PtyOptions) -> Result { state.remote_manager.pty_spawn(&machine_id, &options).await } @@ -532,6 +557,9 @@ pub fn run() { // Force dark GTK theme for native dialogs (file chooser, etc.) std::env::set_var("GTK_THEME", "Adwaita:dark"); + // Initialize tracing + optional OTLP export (before any tracing macros) + let telemetry_guard = telemetry::init(); + tauri::Builder::default() .invoke_handler(tauri::generate_handler![ pty_spawn, @@ -588,6 +616,7 @@ pub fn run() { project_agent_state_load, cli_get_group, pick_directory, + frontend_log, ]) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) @@ -652,6 +681,7 @@ pub fn run() { file_watcher, ctx_db, remote_manager, + _telemetry: telemetry_guard, }); Ok(()) diff --git a/v2/src-tauri/src/telemetry.rs b/v2/src-tauri/src/telemetry.rs new file mode 100644 index 0000000..0251f6b --- /dev/null +++ b/v2/src-tauri/src/telemetry.rs @@ -0,0 +1,101 @@ +// OpenTelemetry telemetry — tracing spans + OTLP export to Tempo/Grafana +// +// Controlled by BTERMINAL_OTLP_ENDPOINT env var: +// - Set (e.g. "http://localhost:4318") → export traces via OTLP/HTTP + console +// - Absent → console-only (no network calls) + +use opentelemetry::trace::TracerProvider; +use opentelemetry_sdk::trace::SdkTracerProvider; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +/// Holds the tracer provider and shuts it down on drop. +/// Store this in Tauri's managed state so it lives for the app lifetime. +pub struct TelemetryGuard { + provider: Option, +} + +impl Drop for TelemetryGuard { + fn drop(&mut self) { + if let Some(provider) = self.provider.take() { + if let Err(e) = provider.shutdown() { + eprintln!("OTEL shutdown error: {e}"); + } + } + } +} + +/// Initialize tracing with optional OTLP export. +/// Call once at app startup, before any tracing macros fire. +pub fn init() -> TelemetryGuard { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("bterminal=info,bterminal_lib=info,bterminal_core=info")); + + let fmt_layer = tracing_subscriber::fmt::layer() + .with_target(true) + .compact(); + + match std::env::var("BTERMINAL_OTLP_ENDPOINT") { + Ok(endpoint) if !endpoint.is_empty() => { + match build_otlp_provider(&endpoint) { + Ok(provider) => { + let otel_layer = tracing_opentelemetry::layer() + .with_tracer(provider.tracer("bterminal")); + + tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .with(otel_layer) + .init(); + + log::info!("Telemetry: OTLP export enabled → {endpoint}"); + TelemetryGuard { provider: Some(provider) } + } + Err(e) => { + // Fall back to console-only if OTLP setup fails + tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .init(); + + log::warn!("Telemetry: OTLP setup failed ({e}), console-only fallback"); + TelemetryGuard { provider: None } + } + } + } + _ => { + tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .init(); + + log::info!("Telemetry: console-only (BTERMINAL_OTLP_ENDPOINT not set)"); + TelemetryGuard { provider: None } + } + } +} + +fn build_otlp_provider(endpoint: &str) -> Result> { + use opentelemetry_otlp::{SpanExporter, WithExportConfig}; + use opentelemetry_sdk::trace::SdkTracerProvider; + use opentelemetry_sdk::Resource; + use opentelemetry::KeyValue; + + let exporter = SpanExporter::builder() + .with_http() + .with_endpoint(endpoint) + .build()?; + + let resource = Resource::builder() + .with_attributes([ + KeyValue::new("service.name", "bterminal"), + KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), + ]) + .build(); + + let provider = SdkTracerProvider::builder() + .with_batch_exporter(exporter) + .with_resource(resource) + .build(); + + Ok(provider) +} diff --git a/v2/src/lib/adapters/telemetry-bridge.ts b/v2/src/lib/adapters/telemetry-bridge.ts new file mode 100644 index 0000000..394c596 --- /dev/null +++ b/v2/src/lib/adapters/telemetry-bridge.ts @@ -0,0 +1,26 @@ +// Telemetry bridge — routes frontend events to Rust tracing via IPC +// No browser OTEL SDK needed (WebKit2GTK incompatible) + +import { invoke } from '@tauri-apps/api/core'; + +type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; + +/** Emit a structured log event to the Rust tracing layer */ +export function telemetryLog( + level: LogLevel, + message: string, + context?: Record, +): void { + invoke('frontend_log', { level, message, context: context ?? null }).catch(() => { + // Swallow IPC errors — telemetry must never break the app + }); +} + +/** Convenience wrappers */ +export const tel = { + error: (msg: string, ctx?: Record) => telemetryLog('error', msg, ctx), + warn: (msg: string, ctx?: Record) => telemetryLog('warn', msg, ctx), + info: (msg: string, ctx?: Record) => telemetryLog('info', msg, ctx), + debug: (msg: string, ctx?: Record) => telemetryLog('debug', msg, ctx), + trace: (msg: string, ctx?: Record) => telemetryLog('trace', msg, ctx), +}; diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index 261a474..e4e91c8 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -22,6 +22,7 @@ import { saveAgentMessages, type AgentMessageRecord, } from './adapters/groups-bridge'; +import { tel } from './adapters/telemetry-bridge'; let unlistenMsg: (() => void) | null = null; let unlistenExit: (() => void) | null = null; @@ -66,6 +67,7 @@ export async function startAgentDispatcher(): Promise { switch (msg.type) { case 'agent_started': updateAgentStatus(sessionId, 'running'); + tel.info('agent_started', { sessionId }); break; case 'agent_event': @@ -74,11 +76,13 @@ export async function startAgentDispatcher(): Promise { case 'agent_stopped': updateAgentStatus(sessionId, 'done'); + tel.info('agent_stopped', { sessionId }); notify('success', `Agent ${sessionId.slice(0, 8)} completed`); break; case 'agent_error': updateAgentStatus(sessionId, 'error', msg.message); + tel.error('agent_error', { sessionId, error: msg.message }); notify('error', `Agent error: ${msg.message ?? 'Unknown'}`); break; @@ -89,6 +93,7 @@ export async function startAgentDispatcher(): Promise { unlistenExit = await onSidecarExited(async () => { sidecarAlive = false; + tel.error('sidecar_crashed', { restartAttempts }); // Guard against re-entrant exit handler (double-restart race) if (restarting) return; @@ -176,6 +181,15 @@ function handleAgentEvent(sessionId: string, event: Record): vo numTurns: cost.numTurns, durationMs: cost.durationMs, }); + tel.info('agent_cost', { + sessionId, + costUsd: cost.totalCostUsd, + inputTokens: cost.inputTokens, + outputTokens: cost.outputTokens, + numTurns: cost.numTurns, + durationMs: cost.durationMs, + isError: cost.isError, + }); if (cost.isError) { updateAgentStatus(sessionId, 'error', cost.errors?.join('; ')); notify('error', `Agent failed: ${cost.errors?.[0] ?? 'Unknown error'}`);