Add diagnostics reporting (#264)

* Add diagnostics reporting

* Some tidying

* Remove injected failure

* Update URL

* Fixups

* Fix tests

* Use triples instead of architecture
This commit is contained in:
Ana Hobden 2023-02-24 10:11:12 -08:00 committed by GitHub
parent 8de35b2477
commit 19dd7a13d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 460 additions and 21 deletions

33
Cargo.lock generated
View file

@ -927,6 +927,7 @@ dependencies = [
"eyre",
"glob",
"nix",
"os-release",
"owo-colors",
"plist",
"rand 0.8.5",
@ -935,6 +936,7 @@ dependencies = [
"serde",
"serde_json",
"serde_with",
"strum",
"tar",
"target-lexicon",
"tempdir",
@ -1004,6 +1006,15 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66"
[[package]]
name = "os-release"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f29ae2f71b53ec19cc23385f8e4f3d90975195aa3d09171ba3bef7159bec27"
dependencies = [
"lazy_static",
]
[[package]]
name = "os_str_bytes"
version = "6.4.1"
@ -1541,6 +1552,28 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strum"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "supports-color"
version = "1.3.1"

View file

@ -15,8 +15,9 @@ build-inputs = ["darwin.apple_sdk.frameworks.Security"]
build-inputs = ["darwin.apple_sdk.frameworks.Security"]
[features]
default = ["cli"]
default = ["cli", "diagnostics"]
cli = ["eyre", "color-eyre", "clap", "tracing-subscriber", "tracing-error", "atty"]
diagnostics = ["os-release"]
[[bin]]
name = "nix-installer"
@ -53,6 +54,8 @@ rand = { version = "0.8.5", default-features = false, features = [ "std", "std_r
semver = { version = "1.0.14", default-features = false, features = ["serde", "std"] }
term = { version = "0.7.0", default-features = false }
uuid = { version = "1.2.2", features = ["serde"] }
os-release = { version = "0.1.0", default-features = false, optional = true }
strum = { version = "0.24.1", features = ["derive"] }
[dev-dependencies]
eyre = { version = "0.6.8", default-features = false, features = [ "track-caller" ] }

View file

@ -133,6 +133,15 @@ impl Planner for MyPlanner {
Ok(map)
}
#[cfg(feature = "diagnostics")]
async fn diagnostic_data(&self) -> Result<nix_installer::diagnostics::DiagnosticData, PlannerError> {
Ok(nix_installer::diagnostics::DiagnosticData::new(
self.common.diagnostic_endpoint.clone(),
self.typetag_name().into(),
self.configured_settings().await?,
))
}
}
# async fn custom_planner_install() -> color_eyre::Result<()> {
@ -244,7 +253,7 @@ impl ActionDescription {
}
/// An error occurring during an action
#[derive(thiserror::Error, Debug)]
#[derive(thiserror::Error, Debug, strum::IntoStaticStr)]
pub enum ActionError {
/// A custom error
#[error(transparent)]

View file

@ -47,6 +47,7 @@ pub struct Install {
global = true
)]
pub explain: bool,
#[clap(env = "NIX_INSTALLER_PLAN")]
pub plan: Option<PathBuf>,
@ -133,12 +134,12 @@ impl CommandExecute for Install {
let res = builtin_planner.plan().await;
match res {
Ok(plan) => plan,
Err(e) => {
if let Some(expected) = e.expected() {
Err(err) => {
if let Some(expected) = err.expected() {
eprintln!("{}", expected.red());
return Ok(ExitCode::FAILURE);
}
return Err(e.into())
return Err(err.into())
}
}
},

View file

@ -28,6 +28,7 @@ pub struct Uninstall {
global = true
)]
pub no_confirm: bool,
#[clap(
long,
env = "NIX_INSTALLER_EXPLAIN",
@ -36,6 +37,7 @@ pub struct Uninstall {
global = true
)]
pub explain: bool,
#[clap(default_value = RECEIPT_LOCATION)]
pub receipt: PathBuf,
}

156
src/diagnostics.rs Normal file
View file

@ -0,0 +1,156 @@
/*! Diagnostic reporting functionality
When enabled with the `diagnostics` feature (default) this module provides automated install success/failure reporting to an endpoint.
That endpoint can be a URL such as `https://our.project.org/nix-installer/diagnostics` or `file:///home/$USER/diagnostic.json` which receives a [`DiagnosticReport`] in JSON format.
*/
use std::time::Duration;
use os_release::OsRelease;
use reqwest::Url;
/// The static of an action attempt
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
pub enum DiagnosticStatus {
Cancelled,
Success,
/// This includes the [`strum::IntoStaticStr`] representation of the error, we take special care not to include parameters of the error (which may include secrets)
Failure(String),
Pending,
}
/// The action attempted
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, Copy)]
pub enum DiagnosticAction {
Install,
Uninstall,
}
/// A report sent to an endpoint
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
pub struct DiagnosticReport {
pub version: String,
pub planner: String,
pub configured_settings: Vec<String>,
pub os_name: String,
pub os_version: String,
pub triple: String,
pub action: DiagnosticAction,
pub status: DiagnosticStatus,
}
/// A preparation of data to be sent to the `endpoint`.
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, Default)]
pub struct DiagnosticData {
version: String,
planner: String,
configured_settings: Vec<String>,
os_name: String,
os_version: String,
triple: String,
endpoint: Option<Url>,
}
impl DiagnosticData {
pub fn new(endpoint: Option<Url>, planner: String, configured_settings: Vec<String>) -> Self {
let (os_name, os_version) = match OsRelease::new() {
Ok(os_release) => (os_release.name, os_release.version),
Err(_) => ("unknown".into(), "unknown".into()),
};
Self {
endpoint,
version: env!("CARGO_PKG_VERSION").into(),
planner,
configured_settings,
os_name,
os_version,
triple: target_lexicon::HOST.to_string(),
}
}
pub fn report(&self, action: DiagnosticAction, status: DiagnosticStatus) -> DiagnosticReport {
let Self {
version,
planner,
configured_settings,
os_name,
os_version,
triple,
endpoint: _,
} = self;
DiagnosticReport {
version: version.clone(),
planner: planner.clone(),
configured_settings: configured_settings.clone(),
os_name: os_name.clone(),
os_version: os_version.clone(),
triple: triple.clone(),
action,
status,
}
}
#[tracing::instrument(level = "debug", skip_all)]
pub async fn send(
self,
action: DiagnosticAction,
status: DiagnosticStatus,
) -> Result<(), DiagnosticError> {
let serialized = serde_json::to_string_pretty(&self.report(action, status))?;
let endpoint = match self.endpoint {
Some(endpoint) => endpoint,
None => return Ok(()),
};
match endpoint.scheme() {
"https" | "http" => {
tracing::debug!("Sending diagnostic to `{endpoint}`");
let client = reqwest::Client::new();
let res = client
.post(endpoint.clone())
.body(serialized)
.header("Content-Type", "application/json")
.timeout(Duration::from_millis(3000))
.send()
.await;
if let Err(_err) = res {
tracing::info!("Failed to send diagnostic to `{endpoint}`, continuing")
}
},
"file" => {
let path = endpoint.path();
tracing::debug!("Writing diagnostic to `{path}`");
let res = tokio::fs::write(path, serialized).await;
if let Err(_err) = res {
tracing::info!("Failed to send diagnostic to `{path}`, continuing")
}
},
_ => return Err(DiagnosticError::UnknownUrlScheme),
};
Ok(())
}
}
#[derive(thiserror::Error, Debug)]
pub enum DiagnosticError {
#[error("Unknown url scheme")]
UnknownUrlScheme,
#[error("Request error")]
Reqwest(
#[from]
#[source]
reqwest::Error,
),
#[error("Write path `{0}`")]
Write(std::path::PathBuf, #[source] std::io::Error),
#[error("Serializing receipt")]
Serializing(
#[from]
#[source]
serde_json::Error,
),
}

View file

@ -3,7 +3,7 @@ use std::path::PathBuf;
use crate::{action::ActionError, planner::PlannerError, settings::InstallSettingsError};
/// An error occurring during a call defined in this crate
#[derive(thiserror::Error, Debug)]
#[derive(thiserror::Error, Debug, strum::IntoStaticStr)]
pub enum NixInstallerError {
/// An error originating from an [`Action`](crate::action::Action)
#[error("Error executing action")]
@ -53,6 +53,15 @@ pub enum NixInstallerError {
#[source]
InstallSettingsError,
),
#[cfg(feature = "diagnostics")]
/// Diagnostic error
#[error("Diagnostic error")]
Diagnostic(
#[from]
#[source]
crate::diagnostics::DiagnosticError,
),
}
pub(crate) trait HasExpectedErrors: std::error::Error + Sized + Send + Sync {
@ -70,6 +79,25 @@ impl HasExpectedErrors for NixInstallerError {
NixInstallerError::SemVer(_) => None,
NixInstallerError::Planner(planner_error) => planner_error.expected(),
NixInstallerError::InstallSettings(_) => None,
#[cfg(feature = "diagnostics")]
NixInstallerError::Diagnostic(_) => None,
}
}
}
// #[cfg(feature = "diagnostics")]
// impl NixInstallerError {
// pub fn diagnostic_synopsis(&self) -> &'static str {
// match self {
// NixInstallerError::Action(inner) => inner.into(),
// NixInstallerError::Planner(inner) => inner.into(),
// NixInstallerError::RecordingReceipt(_, _)
// | NixInstallerError::CopyingSelf(_)
// | NixInstallerError::SerializingReceipt(_)
// | NixInstallerError::Cancelled
// | NixInstallerError::SemVer(_)
// | NixInstallerError::Diagnostic(_)
// | NixInstallerError::InstallSettings(_) => self.into(),
// }
// }
// }

View file

@ -73,6 +73,8 @@ pub mod action;
mod channel_value;
#[cfg(feature = "cli")]
pub mod cli;
#[cfg(feature = "diagnostics")]
pub mod diagnostics;
mod error;
mod os;
mod plan;

View file

@ -24,17 +24,27 @@ pub struct InstallPlan {
pub(crate) actions: Vec<StatefulAction<Box<dyn Action>>>,
pub(crate) planner: Box<dyn Planner>,
#[cfg(feature = "diagnostics")]
pub(crate) diagnostic_data: Option<crate::diagnostics::DiagnosticData>,
}
impl InstallPlan {
pub async fn default() -> Result<Self, NixInstallerError> {
let planner = BuiltinPlanner::default().await?.boxed();
let planner = BuiltinPlanner::default().await?;
#[cfg(feature = "diagnostics")]
let diagnostic_data = Some(planner.diagnostic_data().await?);
let planner = planner.boxed();
let actions = planner.plan().await?;
Ok(Self {
planner,
actions,
version: current_version()?,
#[cfg(feature = "diagnostics")]
diagnostic_data,
})
}
@ -42,11 +52,16 @@ impl InstallPlan {
where
P: Planner + 'static,
{
#[cfg(feature = "diagnostics")]
let diagnostic_data = Some(planner.diagnostic_data().await?);
let actions = planner.plan().await?;
Ok(Self {
planner: planner.boxed(),
actions,
version: current_version()?,
#[cfg(feature = "diagnostics")]
diagnostic_data,
})
}
#[tracing::instrument(level = "debug", skip_all)]
@ -55,6 +70,7 @@ impl InstallPlan {
planner,
actions,
version,
..
} = self;
let buf = format!(
"\
@ -107,11 +123,7 @@ impl InstallPlan {
&mut self,
cancel_channel: impl Into<Option<Receiver<()>>>,
) -> Result<(), NixInstallerError> {
let Self {
version: _,
actions,
planner: _,
} = self;
let Self { actions, .. } = self;
let mut cancel_channel = cancel_channel.into();
// This is **deliberately sequential**.
@ -125,6 +137,18 @@ impl InstallPlan {
if let Err(err) = write_receipt(self.clone()).await {
tracing::error!("Error saving receipt: {:?}", err);
}
#[cfg(feature = "diagnostics")]
if let Some(diagnostic_data) = &self.diagnostic_data {
diagnostic_data
.clone()
.send(
crate::diagnostics::DiagnosticAction::Install,
crate::diagnostics::DiagnosticStatus::Cancelled,
)
.await?;
}
return Err(NixInstallerError::Cancelled);
}
}
@ -134,11 +158,35 @@ impl InstallPlan {
if let Err(err) = write_receipt(self.clone()).await {
tracing::error!("Error saving receipt: {:?}", err);
}
#[cfg(feature = "diagnostics")]
if let Some(diagnostic_data) = &self.diagnostic_data {
diagnostic_data
.clone()
.send(
crate::diagnostics::DiagnosticAction::Install,
crate::diagnostics::DiagnosticStatus::Failure({
let x: &'static str = (&err).into();
x.to_string()
}),
)
.await?;
}
return Err(NixInstallerError::Action(err));
}
}
write_receipt(self.clone()).await?;
#[cfg(feature = "diagnostics")]
if let Some(diagnostic_data) = &self.diagnostic_data {
diagnostic_data
.clone()
.send(
crate::diagnostics::DiagnosticAction::Install,
crate::diagnostics::DiagnosticStatus::Success,
)
.await?;
}
Ok(())
}
@ -149,6 +197,7 @@ impl InstallPlan {
version: _,
planner,
actions,
..
} = self;
let buf = format!(
"\
@ -207,11 +256,7 @@ impl InstallPlan {
&mut self,
cancel_channel: impl Into<Option<Receiver<()>>>,
) -> Result<(), NixInstallerError> {
let Self {
version: _,
actions,
planner: _,
} = self;
let Self { actions, .. } = self;
let mut cancel_channel = cancel_channel.into();
// This is **deliberately sequential**.
@ -225,6 +270,17 @@ impl InstallPlan {
if let Err(err) = write_receipt(self.clone()).await {
tracing::error!("Error saving receipt: {:?}", err);
}
#[cfg(feature = "diagnostics")]
if let Some(diagnostic_data) = &self.diagnostic_data {
diagnostic_data
.clone()
.send(
crate::diagnostics::DiagnosticAction::Uninstall,
crate::diagnostics::DiagnosticStatus::Cancelled,
)
.await?;
}
return Err(NixInstallerError::Cancelled);
}
}
@ -234,10 +290,34 @@ impl InstallPlan {
if let Err(err) = write_receipt(self.clone()).await {
tracing::error!("Error saving receipt: {:?}", err);
}
#[cfg(feature = "diagnostics")]
if let Some(diagnostic_data) = &self.diagnostic_data {
diagnostic_data
.clone()
.send(
crate::diagnostics::DiagnosticAction::Uninstall,
crate::diagnostics::DiagnosticStatus::Failure({
let x: &'static str = (&err).into();
x.to_string()
}),
)
.await?;
}
return Err(NixInstallerError::Action(err));
}
}
#[cfg(feature = "diagnostics")]
if let Some(diagnostic_data) = &self.diagnostic_data {
diagnostic_data
.clone()
.send(
crate::diagnostics::DiagnosticAction::Uninstall,
crate::diagnostics::DiagnosticStatus::Success,
)
.await?;
}
Ok(())
}
}

View file

@ -96,6 +96,15 @@ impl Planner for Linux {
Ok(map)
}
#[cfg(feature = "diagnostics")]
async fn diagnostic_data(&self) -> Result<crate::diagnostics::DiagnosticData, PlannerError> {
Ok(crate::diagnostics::DiagnosticData::new(
self.settings.diagnostic_endpoint.clone(),
self.typetag_name().into(),
self.configured_settings().await?,
))
}
}
impl Into<BuiltinPlanner> for Linux {

View file

@ -169,6 +169,15 @@ impl Planner for Macos {
Ok(map)
}
#[cfg(feature = "diagnostics")]
async fn diagnostic_data(&self) -> Result<crate::diagnostics::DiagnosticData, PlannerError> {
Ok(crate::diagnostics::DiagnosticData::new(
self.settings.diagnostic_endpoint.clone(),
self.typetag_name().into(),
self.configured_settings().await?,
))
}
}
impl Into<BuiltinPlanner> for Macos {

View file

@ -52,6 +52,15 @@ impl Planner for MyPlanner {
Ok(map)
}
#[cfg(feature = "diagnostics")]
async fn diagnostic_data(&self) -> Result<nix_installer::diagnostics::DiagnosticData, PlannerError> {
Ok(nix_installer::diagnostics::DiagnosticData::new(
self.common.diagnostic_endpoint.clone(),
self.typetag_name().into(),
self.configured_settings().await?,
))
}
}
# async fn custom_planner_install() -> color_eyre::Result<()> {
@ -101,6 +110,23 @@ pub trait Planner: std::fmt::Debug + Send + Sync + dyn_clone::DynClone {
async fn plan(&self) -> Result<Vec<StatefulAction<Box<dyn Action>>>, PlannerError>;
/// The settings being used by the planner
fn settings(&self) -> Result<HashMap<String, serde_json::Value>, InstallSettingsError>;
async fn configured_settings(&self) -> Result<Vec<String>, PlannerError>
where
Self: Sized,
{
let default = Self::default().await?.settings()?;
let configured = self.settings()?;
let mut keys: Vec<String> = Vec::new();
for (key, value) in configured.iter() {
if default.get(key) != Some(value) {
keys.push(key.clone())
}
}
Ok(keys)
}
/// A boxed, type erased planner
fn boxed(self) -> Box<dyn Planner>
where
@ -108,6 +134,9 @@ pub trait Planner: std::fmt::Debug + Send + Sync + dyn_clone::DynClone {
{
Box::new(self)
}
#[cfg(feature = "diagnostics")]
async fn diagnostic_data(&self) -> Result<crate::diagnostics::DiagnosticData, PlannerError>;
}
dyn_clone::clone_trait_object!(Planner);
@ -171,6 +200,17 @@ impl BuiltinPlanner {
Ok(built)
}
pub async fn configured_settings(&self) -> Result<Vec<String>, PlannerError> {
match self {
#[cfg(target_os = "linux")]
BuiltinPlanner::Linux(inner) => inner.configured_settings().await,
#[cfg(target_os = "linux")]
BuiltinPlanner::SteamDeck(inner) => inner.configured_settings().await,
#[cfg(target_os = "macos")]
BuiltinPlanner::Macos(inner) => inner.configured_settings().await,
}
}
pub async fn plan(self) -> Result<InstallPlan, NixInstallerError> {
match self {
#[cfg(target_os = "linux")]
@ -213,10 +253,24 @@ impl BuiltinPlanner {
BuiltinPlanner::Macos(i) => i.settings(),
}
}
#[cfg(feature = "diagnostics")]
pub async fn diagnostic_data(
&self,
) -> Result<crate::diagnostics::DiagnosticData, PlannerError> {
match self {
#[cfg(target_os = "linux")]
BuiltinPlanner::Linux(i) => i.diagnostic_data().await,
#[cfg(target_os = "linux")]
BuiltinPlanner::SteamDeck(i) => i.diagnostic_data().await,
#[cfg(target_os = "macos")]
BuiltinPlanner::Macos(i) => i.diagnostic_data().await,
}
}
}
/// An error originating from a [`Planner`]
#[derive(thiserror::Error, Debug)]
#[derive(thiserror::Error, Debug, strum::IntoStaticStr)]
pub enum PlannerError {
/// `nix-installer` does not have a default planner for the target architecture right now
#[error("`nix-installer` does not have a default planner for the `{0}` architecture right now, pass a specific archetype")]

View file

@ -252,6 +252,15 @@ impl Planner for SteamDeck {
Ok(map)
}
#[cfg(feature = "diagnostics")]
async fn diagnostic_data(&self) -> Result<crate::diagnostics::DiagnosticData, PlannerError> {
Ok(crate::diagnostics::DiagnosticData::new(
self.settings.diagnostic_endpoint.clone(),
self.typetag_name().into(),
self.configured_settings().await?,
))
}
}
impl Into<BuiltinPlanner> for SteamDeck {

View file

@ -202,6 +202,31 @@ pub struct CommonSettings {
)
)]
pub(crate) force: bool,
#[cfg(feature = "diagnostics")]
/// The URL or file path for an installation diagnostic to be sent
///
/// Sample of the data sent:
///
/// {
/// "version": "0.3.0",
/// "planner": "linux",
/// "configured-settings": [ "modify_profile" ],
/// "os-name": "Ubuntu",
/// "os-version": "22.04.1 LTS (Jammy Jellyfish)",
/// "triple": "x86_64-unknown-linux-gnu",
/// "action": "Install",
/// "status": "Success"
/// }
///
/// To disable diagnostic reporting, unset the default with `--diagnostic-endpoint=`
#[clap(
long,
env = "NIX_INSTALLER_DIAGNOSTIC_ENDPOINT",
global = true,
default_value = "https://install.determinate.systems/nix/diagnostic"
)]
pub diagnostic_endpoint: Option<Url>,
}
impl CommonSettings {
@ -217,19 +242,19 @@ impl CommonSettings {
(Architecture::X86_64, OperatingSystem::Linux) => {
url = NIX_X64_64_LINUX_URL;
nix_build_user_prefix = "nixbld";
nix_build_user_id_base = 3000;
nix_build_user_id_base = 30000;
},
#[cfg(target_os = "linux")]
(Architecture::X86_32(_), OperatingSystem::Linux) => {
url = NIX_I686_LINUX_URL;
nix_build_user_prefix = "nixbld";
nix_build_user_id_base = 3000;
nix_build_user_id_base = 30000;
},
#[cfg(target_os = "linux")]
(Architecture::Aarch64(_), OperatingSystem::Linux) => {
url = NIX_AARCH64_LINUX_URL;
nix_build_user_prefix = "nixbld";
nix_build_user_id_base = 3000;
nix_build_user_id_base = 30000;
},
#[cfg(target_os = "macos")]
(Architecture::X86_64, OperatingSystem::MacOSX { .. })
@ -267,6 +292,10 @@ impl CommonSettings {
nix_package_url: url.parse()?,
extra_conf: Default::default(),
force: false,
#[cfg(feature = "diagnostics")]
diagnostic_endpoint: Some(
"https://install.determinate.systems/diagnostics".try_into()?,
),
})
}
@ -283,6 +312,8 @@ impl CommonSettings {
nix_package_url,
extra_conf,
force,
#[cfg(feature = "diagnostics")]
diagnostic_endpoint,
} = self;
let mut map = HashMap::default();
@ -326,6 +357,12 @@ impl CommonSettings {
map.insert("extra_conf".into(), serde_json::to_value(extra_conf)?);
map.insert("force".into(), serde_json::to_value(force)?);
#[cfg(feature = "diagnostics")]
map.insert(
"diagnostic_endpoint".into(),
serde_json::to_value(diagnostic_endpoint)?,
);
Ok(map)
}
}
@ -418,6 +455,13 @@ impl CommonSettings {
self.force = force;
self
}
#[cfg(feature = "diagnostics")]
/// The URL or file path for an [`DiagnosticReport`][crate::diagnostics::DiagnosticReport] to be sent
pub fn diagnostic_endpoint(&mut self, diagnostic_endpoint: Option<Url>) -> &mut Self {
self.diagnostic_endpoint = diagnostic_endpoint;
self
}
}
#[serde_with::serde_as]