dioxus_cli_telemetry/
lib.rs

1//! # Telemetry for the Dioxus CLI
2//!
3//! Dioxus uses telemetry in the CLI to get insight into metrics like performance, panics, and usage
4//! of various arguments. This data helps us track down bugs and improve quality of the tooling.
5//!
6//! Usage of telemetry in open source products can be controversial. Our goal here is to collect
7//! minimally invasive data used exclusively to improve our tooling. Github issues only show *some*
8//! of the problem, but many users stumble into issues which go unreported.
9//!
10//! Our policy follows:
11//! - minimally invasive
12//! - anonymous
13//! - periodic
14//! - transparent
15//! - easy to disable
16//!
17//! We send a heartbeat when the CLI is executed and then rollups of logs over time.
18//! - Heartbeat: helps us track version distribution of the CLI and critical "failures on launch" useful during new version rollouts.
19//! - Rollups: helps us track performance and issues over time, as well as usage of various commands.
20//!
21//! If you don't run the CLI, then we won't send any data. Rollups are not done in background processes.
22//!
23//! We don't collect any PII, but we do collect three "controversial" pieces of data:
24//! - the target triple of your system (OS, arch, etc)
25//! - a session ID which is a random number generated on each run
26//! - a distinct ID per `.dx` installation which is a random number generated on initial run.
27//!
28//! The distinct ID is used to track the same installation over time, but it is not tied to any user
29//! account or PII. Since `dx` doesn't have any accounts or authentication mechanism, this ID is used
30//! as a "best effort" identifier. If you still want to participate in telemetry but don't want a
31//! distinct ID, you can replace the stable_id.json file in the `.dx` directory with an empty string.
32//!
33//! In the CLI, you can disable this by using any of the methods:
34//! - installing with the "disable-telemetry" feature flag
35//! - setting TELEMETRY=false in your env
36//! - setting `dx config set disable-telemetry true`
37
38use chrono::{DateTime, Utc};
39use serde::{Deserialize, Serialize};
40use std::{
41    collections::{BTreeMap, HashMap},
42    time::SystemTime,
43};
44
45/// An event's data, corresponding roughly to data collected from an individual trace.
46///
47/// This can be something like a build, bundle, translate, etc
48/// We collect the phases of the build in a list of events to get a better sense of how long
49/// it took.
50///
51/// Note that this is just the data and does not include the reporter information.
52///
53/// On the analytics, side, we reconstruct the trace messages into a sequence of events, using
54/// the stage as a marker.
55///
56/// If the event contains a stack trace, it is considered a crash event and will be sent to the crash reporting service.
57///
58/// We store this type on disk without the reporter information or any information about the CLI.
59#[derive(Serialize, Deserialize, Debug, Clone)]
60pub struct TelemetryEventData {
61    /// The name of the command that was run, e.g. "dx build", "dx bundle", "dx serve"
62    pub command: String,
63
64    /// The action that was taken, e.g. "build", "bundle", "cli_invoked", "cli_crashed" etc
65    pub action: String,
66
67    /// An additional message to include in the event, e.g. "start", "end", "error", etc
68    pub message: String,
69
70    /// The "name" of the error. In our case, usually" "RustError" or "RustPanic". In other languages
71    /// this might be the exception type. In Rust, this is usually the name of the error type. (e.g. "std::io::Error", etc)
72    pub error_type: Option<String>,
73
74    /// Whether the event was handled or not. Unhandled errors are the default, but some we recover from (like hotpatching issues).
75    pub error_handled: bool,
76
77    /// Additional values to include in the event, e.g. "duration", "enabled", etc.
78    pub values: HashMap<String, serde_json::Value>,
79
80    /// Timestamp of the event, in UTC, derived from the user's system time. Might not be reliable.
81    pub time: DateTime<Utc>,
82
83    /// The module where the event occurred, stripped of paths for privacy.
84    pub module: Option<String>,
85
86    /// The file or module where the event occurred, stripped of paths for privacy, relative to the monorepo root.
87    pub file: Option<String>,
88
89    /// The line and column where the event occurred, if applicable.
90    pub line: Option<u32>,
91
92    /// The column where the event occurred, if applicable.
93    pub column: Option<u32>,
94
95    /// The stack frames of the event, if applicable.
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub stack_frames: Vec<StackFrame>,
98}
99
100impl TelemetryEventData {
101    pub fn new(name: impl ToString, message: impl ToString) -> Self {
102        Self {
103            command: std::env::args()
104                .nth(1)
105                .unwrap_or_else(|| "unknown".to_string()),
106            action: strip_paths(&name.to_string()),
107            message: strip_paths(&message.to_string()),
108            file: None,
109            module: None,
110            time: DateTime::<Utc>::from(SystemTime::now()),
111            values: HashMap::new(),
112            error_type: None,
113            column: None,
114            line: None,
115            stack_frames: vec![],
116            error_handled: false,
117        }
118    }
119
120    pub fn with_value<K: ToString, V: serde::Serialize>(mut self, key: K, value: V) -> Self {
121        let mut value = serde_json::to_value(value).unwrap();
122        strip_paths_value(&mut value);
123        self.values.insert(key.to_string(), value);
124        self
125    }
126
127    pub fn with_module(mut self, module: impl ToString) -> Self {
128        self.module = Some(strip_paths(&module.to_string()));
129        self
130    }
131
132    pub fn with_file(mut self, file: impl ToString) -> Self {
133        self.file = Some(strip_paths(&file.to_string()));
134        self
135    }
136
137    pub fn with_line_column(mut self, line: u32, column: u32) -> Self {
138        self.line = Some(line);
139        self.column = Some(column);
140        self
141    }
142
143    pub fn with_error_handled(mut self, error_handled: bool) -> Self {
144        self.error_handled = error_handled;
145        self
146    }
147
148    pub fn with_error_type(mut self, error_type: String) -> Self {
149        self.error_type = Some(error_type);
150        self
151    }
152
153    pub fn with_stack_frames(mut self, stack_frames: Vec<StackFrame>) -> Self {
154        self.stack_frames = stack_frames;
155        self
156    }
157
158    pub fn with_values(mut self, fields: serde_json::Map<String, serde_json::Value>) -> Self {
159        for (key, value) in fields {
160            self = self.with_value(key, value);
161        }
162        self
163    }
164
165    pub fn to_json(&self) -> serde_json::Value {
166        serde_json::to_value(self).unwrap()
167    }
168}
169
170/// Display implementation for TelemetryEventData, such that you can use it in tracing macros with the "%" syntax.
171impl std::fmt::Display for TelemetryEventData {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        write!(f, "{}", serde_json::to_string(self).unwrap())
174    }
175}
176
177/// A serialized stack frame, in a format that matches PostHog's stack frame format.
178///
179/// Read more:
180/// <https://github.com/PostHog/posthog-js/blob/6e35a639a4d06804f6844cbde15adf11a069b92b/packages/node/src/extensions/error-tracking/types.ts#L55>
181///
182/// Supposedly, this is compatible with Sentry's stack frames as well. In the CLI we use sentry-backtrace
183/// even though we don't actually use sentry.
184#[derive(Serialize, Deserialize, Debug, Clone)]
185#[serde(rename_all = "snake_case")]
186pub struct StackFrame {
187    pub raw_id: String,
188
189    pub mangled_name: String,
190
191    pub resolved_name: String,
192
193    pub lang: String,
194
195    pub resolved: bool,
196
197    pub platform: String,
198
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub filename: Option<String>,
201
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub function: Option<String>,
204
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub module: Option<String>,
207
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub lineno: Option<u64>,
210
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub colno: Option<u64>,
213
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub abs_path: Option<String>,
216
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub context_line: Option<String>,
219
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub pre_context: Vec<String>,
222
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub post_context: Vec<String>,
225
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub in_app: Option<bool>,
228
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub instruction_addr: Option<String>,
231
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub addr_mode: Option<String>,
234
235    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
236    pub vars: BTreeMap<String, serde_json::Value>,
237
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub chunk_id: Option<String>,
240}
241
242// If the CLI is compiled locally, it can contain backtraces which contain the home path with the username in it.
243pub fn strip_paths(string: &str) -> String {
244    // Strip the home path from any paths in the backtrace
245    let home_dir = dirs::home_dir().unwrap_or_default();
246
247    // Strip every path between the current path and the home directory
248    let mut cwd = std::env::current_dir().unwrap_or_default();
249    let mut string = string.to_string();
250    loop {
251        string = string.replace(&*cwd.to_string_lossy(), "<stripped>");
252        let Some(parent) = cwd.parent() else {
253            break;
254        };
255        cwd = parent.to_path_buf();
256        if cwd == home_dir {
257            break;
258        }
259    }
260
261    // Finally, strip the home directory itself (in case the cwd is outside the home directory)
262    string.replace(&*home_dir.to_string_lossy(), "~")
263}
264
265fn strip_paths_value(value: &mut serde_json::Value) {
266    match value {
267        serde_json::Value::String(s) => *s = strip_paths(s),
268        serde_json::Value::Object(map) => map.values_mut().for_each(strip_paths_value),
269        serde_json::Value::Array(arr) => arr.iter_mut().for_each(strip_paths_value),
270        _ => {}
271    }
272}