From 68cc8b7cd88e0507ecee16e738522d0141efc7c8 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Fri, 22 May 2026 14:08:04 +0200 Subject: [PATCH] feat(client-reports): Client report protocol Added WIP client report protocol. Resolves [#1001](https://github.com/getsentry/sentry-rust/issues/1001) Resolves [RUST-153](https://linear.app/getsentry/issue/RUST-153/add-client-report-protocol-envelope-item-support-in-sentry-types) --- sentry-types/src/indexed_enum.rs | 19 +++ sentry-types/src/lib.rs | 2 + sentry-types/src/macros.rs | 126 ++++++++++++++++++ .../src/protocol/client_report/list.rs | 46 +++++++ .../src/protocol/client_report/mod.rs | 48 +++++++ sentry-types/src/protocol/mod.rs | 1 + sentry-types/src/protocol/v7.rs | 1 + 7 files changed, 243 insertions(+) create mode 100644 sentry-types/src/indexed_enum.rs create mode 100644 sentry-types/src/protocol/client_report/list.rs create mode 100644 sentry-types/src/protocol/client_report/mod.rs diff --git a/sentry-types/src/indexed_enum.rs b/sentry-types/src/indexed_enum.rs new file mode 100644 index 000000000..02a0d7d17 --- /dev/null +++ b/sentry-types/src/indexed_enum.rs @@ -0,0 +1,19 @@ +//! Defines the [`IndexedEnum`] trait. + +/// Trait for enums that have fixed indexed variants. +pub trait IndexedEnum: private::Sealed { + /// The number of variants in this enum. + const VARIANT_COUNT: usize; + + /// Returns this variant's unique zero-based index. + /// + /// The index satisfies `0 <= self.as_index() < Self::VARIANT_COUNT`. + fn as_index(&self) -> usize; + + /// Returns an iterator over the enum variants in index order. + fn iter_variants() -> impl Iterator; +} + +pub(crate) mod private { + pub trait Sealed {} +} diff --git a/sentry-types/src/lib.rs b/sentry-types/src/lib.rs index 7f662ca9d..61e871cae 100644 --- a/sentry-types/src/lib.rs +++ b/sentry-types/src/lib.rs @@ -42,12 +42,14 @@ mod macros; mod auth; mod crontab_validator; mod dsn; +mod indexed_enum; mod project_id; pub mod protocol; pub(crate) mod utils; pub use crate::auth::*; pub use crate::dsn::*; +pub use crate::indexed_enum::IndexedEnum; pub use crate::project_id::*; // Re-export external types and traits for convenience diff --git a/sentry-types/src/macros.rs b/sentry-types/src/macros.rs index d59c4825d..0eefab1a2 100644 --- a/sentry-types/src/macros.rs +++ b/sentry-types/src/macros.rs @@ -246,3 +246,129 @@ mod hex_tests { ); } } + +/// A macro which can wrap any number of enum definitions to make them "indexed." +/// +/// Specifically, the macro adds an implementation to each enum, which contains the following: +/// - `const VARIANT_COUNT: usize`: the total number of variants in the enum. +/// - `const fn as_index(&self) -> usize`: the unique zero-based index of the enum variant. +/// This is implemented without relying on `as`-casting. +/// - `fn iter_variants() -> impl Iterator`: An iterator over all enum variants in +/// the index order. +/// +/// Both of the added items have the same visibility as the enum itself. +/// +/// This is super useful, for example, if you want to store something for each variant. Rather +/// than using a `HashMap`, it is possible to allocate a fixed-length array of length +/// `VARIANT_COUNT`, indexed by `as_index`. +macro_rules! indexed_enum { + () => {}; + + { + $(#[$meta:meta])* + $vis:vis enum $name:ident { + $( + $(#[$variant_meta:meta])* + $variant:ident + ),* $(,)? + } + $($rest:tt)* + } => { + $(#[$meta])* + $vis enum $name { + $( + $(#[$variant_meta])* + $variant, + )* + } + + impl $crate::IndexedEnum for $name { + /// The number of variants in this enum. + const VARIANT_COUNT: usize = indexed_enum!(@count $($variant),*); + + indexed_enum!(@methods [] [] 0usize; $($variant),*); + } + + impl $crate::indexed_enum::private::Sealed for $name {} + + indexed_enum! { + $($rest)* + } + }; + + (@methods [$($as_index_arms:tt)*] [$($variants_array:expr),*] $idx:expr;) => { + fn as_index(&self) -> usize { + match *self { + $($as_index_arms)* + } + } + + fn iter_variants() -> impl ::std::iter::Iterator { + <_ as ::std::iter::IntoIterator>::into_iter([$($variants_array),*]) + } + }; + + (@methods [$($as_index_arms:tt)*] [$($variants_array:expr),*] $idx:expr; $variant:ident $(, $rest:ident)*) => { + indexed_enum!( + @methods + [$($as_index_arms)* Self::$variant => $idx,] + [$($variants_array,)* Self::$variant] + $idx + 1usize; + $($rest),* + ); + }; + + (@count) => { 0usize }; + + (@count $variant:ident $(, $rest:ident)*) => { + 1usize + indexed_enum!(@count $($rest),*) + }; +} + +#[cfg(test)] +mod tests { + use crate::indexed_enum::IndexedEnum as _; + + indexed_enum! { + /// A test enum to test the `indexed_enum!` macro. + enum IndexedEnumTest { + V0, + V1, + V2, + V3, + } + } + + #[test] + fn variant_count_is_accurate() { + assert_eq!(IndexedEnumTest::VARIANT_COUNT, 4); + } + + #[test] + fn as_index_returns_unique_index() { + let mut indexes_seen = [false; IndexedEnumTest::VARIANT_COUNT]; + + for variant in [ + IndexedEnumTest::V0, + IndexedEnumTest::V1, + IndexedEnumTest::V2, + IndexedEnumTest::V3, + ] { + let index = variant.as_index(); + assert!(!indexes_seen[index]); + indexes_seen[index] = true; + } + + assert!(indexes_seen.into_iter().all(|seen| seen)) + } + + #[test] + fn variant_iter_is_in_index_order() { + let iter_length = IndexedEnumTest::iter_variants() + .enumerate() + .map(|(index, variant)| assert_eq!(index, variant.as_index())) + .count(); + + assert_eq!(iter_length, IndexedEnumTest::VARIANT_COUNT) + } +} diff --git a/sentry-types/src/protocol/client_report/list.rs b/sentry-types/src/protocol/client_report/list.rs new file mode 100644 index 000000000..8b68579ad --- /dev/null +++ b/sentry-types/src/protocol/client_report/list.rs @@ -0,0 +1,46 @@ +//! Module with code for representing the underlying list of client reports. + +use serde::ser::SerializeSeq as _; +use serde::{Serialize, Serializer}; + +use super::{DataCategory, DiscardReason}; + +/// An entry in a client report. +/// +/// Contains the quantity dropped for a certain category and reason. +#[derive(Debug, Serialize)] +pub struct ClientReportItem { + category: DataCategory, + reason: DiscardReason, + quantity: u64, +} + +#[derive(Debug)] +pub(super) struct ClientReportList(Vec); + +impl ClientReportItem { + /// Create a new [`ClientReportItem`]. + pub fn new(category: DataCategory, reason: DiscardReason, quantity: u64) -> Self { + Self { + category, + reason, + quantity, + } + } +} + +impl Serialize for ClientReportList { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let seq = serializer.serialize_seq(Some(self.0.len()))?; + + self.0 + .iter() + .try_fold(seq, |mut seq, item| { + seq.serialize_element(&item).map(|()| seq) + })? + .end() + } +} diff --git a/sentry-types/src/protocol/client_report/mod.rs b/sentry-types/src/protocol/client_report/mod.rs new file mode 100644 index 000000000..ab8f74f09 --- /dev/null +++ b/sentry-types/src/protocol/client_report/mod.rs @@ -0,0 +1,48 @@ +//! Module containing types related to [Client Reports]. +//! +//! [Client Reports]: https://develop.sentry.dev/sdk/telemetry/client-reports/ + +use std::time::SystemTime; + +use serde::Serialize; + +use self::list::ClientReportList; +use crate::utils; + +pub use self::list::ClientReportItem; + +mod list; + +/// A [client report]. +/// +/// [client report]: https://develop.sentry.dev/sdk/telemetry/client-reports/ +#[derive(Debug, Serialize)] +pub struct ClientReport { + #[serde(with = "utils::ts_seconds_float")] + timestamp: SystemTime, + discarded_events: ClientReportList, +} + +indexed_enum! { + /// The reason why a telemetry item was discarded. + /// + /// Valid discard reasons are listed in the [develop docs]; this enum may only define a subset of + /// these data categories, but we will add further categories as we begin using them in the SDK. + /// + /// [develop docs]: https://develop.sentry.dev/sdk/telemetry/client-reports/#discard-reasons-1 + #[derive(Debug, Serialize, PartialEq, Eq, Hash, Clone, Copy)] + #[serde(rename_all = "snake_case")] + #[non_exhaustive] + pub enum DiscardReason {} + + /// The category of data which was dropped. + /// + /// Valid categories are listed in the [develop docs]; this enum may only define a subset of these + /// valid data categories, but we will add further categories as we begin using them in the SDK. + /// + /// [develop docs]: https://develop.sentry.dev/sdk/foundations/transport/rate-limiting/#definitions + #[derive(Debug, Serialize, PartialEq, Eq, Hash, Clone, Copy)] + #[serde(rename_all = "snake_case")] + #[non_exhaustive] + pub enum DataCategory {} +} diff --git a/sentry-types/src/protocol/mod.rs b/sentry-types/src/protocol/mod.rs index ec80a4db6..7639466c1 100644 --- a/sentry-types/src/protocol/mod.rs +++ b/sentry-types/src/protocol/mod.rs @@ -14,6 +14,7 @@ pub const LATEST: u16 = 7; pub use v7 as latest; mod attachment; +mod client_report; mod envelope; mod monitor; mod session; diff --git a/sentry-types/src/protocol/v7.rs b/sentry-types/src/protocol/v7.rs index c952ee2be..0b08914f3 100644 --- a/sentry-types/src/protocol/v7.rs +++ b/sentry-types/src/protocol/v7.rs @@ -25,6 +25,7 @@ pub use uuid::Uuid; use crate::utils::{display_from_str_opt, ts_rfc3339_opt, ts_seconds_float}; pub use super::attachment::*; +pub use super::client_report::{ClientReport, ClientReportItem, DataCategory, DiscardReason}; pub use super::envelope::*; pub use super::monitor::*; pub use super::session::*;