use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct Exception {
#[serde(rename = "type")]
pub exception_type: String,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mechanism: Option<Mechanism>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<i32>,
pub stacktrace: Stacktrace,
}
#[derive(Debug, Clone, Serialize)]
pub struct Mechanism {
#[serde(rename = "type")]
pub mechanism_type: String,
pub handled: bool,
pub synthetic: bool,
}
impl Mechanism {
pub fn panic() -> Self {
Self {
mechanism_type: "panic".to_string(),
handled: false,
synthetic: false,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Stacktrace {
Raw { frames: Vec<Frame> },
}
impl Stacktrace {
pub fn frames(&self) -> &[Frame] {
match self {
Stacktrace::Raw { frames } => frames,
}
}
pub fn frames_mut(&mut self) -> &mut [Frame] {
match self {
Stacktrace::Raw { frames } => frames,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Frame {
pub platform: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub instruction_addr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_addr: Option<String>,
pub lang: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub module: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lineno: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub colno: Option<u32>,
pub client_resolved: bool,
pub in_app: bool,
pub synthetic: bool,
}
impl Frame {
pub fn address_only(instruction_addr: Option<String>, image_addr: Option<String>) -> Self {
Self {
platform: "native",
instruction_addr,
image_addr,
lang: "rust",
module: None,
function: None,
filename: None,
lineno: None,
colno: None,
client_resolved: false,
in_app: false,
synthetic: false,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct DebugImage {
pub debug_id: String,
pub image_addr: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_vmaddr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_file: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub image_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arch: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn resolved_frame() -> Frame {
Frame {
platform: "native",
instruction_addr: Some("0x7f3a9c041b2d".into()),
image_addr: Some("0x7f3a9c000000".into()),
lang: "rust",
module: Some("openlatch_client".into()),
function: Some("openlatch_client::daemon::run".into()),
filename: Some("src/daemon/mod.rs".into()),
lineno: Some(42),
colno: Some(9),
client_resolved: true,
in_app: true,
synthetic: false,
}
}
#[test]
fn exception_serializes_to_the_cymbal_wire_shape() {
let exc = Exception {
exception_type: "panic".into(),
value: "index out of bounds".into(),
mechanism: Some(Mechanism::panic()),
thread_id: None,
stacktrace: Stacktrace::Raw {
frames: vec![
resolved_frame(),
Frame::address_only(
Some("0x7f3a9c0410aa".into()),
Some("0x7f3a9c000000".into()),
),
],
},
};
let got = serde_json::to_value(&exc).expect("exception serializes");
let want = json!({
"type": "panic",
"value": "index out of bounds",
"mechanism": { "type": "panic", "handled": false, "synthetic": false },
"stacktrace": {
"type": "raw",
"frames": [
{
"platform": "native",
"instruction_addr": "0x7f3a9c041b2d",
"image_addr": "0x7f3a9c000000",
"lang": "rust",
"module": "openlatch_client",
"function": "openlatch_client::daemon::run",
"filename": "src/daemon/mod.rs",
"lineno": 42,
"colno": 9,
"client_resolved": true,
"in_app": true,
"synthetic": false
},
{
"platform": "native",
"instruction_addr": "0x7f3a9c0410aa",
"image_addr": "0x7f3a9c000000",
"lang": "rust",
"client_resolved": false,
"in_app": false,
"synthetic": false
}
]
}
});
assert_eq!(got, want);
}
#[test]
fn absent_optionals_are_omitted_not_nulled() {
let exc = Exception {
exception_type: "panic".into(),
value: String::new(),
mechanism: None,
thread_id: None,
stacktrace: Stacktrace::Raw { frames: vec![] },
};
let got = serde_json::to_value(&exc).expect("serializes");
let obj = got.as_object().expect("object");
assert!(!obj.contains_key("thread_id"));
assert!(!obj.contains_key("mechanism"));
assert!(
obj.contains_key("value"),
"value is required, never skipped"
);
}
#[test]
fn debug_image_serializes_with_the_snapshot_field_names() {
let img = DebugImage {
debug_id: "67E9247C-814E-392B-A027-DBDE6748FCBF".into(),
image_addr: "0x100000000".into(),
image_size: Some(4096),
image_vmaddr: Some("0x0".into()),
code_file: Some("openlatch".into()),
image_type: Some("macho".into()),
arch: Some("arm64".into()),
};
let got = serde_json::to_value(&img).expect("image serializes");
assert_eq!(
got,
json!({
"debug_id": "67E9247C-814E-392B-A027-DBDE6748FCBF",
"image_addr": "0x100000000",
"image_size": 4096,
"image_vmaddr": "0x0",
"code_file": "openlatch",
"type": "macho",
"arch": "arm64"
})
);
assert!(
got["image_size"].is_number(),
"image_size is a decimal integer, never a hex string"
);
}
#[test]
fn every_frame_serializes_in_app_explicitly() {
for frame in [resolved_frame(), Frame::address_only(None, None)] {
let v = serde_json::to_value(&frame).expect("frame serializes");
assert!(
v.as_object().is_some_and(|o| o.contains_key("in_app")),
"in_app must never be skipped: {v}"
);
}
}
}