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