Files
shorebird-updater/library/src/events.rs
T
Brandon DeRosier d3a161d14c feat: report diagnostic events for failed patch updates (#347)
* 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.
2026-05-11 18:37:25 -07:00

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}"
);
}
}