diff --git a/CHANGELOG.md b/CHANGELOG.md index 030b4aeb..76098dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ - Removed the public `ClientOptions::sample_rate` field. Use `ClientOptions::event_sampling_strategy` to inspect the configured event sampling strategy, and use the existing `ClientOptions::sample_rate(...)` builder setter to configure fixed-rate sampling ([#1228](https://github.com/getsentry/sentry-rust/pull/1228)). - Removed the public `ClientOptions::traces_sample_rate` and `ClientOptions::traces_sampler` fields. Use `ClientOptions::traces_sampling_strategy` to inspect the configured traces sampling strategy, and use the existing `ClientOptions::traces_sample_rate(...)` and `ClientOptions::traces_sampler(...)` builder setters to configure fixed-rate and callback-based sampling ([#1227](https://github.com/getsentry/sentry-rust/pull/1227)). +### New Features + +- Added support for the [User Feedback](https://docs.sentry.io/product/user-feedback/) API, allowing user feedback to be captured and sent to Sentry as a feedback envelope item ([#1259](https://github.com/getsentry/sentry-rust/pull/1259)). + ### Fixes - Restored the reqwest transport's pre-0.13 protocol features by disabling HTTP/2 and native-TLS ALPN ([#1258](https://github.com/getsentry/sentry-rust/pull/1258)). diff --git a/sentry-types/src/protocol/client_report/envelope_losses.rs b/sentry-types/src/protocol/client_report/envelope_losses.rs index 0db2482d..f579a7c4 100644 --- a/sentry-types/src/protocol/client_report/envelope_losses.rs +++ b/sentry-types/src/protocol/client_report/envelope_losses.rs @@ -171,6 +171,7 @@ fn envelope_item_losses(envelope_item: &EnvelopeItem) -> ItemLossIter<'_> { EnvelopeItem::MonitorCheckIn(check_in) => monitor_check_in_losses(check_in), EnvelopeItem::ClientReport(client_report) => client_report_losses(client_report), EnvelopeItem::ItemContainer(item_container) => item_container_losses(item_container), + EnvelopeItem::Feedback(feedback) => feedback_losses(feedback), EnvelopeItem::Raw => ItemLossIter::new([]), } } @@ -239,6 +240,11 @@ fn monitor_check_in_losses(_check_in: &MonitorCheckIn) -> ItemLossIter<'static> ItemLossIter::new([ItemLoss::new(Category::Monitor, 1)]) } +/// Returns feedback losses for a discarded feedback event. +fn feedback_losses(_feedback: &Event<'_>) -> ItemLossIter<'static> { + ItemLossIter::new([ItemLoss::new(Category::Feedback, 1)]) +} + /// Returns the losses for a discarded client report. /// /// Client reports are never themselves recorded as losses; however, all the items recorded as diff --git a/sentry-types/src/protocol/client_report/mod.rs b/sentry-types/src/protocol/client_report/mod.rs index 91a170f2..df84a991 100644 --- a/sentry-types/src/protocol/client_report/mod.rs +++ b/sentry-types/src/protocol/client_report/mod.rs @@ -87,6 +87,8 @@ indexed_enum! { Attachment, /// A monitor check-in. Monitor, + /// A user feedback event. + Feedback, /// A log item. /// /// Dropped logs should also be counted as dropped [`LogByte`]s so client reports include diff --git a/sentry-types/src/protocol/envelope.rs b/sentry-types/src/protocol/envelope.rs index e76ce354..35e070d2 100644 --- a/sentry-types/src/protocol/envelope.rs +++ b/sentry-types/src/protocol/envelope.rs @@ -5,11 +5,10 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; +use super::{feedback::Feedback, v7 as protocol}; use crate::Dsn; use crate::{protocol::v7::ClientReport, utils::ts_rfc3339_opt}; -use super::v7 as protocol; - use protocol::{ Attachment, AttachmentType, ClientSdkInfo, DynamicSamplingContext, Event, Log, Metric, MonitorCheckIn, SessionAggregates, SessionUpdate, Transaction, @@ -135,6 +134,9 @@ enum EnvelopeItemType { /// A client report. #[serde(rename = "client_report")] ClientReport, + /// A User Feedback Item type. + #[serde(rename = "feedback")] + Feedback, } /// An Envelope Item Header. @@ -188,6 +190,12 @@ pub enum EnvelopeItem { ClientReport(ClientReport), /// A container for a list of multiple items. ItemContainer(ItemContainer), + /// A User Feedback item. + /// + /// Feedback is transmitted as an [`Event`] carrying a `feedback` context, so this variant + /// wraps an `Event` rather than a [`Feedback`]. Construct it via `EnvelopeItem::from(feedback)` + /// rather than the raw variant, and use [`EnvelopeItem::as_feedback`] to recover the feedback. + Feedback(Event<'static>), /// This is a sentinel item used to `filter` raw envelopes. Raw, // TODO: @@ -276,9 +284,24 @@ impl EnvelopeItem { Self::MonitorCheckIn(_) => Some(EnvelopeItemType::MonitorCheckIn), Self::ClientReport(_) => Some(EnvelopeItemType::ClientReport), Self::ItemContainer(container) => Some(container.item_type()), + Self::Feedback(_) => Some(EnvelopeItemType::Feedback), Self::Raw => None, } } + + /// Returns the [`Feedback`] carried by this item. + /// + /// Returns `None` for any item that is not a feedback item, or whose wrapped event is missing + /// its `feedback` context. + pub fn as_feedback(&self) -> Option<&Feedback> { + let Self::Feedback(event) = self else { + return None; + }; + match event.contexts.get("feedback") { + Some(protocol::Context::Feedback(feedback)) => Some(feedback.as_ref()), + _ => None, + } + } } impl From> for EnvelopeItem { @@ -341,6 +364,12 @@ impl From for EnvelopeItem { } } +impl From for EnvelopeItem { + fn from(feedback: Feedback) -> Self { + EnvelopeItem::Feedback(feedback.to_new_event()) + } +} + /// An Iterator over the items of an Envelope. #[derive(Clone)] pub struct EnvelopeItemIter<'s> { @@ -457,10 +486,16 @@ impl Envelope { }; if self.headers.event_id.is_none() { - if let EnvelopeItem::Event(ref event) = item { - self.headers.event_id = Some(event.event_id); - } else if let EnvelopeItem::Transaction(ref transaction) = item { - self.headers.event_id = Some(transaction.event_id); + match item { + // Feedback wraps an event with its own id, so it sets the envelope `event_id` too; + // otherwise `filter` would drop attachments from a feedback-only envelope. + EnvelopeItem::Event(ref event) | EnvelopeItem::Feedback(ref event) => { + self.headers.event_id = Some(event.event_id); + } + EnvelopeItem::Transaction(ref transaction) => { + self.headers.event_id = Some(transaction.event_id); + } + _ => {} } } items.push(item); @@ -626,6 +661,7 @@ impl Envelope { serde_json::to_writer(&mut item_buf, &wrapper)? } }, + EnvelopeItem::Feedback(feedback) => serde_json::to_writer(&mut item_buf, feedback)?, EnvelopeItem::Raw => { continue; } @@ -823,6 +859,21 @@ impl Envelope { serde_json::from_slice::>(payload) .map(|x| EnvelopeItem::ItemContainer(ItemContainer::Metrics(x.items.into()))) } + EnvelopeItemType::Feedback => { + serde_json::from_slice(payload).and_then(|event: Event| { + // A feedback item and a plain event share the same payload type, so guard + // against a `feedback`-typed item whose event is missing the feedback context; + // otherwise a mislabeled event would be silently accepted as feedback. + let item = EnvelopeItem::Feedback(event); + if item.as_feedback().is_some() { + Ok(item) + } else { + Err(::custom( + "feedback item is missing its feedback context", + )) + } + }) + } } .map_err(EnvelopeError::InvalidItemPayload)?; @@ -983,6 +1034,111 @@ mod test { ) } + #[test] + fn test_feedback() { + let feedback = Feedback::new("It broke.") + .with_contact_email("john.doe@example.com") + .with_name("John Doe"); + // `to_new_event` fills in a random event id and the current timestamp, so + // overwrite them here to keep the serialized output deterministic. + let mut event = feedback.to_new_event(); + event.event_id = Uuid::parse_str("22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c").unwrap(); + event.timestamp = timestamp("2020-07-20T14:51:14.296Z"); + + let mut envelope = Envelope::new(); + envelope.add_item(EnvelopeItem::Feedback(event)); + assert_eq!( + to_str(envelope), + r#"{"event_id":"22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c"} +{"type":"feedback","length":212} +{"event_id":"22d00b3fd1b14b5d8d2049d138cd8a9c","level":"info","timestamp":1595256674.296,"contexts":{"feedback":{"type":"feedback","contact_email":"john.doe@example.com","name":"John Doe","message":"It broke."}}} +"# + ) + } + + #[test] + fn test_feedback_omits_empty_optional_fields() { + let feedback = Feedback::new("It broke."); + // `to_new_event` fills in a random event id and the current timestamp, so + // overwrite them here to keep the serialized output deterministic. + let mut event = feedback.to_new_event(); + event.event_id = Uuid::parse_str("22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c").unwrap(); + event.timestamp = timestamp("2020-07-20T14:51:14.296Z"); + + let mut envelope = Envelope::new(); + envelope.add_item(EnvelopeItem::Feedback(event)); + // The absent optional fields are omitted rather than serialized as `null`. + let serialized = to_str(envelope); + assert_eq!( + serialized, + r#"{"event_id":"22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c"} +{"type":"feedback","length":155} +{"event_id":"22d00b3fd1b14b5d8d2049d138cd8a9c","level":"info","timestamp":1595256674.296,"contexts":{"feedback":{"type":"feedback","message":"It broke."}}} +"# + ); + + // The item round-trips back into a feedback item, and the feedback is recoverable. + let deserialized = Envelope::from_slice(serialized.as_bytes()).unwrap(); + let item = deserialized.items().next().unwrap(); + assert!(matches!(item, EnvelopeItem::Feedback(_))); + let recovered = item.as_feedback().unwrap(); + assert_eq!(recovered.message, "It broke."); + assert_eq!(recovered.contact_email, None); + assert_eq!(recovered.name, None); + } + + #[test] + fn test_feedback_without_context_is_rejected() { + // A `feedback`-typed item whose payload is a plain event with no feedback context must be + // rejected rather than silently accepted as feedback. + let bytes = b"\ + {}\n\ + {\"type\":\"feedback\"}\n\ + {\"event_id\":\"22d00b3fd1b14b5d8d2049d138cd8a9c\"}\n\ + "; + + let err = Envelope::from_slice(bytes).unwrap_err(); + assert!(matches!(err, EnvelopeError::InvalidItemPayload(_))); + } + + #[test] + fn test_feedback_context_type_inferred() { + // A feedback context without an explicit `type` is inferred from its `feedback` key, so the + // item deserializes as feedback rather than being rejected. + let bytes = b"\ + {}\n\ + {\"type\":\"feedback\"}\n\ + {\"event_id\":\"22d00b3fd1b14b5d8d2049d138cd8a9c\",\"contexts\":{\"feedback\":{\"message\":\"It broke.\"}}}\n\ + "; + + let envelope = Envelope::from_slice(bytes).unwrap(); + let item = envelope.items().next().unwrap(); + assert_eq!(item.as_feedback().unwrap().message, "It broke."); + } + + #[test] + fn test_feedback_sets_envelope_event_id() { + let event_id = Uuid::parse_str("22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c").unwrap(); + let mut event = Feedback::new("It broke.").to_new_event(); + event.event_id = event_id; + + let mut envelope = Envelope::new(); + envelope.add_item(EnvelopeItem::Feedback(event)); + envelope.add_item(Attachment { + buffer: b"screenshot".to_vec(), + filename: "screenshot.png".to_owned(), + ..Default::default() + }); + + // The feedback item populates the envelope `event_id`. + assert_eq!(envelope.uuid(), Some(&event_id)); + + // Because the envelope has an `event_id`, `filter` keeps the feedback's attachment instead + // of dropping it as an orphan. + let filtered = envelope.filter(|_item: &EnvelopeItem| true).unwrap(); + assert_eq!(filtered.items().count(), 2); + } + #[test] fn test_session() { let session_id = Uuid::parse_str("22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c").unwrap(); @@ -1465,6 +1621,11 @@ some content }] .into(); + // Feedback + let feedback = Feedback::new("It broke.") + .with_contact_email("john.doe@example.com") + .with_name("John Doe"); + let mut envelope: Envelope = Envelope::new(); envelope.add_item(event); envelope.add_item(transaction); @@ -1472,6 +1633,7 @@ some content envelope.add_item(attachment); envelope.add_item(logs); envelope.add_item(metrics); + envelope.add_item(feedback); let serialized = to_str(envelope); let deserialized = Envelope::from_slice(serialized.as_bytes()).unwrap(); @@ -1682,6 +1844,13 @@ some content ); } + #[test] + fn losses_on_drop_maps_feedback_to_feedback() { + let envelope: Envelope = Feedback::new("It broke.").into(); + + assert_eq!(collect_losses(&envelope), vec![(Category::Feedback, 1)]); + } + #[test] fn losses_on_drop_skips_client_reports() { let envelope: Envelope = ClientReport::new(<[Item; 0]>::default()).into(); @@ -1723,6 +1892,7 @@ some content unit: None, attributes: Map::new(), }]); + envelope.add_item(Feedback::new("flattened feedback")); assert_eq!( collect_losses(&envelope), @@ -1734,6 +1904,7 @@ some content (Category::LogByte, 9), (Category::TraceMetric, 1), (Category::TraceMetricByte, 24), + (Category::Feedback, 1), ] ); } diff --git a/sentry-types/src/protocol/feedback.rs b/sentry-types/src/protocol/feedback.rs new file mode 100644 index 00000000..0ae33502 --- /dev/null +++ b/sentry-types/src/protocol/feedback.rs @@ -0,0 +1,171 @@ +use std::{collections::BTreeMap, time::SystemTime}; + +use serde::{Deserialize, Serialize}; + +use crate::random_uuid; + +use super::v7::{Context, Event, Level}; + +/// Represents feedback from a user. +/// +/// Convert a `Feedback` into an [`EnvelopeItem`] with [`From`]/[`Into`] to send it to Sentry as a +/// feedback envelope item. +/// +/// [`EnvelopeItem`]: super::v7::EnvelopeItem +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct Feedback { + /// The user's contact email, if provided. + /// + /// Sentry attempts to populate this from the user context when it is omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contact_email: Option, + /// The user's name, if provided. + /// + /// Sentry attempts to populate this from the user context when it is omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// The feedback message from the user. + /// + /// The Sentry protocol limits this to a maximum of 4096 characters; longer messages are + /// truncated by Relay. + pub message: String, + /// The URL of the webpage the user was on when submitting the feedback, if applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// The identifier of a related error event in the same project. + /// + /// Links the feedback to that error in the Sentry User Feedback UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub associated_event_id: Option, + /// The identifier of a related Session Replay in the same project. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replay_id: Option, +} + +impl Feedback { + /// Creates new feedback from a user's message. + pub fn new(message: impl Into) -> Self { + Self { + contact_email: None, + name: None, + message: message.into(), + url: None, + associated_event_id: None, + replay_id: None, + } + } + + /// Associates the feedback with the user's contact email. + #[must_use] + pub fn with_contact_email(mut self, contact_email: impl Into) -> Self { + self.contact_email = Some(contact_email.into()); + self + } + + /// Associates the feedback with the user's name. + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Records the URL of the webpage the user was on when submitting the feedback. + #[must_use] + pub fn with_url(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + + /// Links the feedback to a related error event in the same project. + #[must_use] + pub fn with_associated_event_id(mut self, associated_event_id: impl Into) -> Self { + self.associated_event_id = Some(associated_event_id.into()); + self + } + + /// Links the feedback to a related Session Replay in the same project. + #[must_use] + pub fn with_replay_id(mut self, replay_id: impl Into) -> Self { + self.replay_id = Some(replay_id.into()); + self + } + + pub(crate) fn to_context(&self) -> Context { + Context::Feedback(Box::new(self.clone())) + } + + pub(crate) fn to_new_event(&self) -> Event<'static> { + let map = { + let mut map = BTreeMap::new(); + map.insert("feedback".to_string(), self.to_context()); + map + }; + // Feedback is identified by the `feedback` envelope item type and the `feedback` context; + // Sentry derives the event type from the item type, so it is not set on the event itself. + Event { + event_id: random_uuid(), + level: Level::Info, + timestamp: SystemTime::now(), + contexts: map, + ..Default::default() + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + fn feedback() -> Feedback { + Feedback::new("It broke.") + .with_contact_email("john.doe@example.com") + .with_name("John Doe") + } + + #[test] + fn test_to_context() { + let Context::Feedback(context) = feedback().to_context() else { + panic!("invalid context type"); + }; + assert_eq!(context.message, "It broke."); + assert_eq!(context.name, Some("John Doe".to_string())); + assert_eq!( + context.contact_email, + Some("john.doe@example.com".to_string()) + ); + } + + #[test] + fn test_to_new_event() { + let event = feedback().to_new_event(); + assert_eq!(event.level, Level::Info); + + let Context::Feedback(context) = event.contexts.get("feedback").unwrap() else { + panic!("invalid context type"); + }; + assert_eq!(context.message, "It broke."); + } + + #[test] + fn test_all_fields_round_trip() { + let feedback = feedback() + .with_url("https://example.com/checkout") + .with_associated_event_id("22d00b3fd1b14b5d8d2049d138cd8a9c") + .with_replay_id("d1b14b5d8d2049d138cd8a9c22d00b3f"); + + let json = serde_json::to_string(&feedback).unwrap(); + let parsed: Feedback = serde_json::from_str(&json).unwrap(); + + assert_eq!(feedback, parsed); + assert_eq!(parsed.url.as_deref(), Some("https://example.com/checkout")); + assert_eq!( + parsed.associated_event_id.as_deref(), + Some("22d00b3fd1b14b5d8d2049d138cd8a9c") + ); + assert_eq!( + parsed.replay_id.as_deref(), + Some("d1b14b5d8d2049d138cd8a9c22d00b3f") + ); + } +} diff --git a/sentry-types/src/protocol/mod.rs b/sentry-types/src/protocol/mod.rs index 7639466c..53f0c434 100644 --- a/sentry-types/src/protocol/mod.rs +++ b/sentry-types/src/protocol/mod.rs @@ -16,6 +16,7 @@ pub use v7 as latest; mod attachment; mod client_report; mod envelope; +mod feedback; mod monitor; mod session; mod unit; diff --git a/sentry-types/src/protocol/v7.rs b/sentry-types/src/protocol/v7.rs index f0539425..3c97cafb 100644 --- a/sentry-types/src/protocol/v7.rs +++ b/sentry-types/src/protocol/v7.rs @@ -27,6 +27,7 @@ use crate::utils::{display_from_str_opt, ts_rfc3339_opt, ts_seconds_float}; pub use self::client_report::Report as ClientReport; pub use super::attachment::*; pub use super::envelope::*; +pub use super::feedback::*; pub use super::monitor::*; pub use super::session::*; pub use super::unit::Unit; @@ -1111,6 +1112,8 @@ pub enum Context { Otel(Box), /// HTTP response data. Response(Box), + /// User feedback + Feedback(Box), /// Generic other context data. #[serde(rename = "unknown")] Other(Map), @@ -1129,6 +1132,7 @@ impl Context { Context::Gpu(..) => "gpu", Context::Otel(..) => "otel", Context::Response(..) => "response", + Context::Feedback(..) => "feedback", Context::Other(..) => "unknown", } } @@ -1543,7 +1547,7 @@ into_context!(Otel, OtelContext); into_context!(Response, ResponseContext); const INFERABLE_CONTEXTS: &[&str] = &[ - "device", "os", "runtime", "app", "browser", "trace", "gpu", "otel", "response", + "device", "os", "runtime", "app", "browser", "trace", "gpu", "otel", "response", "feedback", ]; struct ContextsVisitor;