d3a161d14c
* feat: report diagnostic events for failed patch updates Download failures, inflate errors, hash mismatches, and install failures were previously invisible — no event was sent to the server when these occurred. Users who never received a patch were undetectable. Add a new `__patch_update_failure__` event type that is queued whenever the update pipeline fails after a patch is available. The message includes the truncated error string (max 256 chars) for diagnosing root causes like network errors, decompression failures, size mismatches, and hash validation failures. The event is queued (not sent inline) so it doesn't block the update flow, and will be delivered on the next successful update check. * test: add coverage for update failure events and message truncation - Verify PatchUpdateFailure event is queued on download size mismatch - Add tests for truncate_error_message with short and long errors * test: add event type serialization round-trip and unknown type tests * refactor: omit boot_started_at field when unknown Per review feedback, drop the "unknown" placeholder and omit the field entirely so consumers don't need to special-case a sentinel value.
139 lines
4.5 KiB
Rust
139 lines
4.5 KiB
Rust
// This file's job is to deal with the update_server and network side
|
|
// of the updater library.
|
|
|
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|
|
|
use crate::{
|
|
config::{current_arch, current_platform, UpdateConfig},
|
|
time,
|
|
};
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
#[allow(clippy::enum_variant_names)] // Prefix matches the domain concept, not a naming mistake.
|
|
pub enum EventType {
|
|
PatchInstallSuccess,
|
|
PatchInstallFailure,
|
|
PatchDownload,
|
|
PatchUpdateFailure,
|
|
}
|
|
|
|
impl Serialize for EventType {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: Serializer,
|
|
{
|
|
serializer.serialize_str(match self {
|
|
EventType::PatchInstallSuccess => "__patch_install__",
|
|
EventType::PatchInstallFailure => "__patch_install_failure__",
|
|
EventType::PatchDownload => "__patch_download__",
|
|
EventType::PatchUpdateFailure => "__patch_update_failure__",
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for EventType {
|
|
fn deserialize<D>(deserializer: D) -> Result<EventType, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let s = String::deserialize(deserializer)?;
|
|
match s.as_str() {
|
|
"__patch_install__" => Ok(EventType::PatchInstallSuccess),
|
|
"__patch_install_failure__" => Ok(EventType::PatchInstallFailure),
|
|
"__patch_download__" => Ok(EventType::PatchDownload),
|
|
"__patch_update_failure__" => Ok(EventType::PatchUpdateFailure),
|
|
_ => Err(serde::de::Error::custom(format!("Unknown event type: {s}"))),
|
|
}
|
|
}
|
|
}
|
|
/// Any edits to this struct should be made carefully and in accordance
|
|
/// with our privacy policy:
|
|
/// <https://docs.shorebird.dev/privacy>
|
|
/// An event that is sent to the server when a patch is successfully installed.
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct PatchEvent {
|
|
/// The Shorebird app_id built into the shorebird.yaml in the app.
|
|
pub app_id: String,
|
|
|
|
/// The architecture we're running (e.g. "aarch64", "x86", "x86_64").
|
|
pub arch: String,
|
|
|
|
/// The unique ID of this device.
|
|
pub client_id: String,
|
|
|
|
/// The identifier of this event.
|
|
#[serde(rename = "type")]
|
|
pub identifier: EventType,
|
|
|
|
/// The patch number that was installed.
|
|
pub patch_number: usize,
|
|
|
|
/// The platform we're running on (e.g. "android", "ios", "windows", "macos", "linux").
|
|
pub platform: String,
|
|
|
|
/// The release version from AndroidManifest.xml, Info.plist in the app.
|
|
pub release_version: String,
|
|
|
|
/// When this event occurred as a Unix epoch timestamp in seconds.
|
|
pub timestamp: u64,
|
|
|
|
/// An optional message to be sent with the event.
|
|
/// Care should be taken that this field *never* contain PII or sensitive information.
|
|
pub message: Option<String>,
|
|
}
|
|
|
|
impl PatchEvent {
|
|
/// Creates a `PatchEvent` for the given `EventType` and patch number for reporting to the server.
|
|
pub fn new(
|
|
config: &UpdateConfig,
|
|
event_type: EventType,
|
|
patch_number: usize,
|
|
client_id: String,
|
|
message: Option<&str>,
|
|
) -> PatchEvent {
|
|
PatchEvent {
|
|
app_id: config.app_id.clone(),
|
|
arch: current_arch().to_string(),
|
|
client_id,
|
|
identifier: event_type,
|
|
patch_number,
|
|
platform: current_platform().to_string(),
|
|
release_version: config.release_version.clone(),
|
|
timestamp: time::unix_timestamp(),
|
|
message: message.map(|s| s.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn event_type_round_trips() {
|
|
let types = [
|
|
(EventType::PatchInstallSuccess, "__patch_install__"),
|
|
(EventType::PatchInstallFailure, "__patch_install_failure__"),
|
|
(EventType::PatchDownload, "__patch_download__"),
|
|
(EventType::PatchUpdateFailure, "__patch_update_failure__"),
|
|
];
|
|
for (event_type, expected_str) in &types {
|
|
let json = serde_json::to_string(event_type).unwrap();
|
|
assert_eq!(json, format!("\"{expected_str}\""));
|
|
let deserialized: EventType = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(&deserialized, event_type);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_event_type_fails_deserialization() {
|
|
let result = serde_json::from_str::<EventType>("\"__bogus__\"");
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("Unknown event type"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
}
|