telemetrydeck_wasm/core.rs
1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use uuid::Uuid;
6
7const VERSION: &str = env!("CARGO_PKG_VERSION");
8const CLIENT_VERSION_KEY: &str = "telemetryClientVersion";
9
10/// An instance of an outgoing telemetry signal
11///
12///This struct represents a single telemetry event that will be sent to TelemetryDeck.
13/// Signals are automatically created by [`TelemetryDeck::send`] and [`TelemetryDeck::send_sync`]
14/// methods - you typically don't need to construct these manually.
15///
16/// # Serialization
17///
18/// This struct serializes to JSON in camelCase format as expected by the TelemetryDeck API:
19///
20/// ```json
21/// {
22/// "receivedAt": "2025-01-15T10:30:00Z",
23/// "appID": "xxx-xxx-xxx",
24/// "clientUser": "hashed-user-id",
25/// "sessionID": "session-uuid",
26/// "type": "signalType",
27/// "payload": ["key1:value1", "key2:value2"],
28/// "isTestMode": "false",
29/// "floatValue": 42.5
30/// }
31/// ```
32///
33/// # Privacy
34///
35/// - `client_user` is always SHA-256 hashed before being set
36/// - `session_id` is a UUID v4 generated per client instance
37/// - `is_test_mode` is serialized as a string ("true" or "false")
38/// - `float_value` is omitted from JSON when `None`
39#[derive(Serialize, Deserialize, Debug)]
40#[serde(rename_all = "camelCase")]
41pub struct Signal {
42 /// Timestamp when this signal was generated (UTC)
43 pub received_at: DateTime<Utc>,
44
45 /// The TelemetryDeck App ID this signal belongs to
46 #[serde(rename = "appID")]
47 pub app_id: String,
48
49 /// SHA-256 hashed user identifier
50 ///
51 /// This value is automatically hashed by the client before transmission.
52 /// The server will hash it again with its own salt for privacy.
53 pub client_user: String,
54
55 /// Session identifier (UUID v4)
56 ///
57 /// Persists for the lifetime of a [`TelemetryDeck`] instance.
58 /// Can be reset using [`TelemetryDeck::reset_session`].
59 #[serde(rename = "sessionID")]
60 pub session_id: String,
61
62 /// The type/name of this signal (e.g., "userLogin", "buttonClick")
63 #[serde(rename = "type")]
64 pub signal_type: String,
65
66 /// Custom parameters encoded as "key:value" strings
67 ///
68 /// Created from the HashMap passed to `send()` or `send_sync()`.
69 /// Keys containing colons are sanitized (`:` → `_`).
70 pub payload: Vec<String>,
71
72 /// Whether this is a test signal (serialized as string "true" or "false")
73 ///
74 /// Test signals are shown separately in the TelemetryDeck UI.
75 pub is_test_mode: String,
76
77 /// Optional floating-point value associated with this signal
78 ///
79 /// Useful for tracking numeric metrics like revenue, duration, score, etc.
80 /// Omitted from JSON when `None`.
81 #[serde(rename = "floatValue")]
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub float_value: Option<f64>,
84}
85
86/// TelemetryDeck API client
87///
88/// This is the main entry point for sending telemetry signals to TelemetryDeck.
89/// The client handles session management, user identifier hashing, and HTTP communication.
90///
91/// # Examples
92///
93/// ## Basic Usage
94///
95/// ```no_run
96/// use telemetrydeck_wasm::TelemetryDeck;
97///
98/// let client = TelemetryDeck::new("YOUR-APP-ID");
99/// client.send("userLogin", Some("user@example.com"), None, None, None);
100/// ```
101///
102/// ## With Configuration
103///
104/// ```no_run
105/// use telemetrydeck_wasm::TelemetryDeck;
106/// use std::collections::HashMap;
107///
108/// let client = TelemetryDeck::new_with_config(
109/// "YOUR-APP-ID",
110/// Some("my-tenant".to_string()), // namespace
111/// Some("random-salt-64-chars".to_string()), // salt
112/// HashMap::new(), // default params
113/// );
114/// ```
115///
116/// # Platform Support
117///
118/// - **Native**: Uses `reqwest` + `tokio::spawn`
119/// - **WASM**: Uses `reqwasm` + `spawn_local`
120///
121/// # Privacy
122///
123/// - User identifiers are always SHA-256 hashed
124/// - Optional salt is concatenated after user ID before hashing
125/// - Session IDs are random UUIDs
126#[derive(Debug)]
127pub struct TelemetryDeck {
128 /// Base URL of the TelemetryDeck service
129 ///
130 /// Default: `https://nom.telemetrydeck.com`
131 url: String,
132
133 /// Your TelemetryDeck App ID
134 pub app_id: String,
135
136 /// Optional namespace for multi-tenant deployments
137 ///
138 /// When set, signals are sent to `/v2/namespace/{namespace}/`
139 /// instead of `/v2/`.
140 pub namespace: Option<String>,
141
142 /// Optional salt for user identifier hashing
143 ///
144 /// The salt is concatenated after the user identifier before
145 /// SHA-256 hashing: `hash(user_id + salt)`.
146 ///
147 /// # Security Note
148 ///
149 /// It is recommended to use a cryptographically random salt of at least
150 /// 64 characters. The salt should be unique per application but consistent
151 /// across all users of the same application.
152 pub salt: Option<String>,
153
154 /// Default parameters appended to all outgoing signals
155 ///
156 /// These are merged with per-signal parameters.
157 /// The library version is automatically added as `telemetryClientVersion`.
158 pub default_params: HashMap<String, String>,
159
160 /// Current session identifier (UUID v4)
161 ///
162 /// Generated automatically when the client is created.
163 /// Can be reset using [`TelemetryDeck::reset_session`].
164 pub session_id: String,
165}
166
167impl TelemetryDeck {
168 /// Create a new instance with the specified application id
169 #[must_use]
170 pub fn new(app_id: &str) -> Self {
171 Self::new_with_config(app_id, None, None, HashMap::new())
172 }
173
174 /// Create a new instance with the specified application id and configuration
175 ///
176 /// # Examples
177 ///
178 /// ```
179 /// use telemetrydeck_wasm::TelemetryDeck;
180 /// use std::collections::HashMap;
181 ///
182 /// // Create client with namespace for multi-tenant deployment
183 /// let client = TelemetryDeck::new_with_config(
184 /// "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
185 /// Some("my-namespace".to_string()),
186 /// None,
187 /// HashMap::new(),
188 /// );
189 ///
190 /// // Create client with salt for enhanced user hashing
191 /// let client = TelemetryDeck::new_with_config(
192 /// "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
193 /// None,
194 /// Some("your-64-char-random-salt-here".to_string()),
195 /// HashMap::new(),
196 /// );
197 ///
198 /// // Create client with default parameters
199 /// let mut defaults = HashMap::new();
200 /// defaults.insert("environment".to_string(), "production".to_string());
201 /// let client = TelemetryDeck::new_with_config(
202 /// "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
203 /// None,
204 /// None,
205 /// defaults,
206 /// );
207 /// ```
208 #[must_use]
209 pub fn new_with_config(
210 app_id: &str,
211 namespace: Option<String>,
212 salt: Option<String>,
213 params: HashMap<String, String>,
214 ) -> Self {
215 TelemetryDeck {
216 url: String::from("https://nom.telemetrydeck.com"),
217 app_id: app_id.to_string(),
218 namespace,
219 salt,
220 default_params: Self::adding_params(
221 ¶ms,
222 Some(HashMap::from([(
223 CLIENT_VERSION_KEY.to_string(),
224 VERSION.to_string(),
225 )])),
226 ),
227 session_id: Uuid::new_v4().to_string(),
228 }
229 }
230
231 /// Reset the session id for future signals
232 pub fn reset_session(&mut self, new_session_id: Option<String>) {
233 self.session_id = new_session_id.unwrap_or_else(|| Uuid::new_v4().to_string());
234 }
235
236 /// Create a signal with the specified parameters
237 pub(crate) fn create_signal(
238 &self,
239 signal_type: &str,
240 client_user: Option<&str>,
241 payload: Option<HashMap<String, String>>,
242 is_test_mode: Option<bool>,
243 float_value: Option<f64>,
244 ) -> Signal {
245 let params = Self::adding_params(&self.default_params, payload);
246 let payload = Self::encoded_payload(params);
247
248 let client_user = client_user.map_or_else(
249 || "rust".to_string(),
250 |u| {
251 let user_with_salt = if let Some(salt) = &self.salt {
252 format!("{}{}", u, salt)
253 } else {
254 u.to_string()
255 };
256 let mut sha256 = Sha256::new();
257 sha256.update(user_with_salt.as_bytes());
258 format!("{:x}", sha256.finalize())
259 },
260 );
261 Signal {
262 received_at: Utc::now(),
263 app_id: self.app_id.clone(),
264 client_user,
265 session_id: self.session_id.clone(),
266 signal_type: signal_type.to_string(),
267 payload,
268 is_test_mode: is_test_mode.unwrap_or(false).to_string(),
269 float_value,
270 }
271 }
272
273 /// Build the API URL for sending signals
274 pub(crate) fn build_url(&self) -> String {
275 if let Some(namespace) = &self.namespace {
276 format!("{}/v2/namespace/{}/", self.url, namespace)
277 } else {
278 format!("{}/v2/", self.url)
279 }
280 }
281
282 fn adding_params(
283 params1: &HashMap<String, String>,
284 params2: Option<HashMap<String, String>>,
285 ) -> HashMap<String, String> {
286 let mut result = params1.clone();
287 if let Some(params) = params2 {
288 result.extend(params);
289 }
290 result
291 }
292
293 /// Encode parameters as "key:value" strings
294 ///
295 /// Colons in parameter keys are replaced with underscores to avoid
296 /// conflicts with the "key:value" encoding format.
297 fn encoded_payload(params: HashMap<String, String>) -> Vec<String> {
298 params
299 .into_iter()
300 .map(|(k, v)| format!("{}:{}", k.replace(':', "_"), v))
301 .collect()
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::TelemetryDeck;
308 use std::collections::HashMap;
309 const VERSION: &str = env!("CARGO_PKG_VERSION");
310
311 #[test]
312 fn create_signal_without_user() {
313 let sut = TelemetryDeck::new("1234");
314 let result = sut.create_signal("signal_type", None, None, None, None);
315 assert_eq!(result.client_user, "rust".to_string());
316 assert_eq!(result.signal_type, "signal_type".to_string());
317 assert_eq!(result.app_id, "1234".to_string());
318 assert_eq!(result.is_test_mode, "false".to_string());
319 assert_eq!(
320 result.payload,
321 vec![format!("telemetryClientVersion:{VERSION}")]
322 );
323 assert_eq!(result.float_value, None);
324 }
325
326 #[test]
327 fn create_signal_with_user_is_hashed() {
328 let sut = TelemetryDeck::new("1234");
329 let result = sut.create_signal("signal_type", Some("clientUser"), None, None, None);
330 assert_eq!(
331 result.client_user,
332 "6721870580401922549fe8fdb09a064dba5b8792fa018d3bd9ffa90fe37a0149".to_string()
333 );
334 assert_eq!(result.signal_type, "signal_type".to_string());
335 assert_eq!(result.app_id, "1234".to_string());
336 assert_eq!(result.is_test_mode, "false".to_string());
337 assert_eq!(
338 result.payload,
339 vec![format!("telemetryClientVersion:{VERSION}")]
340 );
341 }
342
343 #[test]
344 fn create_signal_with_user_and_salt_is_hashed() {
345 let sut = TelemetryDeck::new_with_config(
346 "1234",
347 None,
348 Some("someSalt".to_string()),
349 HashMap::new(),
350 );
351 let result = sut.create_signal("signal_type", Some("clientUser"), None, None, None);
352 assert_eq!(
353 result.client_user,
354 "ffdd613ce521b2e94b8931bdadffd96857f6abbde6c0ee1fcf0b76127fbb9e5a".to_string()
355 );
356 }
357
358 #[test]
359 fn create_signal_with_float_value() {
360 let sut = TelemetryDeck::new("1234");
361 let result = sut.create_signal("signal_type", None, None, None, Some(42.5));
362 assert_eq!(result.float_value, Some(42.5));
363
364 // Verify serialization includes floatValue
365 let json = serde_json::to_string(&result).unwrap();
366 assert!(json.contains("\"floatValue\":42.5"));
367 }
368
369 #[test]
370 fn create_signal_without_float_value_omits_field() {
371 let sut = TelemetryDeck::new("1234");
372 let result = sut.create_signal("signal_type", None, None, None, None);
373 assert_eq!(result.float_value, None);
374
375 // Verify serialization omits floatValue when None
376 let json = serde_json::to_string(&result).unwrap();
377 assert!(!json.contains("floatValue"));
378 }
379
380 #[test]
381 fn build_url_without_namespace() {
382 let sut = TelemetryDeck::new("1234");
383 assert_eq!(sut.build_url(), "https://nom.telemetrydeck.com/v2/");
384 }
385
386 #[test]
387 fn build_url_with_namespace() {
388 let sut = TelemetryDeck::new_with_config(
389 "1234",
390 Some("my-namespace".to_string()),
391 None,
392 HashMap::new(),
393 );
394 assert_eq!(
395 sut.build_url(),
396 "https://nom.telemetrydeck.com/v2/namespace/my-namespace/"
397 );
398 }
399
400 #[test]
401 fn reset_session() {
402 let mut sut = TelemetryDeck::new("1234");
403 let session1 = sut.session_id.clone();
404 sut.reset_session(None);
405 let session2 = sut.session_id.clone();
406 assert_ne!(session1, session2);
407 }
408
409 #[test]
410 fn reset_session_with_specific_id() {
411 let mut sut = TelemetryDeck::new("1234");
412 sut.reset_session(Some("my session".to_string()));
413 let session2 = sut.session_id.clone();
414 assert_eq!(session2, "my session".to_string());
415 }
416}