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