diff --git a/sentry-types/src/protocol/envelope.rs b/sentry-types/src/protocol/envelope.rs index 190c439b2..9200fa090 100644 --- a/sentry-types/src/protocol/envelope.rs +++ b/sentry-types/src/protocol/envelope.rs @@ -1,14 +1,17 @@ -use std::{io::Write, path::Path}; +use std::{io::Write, path::Path, time::SystemTime}; use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; +use crate::utils::ts_rfc3339_opt; +use crate::Dsn; + use super::v7 as protocol; use protocol::{ - Attachment, AttachmentType, Event, Log, MonitorCheckIn, SessionAggregates, SessionUpdate, - Transaction, + Attachment, AttachmentType, ClientSdkInfo, DynamicSamplingContext, Event, Log, MonitorCheckIn, + SessionAggregates, SessionUpdate, Transaction, }; /// Raised if a envelope cannot be parsed from a given input. @@ -37,9 +40,23 @@ pub enum EnvelopeError { InvalidItemPayload(#[source] serde_json::Error), } -#[derive(Deserialize)] -struct EnvelopeHeader { +/// The supported [Sentry Envelope Headers](https://develop.sentry.dev/sdk/data-model/envelopes/#headers). +#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)] +struct EnvelopeHeaders { + #[serde(default, skip_serializing_if = "Option::is_none")] event_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + dsn: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + sdk: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "ts_rfc3339_opt" + )] + sent_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + trace: Option, } /// An Envelope Item Type. @@ -271,7 +288,7 @@ impl Items { /// for more details. #[derive(Clone, Default, Debug, PartialEq)] pub struct Envelope { - event_id: Option, + headers: EnvelopeHeaders, items: Items, } @@ -297,11 +314,11 @@ impl Envelope { return; }; - if self.event_id.is_none() { + if self.headers.event_id.is_none() { if let EnvelopeItem::Event(ref event) = item { - self.event_id = Some(event.event_id); + self.headers.event_id = Some(event.event_id); } else if let EnvelopeItem::Transaction(ref transaction) = item { - self.event_id = Some(transaction.event_id); + self.headers.event_id = Some(transaction.event_id); } } items.push(item); @@ -319,7 +336,7 @@ impl Envelope { /// Returns the Envelopes Uuid, if any. pub fn uuid(&self) -> Option<&Uuid> { - self.event_id.as_ref() + self.headers.event_id.as_ref() } /// Returns the [`Event`] contained in this Envelope, if any. @@ -391,11 +408,8 @@ impl Envelope { }; // write the headers: - let event_id = self.uuid(); - match event_id { - Some(uuid) => writeln!(writer, r#"{{"event_id":"{uuid}"}}"#)?, - _ => writeln!(writer, "{{}}")?, - } + serde_json::to_writer(&mut writer, &self.headers)?; + writeln!(writer)?; let mut item_buf = Vec::new(); // write each item: @@ -466,11 +480,11 @@ impl Envelope { /// Creates a new Envelope from slice. pub fn from_slice(slice: &[u8]) -> Result { - let (header, offset) = Self::parse_header(slice)?; + let (headers, offset) = Self::parse_headers(slice)?; let items = Self::parse_items(slice, offset)?; let mut envelope = Envelope { - event_id: header.event_id, + headers, ..Default::default() }; @@ -484,8 +498,8 @@ impl Envelope { /// Creates a new raw Envelope from the given buffer. pub fn from_bytes_raw(bytes: Vec) -> Result { Ok(Self { - event_id: None, items: Items::Raw(bytes), + ..Default::default() }) } @@ -504,19 +518,19 @@ impl Envelope { Self::from_bytes_raw(bytes) } - fn parse_header(slice: &[u8]) -> Result<(EnvelopeHeader, usize), EnvelopeError> { - let mut stream = serde_json::Deserializer::from_slice(slice).into_iter(); + fn parse_headers(slice: &[u8]) -> Result<(EnvelopeHeaders, usize), EnvelopeError> { + let first_line = slice + .split(|b| *b == b'\n') + .next() + .ok_or(EnvelopeError::MissingHeader)?; - let header: EnvelopeHeader = match stream.next() { - None => return Err(EnvelopeError::MissingHeader), - Some(Err(error)) => return Err(EnvelopeError::InvalidHeader(error)), - Some(Ok(header)) => header, - }; + let headers: EnvelopeHeaders = + serde_json::from_slice(first_line).map_err(EnvelopeError::InvalidHeader)?; - // Each header is terminated by a UNIX newline. - Self::require_termination(slice, stream.byte_offset())?; + let offset = first_line.len(); + Self::require_termination(slice, offset)?; - Ok((header, stream.byte_offset() + 1)) + Ok((headers, offset + 1)) } fn parse_items(slice: &[u8], mut offset: usize) -> Result, EnvelopeError> { @@ -848,7 +862,7 @@ some content let envelope = Envelope::from_slice(bytes).unwrap(); let event_id = Uuid::from_str("9ec79c33ec9942ab8353589fcb2e04dc").unwrap(); - assert_eq!(envelope.event_id, Some(event_id)); + assert_eq!(envelope.headers.event_id, Some(event_id)); assert_eq!(envelope.items().count(), 0); } @@ -1011,6 +1025,20 @@ some content } } + #[test] + fn test_all_envelope_headers_roundtrip() { + let bytes = br#"{"event_id":"22d00b3f-d1b1-4b5d-8d20-49d138cd8a9c","sdk":{"name":"3e934135-3f2b-49bc-8756-9f025b55143e","version":"3e31738e-4106-42d0-8be2-4a3a1bc648d3","integrations":["daec50ae-8729-49b5-82f7-991446745cd5","8fc94968-3499-4a2c-b4d7-ecc058d9c1b0"],"packages":[{"name":"b59a1949-9950-4203-b394-ddd8d02c9633","version":"3d7790f3-7f32-43f7-b82f-9f5bc85205a8"}]},"sent_at":"2020-02-07T14:16:00Z","trace":{"trace_id":"65bcd18546c942069ed957b15b4ace7c","public_key":"5d593cac-f833-4845-bb23-4eabdf720da2","sample_rate":"0.00000021","sample_rand":"0.123456","sampled":"true","environment":"0666ab02-6364-4135-aa59-02e8128ce052","transaction":"0252ec25-cd0a-4230-bd2f-936a4585637e"}} +{"type":"event","length":74} +{"event_id":"22d00b3fd1b14b5d8d2049d138cd8a9c","timestamp":1595256674.296} +"#; + + let envelope = Envelope::from_slice(bytes); + assert!(envelope.is_ok()); + let envelope = envelope.unwrap(); + let serialized = to_str(envelope); + assert_eq!(bytes, serialized.as_bytes()); + } + // Test all possible item types in a single envelope #[test] fn test_deserialize_serialized() { diff --git a/sentry-types/src/protocol/v7.rs b/sentry-types/src/protocol/v7.rs index 6871d7998..945727bf6 100644 --- a/sentry-types/src/protocol/v7.rs +++ b/sentry-types/src/protocol/v7.rs @@ -22,7 +22,7 @@ use thiserror::Error; pub use url::Url; pub use uuid::Uuid; -use crate::utils::{ts_rfc3339_opt, ts_seconds_float}; +use crate::utils::{display_from_str_opt, ts_rfc3339_opt, ts_seconds_float}; pub use super::attachment::*; pub use super::envelope::*; @@ -2336,3 +2336,99 @@ impl<'de> Deserialize<'de> for LogAttribute { deserializer.deserialize_map(LogAttributeVisitor) } } + +/// An ID that identifies an organization in the Sentry backend. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] +struct OrganizationId(u64); + +impl From for OrganizationId { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl std::str::FromStr for OrganizationId { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { + s.parse().map(Self) + } +} + +impl std::fmt::Display for OrganizationId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A random number generated at the start of a trace by the head of trace SDK. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] +struct SampleRand(f64); + +impl From for SampleRand { + fn from(value: f64) -> Self { + Self(value) + } +} + +impl std::str::FromStr for SampleRand { + type Err = std::num::ParseFloatError; + + fn from_str(s: &str) -> Result { + s.parse().map(Self) + } +} + +impl std::fmt::Display for SampleRand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:.6}", self.0) + } +} + +/// The [Dynamic Sampling +/// Context](https://develop.sentry.dev/sdk/telemetry/traces/dynamic-sampling-context/). +/// +/// Sentry supports sampling at the server level through [Dynamic Sampling](https://docs.sentry.io/organization/dynamic-sampling/). +/// This feature allows users to specify target sample rates for each project via the frontend instead of requiring an application redeployment. +/// The backend needs additional information from the SDK to support these features, contained in +/// the Dynamic Sampling Context. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub(crate) struct DynamicSamplingContext { + // Strictly required fields + // Still typed as optional, as when deserializing an envelope created by an older SDK they might still be missing + #[serde(default, skip_serializing_if = "Option::is_none")] + trace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + public_key: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "display_from_str_opt" + )] + sample_rate: Option, + // Required fields + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "display_from_str_opt" + )] + sample_rand: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "display_from_str_opt" + )] + sampled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + release: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + environment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + transaction: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "display_from_str_opt" + )] + org_id: Option, +} diff --git a/sentry-types/src/utils.rs b/sentry-types/src/utils.rs index f5edfb575..83d7a6581 100644 --- a/sentry-types/src/utils.rs +++ b/sentry-types/src/utils.rs @@ -189,6 +189,65 @@ pub mod ts_rfc3339_opt { } } +/// Serialize and deserialize the inner value into/from a string using the `ToString`/`FromStr` implementation. +/// +/// # Example +/// +/// ```ignore +/// use serde::{Deserialize, Serialize}; +/// +/// #[derive(Debug, PartialEq, Serialize, Deserialize)] +/// struct Config { +/// #[serde(with = "sentry_types::utils::display_from_str_opt")] +/// host: Option, +/// #[serde(with = "sentry_types::utils::display_from_str_opt")] +/// port: Option, +/// #[serde(with = "sentry_types::utils::display_from_str_opt")] +/// enabled: Option, +/// } +/// +/// let config = Config { +/// host: Some("localhost".to_string()), +/// port: Some(8080), +/// enabled: Some(true), +/// }; +/// let json = serde_json::to_string(&config).unwrap(); +/// assert_eq!(json, r#"{"host":"localhost","port":"8080","enabled":"true"}"#); +/// +/// let deserialized: Config = serde_json::from_str(&json).unwrap(); +/// assert_eq!(deserialized, config); +/// ``` +pub(crate) mod display_from_str_opt { + use serde::{de, ser, Deserialize}; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + T: ToString, + S: ser::Serializer, + { + match value { + Some(t) => serializer.serialize_str(&t.to_string()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, T, D>(deserializer: D) -> Result, D::Error> + where + T: std::str::FromStr, + T::Err: std::fmt::Display, + D: de::Deserializer<'de>, + { + let opt_string = Option::::deserialize(deserializer)?; + + match opt_string { + Some(s) => T::from_str(&s) + .map(Some) + .map_err(|e| de::Error::custom(format!("failed to parse string to type: {e}"))), + None => Ok(None), + } + } +} + #[cfg(test)] mod tests { use super::timestamp_to_datetime; diff --git a/sentry/src/transports/thread.rs b/sentry/src/transports/thread.rs index 7f45990ff..45d6a0219 100644 --- a/sentry/src/transports/thread.rs +++ b/sentry/src/transports/thread.rs @@ -7,6 +7,7 @@ use std::time::Duration; use super::ratelimit::{RateLimiter, RateLimitingCategory}; use crate::{sentry_debug, Envelope}; +#[expect(clippy::large_enum_variant)] enum Task { SendEnvelope(Envelope), Flush(SyncSender<()>), diff --git a/sentry/src/transports/tokio_thread.rs b/sentry/src/transports/tokio_thread.rs index 9323e482c..21cd19043 100644 --- a/sentry/src/transports/tokio_thread.rs +++ b/sentry/src/transports/tokio_thread.rs @@ -7,6 +7,7 @@ use std::time::Duration; use super::ratelimit::{RateLimiter, RateLimitingCategory}; use crate::{sentry_debug, Envelope}; +#[expect(clippy::large_enum_variant)] enum Task { SendEnvelope(Envelope), Flush(SyncSender<()>),