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..29270e5d 100644 --- a/sentry-types/src/protocol/client_report/envelope_losses.rs +++ b/sentry-types/src/protocol/client_report/envelope_losses.rs @@ -3,8 +3,9 @@ use std::mem; use crate::protocol::v7::{ - Attachment, ClientReport, Envelope, EnvelopeItem, Event, ItemContainer, Log, Metric, - MonitorCheckIn, SessionAggregateItem, SessionAggregates, SessionUpdate, Span, Transaction, + Attachment, ClientReport, Envelope, EnvelopeItem, Event, FeedbackEvent, ItemContainer, Log, + Metric, MonitorCheckIn, SessionAggregateItem, SessionAggregates, SessionUpdate, Span, + Transaction, }; use super::list::Iter as ClientReportItemIter; @@ -171,6 +172,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 +241,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: &FeedbackEvent) -> 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..b50bff27 100644 --- a/sentry-types/src/protocol/envelope.rs +++ b/sentry-types/src/protocol/envelope.rs @@ -5,11 +5,13 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; +use super::{ + feedback::{Feedback, FeedbackEvent}, + 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 +137,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 +193,13 @@ 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, wrapped in a + /// [`FeedbackEvent`] which guarantees that context is present. Construct it via + /// `EnvelopeItem::from(feedback)`, and use [`EnvelopeItem::as_feedback`] to recover the + /// feedback. + Feedback(FeedbackEvent), /// This is a sentinel item used to `filter` raw envelopes. Raw, // TODO: @@ -276,9 +288,21 @@ 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> { + match self { + Self::Feedback(feedback) => Some(feedback.feedback()), + _ => None, + } + } } impl From> for EnvelopeItem { @@ -341,6 +365,18 @@ impl From for EnvelopeItem { } } +impl From for EnvelopeItem { + fn from(feedback: Feedback) -> Self { + EnvelopeItem::Feedback(feedback.into()) + } +} + +impl From for EnvelopeItem { + fn from(feedback: FeedbackEvent) -> Self { + EnvelopeItem::Feedback(feedback) + } +} + /// An Iterator over the items of an Envelope. #[derive(Clone)] pub struct EnvelopeItemIter<'s> { @@ -457,10 +493,19 @@ 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 { + EnvelopeItem::Event(ref event) => { + self.headers.event_id = Some(event.event_id); + } + // 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::Feedback(ref feedback) => { + self.headers.event_id = Some(feedback.event().event_id); + } + EnvelopeItem::Transaction(ref transaction) => { + self.headers.event_id = Some(transaction.event_id); + } + _ => {} } } items.push(item); @@ -626,6 +671,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 +869,11 @@ impl Envelope { serde_json::from_slice::>(payload) .map(|x| EnvelopeItem::ItemContainer(ItemContainer::Metrics(x.items.into()))) } + EnvelopeItemType::Feedback => { + // `FeedbackEvent`'s `Deserialize` rejects an event missing its feedback context, + // so a plain event mislabeled as feedback cannot be silently accepted here. + serde_json::from_slice(payload).map(EnvelopeItem::Feedback) + } } .map_err(EnvelopeError::InvalidItemPayload)?; @@ -983,6 +1034,113 @@ mod test { ) } + #[test] + fn test_feedback() { + let feedback = Feedback::new("It broke.") + .with_contact_email("john.doe@example.com") + .with_name("John Doe"); + // `FeedbackEvent::from` fills in a random event id and the current timestamp, so + // overwrite them here to keep the serialized output deterministic. + let mut feedback = FeedbackEvent::from(feedback); + feedback.event_mut().event_id = + Uuid::parse_str("22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c").unwrap(); + feedback.event_mut().timestamp = timestamp("2020-07-20T14:51:14.296Z"); + + let mut envelope = Envelope::new(); + envelope.add_item(EnvelopeItem::Feedback(feedback)); + 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."); + // `FeedbackEvent::from` fills in a random event id and the current timestamp, so + // overwrite them here to keep the serialized output deterministic. + let mut feedback = FeedbackEvent::from(feedback); + feedback.event_mut().event_id = + Uuid::parse_str("22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c").unwrap(); + feedback.event_mut().timestamp = timestamp("2020-07-20T14:51:14.296Z"); + + let mut envelope = Envelope::new(); + envelope.add_item(EnvelopeItem::Feedback(feedback)); + // 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 feedback = FeedbackEvent::from(Feedback::new("It broke.")); + feedback.event_mut().event_id = event_id; + + let mut envelope = Envelope::new(); + envelope.add_item(EnvelopeItem::Feedback(feedback)); + 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 +1623,16 @@ some content }] .into(); + // Feedback + let mut feedback = FeedbackEvent::from( + Feedback::new("It broke.") + .with_contact_email("john.doe@example.com") + .with_name("John Doe"), + ); + // Pin the timestamp so the `SystemTime -> f64 -> SystemTime` round-trip is stable; the + // sub-second precision of `SystemTime::now()` does not survive it. + feedback.event_mut().timestamp = timestamp("2020-07-20T14:51:14.296Z"); + let mut envelope: Envelope = Envelope::new(); envelope.add_item(event); envelope.add_item(transaction); @@ -1472,6 +1640,7 @@ some content envelope.add_item(attachment); envelope.add_item(logs); envelope.add_item(metrics); + envelope.add_item(EnvelopeItem::Feedback(feedback)); let serialized = to_str(envelope); let deserialized = Envelope::from_slice(serialized.as_bytes()).unwrap(); @@ -1682,6 +1851,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 +1899,7 @@ some content unit: None, attributes: Map::new(), }]); + envelope.add_item(Feedback::new("flattened feedback")); assert_eq!( collect_losses(&envelope), @@ -1734,6 +1911,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..af833a81 --- /dev/null +++ b/sentry-types/src/protocol/feedback.rs @@ -0,0 +1,230 @@ +use std::time::SystemTime; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +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())) + } +} + +/// The key under which the feedback [`Context`] is stored on a feedback [`Event`]. +const FEEDBACK_CONTEXT_KEY: &str = "feedback"; + +/// An [`Event`] carrying a `feedback` context, ready to be sent as a feedback envelope item. +/// +/// This type guarantees the presence of the `feedback` context, so [`FeedbackEvent::feedback`] +/// always succeeds. Build one from a [`Feedback`] via [`From`]/[`Into`], then convert it into an +/// [`EnvelopeItem`] the same way. +/// +/// [`EnvelopeItem`]: super::v7::EnvelopeItem +#[derive(Debug, Clone, PartialEq)] +pub struct FeedbackEvent(Event<'static>); + +impl FeedbackEvent { + /// Returns the [`Feedback`] carried by the event's `feedback` context. + pub fn feedback(&self) -> &Feedback { + match self.0.contexts.get(FEEDBACK_CONTEXT_KEY) { + Some(Context::Feedback(feedback)) => feedback, + _ => unreachable!("a FeedbackEvent always carries its feedback context"), + } + } + + /// Returns the underlying [`Event`]. + pub fn event(&self) -> &Event<'static> { + &self.0 + } + + /// Returns a mutable reference to the underlying [`Event`], for tests that need a deterministic + /// event id or timestamp. Gated to test builds so the feedback-context invariant cannot be + /// broken by production callers. + #[cfg(test)] + pub(crate) fn event_mut(&mut self) -> &mut Event<'static> { + &mut self.0 + } +} + +impl From for FeedbackEvent { + fn from(feedback: Feedback) -> Self { + // 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. + let mut event = Event { + event_id: random_uuid(), + level: Level::Info, + timestamp: SystemTime::now(), + ..Default::default() + }; + event + .contexts + .insert(FEEDBACK_CONTEXT_KEY.to_string(), feedback.to_context()); + Self(event) + } +} + +impl Serialize for FeedbackEvent { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for FeedbackEvent { + fn deserialize>(deserializer: D) -> Result { + let event = Event::deserialize(deserializer)?; + // The event and a plain event share the same payload type, so reject a feedback item + // whose event is missing the feedback context instead of silently accepting it. + match event.contexts.get(FEEDBACK_CONTEXT_KEY) { + Some(Context::Feedback(_)) => Ok(Self(event)), + _ => Err(serde::de::Error::custom( + "feedback item is missing its feedback context", + )), + } + } +} + +#[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_feedback_event_from() { + let feedback_event = FeedbackEvent::from(feedback()); + assert_eq!(feedback_event.event().level, Level::Info); + assert_eq!(feedback_event.feedback().message, "It broke."); + } + + #[test] + fn test_feedback_event_missing_context_is_rejected() { + let json = serde_json::to_string(&Event::default()).unwrap(); + let err = serde_json::from_str::(&json).unwrap_err(); + assert!(err.to_string().contains("missing its feedback context")); + } + + #[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..1149f17f 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::{Feedback, FeedbackEvent}; 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;