Skip to content

Commit

Permalink
feat(telemetry): implement opt-in crash and usage telemetry (#262)
Browse files Browse the repository at this point in the history
Fixes: #261
  • Loading branch information
zkat committed May 10, 2023
1 parent 594081f commit 8d5e1f5
Show file tree
Hide file tree
Showing 11 changed files with 650 additions and 23 deletions.
310 changes: 291 additions & 19 deletions Cargo.lock

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion Cargo.toml
Expand Up @@ -32,11 +32,16 @@ chrono = { workspace = true }
chrono-humanize = { workspace = true }
clap = { workspace = true, features = ["derive"] }
colored = { workspace = true }
humansize = { workspace = true }
dialoguer = { workspace = true, default_features = false }
directories = { workspace = true }
humansize = { workspace = true }
indicatif = { workspace = true }
is_ci = { workspace = true }
is-terminal = { workspace = true }
kdl = { workspace = true }
miette = { workspace = true, features = ["fancy"] }
rand = { workspace = true, default_features = false }
sentry = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
supports-unicode = { workspace = true }
Expand Down Expand Up @@ -80,13 +85,16 @@ console_error_panic_hook = "0.1.7"
darling = "0.10.2"
dashmap = "4.0.0-rc6"
derive_builder = "0.11.2"
dialoguer = "0.10.4"
directories = "4.0.1"
dunce = "1.0.3"
flate2 = "1.0.25"
futures = "0.3.26"
indexmap = "1.9.3"
indicatif = "0.17.3"
io_tee = "0.1.1"
is-terminal = "0.4.7"
is_ci = "1.1.1"
http-cache-reqwest = "0.6.0"
humansize = "1.1.0"
insta = "1.28.0"
Expand All @@ -112,6 +120,7 @@ reqwest = "0.11.14"
reqwest-middleware = "0.2.0"
resvg = "0.29.0"
rkyv = "0.7.41"
sentry = "0.31.0"
serde = "1.0.152"
serde_json = "1.0.93"
serde-wasm-bindgen = "0.4.5"
Expand Down
1 change: 1 addition & 0 deletions book/src/SUMMARY.md
Expand Up @@ -8,6 +8,7 @@

- [Configuration](./guide/configuration.md)
- [Managing `node_modules/`](./guide/node_modules.md)
- [Telemetry](./guide/telemetry.md)

---

Expand Down
43 changes: 43 additions & 0 deletions book/src/guide/telemetry.md
@@ -0,0 +1,43 @@
# Telemetry

Orogene supports fully opt-in, anonymous telemetry in order to improve the
project and find issues that would otherwise not get reported, or lack enough
information to take action on.

## Configuration

You'll be prompted on first orogene run if you would like to enable telemetry.
It will not be enabled unless you explicitly say yes to the prompt. The
configuration will then be saved to your [global `oro.kdl` config
file](./configuration.md#the-orokdl-config-file), under `options { telemetry
<value>; }`. You can change your decision at any time by changing this
setting.

Telemetry is currently processed using [Sentry.io](https://sentry.io). If
you'd like to send telemetry information to your own Sentry organization, you
can do so with the `--sentry-dsn` option (or `sentry-dsn` in your `oro.kdl`
files, either global or per-project, or `oro_sentry_dsn` environment
variable).

## Privacy & PII

Orogene uses as many settings as possible in Sentry to make sure all possible
PII is scrubbed from telemetry events. Additionally, data is only retained for
90 days, including error reports, at which point it's automatically scrubbed
by Sentry. Unfortunately, this is not configurable.

Additionally, when errors happen, the `oro-debug-*.log` file may be uploaded
as an attachment to the error report. This may contain paths related to your
project, which may include the username, and the names of registries and
packages you may be using. It is recommended that you not opt in to telemetry
if this is unacceptable.

## Public Dashboard

In the interest of sharing, transparency, and helping Orogene's users, a
number of anonymous statistics collected from telemetry are made available [on
a public Grafana
dashboard](https://orogene.grafana.net/public-dashboards/f75247ab87e14eac9e11ad2034ae3f66?orgId=1).

Please note that dashboard queries may change at any time, but the general
intentions behind privacy/PII concerns will be maintained.
224 changes: 221 additions & 3 deletions src/lib.rs
Expand Up @@ -79,14 +79,19 @@
//! [Apache 2.0 License]: https://github.com/orogene/orogene/blob/main/LICENSE

use std::{
borrow::Cow,
collections::VecDeque,
ffi::OsString,
path::{Path, PathBuf},
panic::PanicInfo,
path::{Path, PathBuf}, sync::Arc,
};

use async_trait::async_trait;
use clap::{Args, Command, CommandFactory, FromArgMatches as _, Parser, Subcommand};
use dialoguer::{theme::ColorfulTheme, Confirm};
use directories::ProjectDirs;
use is_terminal::IsTerminal;
use kdl::{KdlDocument, KdlNode, KdlValue};
use miette::{IntoDiagnostic, Result};
use oro_config::{OroConfig, OroConfigLayerExt, OroConfigOptions};
use tracing_appender::non_blocking::WorkerGuard;
Expand Down Expand Up @@ -221,6 +226,33 @@ pub struct Orogene {
)]
emoji: bool,

/// Skip first-time setup.
#[arg(
help_heading = "Global Options",
global = true,
long = "no-first-time",
action = clap::ArgAction::SetFalse,
)]
first_time: bool,

/// Disable telemetry.
///
/// Telemetry for Orogene is opt-in, anonymous, and is used to help the
/// team improve the product. It is usually configured on first run, but
/// you can use this flag to force-disable it either in an individual CLI
/// call, or in a project-local oro.kdl.
#[arg(
help_heading = "Global Options",
global = true,
long = "no-telemetry",
action = clap::ArgAction::SetFalse,
)]
telemetry: bool,

/// Sentry DSN (access token) where telemetry will be sent (if enabled).
#[arg(help_heading = "Global Options", global = true, long)]
sentry_dsn: Option<String>,

#[command(subcommand)]
subcommand: OroCmd,
}
Expand Down Expand Up @@ -424,6 +456,176 @@ impl Orogene {
Ok(())
}

fn first_time_setup(&mut self) -> Result<()> {
// We skip first-time-setup operations in CI entirely.
if self.first_time && !is_ci::cached() {
tracing::info!("Performing first-time setup...");
if let Some(dirs) = ProjectDirs::from("", "", "orogene") {
let config_dir = dirs.config_dir();
if !config_dir.exists() {
std::fs::create_dir_all(config_dir).unwrap();
}
let config_path = config_dir.join("oro.kdl");
let mut config: KdlDocument = std::fs::read_to_string(&config_path)
.unwrap_or_default()
.parse()?;
let telemetry_exists = config.query("options > telemetry")?.is_some();
if config.get("options").is_none() {
config.nodes_mut().push(KdlNode::new("options"));
}
if std::io::stdout().is_terminal() {
if let Some(opts) = config.get_mut("options") {
self.telemetry = self.prompt_telemetry_opt_in()?;
if !telemetry_exists {
let mut node = KdlNode::new("telemetry");
node.push(KdlValue::Bool(self.telemetry));
opts.ensure_children();
if let Some(doc) = opts.children_mut().as_mut() {
doc.nodes_mut().push(node)
}
}
if let Some(opt) = config
.get_mut("options")
.unwrap()
.children_mut()
.as_mut()
.unwrap()
.get_mut("telemetry")
{
if let Some(val) = opt.get_mut(0) {
*val = self.telemetry.into();
} else {
opt.push(KdlValue::Bool(self.telemetry));
}
}
}
}
if config.query("options > first-time")?.is_none() {
let mut node = KdlNode::new("first-time");
node.push(KdlValue::Bool(false));
let opts = config.get_mut("options").unwrap();
opts.ensure_children();
if let Some(doc) = opts.children_mut().as_mut() {
doc.nodes_mut().push(node)
}
}
if let Some(opt) = config
.get_mut("options")
.unwrap()
.children_mut()
.as_mut()
.unwrap()
.get_mut("first-time")
{
if let Some(val) = opt.get_mut(0) {
*val = false.into();
} else {
opt.push(KdlValue::Bool(false));
}
}
std::fs::write(config_path, config.to_string()).into_diagnostic()?;
}
}
Ok(())
}

fn prompt_telemetry_opt_in(&self) -> Result<bool> {
tracing::info!("Orogene is able to collect anonymous usage statistics and");
tracing::info!("crash reports to help the team improve the tool.");
tracing::info!(
"Anonymous, aggregate metrics are publicly available (see `oro telemetry`),"
);
tracing::info!("and no personally identifiable information is collected.");
tracing::info!("This is entirely opt-in, but we would appreciate it if you considered it!");
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Do you wish to enable anonymous telemetry?")
.interact()
.into_diagnostic()
}

fn setup_telemetry(
&self,
log_file: Option<PathBuf>,
) -> Result<Option<sentry::ClientInitGuard>> {
if !self.telemetry {
return Ok(None);
}

if let Some(dsn) = self
.sentry_dsn
.as_deref()
.or_else(|| option_env!("OROGENE_SENTRY_DSN"))
{
let ret = sentry::init(
sentry::ClientOptions {
dsn: Some(dsn.parse().into_diagnostic()?),
release: sentry::release_name!(),
server_name: None,
sample_rate: 1.0,
user_agent: Cow::from(format!(
"orogene@{} ({}/{})",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
)),
default_integrations: false,
before_send: Some(Arc::new(|mut event| {
event.server_name = None; // Don't send server name
Some(event)
})),
..Default::default()
}
.add_integration(
sentry::integrations::backtrace::AttachStacktraceIntegration::default(),
)
.add_integration(
sentry::integrations::panic::PanicIntegration::default().add_extractor(
move |info: &PanicInfo| {
if let Some(log_file) = log_file.as_deref() {
sentry::configure_scope(|s| {
s.add_attachment(sentry::protocol::Attachment {
filename: log_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| "oro-debug.log".into()),
content_type: Some("text/plain".into()),
buffer: std::fs::read(log_file).unwrap_or_default(),
ty: None,
});
});
}
let msg = sentry::integrations::panic::message_from_panic_info(info);
Some(sentry::protocol::Event {
exception: vec![sentry::protocol::Exception {
ty: "panic".into(),
mechanism: Some(sentry::protocol::Mechanism {
ty: "panic".into(),
handled: Some(false),
..Default::default()
}),
value: Some(msg.to_string()),
stacktrace: sentry::integrations::backtrace::current_stacktrace(
),
..Default::default()
}]
.into(),
level: sentry::Level::Fatal,
..Default::default()
})
},
),
)
.add_integration(sentry::integrations::contexts::ContextIntegration::new())
.add_integration(
sentry::integrations::backtrace::ProcessStacktraceIntegration::default(),
),
);
Ok(Some(ret))
} else {
Ok(None)
}
}

pub async fn load() -> Result<()> {
let start = std::time::Instant::now();
// We have to instantiate Orogene twice: once to pick up "base" config
Expand All @@ -438,13 +640,16 @@ impl Orogene {
let config = oro.build_config()?;
let mut args = std::env::args_os().collect::<Vec<_>>();
Self::layer_command_args(&command, &mut args, &config)?;
let oro = Orogene::from_arg_matches(&command.get_matches_from(&args)).into_diagnostic()?;
let mut oro =
Orogene::from_arg_matches(&command.get_matches_from(&args)).into_diagnostic()?;
let log_file = oro
.cache
.clone()
.or_else(|| config.get::<String>("cache").ok().map(PathBuf::from))
.map(|c| c.join("_logs").join(log_file_name()));
let _guard = oro.setup_logging(log_file.as_deref())?;
let _logging_guard = oro.setup_logging(log_file.as_deref())?;
oro.first_time_setup()?;
let _telemetry_guard = oro.setup_telemetry(log_file.clone())?;
oro.execute().await.map_err(|e| {
// We toss this in a debug so execution errors show up in our
// debug logs. Unfortunately, we can't do the same for other
Expand All @@ -453,7 +658,20 @@ impl Orogene {
tracing::debug!("{e:?}");
if let Some(log_file) = log_file.as_deref() {
tracing::warn!("A debug log was written to {}", log_file.display());
sentry::configure_scope(|s| {
s.add_attachment(sentry::protocol::Attachment {
filename: log_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| "oro-debug.log".into()),
content_type: Some("text/plain".into()),
buffer: std::fs::read(log_file).unwrap_or_default(),
ty: None,
});
});
}
let dyn_err: &dyn std::error::Error = e.as_ref();
sentry::capture_error(dyn_err);
e
})?;
tracing::debug!("Ran in {}s", start.elapsed().as_millis() as f32 / 1000.0);
Expand Down
14 changes: 14 additions & 0 deletions tests/snapshots/help__add.snap
Expand Up @@ -183,4 +183,18 @@ Disable printing emoji.
By default, this will show emoji when outputting to a TTY that supports unicode.
#### `--no-first-time`
Skip first-time setup
#### `--no-telemetry`
Disable telemetry.
Telemetry for Orogene is opt-in, anonymous, and is used to help the team improve the product. It is usually configured on first run, but you can use this flag to force-disable it either in an individual CLI call, or in a project-local oro.kdl.
#### `--sentry-dsn <SENTRY_DSN>`
Sentry DSN (access token) where telemetry will be sent (if enabled)

0 comments on commit 8d5e1f5

Please sign in to comment.