Skip to content

Commit 9c733a0

Browse files
xremmingzmitchell
andcommitted
feat: Add Feedback API
Co-authored-by: Zach Mitchell <zmitchell@fastmail.com>
1 parent 3cb81ac commit 9c733a0

7 files changed

Lines changed: 435 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
- 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)).
2727
- 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)).
2828

29+
### New Features
30+
31+
- 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)).
32+
2933
### Fixes
3034

3135
- 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)).

sentry-types/src/protocol/client_report/envelope_losses.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
use std::mem;
44

55
use crate::protocol::v7::{
6-
Attachment, ClientReport, Envelope, EnvelopeItem, Event, ItemContainer, Log, Metric,
7-
MonitorCheckIn, SessionAggregateItem, SessionAggregates, SessionUpdate, Span, Transaction,
6+
Attachment, ClientReport, Envelope, EnvelopeItem, Event, FeedbackEvent, ItemContainer, Log,
7+
Metric, MonitorCheckIn, SessionAggregateItem, SessionAggregates, SessionUpdate, Span,
8+
Transaction,
89
};
910

1011
use super::list::Iter as ClientReportItemIter;
@@ -171,6 +172,7 @@ fn envelope_item_losses(envelope_item: &EnvelopeItem) -> ItemLossIter<'_> {
171172
EnvelopeItem::MonitorCheckIn(check_in) => monitor_check_in_losses(check_in),
172173
EnvelopeItem::ClientReport(client_report) => client_report_losses(client_report),
173174
EnvelopeItem::ItemContainer(item_container) => item_container_losses(item_container),
175+
EnvelopeItem::Feedback(feedback) => feedback_losses(feedback),
174176
EnvelopeItem::Raw => ItemLossIter::new([]),
175177
}
176178
}
@@ -239,6 +241,11 @@ fn monitor_check_in_losses(_check_in: &MonitorCheckIn) -> ItemLossIter<'static>
239241
ItemLossIter::new([ItemLoss::new(Category::Monitor, 1)])
240242
}
241243

244+
/// Returns feedback losses for a discarded feedback event.
245+
fn feedback_losses(_feedback: &FeedbackEvent) -> ItemLossIter<'static> {
246+
ItemLossIter::new([ItemLoss::new(Category::Feedback, 1)])
247+
}
248+
242249
/// Returns the losses for a discarded client report.
243250
///
244251
/// Client reports are never themselves recorded as losses; however, all the items recorded as

sentry-types/src/protocol/client_report/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ indexed_enum! {
8787
Attachment,
8888
/// A monitor check-in.
8989
Monitor,
90+
/// A user feedback event.
91+
Feedback,
9092
/// A log item.
9193
///
9294
/// Dropped logs should also be counted as dropped [`LogByte`]s so client reports include

sentry-types/src/protocol/envelope.rs

Lines changed: 184 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ use serde::{Deserialize, Serialize};
55
use thiserror::Error;
66
use uuid::Uuid;
77

8+
use super::{
9+
feedback::{Feedback, FeedbackEvent},
10+
v7 as protocol,
11+
};
812
use crate::Dsn;
913
use crate::{protocol::v7::ClientReport, utils::ts_rfc3339_opt};
1014

11-
use super::v7 as protocol;
12-
1315
use protocol::{
1416
Attachment, AttachmentType, ClientSdkInfo, DynamicSamplingContext, Event, Log, Metric,
1517
MonitorCheckIn, SessionAggregates, SessionUpdate, Transaction,
@@ -135,6 +137,9 @@ enum EnvelopeItemType {
135137
/// A client report.
136138
#[serde(rename = "client_report")]
137139
ClientReport,
140+
/// A User Feedback Item type.
141+
#[serde(rename = "feedback")]
142+
Feedback,
138143
}
139144

140145
/// An Envelope Item Header.
@@ -188,6 +193,13 @@ pub enum EnvelopeItem {
188193
ClientReport(ClientReport),
189194
/// A container for a list of multiple items.
190195
ItemContainer(ItemContainer),
196+
/// A User Feedback item.
197+
///
198+
/// Feedback is transmitted as an [`Event`] carrying a `feedback` context, wrapped in a
199+
/// [`FeedbackEvent`] which guarantees that context is present. Construct it via
200+
/// `EnvelopeItem::from(feedback)`, and use [`EnvelopeItem::as_feedback`] to recover the
201+
/// feedback.
202+
Feedback(FeedbackEvent),
191203
/// This is a sentinel item used to `filter` raw envelopes.
192204
Raw,
193205
// TODO:
@@ -276,9 +288,21 @@ impl EnvelopeItem {
276288
Self::MonitorCheckIn(_) => Some(EnvelopeItemType::MonitorCheckIn),
277289
Self::ClientReport(_) => Some(EnvelopeItemType::ClientReport),
278290
Self::ItemContainer(container) => Some(container.item_type()),
291+
Self::Feedback(_) => Some(EnvelopeItemType::Feedback),
279292
Self::Raw => None,
280293
}
281294
}
295+
296+
/// Returns the [`Feedback`] carried by this item.
297+
///
298+
/// Returns `None` for any item that is not a feedback item, or whose wrapped event is missing
299+
/// its `feedback` context.
300+
pub fn as_feedback(&self) -> Option<&Feedback> {
301+
match self {
302+
Self::Feedback(feedback) => Some(feedback.feedback()),
303+
_ => None,
304+
}
305+
}
282306
}
283307

284308
impl From<Event<'static>> for EnvelopeItem {
@@ -341,6 +365,18 @@ impl From<ClientReport> for EnvelopeItem {
341365
}
342366
}
343367

368+
impl From<Feedback> for EnvelopeItem {
369+
fn from(feedback: Feedback) -> Self {
370+
EnvelopeItem::Feedback(feedback.into())
371+
}
372+
}
373+
374+
impl From<FeedbackEvent> for EnvelopeItem {
375+
fn from(feedback: FeedbackEvent) -> Self {
376+
EnvelopeItem::Feedback(feedback)
377+
}
378+
}
379+
344380
/// An Iterator over the items of an Envelope.
345381
#[derive(Clone)]
346382
pub struct EnvelopeItemIter<'s> {
@@ -457,10 +493,19 @@ impl Envelope {
457493
};
458494

459495
if self.headers.event_id.is_none() {
460-
if let EnvelopeItem::Event(ref event) = item {
461-
self.headers.event_id = Some(event.event_id);
462-
} else if let EnvelopeItem::Transaction(ref transaction) = item {
463-
self.headers.event_id = Some(transaction.event_id);
496+
match item {
497+
EnvelopeItem::Event(ref event) => {
498+
self.headers.event_id = Some(event.event_id);
499+
}
500+
// Feedback wraps an event with its own id, so it sets the envelope `event_id` too;
501+
// otherwise `filter` would drop attachments from a feedback-only envelope.
502+
EnvelopeItem::Feedback(ref feedback) => {
503+
self.headers.event_id = Some(feedback.event().event_id);
504+
}
505+
EnvelopeItem::Transaction(ref transaction) => {
506+
self.headers.event_id = Some(transaction.event_id);
507+
}
508+
_ => {}
464509
}
465510
}
466511
items.push(item);
@@ -626,6 +671,7 @@ impl Envelope {
626671
serde_json::to_writer(&mut item_buf, &wrapper)?
627672
}
628673
},
674+
EnvelopeItem::Feedback(feedback) => serde_json::to_writer(&mut item_buf, feedback)?,
629675
EnvelopeItem::Raw => {
630676
continue;
631677
}
@@ -823,6 +869,11 @@ impl Envelope {
823869
serde_json::from_slice::<ItemsSerdeWrapper<_>>(payload)
824870
.map(|x| EnvelopeItem::ItemContainer(ItemContainer::Metrics(x.items.into())))
825871
}
872+
EnvelopeItemType::Feedback => {
873+
// `FeedbackEvent`'s `Deserialize` rejects an event missing its feedback context,
874+
// so a plain event mislabeled as feedback cannot be silently accepted here.
875+
serde_json::from_slice(payload).map(EnvelopeItem::Feedback)
876+
}
826877
}
827878
.map_err(EnvelopeError::InvalidItemPayload)?;
828879

@@ -983,6 +1034,113 @@ mod test {
9831034
)
9841035
}
9851036

1037+
#[test]
1038+
fn test_feedback() {
1039+
let feedback = Feedback::new("It broke.")
1040+
.with_contact_email("john.doe@example.com")
1041+
.with_name("John Doe");
1042+
// `FeedbackEvent::from` fills in a random event id and the current timestamp, so
1043+
// overwrite them here to keep the serialized output deterministic.
1044+
let mut feedback = FeedbackEvent::from(feedback);
1045+
feedback.event_mut().event_id =
1046+
Uuid::parse_str("22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c").unwrap();
1047+
feedback.event_mut().timestamp = timestamp("2020-07-20T14:51:14.296Z");
1048+
1049+
let mut envelope = Envelope::new();
1050+
envelope.add_item(EnvelopeItem::Feedback(feedback));
1051+
assert_eq!(
1052+
to_str(envelope),
1053+
r#"{"event_id":"22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c"}
1054+
{"type":"feedback","length":212}
1055+
{"event_id":"22d00b3fd1b14b5d8d2049d138cd8a9c","level":"info","timestamp":1595256674.296,"contexts":{"feedback":{"type":"feedback","contact_email":"john.doe@example.com","name":"John Doe","message":"It broke."}}}
1056+
"#
1057+
)
1058+
}
1059+
1060+
#[test]
1061+
fn test_feedback_omits_empty_optional_fields() {
1062+
let feedback = Feedback::new("It broke.");
1063+
// `FeedbackEvent::from` fills in a random event id and the current timestamp, so
1064+
// overwrite them here to keep the serialized output deterministic.
1065+
let mut feedback = FeedbackEvent::from(feedback);
1066+
feedback.event_mut().event_id =
1067+
Uuid::parse_str("22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c").unwrap();
1068+
feedback.event_mut().timestamp = timestamp("2020-07-20T14:51:14.296Z");
1069+
1070+
let mut envelope = Envelope::new();
1071+
envelope.add_item(EnvelopeItem::Feedback(feedback));
1072+
// The absent optional fields are omitted rather than serialized as `null`.
1073+
let serialized = to_str(envelope);
1074+
assert_eq!(
1075+
serialized,
1076+
r#"{"event_id":"22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c"}
1077+
{"type":"feedback","length":155}
1078+
{"event_id":"22d00b3fd1b14b5d8d2049d138cd8a9c","level":"info","timestamp":1595256674.296,"contexts":{"feedback":{"type":"feedback","message":"It broke."}}}
1079+
"#
1080+
);
1081+
1082+
// The item round-trips back into a feedback item, and the feedback is recoverable.
1083+
let deserialized = Envelope::from_slice(serialized.as_bytes()).unwrap();
1084+
let item = deserialized.items().next().unwrap();
1085+
assert!(matches!(item, EnvelopeItem::Feedback(_)));
1086+
let recovered = item.as_feedback().unwrap();
1087+
assert_eq!(recovered.message, "It broke.");
1088+
assert_eq!(recovered.contact_email, None);
1089+
assert_eq!(recovered.name, None);
1090+
}
1091+
1092+
#[test]
1093+
fn test_feedback_without_context_is_rejected() {
1094+
// A `feedback`-typed item whose payload is a plain event with no feedback context must be
1095+
// rejected rather than silently accepted as feedback.
1096+
let bytes = b"\
1097+
{}\n\
1098+
{\"type\":\"feedback\"}\n\
1099+
{\"event_id\":\"22d00b3fd1b14b5d8d2049d138cd8a9c\"}\n\
1100+
";
1101+
1102+
let err = Envelope::from_slice(bytes).unwrap_err();
1103+
assert!(matches!(err, EnvelopeError::InvalidItemPayload(_)));
1104+
}
1105+
1106+
#[test]
1107+
fn test_feedback_context_type_inferred() {
1108+
// A feedback context without an explicit `type` is inferred from its `feedback` key, so the
1109+
// item deserializes as feedback rather than being rejected.
1110+
let bytes = b"\
1111+
{}\n\
1112+
{\"type\":\"feedback\"}\n\
1113+
{\"event_id\":\"22d00b3fd1b14b5d8d2049d138cd8a9c\",\"contexts\":{\"feedback\":{\"message\":\"It broke.\"}}}\n\
1114+
";
1115+
1116+
let envelope = Envelope::from_slice(bytes).unwrap();
1117+
let item = envelope.items().next().unwrap();
1118+
assert_eq!(item.as_feedback().unwrap().message, "It broke.");
1119+
}
1120+
1121+
#[test]
1122+
fn test_feedback_sets_envelope_event_id() {
1123+
let event_id = Uuid::parse_str("22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c").unwrap();
1124+
let mut feedback = FeedbackEvent::from(Feedback::new("It broke."));
1125+
feedback.event_mut().event_id = event_id;
1126+
1127+
let mut envelope = Envelope::new();
1128+
envelope.add_item(EnvelopeItem::Feedback(feedback));
1129+
envelope.add_item(Attachment {
1130+
buffer: b"screenshot".to_vec(),
1131+
filename: "screenshot.png".to_owned(),
1132+
..Default::default()
1133+
});
1134+
1135+
// The feedback item populates the envelope `event_id`.
1136+
assert_eq!(envelope.uuid(), Some(&event_id));
1137+
1138+
// Because the envelope has an `event_id`, `filter` keeps the feedback's attachment instead
1139+
// of dropping it as an orphan.
1140+
let filtered = envelope.filter(|_item: &EnvelopeItem| true).unwrap();
1141+
assert_eq!(filtered.items().count(), 2);
1142+
}
1143+
9861144
#[test]
9871145
fn test_session() {
9881146
let session_id = Uuid::parse_str("22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c").unwrap();
@@ -1465,13 +1623,24 @@ some content
14651623
}]
14661624
.into();
14671625

1626+
// Feedback
1627+
let mut feedback = FeedbackEvent::from(
1628+
Feedback::new("It broke.")
1629+
.with_contact_email("john.doe@example.com")
1630+
.with_name("John Doe"),
1631+
);
1632+
// Pin the timestamp so the `SystemTime -> f64 -> SystemTime` round-trip is stable; the
1633+
// sub-second precision of `SystemTime::now()` does not survive it.
1634+
feedback.event_mut().timestamp = timestamp("2020-07-20T14:51:14.296Z");
1635+
14681636
let mut envelope: Envelope = Envelope::new();
14691637
envelope.add_item(event);
14701638
envelope.add_item(transaction);
14711639
envelope.add_item(session);
14721640
envelope.add_item(attachment);
14731641
envelope.add_item(logs);
14741642
envelope.add_item(metrics);
1643+
envelope.add_item(EnvelopeItem::Feedback(feedback));
14751644

14761645
let serialized = to_str(envelope);
14771646
let deserialized = Envelope::from_slice(serialized.as_bytes()).unwrap();
@@ -1682,6 +1851,13 @@ some content
16821851
);
16831852
}
16841853

1854+
#[test]
1855+
fn losses_on_drop_maps_feedback_to_feedback() {
1856+
let envelope: Envelope = Feedback::new("It broke.").into();
1857+
1858+
assert_eq!(collect_losses(&envelope), vec![(Category::Feedback, 1)]);
1859+
}
1860+
16851861
#[test]
16861862
fn losses_on_drop_skips_client_reports() {
16871863
let envelope: Envelope = ClientReport::new(<[Item; 0]>::default()).into();
@@ -1723,6 +1899,7 @@ some content
17231899
unit: None,
17241900
attributes: Map::new(),
17251901
}]);
1902+
envelope.add_item(Feedback::new("flattened feedback"));
17261903

17271904
assert_eq!(
17281905
collect_losses(&envelope),
@@ -1734,6 +1911,7 @@ some content
17341911
(Category::LogByte, 9),
17351912
(Category::TraceMetric, 1),
17361913
(Category::TraceMetricByte, 24),
1914+
(Category::Feedback, 1),
17371915
]
17381916
);
17391917
}

0 commit comments

Comments
 (0)