Skip to main content

relay_base_schema/
data_category.rs

1//! Defines the [`DataCategory`] type that classifies data Relay can handle.
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8use crate::events::EventType;
9
10/// An error that occurs if a number cannot be converted into a [`DataCategory`].
11#[derive(Debug, PartialEq, thiserror::Error)]
12#[error("Unknown numeric data category {0} can not be converted into a DataCategory.")]
13pub struct UnknownDataCategory(pub u32);
14
15/// Classifies the type of data that is being ingested.
16#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17#[repr(i8)]
18pub enum DataCategory {
19    /// Reserved and unused.
20    ///
21    /// SDK rate limiting behavior: ignore.
22    Default = 0,
23    /// Error events and Events with an `event_type` not explicitly listed below.
24    ///
25    /// SDK rate limiting behavior: apply to the entire envelope if it contains an item type `event`.
26    Error = 1,
27    /// Transaction events.
28    ///
29    /// SDK rate limiting behavior: apply to the entire envelope if it contains an item `transaction`.
30    Transaction = 2,
31    /// Events with an event type of `csp`.
32    ///
33    /// SDK rate limiting behavior: ignore.
34    Security = 3,
35    /// An attachment. Quantity is the size of the attachment in bytes.
36    ///
37    /// SDK rate limiting behavior: apply to all attachments.
38    Attachment = 4,
39    /// Session updates. Quantity is the number of updates in the batch.
40    ///
41    /// SDK rate limiting behavior: apply to all sessions and session aggregates.
42    Session = 5,
43    /// Profile
44    ///
45    /// This is the category for processed profiles (all profiles, whether or not we store them).
46    ///
47    /// SDK rate limiting behavior: apply to all profiles.
48    Profile = 6,
49    /// Session Replays
50    ///
51    /// SDK rate limiting behavior: apply to all Session Replay data.
52    Replay = 7,
53    /// DEPRECATED: A transaction for which metrics were extracted.
54    ///
55    /// This category is now obsolete because the `Transaction` variant will represent
56    /// processed transactions from now on.
57    ///
58    /// SDK rate limiting behavior: ignore.
59    TransactionProcessed = 8,
60    /// Indexed transaction events.
61    ///
62    /// This is the category for transaction payloads that were accepted and stored in full. In
63    /// contrast, `transaction` only guarantees that metrics have been accepted for the transaction.
64    ///
65    /// SDK rate limiting behavior: ignore.
66    TransactionIndexed = 9,
67    /// Monitor check-ins.
68    ///
69    /// SDK rate limiting behavior: apply to items of type `check_in`.
70    Monitor = 10,
71    /// Indexed Profile
72    ///
73    /// This is the category for indexed profiles that will be stored later.
74    ///
75    /// SDK rate limiting behavior: ignore.
76    ProfileIndexed = 11,
77    /// Span
78    ///
79    /// This is the category for spans from which we extracted metrics from.
80    ///
81    /// SDK rate limiting behavior: apply to spans that are not sent in a transaction.
82    Span = 12,
83    /// Monitor Seat
84    ///
85    /// Represents a monitor job that has scheduled monitor checkins. The seats are not ingested
86    /// but we define it here to prevent clashing values since this data category enumeration
87    /// is also used outside of Relay via the Python package.
88    ///
89    /// SDK rate limiting behavior: ignore.
90    MonitorSeat = 13,
91    /// User Feedback
92    ///
93    /// Represents a User Feedback processed.
94    /// Currently standardized on name UserReportV2 to avoid clashing with the old UserReport.
95    /// TODO(jferg): Rename this to UserFeedback once old UserReport is deprecated.
96    ///
97    /// SDK rate limiting behavior: apply to items of type 'feedback'.
98    UserReportV2 = 14,
99    /// Metric buckets.
100    ///
101    /// These are metrics generated and consumed by Relay.
102    ///
103    /// SDK rate limiting behavior: ignore.
104    MetricBucket = 15,
105    /// SpanIndexed
106    ///
107    /// This is the category for spans we store in full.
108    ///
109    /// SDK rate limiting behavior: ignore.
110    SpanIndexed = 16,
111    /// ProfileDuration
112    ///
113    /// This data category is used to count the number of milliseconds per indexed profile chunk,
114    /// excluding UI profile chunks.
115    ///
116    /// SDK rate limiting behavior: apply to profile chunks.
117    ProfileDuration = 17,
118    /// ProfileChunk
119    ///
120    /// This is a count of profile chunks received. It will not be used for billing but will be
121    /// useful for customers to track what's being dropped.
122    ///
123    /// SDK rate limiting behavior: apply to profile chunks.
124    ProfileChunk = 18,
125    /// MetricSecond
126    ///
127    /// Reserved by billing to summarize the bucketed product of metric volume
128    /// and metric cardinality. Defined here so as not to clash with future
129    /// categories.
130    ///
131    /// SDK rate limiting behavior: ignore.
132    MetricSecond = 19,
133    /// Replay Video
134    ///
135    /// This is the data category for Session Replays produced via a video recording.
136    ///
137    /// SDK rate limiting behavior: ignore.
138    DoNotUseReplayVideo = 20,
139    /// This is the data category for Uptime monitors.
140    ///
141    /// SDK rate limiting behavior: ignore.
142    Uptime = 21,
143    /// Counts the number of individual attachments, as opposed to the number of bytes in an attachment.
144    ///
145    /// SDK rate limiting behavior: apply to attachments.
146    AttachmentItem = 22,
147    /// LogItem
148    ///
149    /// This is the category for logs for which we store the count log events for users for measuring
150    /// missing breadcrumbs, and count of logs for rate limiting purposes.
151    ///
152    /// SDK rate limiting behavior: apply to logs.
153    LogItem = 23,
154    /// LogByte
155    ///
156    /// This is the category for logs for which we store log event total bytes for users.
157    ///
158    /// SDK rate limiting behavior: apply to logs.
159    LogByte = 24,
160    /// Profile duration of a UI profile.
161    ///
162    /// This data category is used to count the number of milliseconds per indexed UI profile
163    /// chunk.
164    ///
165    /// See also: [`Self::ProfileDuration`]
166    ///
167    /// SDK rate limiting behavior: apply to profile chunks.
168    ProfileDurationUi = 25,
169    /// UI Profile Chunk.
170    ///
171    /// This data category is used to count the number of milliseconds per indexed UI profile
172    /// chunk.
173    ///
174    /// See also: [`Self::ProfileChunk`]
175    ///
176    /// SDK rate limiting behavior: apply to profile chunks.
177    ProfileChunkUi = 26,
178    /// This is the data category to count Seer Autofix run events.
179    ///
180    /// SDK rate limiting behavior: ignore.
181    SeerAutofix = 27,
182    /// This is the data category to count Seer Scanner run events.
183    ///
184    /// SDK rate limiting behavior: ignore.
185    SeerScanner = 28,
186    /// DEPRECATED: Use SeerUser instead.
187    ///
188    /// PreventUser
189    ///
190    /// This is the data category to count the number of assigned Prevent Users.
191    ///
192    /// SDK rate limiting behavior: ignore.
193    PreventUser = 29,
194    /// PreventReview
195    ///
196    /// This is the data category to count the number of Prevent review events.
197    ///
198    /// SDK rate limiting behavior: ignore.
199    PreventReview = 30,
200    /// Size analysis
201    ///
202    /// This is the data category to count the number of size analyses performed.
203    /// 'Size analysis' a static binary analysis of a preprod build artifact
204    /// (e.g. the .apk of an Android app or MacOS .app).
205    /// When enabled there will typically be one such analysis per uploaded artifact.
206    ///
207    /// SDK rate limiting behavior: ignore.
208    SizeAnalysis = 31,
209    /// InstallableBuild
210    ///
211    /// This is the data category to count the number of installable builds.
212    /// It counts the number of artifacts uploaded *not* the number of times the
213    /// artifacts are downloaded for installation.
214    /// When enabled there will typically be one 'InstallableBuild' per uploaded artifact.
215    ///
216    /// SDK rate limiting behavior: ignore.
217    InstallableBuild = 32,
218    /// TraceMetric
219    ///
220    /// This is the data category to count the number of trace metric items.
221    TraceMetric = 33,
222    /// SeerUser
223    ///
224    /// This is the data category to count the number of Seer users.
225    ///
226    /// SDK rate limiting behavior: ignore.
227    SeerUser = 34,
228    /// Transaction profiles for backend platforms.
229    ///
230    /// This is an extension of [`Self::Profile`], but additionally discriminates on the profile
231    /// platform, see also [`Self::ProfileUi`].
232    ///
233    /// Continuous profiling uses [`Self::ProfileChunk`] and [`Self::ProfileChunkUi`].
234    ///
235    /// SDK rate limiting behavior: optional, apply to transaction profiles on "backend platforms".
236    ProfileBackend = 35,
237    /// Transaction profiles for ui platforms.
238    ///
239    /// This is an extension of [`Self::Profile`], but additionally discriminates on the profile
240    /// platform, see also [`Self::ProfileBackend`].
241    ///
242    /// Continuous profiling uses [`Self::ProfileChunk`] and [`Self::ProfileChunkUi`].
243    ///
244    /// SDK rate limiting behavior: optional, apply to transaction profiles on "ui platforms".
245    ProfileUi = 36,
246    /// TraceMetricByte
247    ///
248    /// This is the category for trace metrics for which we store total bytes for users.
249    TraceMetricByte = 37,
250    /// Snapshot image
251    ///
252    /// Counts images accepted by the preprod snapshot upload API.
253    ///
254    /// SDK rate limiting behavior: ignore.
255    SnapshotImage = 38,
256    //
257    // IMPORTANT: After adding a new entry to DataCategory, go to the `relay-cabi` subfolder and run
258    // `make header` to regenerate the C-binding. This allows using the data category from Python.
259    // Rerun this step every time the **code name** of the variant is updated.
260    //
261    /// Any other data category not known by this Relay.
262    Unknown = -1,
263}
264
265impl DataCategory {
266    /// Returns the data category corresponding to the given name.
267    pub fn from_name(string: &str) -> Self {
268        match string {
269            "default" => Self::Default,
270            "error" => Self::Error,
271            "transaction" => Self::Transaction,
272            "security" => Self::Security,
273            "attachment" => Self::Attachment,
274            "session" => Self::Session,
275            "profile" => Self::Profile,
276            "profile_indexed" => Self::ProfileIndexed,
277            "replay" => Self::Replay,
278            "transaction_processed" => Self::TransactionProcessed,
279            "transaction_indexed" => Self::TransactionIndexed,
280            "monitor" => Self::Monitor,
281            "span" => Self::Span,
282            "log_item" => Self::LogItem,
283            "log_byte" => Self::LogByte,
284            "monitor_seat" => Self::MonitorSeat,
285            "feedback" => Self::UserReportV2,
286            "user_report_v2" => Self::UserReportV2,
287            "metric_bucket" => Self::MetricBucket,
288            "span_indexed" => Self::SpanIndexed,
289            "profile_duration" => Self::ProfileDuration,
290            "profile_duration_ui" => Self::ProfileDurationUi,
291            "profile_chunk" => Self::ProfileChunk,
292            "profile_chunk_ui" => Self::ProfileChunkUi,
293            "metric_second" => Self::MetricSecond,
294            "replay_video" => Self::DoNotUseReplayVideo,
295            "uptime" => Self::Uptime,
296            "attachment_item" => Self::AttachmentItem,
297            "seer_autofix" => Self::SeerAutofix,
298            "seer_scanner" => Self::SeerScanner,
299            "prevent_user" => Self::PreventUser,
300            "prevent_review" => Self::PreventReview,
301            "size_analysis" => Self::SizeAnalysis,
302            "installable_build" => Self::InstallableBuild,
303            "trace_metric" => Self::TraceMetric,
304            "trace_metric_byte" => Self::TraceMetricByte,
305            "snapshot_image" => Self::SnapshotImage,
306            "seer_user" => Self::SeerUser,
307            "profile_backend" => Self::ProfileBackend,
308            "profile_ui" => Self::ProfileUi,
309            _ => Self::Unknown,
310        }
311    }
312
313    /// Returns the canonical name of this data category.
314    pub fn name(self) -> &'static str {
315        match self {
316            Self::Default => "default",
317            Self::Error => "error",
318            Self::Transaction => "transaction",
319            Self::Security => "security",
320            Self::Attachment => "attachment",
321            Self::Session => "session",
322            Self::Profile => "profile",
323            Self::ProfileIndexed => "profile_indexed",
324            Self::Replay => "replay",
325            Self::DoNotUseReplayVideo => "replay_video",
326            Self::TransactionProcessed => "transaction_processed",
327            Self::TransactionIndexed => "transaction_indexed",
328            Self::Monitor => "monitor",
329            Self::Span => "span",
330            Self::LogItem => "log_item",
331            Self::LogByte => "log_byte",
332            Self::MonitorSeat => "monitor_seat",
333            Self::UserReportV2 => "feedback",
334            Self::MetricBucket => "metric_bucket",
335            Self::SpanIndexed => "span_indexed",
336            Self::ProfileDuration => "profile_duration",
337            Self::ProfileDurationUi => "profile_duration_ui",
338            Self::ProfileChunk => "profile_chunk",
339            Self::ProfileChunkUi => "profile_chunk_ui",
340            Self::MetricSecond => "metric_second",
341            Self::Uptime => "uptime",
342            Self::AttachmentItem => "attachment_item",
343            Self::SeerAutofix => "seer_autofix",
344            Self::SeerScanner => "seer_scanner",
345            Self::PreventUser => "prevent_user",
346            Self::PreventReview => "prevent_review",
347            Self::SizeAnalysis => "size_analysis",
348            Self::InstallableBuild => "installable_build",
349            Self::TraceMetric => "trace_metric",
350            Self::TraceMetricByte => "trace_metric_byte",
351            Self::SnapshotImage => "snapshot_image",
352            Self::SeerUser => "seer_user",
353            Self::ProfileBackend => "profile_backend",
354            Self::ProfileUi => "profile_ui",
355            Self::Unknown => "unknown",
356        }
357    }
358
359    /// Returns true if the DataCategory refers to an error (i.e an error event).
360    pub fn is_error(self) -> bool {
361        matches!(self, Self::Error | Self::Default | Self::Security)
362    }
363
364    /// Returns the numeric value for this outcome.
365    pub fn value(self) -> Option<u8> {
366        // negative values (Internal and Unknown) cannot be sent as
367        // outcomes (internally so!)
368        (self as i8).try_into().ok()
369    }
370
371    /// Returns a dedicated category for indexing if this data can be converted to metrics.
372    ///
373    /// This returns `None` for most data categories.
374    pub fn index_category(self) -> Option<Self> {
375        match self {
376            Self::Transaction => Some(Self::TransactionIndexed),
377            Self::Span => Some(Self::SpanIndexed),
378            Self::Profile => Some(Self::ProfileIndexed),
379            _ => None,
380        }
381    }
382
383    /// Returns `true` if this data category is an indexed data category.
384    pub fn is_indexed(self) -> bool {
385        matches!(
386            self,
387            Self::TransactionIndexed | Self::SpanIndexed | Self::ProfileIndexed
388        )
389    }
390}
391
392relay_common::impl_str_serde!(DataCategory, "a data category");
393
394impl fmt::Display for DataCategory {
395    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
396        write!(f, "{}", self.name())
397    }
398}
399
400impl FromStr for DataCategory {
401    type Err = std::convert::Infallible;
402
403    fn from_str(string: &str) -> Result<Self, Self::Err> {
404        Ok(Self::from_name(string))
405    }
406}
407
408impl From<EventType> for DataCategory {
409    fn from(ty: EventType) -> Self {
410        match ty {
411            EventType::Default | EventType::Error => Self::Error,
412            EventType::Transaction => Self::Transaction,
413            EventType::Csp => Self::Security,
414            EventType::UserReportV2 => Self::UserReportV2,
415        }
416    }
417}
418
419impl TryFrom<u8> for DataCategory {
420    type Error = UnknownDataCategory;
421
422    fn try_from(value: u8) -> Result<Self, UnknownDataCategory> {
423        match value {
424            0 => Ok(Self::Default),
425            1 => Ok(Self::Error),
426            2 => Ok(Self::Transaction),
427            3 => Ok(Self::Security),
428            4 => Ok(Self::Attachment),
429            5 => Ok(Self::Session),
430            6 => Ok(Self::Profile),
431            7 => Ok(Self::Replay),
432            8 => Ok(Self::TransactionProcessed),
433            9 => Ok(Self::TransactionIndexed),
434            10 => Ok(Self::Monitor),
435            11 => Ok(Self::ProfileIndexed),
436            12 => Ok(Self::Span),
437            13 => Ok(Self::MonitorSeat),
438            14 => Ok(Self::UserReportV2),
439            15 => Ok(Self::MetricBucket),
440            16 => Ok(Self::SpanIndexed),
441            17 => Ok(Self::ProfileDuration),
442            18 => Ok(Self::ProfileChunk),
443            19 => Ok(Self::MetricSecond),
444            20 => Ok(Self::DoNotUseReplayVideo),
445            21 => Ok(Self::Uptime),
446            22 => Ok(Self::AttachmentItem),
447            23 => Ok(Self::LogItem),
448            24 => Ok(Self::LogByte),
449            25 => Ok(Self::ProfileDurationUi),
450            26 => Ok(Self::ProfileChunkUi),
451            27 => Ok(Self::SeerAutofix),
452            28 => Ok(Self::SeerScanner),
453            29 => Ok(Self::PreventUser),
454            30 => Ok(Self::PreventReview),
455            31 => Ok(Self::SizeAnalysis),
456            32 => Ok(Self::InstallableBuild),
457            33 => Ok(Self::TraceMetric),
458            34 => Ok(Self::SeerUser),
459            35 => Ok(Self::ProfileBackend),
460            36 => Ok(Self::ProfileUi),
461            37 => Ok(Self::TraceMetricByte),
462            38 => Ok(Self::SnapshotImage),
463            other => Err(UnknownDataCategory(other as u32)),
464        }
465    }
466}
467
468impl TryFrom<u32> for DataCategory {
469    type Error = UnknownDataCategory;
470
471    fn try_from(value: u32) -> Result<Self, UnknownDataCategory> {
472        let value = u8::try_from(value).map_err(|_| UnknownDataCategory(value))?;
473        value.try_into()
474    }
475}
476
477/// The unit in which a data category is measured.
478///
479/// This enum specifies how quantities for different data categories are measured,
480/// which affects how quota limits are interpreted and enforced.
481///
482/// Note: There is no `Unknown` variant. For categories without a defined unit
483/// (e.g., `DataCategory::Unknown`), methods return `Option::None`.
484//
485// IMPORTANT: After adding a new entry to CategoryUnit, go to the `relay-cabi` subfolder and run
486// `make header` to regenerate the C-binding. This allows using the category unit from Python.
487//
488#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
489#[serde(rename_all = "snake_case")]
490#[repr(i8)]
491pub enum CategoryUnit {
492    /// Counts the number of discrete items.
493    Count = 0,
494    /// Counts the number of bytes across items.
495    Bytes = 1,
496    /// Counts the accumulated time in milliseconds across items.
497    Milliseconds = 2,
498}
499
500impl CategoryUnit {
501    /// Returns the canonical name of this category unit.
502    pub fn name(self) -> &'static str {
503        match self {
504            Self::Count => "count",
505            Self::Bytes => "bytes",
506            Self::Milliseconds => "milliseconds",
507        }
508    }
509
510    /// Returns the category unit corresponding to the given name string.
511    ///
512    /// Returns `None` if the string doesn't match any known unit.
513    pub fn from_name(string: &str) -> Option<Self> {
514        match string {
515            "count" => Some(Self::Count),
516            "bytes" => Some(Self::Bytes),
517            "milliseconds" => Some(Self::Milliseconds),
518            _ => None,
519        }
520    }
521
522    /// Returns the `CategoryUnit` for the given `DataCategory`.
523    ///
524    /// Returns `None` for `DataCategory::Unknown`.
525    ///
526    /// Note: Takes a reference to avoid unnecessary copying and allow direct use with iterators.
527    pub fn from_category(category: DataCategory) -> Option<Self> {
528        match category {
529            DataCategory::Default
530            | DataCategory::Error
531            | DataCategory::Transaction
532            | DataCategory::Replay
533            | DataCategory::DoNotUseReplayVideo
534            | DataCategory::Security
535            | DataCategory::Profile
536            | DataCategory::ProfileIndexed
537            | DataCategory::TransactionProcessed
538            | DataCategory::TransactionIndexed
539            | DataCategory::LogItem
540            | DataCategory::Span
541            | DataCategory::SpanIndexed
542            | DataCategory::MonitorSeat
543            | DataCategory::Monitor
544            | DataCategory::MetricBucket
545            | DataCategory::UserReportV2
546            | DataCategory::ProfileChunk
547            | DataCategory::ProfileChunkUi
548            | DataCategory::Uptime
549            | DataCategory::MetricSecond
550            | DataCategory::AttachmentItem
551            | DataCategory::SeerAutofix
552            | DataCategory::SeerScanner
553            | DataCategory::PreventUser
554            | DataCategory::PreventReview
555            | DataCategory::Session
556            | DataCategory::SizeAnalysis
557            | DataCategory::InstallableBuild
558            | DataCategory::TraceMetric
559            | DataCategory::SeerUser
560            | DataCategory::ProfileBackend
561            | DataCategory::ProfileUi
562            | DataCategory::SnapshotImage => Some(Self::Count),
563
564            DataCategory::Attachment | DataCategory::LogByte | DataCategory::TraceMetricByte => {
565                Some(Self::Bytes)
566            }
567
568            DataCategory::ProfileDuration | DataCategory::ProfileDurationUi => {
569                Some(Self::Milliseconds)
570            }
571
572            DataCategory::Unknown => None,
573        }
574    }
575}
576
577impl fmt::Display for CategoryUnit {
578    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
579        write!(f, "{}", self.name())
580    }
581}
582
583impl FromStr for CategoryUnit {
584    type Err = ();
585
586    fn from_str(string: &str) -> Result<Self, Self::Err> {
587        Self::from_name(string).ok_or(())
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    #[test]
596    fn test_last_variant_conversion() {
597        // If this test fails, update the numeric bounds so that the first assertion
598        // maps to the last variant in the enum and the second assertion produces an error
599        // that the DataCategory does not exist.
600        assert_eq!(
601            DataCategory::try_from(38u8),
602            Ok(DataCategory::SnapshotImage)
603        );
604        assert_eq!(DataCategory::try_from(39u8), Err(UnknownDataCategory(39)));
605    }
606
607    #[test]
608    fn test_data_category_alias() {
609        assert_eq!("feedback".parse(), Ok(DataCategory::UserReportV2));
610        assert_eq!("user_report_v2".parse(), Ok(DataCategory::UserReportV2));
611        assert_eq!(&DataCategory::UserReportV2.to_string(), "feedback");
612
613        assert_eq!(
614            serde_json::from_str::<DataCategory>(r#""feedback""#).unwrap(),
615            DataCategory::UserReportV2,
616        );
617        assert_eq!(
618            serde_json::from_str::<DataCategory>(r#""user_report_v2""#).unwrap(),
619            DataCategory::UserReportV2,
620        );
621        assert_eq!(
622            &serde_json::to_string(&DataCategory::UserReportV2).unwrap(),
623            r#""feedback""#
624        )
625    }
626
627    #[test]
628    fn test_category_unit_name() {
629        assert_eq!(CategoryUnit::Count.name(), "count");
630        assert_eq!(CategoryUnit::Bytes.name(), "bytes");
631        assert_eq!(CategoryUnit::Milliseconds.name(), "milliseconds");
632    }
633
634    #[test]
635    fn test_category_unit_from_name() {
636        assert_eq!(CategoryUnit::from_name("count"), Some(CategoryUnit::Count));
637        assert_eq!(CategoryUnit::from_name("bytes"), Some(CategoryUnit::Bytes));
638        assert_eq!(
639            CategoryUnit::from_name("milliseconds"),
640            Some(CategoryUnit::Milliseconds)
641        );
642        assert_eq!(CategoryUnit::from_name("unknown"), None);
643        assert_eq!(CategoryUnit::from_name(""), None);
644    }
645
646    #[test]
647    fn test_category_unit_from_category() {
648        // Count categories
649        assert_eq!(
650            CategoryUnit::from_category(DataCategory::Error),
651            Some(CategoryUnit::Count)
652        );
653        assert_eq!(
654            CategoryUnit::from_category(DataCategory::Transaction),
655            Some(CategoryUnit::Count)
656        );
657        assert_eq!(
658            CategoryUnit::from_category(DataCategory::Span),
659            Some(CategoryUnit::Count)
660        );
661        assert_eq!(
662            CategoryUnit::from_category(DataCategory::SnapshotImage),
663            Some(CategoryUnit::Count)
664        );
665
666        // Bytes categories
667        assert_eq!(
668            CategoryUnit::from_category(DataCategory::Attachment),
669            Some(CategoryUnit::Bytes)
670        );
671        assert_eq!(
672            CategoryUnit::from_category(DataCategory::LogByte),
673            Some(CategoryUnit::Bytes)
674        );
675
676        // Milliseconds categories
677        assert_eq!(
678            CategoryUnit::from_category(DataCategory::ProfileDuration),
679            Some(CategoryUnit::Milliseconds)
680        );
681        assert_eq!(
682            CategoryUnit::from_category(DataCategory::ProfileDurationUi),
683            Some(CategoryUnit::Milliseconds)
684        );
685
686        // Unknown returns None
687        assert_eq!(CategoryUnit::from_category(DataCategory::Unknown), None);
688    }
689
690    #[test]
691    fn test_category_unit_display() {
692        assert_eq!(format!("{}", CategoryUnit::Count), "count");
693        assert_eq!(format!("{}", CategoryUnit::Bytes), "bytes");
694        assert_eq!(format!("{}", CategoryUnit::Milliseconds), "milliseconds");
695    }
696
697    #[test]
698    fn test_category_unit_from_str() {
699        assert_eq!("count".parse::<CategoryUnit>(), Ok(CategoryUnit::Count));
700        assert_eq!("bytes".parse::<CategoryUnit>(), Ok(CategoryUnit::Bytes));
701        assert_eq!(
702            "milliseconds".parse::<CategoryUnit>(),
703            Ok(CategoryUnit::Milliseconds)
704        );
705        assert!("invalid".parse::<CategoryUnit>().is_err());
706    }
707
708    #[test]
709    fn test_category_unit_repr_values() {
710        // Verify the repr(i8) values are correct for FFI
711        assert_eq!(CategoryUnit::Count as i8, 0);
712        assert_eq!(CategoryUnit::Bytes as i8, 1);
713        assert_eq!(CategoryUnit::Milliseconds as i8, 2);
714    }
715}