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}