[go: up one dir, main page]

re_analytics 0.26.0-rc.3

Rerun's analytics SDK
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
//! Rerun's analytics SDK.
//!
//! We never collect any personal identifiable information.
//! You can always opt-out with `rerun analytics disable`.
//!
//! No analytics will be collected the first time you start the Rerun viewer,
//! giving you an opportunity to opt-out first if you wish.
//!
//! All the data we collect can be found in [`event`].

// We never use any log levels other than `trace` and `debug` because analytics is not important
// enough to require the attention of our users.

#[cfg(not(target_arch = "wasm32"))]
mod native;
#[cfg(not(target_arch = "wasm32"))]
pub use native::{Config, ConfigError};
#[cfg(not(target_arch = "wasm32"))]
use native::{Pipeline, PipelineError};

#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(target_arch = "wasm32")]
pub use web::{Config, ConfigError};
#[cfg(target_arch = "wasm32")]
use web::{Pipeline, PipelineError};

#[cfg(not(target_arch = "wasm32"))]
pub mod cli;

mod posthog;
use posthog::{PostHogBatch, PostHogEvent};

pub mod event;

// ----------------------------------------------------------------------------

use std::{
    borrow::Cow,
    collections::HashMap,
    io::Error as IoError,
    sync::{
        OnceLock,
        atomic::{AtomicU64, Ordering},
    },
    time::Duration,
};

use jiff::Timestamp;

// ----------------------------------------------------------------------------

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum EventKind {
    /// Append a new event to the time series associated with this analytics ID.
    ///
    /// Used e.g. to send an event every time the app start.
    Append,

    /// Update the permanent state associated with this analytics ID.
    ///
    /// Used e.g. to associate an OS with a particular analytics ID upon its creation.
    Update,
}

// ----------------------------------------------------------------------------

/// An error that can occur when flushing.
#[derive(Debug, thiserror::Error)]
pub enum FlushError {
    #[error("Analytics connection closed before flushing completed")]
    Closed,

    #[error("Flush timed out - not all analytics messages were sent.")]
    Timeout,
}

// ----------------------------------------------------------------------------

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AnalyticsEvent {
    time_utc: Timestamp,
    kind: EventKind,
    name: Cow<'static, str>,
    props: HashMap<Cow<'static, str>, Property>,
}

impl AnalyticsEvent {
    #[inline]
    pub fn new(name: impl Into<Cow<'static, str>>, kind: EventKind) -> Self {
        Self {
            time_utc: Timestamp::now(),
            kind,
            name: name.into(),
            props: Default::default(),
        }
    }

    /// Insert a property into the event, overwriting any existing property with the same name.
    #[inline]
    pub fn insert(&mut self, name: impl Into<Cow<'static, str>>, value: impl Into<Property>) {
        self.props.insert(name.into(), value.into());
    }

    /// Insert a property into the event, but only if its `value` is `Some`,
    /// in which case any existing property with the same name will be overwritten.
    ///
    /// This has no effect if `value` is `None`.
    #[inline]
    pub fn insert_opt(
        &mut self,
        name: impl Into<Cow<'static, str>>,
        value: Option<impl Into<Property>>,
    ) {
        if let Some(value) = value {
            self.props.insert(name.into(), value.into());
        }
    }
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum Property {
    Bool(bool),
    Integer(i64),
    Float(f64),
    String(String),
}

impl Property {
    /// Returns a new string property that is a hex representation of the hashed sum of the current
    /// property.
    pub fn hashed(&self) -> Self {
        /// Just a random fixed salt to render pre-built rainbow tables useless.
        const SALT: &str = "d6d6bed3-028a-49ac-94dc-8c89cfb19379";

        use sha2::Digest as _;
        let mut hasher = sha2::Sha256::default();
        hasher.update(SALT);
        match self {
            Self::Bool(data) => hasher.update([*data as u8]),
            Self::Integer(data) => hasher.update(data.to_le_bytes()),
            Self::Float(data) => hasher.update(data.to_le_bytes()),
            Self::String(data) => hasher.update(data),
        }
        Self::String(format!("{:x}", hasher.finalize()))
    }
}

impl From<bool> for Property {
    #[inline]
    fn from(value: bool) -> Self {
        Self::Bool(value)
    }
}

impl From<i64> for Property {
    #[inline]
    fn from(value: i64) -> Self {
        Self::Integer(value)
    }
}

impl From<f32> for Property {
    #[inline]
    fn from(value: f32) -> Self {
        Self::Float(value as _)
    }
}

impl From<f64> for Property {
    #[inline]
    fn from(value: f64) -> Self {
        Self::Float(value)
    }
}

impl From<String> for Property {
    #[inline]
    fn from(value: String) -> Self {
        Self::String(value)
    }
}

impl From<&str> for Property {
    #[inline]
    fn from(value: &str) -> Self {
        Self::String(value.to_owned())
    }
}

// ---

#[cfg(not(target_arch = "wasm32"))]
const DISCLAIMER: &str = "
    Welcome to Rerun!

    This open source library collects anonymous usage data to
    help the Rerun team improve the library.

    Summary:
    - We only collect high level events about the features used within the Rerun Viewer.
    - The actual data you log to Rerun, such as point clouds, images, or text logs,
      will never be collected.
    - We don't log IP addresses.
    - We don't log your user name, file paths, or any personal identifiable data.
    - Usage data we do collect will be sent to and stored on servers within the EU.

    For more details and instructions on how to opt out, run the command:

      rerun analytics details

    As this is this your first session, _no_ usage data has been sent yet,
    giving you an opportunity to opt-out first if you wish.

    Happy Rerunning!
";

#[derive(thiserror::Error, Debug)]
pub enum AnalyticsError {
    #[error(transparent)]
    Config(#[from] ConfigError),

    #[error(transparent)]
    Pipeline(#[from] PipelineError),

    #[error(transparent)]
    Io(#[from] IoError),
}

pub struct Analytics {
    config: Config,

    /// `None` if analytics are disabled.
    pipeline: Option<Pipeline>,

    default_append_props: HashMap<Cow<'static, str>, Property>,
    event_id: AtomicU64,
}

#[cfg(not(target_arch = "wasm32"))] // NOTE: can't block on web
impl Drop for Analytics {
    fn drop(&mut self) {
        if let Some(pipeline) = self.pipeline.as_ref()
            && let Err(err) = pipeline.flush_blocking(Duration::MAX)
        {
            re_log::debug!("Failed to flush analytics events during shutdown: {err}");
        }
    }
}

fn load_config() -> Result<Config, ConfigError> {
    let config = match Config::load() {
        Ok(config) => config,

        Err(err) => {
            // NOTE: This will cause the first run disclaimer to show up again on native,
            //       and analytics will be disabled for the rest of the session.
            if !cfg!(target_arch = "wasm32") {
                re_log::warn!("failed to load analytics config file: {err}");
            }
            None
        }
    };

    if let Some(config) = config {
        re_log::trace!(?config, "loaded analytics config");

        Ok(config)
    } else {
        re_log::trace!(?config, "initialized analytics config");

        // NOTE: If this fails, we give up, because we can't produce
        //       a config on native any other way.
        let config = Config::new()?;

        #[cfg(not(target_arch = "wasm32"))]
        if config.is_first_run() {
            eprintln!("{DISCLAIMER}");

            config.save()?;
            re_log::trace!(?config, "saved analytics config");
        }

        #[cfg(target_arch = "wasm32")]
        {
            // always save the config on web, without printing a disclaimer.
            config.save()?;
            re_log::trace!(?config, "saved analytics config");
        }

        Ok(config)
    }
}

static GLOBAL_ANALYTICS: OnceLock<Option<Analytics>> = OnceLock::new();

impl Analytics {
    /// Get the global analytics instance, initializing it if it's not already initialized.
    ///
    /// Return `None` if analytics is disabled or some error occurred.
    pub fn global_or_init() -> Option<&'static Self> {
        GLOBAL_ANALYTICS
            .get_or_init(|| match Self::new(Duration::from_secs(2)) {
                Ok(analytics) => Some(analytics),
                Err(err) => {
                    re_log::error!("Failed to initialize analytics: {err}");
                    None
                }
            })
            .as_ref()
    }

    /// Get the global analytics instance, but only if it has already been initialized with [`Self::global_or_init`].
    ///
    /// Return `None` if analytics is disabled or some error occurred during initialization.
    ///
    /// Usually it is better to use [`Self::global_or_init`] instead.
    pub fn global_get() -> Option<&'static Self> {
        GLOBAL_ANALYTICS.get()?.as_ref()
    }

    /// Initialize an analytics pipeline which flushes events every `tick`.
    ///
    /// Usually it is better to use [`Self::global_or_init`] instead of calling this directly,
    /// but there are cases where you might want to create a separate instance,
    /// e.g. for testing purposes, or when you want to use a different tick duration.
    fn new(tick: Duration) -> Result<Self, AnalyticsError> {
        let config = load_config()?;
        let pipeline = Pipeline::new(&config, tick)?;
        re_log::trace!("initialized analytics pipeline");

        Ok(Self {
            config,
            default_append_props: Default::default(),
            pipeline,
            event_id: AtomicU64::new(1), // we skip 0 just to be explicit (zeroes can often be implicit)
        })
    }

    pub fn config(&self) -> &Config {
        &self.config
    }

    /// Record a single event.
    ///
    /// The event is constructed using the implementations of [`Event`] and [`Properties`].
    /// The event's properties will be extended with an `event_id`.
    pub fn record<E: Event>(&self, event: E) {
        if self.pipeline.is_none() {
            return;
        }

        let mut e = AnalyticsEvent::new(E::NAME, E::KIND);
        event.serialize(&mut e);
        self.record_raw(e);
    }

    #[cfg(not(target_arch = "wasm32"))] // NOTE: can't block on web
    pub fn flush_blocking(&self, timeout: Duration) -> Result<(), FlushError> {
        if let Some(pipeline) = self.pipeline.as_ref() {
            pipeline.flush_blocking(timeout)
        } else {
            Ok(())
        }
    }

    /// Record an event.
    ///
    /// It will be extended with an `event_id`.
    fn record_raw(&self, mut event: AnalyticsEvent) {
        if let Some(pipeline) = self.pipeline.as_ref() {
            if event.kind == EventKind::Append {
                // Insert default props
                event.props.extend(self.default_append_props.clone());

                // Insert event ID
                event.props.insert(
                    "event_id".into(),
                    (self.event_id.fetch_add(1, Ordering::Relaxed) as i64).into(),
                );
            }

            pipeline.record(event);
        }
    }
}

/// An analytics event.
///
/// This trait requires an implementation of [`Properties`].
pub trait Event: Properties {
    /// The name of the event.
    ///
    /// We prefer `snake_case` when naming events.
    const NAME: &'static str;

    /// What kind of event this is.
    ///
    /// Most events do not update state, so the default here is [`EventKind::Append`].
    const KIND: EventKind = EventKind::Append;
}

/// Trait representing the properties of an analytics event.
///
/// This is separate from [`Event`] to facilitate code re-use.
///
/// For example, [`re_build_info::BuildInfo`] has an implementation of this trait,
/// so that any event which wants to include build info in its properties
/// may include that struct in its own definition, and then call `build_info.serialize`
/// in its own `serialize` implementation.
pub trait Properties: Sized {
    fn serialize(self, event: &mut AnalyticsEvent) {
        let _ = event;
    }
}

impl Properties for re_build_info::BuildInfo {
    fn serialize(self, event: &mut AnalyticsEvent) {
        let git_hash = self.git_hash_or_tag();
        let Self {
            crate_name: _,
            features,
            version,
            rustc_version,
            llvm_version,
            git_hash: _,
            git_branch: _,
            is_in_rerun_workspace,
            target_triple,
            datetime,
            is_debug_build,
        } = self;

        event.insert("features", features.to_string());
        event.insert("git_hash", git_hash);
        event.insert("rerun_version", version.to_string());
        event.insert("rust_version", rustc_version.to_string());
        event.insert("llvm_version", llvm_version.to_string());
        event.insert("target", target_triple.to_string());
        event.insert("build_date", datetime.to_string());
        event.insert("debug", is_debug_build);
        event.insert("rerun_workspace", is_in_rerun_workspace);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::Value;

    #[test]
    fn test_analytics_event_serialization() {
        // Create an event using the new jiff implementation
        let mut event = AnalyticsEvent::new("test_event", EventKind::Append);
        event.insert("test_property", "test_value");

        // Serialize to JSON
        let serialized = serde_json::to_string(&event).expect("Failed to serialize event");
        let parsed: Value = serde_json::from_str(&serialized).expect("Failed to parse JSON");

        // Verify the timestamp format is correct (RFC3339)
        let time_str = parsed["time_utc"]
            .as_str()
            .expect("time_utc should be a string");

        // The format should be like: "2025-04-03T01:20:10.557958200Z"
        // RFC3339 regex pattern
        let re = regex_lite::Regex::new(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$")
            .expect("Failed to compile regex");

        assert!(
            re.is_match(time_str),
            "Timestamp '{time_str}' does not match expected RFC3339 format",
        );

        // Verify other fields
        assert_eq!(parsed["kind"], "Append");
        assert_eq!(parsed["name"], "test_event");

        // Check the property structure - it's an object with "String" field
        let property = &parsed["props"]["test_property"];
        assert!(property.is_object(), "Property should be an object");
        assert_eq!(property["String"], "test_value");
    }

    #[test]
    fn test_timestamp_now_behavior() {
        // Create an event
        let event = AnalyticsEvent::new("test_event", EventKind::Append);

        // Verify the timestamp is close to now
        // This ensures jiff::Timestamp::now() behavior matches time::OffsetDateTime::now_utc()
        let now = jiff::Timestamp::now();
        let event_time = event.time_utc;

        // The timestamps should be within a few seconds of each other
        let diff = (now.as_nanosecond() - event_time.as_nanosecond()).abs();
        let five_seconds_ns = 5_000_000_000;

        assert!(
            diff < five_seconds_ns,
            "Timestamp difference is too large: {diff} nanoseconds"
        );
    }
}