Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
11 changes: 9 additions & 2 deletions sentry-types/src/protocol/client_report/envelope_losses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([]),
}
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions sentry-types/src/protocol/client_report/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ indexed_enum! {
Attachment,
/// A monitor check-in.
Monitor,
/// A user feedback event.
Feedback,
Comment thread
xremming marked this conversation as resolved.
/// A log item.
///
/// Dropped logs should also be counted as dropped [`LogByte`]s so client reports include
Expand Down
190 changes: 184 additions & 6 deletions sentry-types/src/protocol/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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<Event<'static>> for EnvelopeItem {
Expand Down Expand Up @@ -341,6 +365,18 @@ impl From<ClientReport> for EnvelopeItem {
}
}

impl From<Feedback> for EnvelopeItem {
fn from(feedback: Feedback) -> Self {
EnvelopeItem::Feedback(feedback.into())
}
}

impl From<FeedbackEvent> for EnvelopeItem {
fn from(feedback: FeedbackEvent) -> Self {
EnvelopeItem::Feedback(feedback)
}
}

Comment thread
cursor[bot] marked this conversation as resolved.
/// An Iterator over the items of an Envelope.
#[derive(Clone)]
pub struct EnvelopeItemIter<'s> {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -823,6 +869,11 @@ impl Envelope {
serde_json::from_slice::<ItemsSerdeWrapper<_>>(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)?;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1465,13 +1623,24 @@ 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);
envelope.add_item(session);
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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1723,6 +1899,7 @@ some content
unit: None,
attributes: Map::new(),
}]);
envelope.add_item(Feedback::new("flattened feedback"));

assert_eq!(
collect_losses(&envelope),
Expand All @@ -1734,6 +1911,7 @@ some content
(Category::LogByte, 9),
(Category::TraceMetric, 1),
(Category::TraceMetricByte, 24),
(Category::Feedback, 1),
]
);
}
Expand Down
Loading