@ondeinference / onde / commits / c339fad

Add pulse telemetry client with GresIQ integration

- Introduce PulseClient to wrap GresIQ telemetry for Onde - Read ONDE_CLIENT_KEY/SECRET and GRESIQ_API_KEY/SECRET from env - Support GRESIQ_ENVIRONMENT for dev/prod routing - Log telemetry enablement status at engine init - No-op if any required env var is missing; inference never blocks - Update docs for new env var requirements

keypair34 committed Apr 18, 2026 at 14:15 UTC c339fade1af9c6d6d472cb3a0f9715d34b4a3521
5 files changed +183 -35
Cargo.lock
+93 -3
@@ -1280,7 +1280,7 @@ dependencies = [
1280 "bytemuck_derive",
1281 "hashbrown 0.15.5",
1282 "regex-syntax",
1283 - "strum",
1283 + "strum 0.27.2",
1284 ]
1285
1286 [[package]]
@@ -2201,6 +2201,12 @@ dependencies = [
2201 "unicode-segmentation",
2202 ]
2203
2204 +[[package]]
2205 +name = "heck"
2206 +version = "0.4.1"
2207 +source = "registry+https://github.com/rust-lang/crates.io-index"
2208 +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
2209 +
2210 [[package]]
2211 name = "heck"
2212 version = "0.5.0"
@@ -2368,6 +2374,7 @@ dependencies = [
2374 "hyper",
2375 "hyper-util",
2376 "rustls",
2377 + "rustls-native-certs",
2378 "rustls-pki-types",
2379 "tokio",
2380 "tokio-rustls",
@@ -2937,6 +2944,12 @@ dependencies = [
2944 "libc",
2945 ]
2946
2947 +[[package]]
2948 +name = "maplit"
2949 +version = "1.0.2"
2950 +source = "registry+https://github.com/rust-lang/crates.io-index"
2951 +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
2952 +
2953 [[package]]
2954 name = "markup5ever"
2955 version = "0.36.1"
@@ -3205,7 +3218,7 @@ dependencies = [
3218 "serde_json",
3219 "serde_plain",
3220 "statrs",
3208 - "strum",
3221 + "strum 0.27.2",
3222 "symphonia",
3223 "sysinfo",
3224 "thiserror 2.0.18",
@@ -3627,7 +3640,9 @@ dependencies = [
3640 "log",
3641 "mistralrs",
3642 "mistralrs-core",
3643 + "reqwest 0.12.28",
3644 "serde",
3645 + "serde_json",
3646 "smbcloud-gresiq-sdk",
3647 "thiserror 2.0.18",
3648 "tokio",
@@ -4389,6 +4404,7 @@ dependencies = [
4404 "pin-project-lite",
4405 "quinn",
4406 "rustls",
4407 + "rustls-native-certs",
4408 "rustls-pki-types",
4409 "serde",
4410 "serde_json",
@@ -4935,6 +4951,17 @@ dependencies = [
4951 "serde",
4952 ]
4953
4954 +[[package]]
4955 +name = "serde_repr"
4956 +version = "0.1.20"
4957 +source = "registry+https://github.com/rust-lang/crates.io-index"
4958 +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
4959 +dependencies = [
4960 + "proc-macro2",
4961 + "quote",
4962 + "syn 2.0.117",
4963 +]
4964 +
4965 [[package]]
4966 name = "serde_spanned"
4967 version = "1.1.1"
@@ -5130,10 +5157,40 @@ dependencies = [
5157 "reqwest 0.12.28",
5158 "serde",
5159 "serde_json",
5160 + "smbcloud-network",
5161 "thiserror 2.0.18",
5162 "tokio",
5163 ]
5164
5165 +[[package]]
5166 +name = "smbcloud-model"
5167 +version = "0.3.35"
5168 +dependencies = [
5169 + "chrono",
5170 + "log",
5171 + "serde",
5172 + "serde_json",
5173 + "serde_repr",
5174 + "spinners",
5175 + "strum 0.27.2",
5176 + "strum_macros 0.27.2",
5177 + "thiserror 2.0.18",
5178 + "tsync",
5179 +]
5180 +
5181 +[[package]]
5182 +name = "smbcloud-network"
5183 +version = "0.3.35"
5184 +dependencies = [
5185 + "clap 4.6.0",
5186 + "log",
5187 + "reqwest 0.12.28",
5188 + "serde",
5189 + "serde_json",
5190 + "smbcloud-model",
5191 + "wasm-bindgen",
5192 +]
5193 +
5194 [[package]]
5195 name = "socket2"
5196 version = "0.6.3"
@@ -5166,6 +5223,17 @@ dependencies = [
5223 "vob",
5224 ]
5225
5226 +[[package]]
5227 +name = "spinners"
5228 +version = "4.2.0"
5229 +source = "registry+https://github.com/rust-lang/crates.io-index"
5230 +checksum = "071af1a9d34b78b8db3ca4424b0ea4d87052d607dbe96287aebaccd596cabc86"
5231 +dependencies = [
5232 + "lazy_static",
5233 + "maplit",
5234 + "strum 0.24.1",
5235 +]
5236 +
5237 [[package]]
5238 name = "spm_precompiled"
5239 version = "0.1.4"
@@ -5292,13 +5360,35 @@ dependencies = [
5360 "syn 1.0.109",
5361 ]
5362
5363 +[[package]]
5364 +name = "strum"
5365 +version = "0.24.1"
5366 +source = "registry+https://github.com/rust-lang/crates.io-index"
5367 +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f"
5368 +dependencies = [
5369 + "strum_macros 0.24.3",
5370 +]
5371 +
5372 [[package]]
5373 name = "strum"
5374 version = "0.27.2"
5375 source = "registry+https://github.com/rust-lang/crates.io-index"
5376 checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
5377 dependencies = [
5301 - "strum_macros",
5378 + "strum_macros 0.27.2",
5379 +]
5380 +
5381 +[[package]]
5382 +name = "strum_macros"
5383 +version = "0.24.3"
5384 +source = "registry+https://github.com/rust-lang/crates.io-index"
5385 +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59"
5386 +dependencies = [
5387 + "heck 0.4.1",
5388 + "proc-macro2",
5389 + "quote",
5390 + "rustversion",
5391 + "syn 1.0.109",
5392 ]
5393
5394 [[package]]
Cargo.toml
+6 -3
@@ -29,11 +29,17 @@ tokio = { version = "1", features = ["macros", "rt", "sync"] }
29 tsync = "2"
30
31 thiserror = "2"
32 +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
33 +serde_json = "1"
34
35 # Pinned to =0.31.0 — the versions MUST be identical across the crate and
36 # the uniffi-bindgen binary crate.
37 uniffi = { version = "=0.31.0", features = ["tokio"] }
38
39 +[dependencies.smbcloud-gresiq-sdk]
40 +path = "../smbcloud-cli/crates/smbcloud-gresiq-sdk"
41 +# TODO: version = "0.1" after publishing to crates.io
42 +
43 [build-dependencies]
44 # Required for UniFFI scaffolding code generation at build time.
45 uniffi = { version = "=0.31.0", features = ["build"] }
@@ -106,9 +112,6 @@ mistralrs = { git = "https://github.com/setoelkahfi/mistral.rs", branch = "fix/a
112
113 # Patch sysctl with watchOS (target_os = "watchos") support until upstream merges it.
114
109 -[dependencies.smbcloud-gresiq-sdk]
110 -path = "../smbcloud-cli/crates/smbcloud-gresiq-sdk"
111 -# TODO: version = "0.1" after publishing to crates.io
115
116 # Patch sysctl with watchOS support until upstream merges it.
117 # PR: https://github.com/johalun/sysctl-rs/pull/74
src/inference/engine.rs
+23 -1
@@ -216,9 +216,31 @@ impl ChatEngine {
216
217 /// Create a new engine with no model loaded.
218 pub fn new() -> Self {
219 + let environment = Self::pulse_environment();
220 + let pulse = crate::pulse::PulseClient::from_env(environment);
221 +
222 + match &pulse {
223 + Some(_) => log::info!(
224 + "ChatEngine: pulse telemetry enabled (environment={environment})"
225 + ),
226 + None => log::info!(
227 + "ChatEngine: pulse telemetry disabled \
228 + (GRESIQ_API_KEY / GRESIQ_API_SECRET / ONDE_CLIENT_KEY / ONDE_CLIENT_SECRET not set)"
229 + ),
230 + }
231 +
232 Self {
233 inner: Mutex::new(None),
221 - pulse: crate::pulse::PulseClient::from_env(),
234 + pulse,
235 + }
236 + }
237 +
238 + /// Resolve the GresIQ environment from the `GRESIQ_ENVIRONMENT` env var.
239 + /// Defaults to `Production` when the var is absent or unrecognised.
240 + fn pulse_environment() -> smbcloud_gresiq_sdk::Environment {
241 + match std::env::var("GRESIQ_ENVIRONMENT").as_deref() {
242 + Ok("dev") => smbcloud_gresiq_sdk::Environment::Dev,
243 + _ => smbcloud_gresiq_sdk::Environment::Production,
244 }
245 }
246
src/pulse/client.rs
+54 -21
@@ -1,12 +1,15 @@
1 -use smbcloud_gresiq_sdk::GresiqClient;
1 +use std::collections::HashMap;
2 +
3 +use smbcloud_gresiq_sdk::{Environment, GresiqClient, GresiqCredentials};
4
5 use super::events::{InferenceEvent, ModelLoadedEvent};
6
5 -/// onde's thin wrapper around `GresiqClient`.
7 +/// Onde telemetry client. Wraps GresiqClient so pulse events land in the
8 +/// right GresIQ-managed tables without this crate knowing anything about
9 +/// the HTTP auth layer underneath.
10 ///
7 -/// `GresiqClient` handles the HTTP transport and auth headers.
8 -/// This layer adds the `edge_id` (which machine we're running on)
9 -/// and the fire-and-forget spawning so telemetry never blocks inference.
11 +/// Cheap to clone: the inner GresiqClient holds an Arc-backed reqwest::Client,
12 +/// so cloning is a pointer bump, not a new TCP connection.
13 #[derive(Debug, Clone)]
14 pub struct PulseClient {
15 inner: GresiqClient,
@@ -14,22 +17,52 @@ pub struct PulseClient {
17 }
18
19 impl PulseClient {
17 - /// Reads `GRESIQ_BASE_URL`, `GRESIQ_API_KEY`, and `GRESIQ_API_SECRET`
18 - /// from the environment (delegated to `GresiqClient::from_env`), plus
19 - /// `ONDE_EDGE_ID` for the node name. Returns `None` if any required
20 - /// GresIQ var is missing — telemetry stays off, nothing breaks.
21 - pub fn from_env() -> Option<Self> {
22 - let inner = GresiqClient::from_env()?;
23 - let edge_id = std::env::var("ONDE_EDGE_ID")
20 + /// Reads credentials from the environment and resolves the GresIQ
21 + /// gateway URL from the given `Environment`.
22 + ///
23 + /// GresIQ layer (shared across all SDK clients):
24 + /// `GRESIQ_API_KEY`, `GRESIQ_API_SECRET`
25 + ///
26 + /// SDK client layer (this app’s own credentials):
27 + /// `ONDE_CLIENT_KEY`, `ONDE_CLIENT_SECRET`
28 + /// `ONDE_EDGE_ID` (optional, defaults to `"onde-unknown"`)
29 + ///
30 + /// Returns `None` if any required var is absent. Nothing blows up —
31 + /// the engine just skips telemetry for the whole run.
32 + pub fn from_env(environment: Environment) -> Option<Self> {
33 + // GresIQ credentials
34 + let gresiq_api_key = std::env::var("GRESIQ_API_KEY").ok()?;
35 + let gresiq_api_secret = std::env::var("GRESIQ_API_SECRET").ok()?;
36 +
37 + // SDK client credentials
38 + let client_key = std::env::var("ONDE_CLIENT_KEY").ok()?;
39 + let client_secret = std::env::var("ONDE_CLIENT_SECRET").ok()?;
40 + let edge_id = std::env::var("ONDE_EDGE_ID")
41 .unwrap_or_else(|_| "onde-unknown".to_string());
42 +
43 + let mut extra = HashMap::new();
44 + extra.insert("X-Onde-Client-Key".to_string(), client_key);
45 + extra.insert("X-Onde-Client-Secret".to_string(), client_secret);
46 +
47 + let credentials = GresiqCredentials {
48 + api_key: &gresiq_api_key,
49 + api_secret: &gresiq_api_secret,
50 + };
51 +
52 + let inner = GresiqClient::from_credentials(environment, credentials)
53 + .with_extra_headers(extra);
54 +
55 Some(PulseClient { inner, edge_id })
56 }
57
28 - /// Spawns a background task to POST the model-load event and returns
29 - /// immediately. Won't slow down the first message. If the request fails,
30 - /// you get a `warn!` log entry — that's the whole error budget.
58 + /// Spawns a background task that writes the model-load event to the
59 + /// pulse/model_loaded table, then returns immediately. Slow network
60 + /// responses don't block the first inference request.
61 + ///
62 + /// Failed writes emit a warn! log line -- no retry, no queue,
63 + /// and no effect on the caller.
64 pub fn record_model_loaded(&self, model_id: String, model_name: String, load_duration_ms: u64) {
32 - let client = self.clone();
65 + let client = self.clone();
66 tokio::spawn(async move {
67 let event = ModelLoadedEvent {
68 edge_id: client.edge_id.clone(),
@@ -43,14 +76,14 @@ impl PulseClient {
76 });
77 }
78
46 - /// Same deal as `record_model_loaded` but for inference. Spawns, returns,
47 - /// logs on failure.
79 + /// Same fire-and-forget pattern as record_model_loaded but for inference
80 + /// completions. Writes to pulse/inference_event. Logs on failure, no retry.
81 pub fn record_inference(
82 &self,
50 - model_id: String,
51 - request_id: String,
83 + model_id: String,
84 + request_id: String,
85 duration_ms: u64,
53 - status: String,
86 + status: String,
87 ) {
88 let client = self.clone();
89 tokio::spawn(async move {
src/pulse/mod.rs
+7 -7
@@ -1,17 +1,17 @@
1 -//! Sends timing data to the Onde Inference dashboard via smbCloud GresIQ.
1 +//! Sends timing data to the Onde Inference dashboard.
2 //!
3 -//! Opt-in at the infra level, not the code level. Set three env vars and
3 +//! Opt-in at the infra level, not the code level. Set the four env vars and
4 //! telemetry starts flowing. Set none and nothing happens — no warnings,
5 //! no stubs, the branches just never run.
6 //!
7 //! The engine reads the vars once in `ChatEngine::new()`. You can't flip
8 //! telemetry on mid-run without restarting.
9 //!
10 -//! | Variable | Example |
11 -//! |---------------------|--------------------------------|
12 -//! | `GRESIQ_BASE_URL` | `https://api.smbcloud.xyz` |
13 -//! | `GRESIQ_API_KEY` | `gresiq_abc123...` |
14 -//! | `GRESIQ_API_SECRET` | `<secret from GresIQ console>` |
10 +//! | Variable | Example |
11 +//! |-----------------------|------------------------------|
12 +//! | `ONDE_BASE_URL` | `https://ondeinference.com` |
13 +//! | `ONDE_CLIENT_KEY` | `<issued by Onde Inference>` |
14 +//! | `ONDE_CLIENT_SECRET` | `<issued by Onde Inference>` |
15 //!
16 //! `ONDE_EDGE_ID` is what this machine gets called in the dashboard.
17 //! Anything works — just keep it stable across restarts or you'll