1use std::borrow::Cow;
2use std::collections::{BTreeMap, BTreeSet, HashMap};
3use std::error::Error;
4use std::fmt::Debug;
5use std::future::Future;
6use std::io::Write;
7use std::pin::Pin;
8use std::sync::Arc;
9use std::time::Duration;
10
11use anyhow::Context;
12use brotli::CompressorWriter as BrotliEncoder;
13use bytes::Bytes;
14use chrono::{DateTime, Utc};
15use flate2::Compression;
16use flate2::write::{GzEncoder, ZlibEncoder};
17use futures::future::BoxFuture;
18use relay_base_schema::project::{ProjectId, ProjectKey};
19use relay_cogs::{AppFeature, Cogs, FeatureWeights, ResourceId, Token};
20use relay_common::time::UnixTimestamp;
21use relay_config::{Config, ConfigSnapshot, EmitOutcomes, HttpEncoding, UpstreamDescriptor};
22use relay_event_normalization::{ClockDriftProcessor, GeoIpLookup};
23use relay_event_schema::processor::ProcessingAction;
24use relay_event_schema::protocol::ClientReport;
25use relay_filter::FilterStatKey;
26use relay_log::sentry::SentryFutureExt;
27use relay_metrics::{Bucket, BucketMetadata, BucketView, BucketsView, MetricNamespace};
28use relay_quotas::{RateLimits, Scoping};
29use relay_sampling::evaluation::SamplingDecision;
30use relay_statsd::metric;
31use relay_system::{Addr, FromMessage, NoResponse, Service};
32use reqwest::header;
33use zstd::stream::Encoder as ZstdEncoder;
34
35use crate::envelope::{self, ContentType, Envelope, EnvelopeError, Item, ItemType};
36use crate::extractors::{PartialDsn, RequestMeta, RequestTrust};
37use crate::managed::ManagedEnvelope;
38use crate::metrics::{MetricOutcomes, MetricsLimiter, MinimalTrackableBucket};
39use crate::metrics_extraction::ExtractedMetrics;
40use crate::processing::errors::SwitchProcessingError;
41use crate::processing::relay::RelayProcessor;
42use crate::processing::{Forward as _, Output, Outputs, QuotaRateLimiter};
43use crate::service::ServiceError;
44use crate::services::global_config::GlobalConfigHandle;
45use crate::services::metrics::{Aggregator, FlushBuckets, MergeBuckets, ProjectBuckets};
46use crate::services::outcome::{self, DiscardItemType, DiscardReason, Outcome, TrackOutcome};
47use crate::services::projects::cache::ProjectCacheHandle;
48use crate::services::projects::project::{ProjectInfo, ProjectState};
49use crate::services::upstream::{
50 SendRequest, Sign, SignatureType, UpstreamRelay, UpstreamRequest, UpstreamRequestError,
51};
52use crate::statsd::{RelayCounters, RelayDistributions, RelayTimers};
53use crate::utils;
54use crate::{http, processing};
55use relay_threading::AsyncPool;
56#[cfg(feature = "processing")]
57use {
58 crate::services::objectstore::Objectstore,
59 crate::services::store::Store,
60 itertools::Itertools,
61 relay_dynamic_config::GlobalConfig,
62 relay_quotas::{Quota, RateLimitingError, RedisRateLimiter},
63 relay_redis::RedisClients,
64 std::time::Instant,
65 symbolic_unreal::{Unreal4Error, Unreal4ErrorKind},
66};
67
68mod metrics;
69
70pub const MINIMUM_CLOCK_DRIFT: Duration = Duration::from_secs(55 * 60);
72
73#[derive(Debug, thiserror::Error)]
75pub enum ProcessingError {
76 #[error("invalid json in event")]
77 InvalidJson(#[source] serde_json::Error),
78
79 #[error("invalid message pack event payload")]
80 InvalidMsgpack(#[from] rmp_serde::decode::Error),
81
82 #[error("event data too deeply nested")]
83 NestingTooDeep,
84
85 #[cfg(feature = "processing")]
86 #[error("invalid unreal crash report")]
87 InvalidUnrealReport(#[source] Unreal4Error),
88
89 #[error("event payload too large")]
90 PayloadTooLarge(DiscardItemType),
91
92 #[error("invalid transaction event")]
93 InvalidTransaction,
94
95 #[error("the item is not allowed/supported in this envelope")]
96 UnsupportedItem,
97
98 #[error("envelope processor failed")]
99 ProcessingFailed(#[from] ProcessingAction),
100
101 #[error("duplicate {0} in event")]
102 DuplicateItem(ItemType),
103
104 #[error("failed to extract event payload")]
105 NoEventPayload,
106
107 #[error("invalid security report type: {0:?}")]
108 InvalidSecurityType(Bytes),
109
110 #[error("unsupported security report type")]
111 UnsupportedSecurityType,
112
113 #[error("invalid security report")]
114 InvalidSecurityReport(#[source] serde_json::Error),
115
116 #[error("event filtered with reason: {0:?}")]
117 EventFiltered(FilterStatKey),
118
119 #[error("could not serialize event payload")]
120 SerializeFailed(#[source] serde_json::Error),
121
122 #[cfg(feature = "processing")]
123 #[error("failed to apply quotas")]
124 QuotasFailed(#[from] RateLimitingError),
125
126 #[error("nintendo switch dying message processing failed {0:?}")]
127 InvalidNintendoDyingMessage(#[source] SwitchProcessingError),
128
129 #[cfg(all(sentry, feature = "processing"))]
130 #[error("playstation dump processing failed: {0}")]
131 InvalidPlaystationDump(String),
132
133 #[error("invalid flame graph attachment")]
134 InvalidFlamegraph,
135
136 #[cfg(feature = "processing")]
137 #[error("invalid attachment reference")]
138 InvalidAttachmentRef,
139}
140
141impl ProcessingError {
142 pub fn to_outcome(&self) -> Option<Outcome> {
143 match self {
144 Self::PayloadTooLarge(payload_type) => {
145 Some(Outcome::Invalid(DiscardReason::ItemTooLarge(*payload_type)))
146 }
147 Self::InvalidJson(_) => Some(Outcome::Invalid(DiscardReason::InvalidJson)),
148 Self::InvalidMsgpack(_) => Some(Outcome::Invalid(DiscardReason::InvalidMsgpack)),
149 Self::NestingTooDeep => Some(Outcome::Invalid(DiscardReason::NestingTooDeep)),
150 Self::InvalidSecurityType(_) => {
151 Some(Outcome::Invalid(DiscardReason::SecurityReportType))
152 }
153 Self::UnsupportedItem => Some(Outcome::Invalid(DiscardReason::InvalidEnvelope)),
154 Self::InvalidSecurityReport(_) => Some(Outcome::Invalid(DiscardReason::SecurityReport)),
155 Self::UnsupportedSecurityType => Some(Outcome::Filtered(FilterStatKey::InvalidCsp)),
156 Self::InvalidTransaction => Some(Outcome::Invalid(DiscardReason::InvalidTransaction)),
157 Self::InvalidFlamegraph => Some(Outcome::Invalid(DiscardReason::InvalidFlamegraph)),
158 Self::DuplicateItem(_) => Some(Outcome::Invalid(DiscardReason::DuplicateItem)),
159 Self::NoEventPayload => Some(Outcome::Invalid(DiscardReason::NoEventPayload)),
160 Self::InvalidNintendoDyingMessage(_) => Some(Outcome::Invalid(DiscardReason::Payload)),
161 #[cfg(all(sentry, feature = "processing"))]
162 Self::InvalidPlaystationDump(_) => Some(Outcome::Invalid(DiscardReason::Payload)),
163 #[cfg(feature = "processing")]
164 Self::InvalidUnrealReport(err) if err.kind() == Unreal4ErrorKind::BadCompression => {
165 Some(Outcome::Invalid(DiscardReason::InvalidCompression))
166 }
167 #[cfg(feature = "processing")]
168 Self::InvalidUnrealReport(_) => Some(Outcome::Invalid(DiscardReason::ProcessUnreal)),
169 Self::SerializeFailed(_) | Self::ProcessingFailed(_) => {
170 Some(Outcome::Invalid(DiscardReason::Internal))
171 }
172 #[cfg(feature = "processing")]
173 Self::QuotasFailed(_) => Some(Outcome::Invalid(DiscardReason::Internal)),
174 Self::EventFiltered(key) => Some(Outcome::Filtered(key.clone())),
175
176 #[cfg(feature = "processing")]
177 Self::InvalidAttachmentRef => {
178 Some(Outcome::Invalid(DiscardReason::InvalidAttachmentRef))
179 }
180 }
181 }
182}
183
184#[cfg(feature = "processing")]
185impl From<Unreal4Error> for ProcessingError {
186 fn from(err: Unreal4Error) -> Self {
187 match err.kind() {
188 Unreal4ErrorKind::TooLarge => Self::PayloadTooLarge(ItemType::UnrealReport.into()),
189 _ => ProcessingError::InvalidUnrealReport(err),
190 }
191 }
192}
193
194#[derive(Debug)]
199pub struct ProcessingExtractedMetrics {
200 metrics: ExtractedMetrics,
201}
202
203impl ProcessingExtractedMetrics {
204 pub fn new() -> Self {
205 Self {
206 metrics: ExtractedMetrics::default(),
207 }
208 }
209
210 pub fn into_inner(self) -> ExtractedMetrics {
211 self.metrics
212 }
213
214 pub fn extend(
216 &mut self,
217 extracted: ExtractedMetrics,
218 sampling_decision: Option<SamplingDecision>,
219 ) {
220 self.extend_project_metrics(extracted.0, sampling_decision);
221 }
222
223 pub fn extend_project_metrics<I>(
225 &mut self,
226 buckets: I,
227 sampling_decision: Option<SamplingDecision>,
228 ) where
229 I: IntoIterator<Item = Bucket>,
230 {
231 self.metrics.0.extend(buckets.into_iter().map(|mut bucket| {
232 bucket.metadata.extracted_from_indexed =
233 sampling_decision == Some(SamplingDecision::Keep);
234 bucket
235 }));
236 }
237}
238
239fn send_metrics(metrics: ExtractedMetrics, project_key: ProjectKey, aggregator: &Addr<Aggregator>) {
240 let ExtractedMetrics(project_metrics) = metrics;
241
242 if !project_metrics.is_empty() {
243 aggregator.send(MergeBuckets {
244 project_key,
245 buckets: project_metrics,
246 });
247 }
248}
249
250#[derive(Debug)]
260pub struct ProcessEnvelope {
261 pub envelope: ManagedEnvelope,
263 pub project_info: Arc<ProjectInfo>,
265 pub rate_limits: Arc<RateLimits>,
267 pub sampling_project_info: Option<Arc<ProjectInfo>>,
269}
270
271#[derive(Debug)]
279pub struct ProcessMetrics {
280 pub data: MetricData,
282 pub project_key: ProjectKey,
284 pub source: BucketSource,
286 pub received_at: DateTime<Utc>,
288 pub sent_at: Option<DateTime<Utc>>,
291}
292
293#[derive(Debug)]
295pub enum MetricData {
296 Raw(Vec<Item>),
298 Parsed(Vec<Bucket>),
300}
301
302impl MetricData {
303 fn into_buckets(self) -> Vec<Bucket> {
307 let items = match self {
308 Self::Parsed(buckets) => return buckets,
309 Self::Raw(items) => items,
310 };
311
312 let mut buckets = Vec::new();
313 for item in items {
314 let payload = item.payload();
315 if item.ty() == &ItemType::MetricBuckets {
316 match serde_json::from_slice::<Vec<Bucket>>(&payload) {
317 Ok(parsed_buckets) => {
318 if buckets.is_empty() {
320 buckets = parsed_buckets;
321 } else {
322 buckets.extend(parsed_buckets);
323 }
324 }
325 Err(error) => {
326 relay_log::debug!(
327 error = &error as &dyn Error,
328 "failed to parse metric bucket",
329 );
330 metric!(counter(RelayCounters::MetricBucketsParsingFailed) += 1);
331 }
332 }
333 } else {
334 relay_log::error!(
335 "invalid item of type {} passed to ProcessMetrics",
336 item.ty()
337 );
338 }
339 }
340 buckets
341 }
342}
343
344#[derive(Debug)]
345pub struct ProcessBatchedMetrics {
346 pub payload: Bytes,
348 pub source: BucketSource,
350 pub received_at: DateTime<Utc>,
352 pub sent_at: Option<DateTime<Utc>>,
354}
355
356#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
358pub enum BucketSource {
359 Internal,
365 External,
370}
371
372impl BucketSource {
373 pub fn from_meta(meta: &RequestMeta) -> Self {
375 match meta.request_trust() {
376 RequestTrust::Trusted => Self::Internal,
377 RequestTrust::Untrusted => Self::External,
378 }
379 }
380}
381
382#[derive(Debug)]
384pub struct SubmitClientReports {
385 pub client_reports: Vec<ClientReport>,
387 pub scoping: Scoping,
389}
390
391#[derive(Debug)]
393pub enum EnvelopeProcessor {
394 ProcessEnvelope(Box<ProcessEnvelope>),
395 ProcessProjectMetrics(Box<ProcessMetrics>),
396 ProcessBatchedMetrics(Box<ProcessBatchedMetrics>),
397 FlushBuckets(Box<FlushBuckets>),
398 SubmitClientReports(Box<SubmitClientReports>),
399}
400
401impl EnvelopeProcessor {
402 pub fn variant(&self) -> &'static str {
404 match self {
405 EnvelopeProcessor::ProcessEnvelope(_) => "ProcessEnvelope",
406 EnvelopeProcessor::ProcessProjectMetrics(_) => "ProcessProjectMetrics",
407 EnvelopeProcessor::ProcessBatchedMetrics(_) => "ProcessBatchedMetrics",
408 EnvelopeProcessor::FlushBuckets(_) => "FlushBuckets",
409 EnvelopeProcessor::SubmitClientReports(_) => "SubmitClientReports",
410 }
411 }
412}
413
414impl relay_system::Interface for EnvelopeProcessor {}
415
416impl FromMessage<ProcessEnvelope> for EnvelopeProcessor {
417 type Response = relay_system::NoResponse;
418
419 fn from_message(message: ProcessEnvelope, _sender: ()) -> Self {
420 Self::ProcessEnvelope(Box::new(message))
421 }
422}
423
424impl FromMessage<ProcessMetrics> for EnvelopeProcessor {
425 type Response = NoResponse;
426
427 fn from_message(message: ProcessMetrics, _: ()) -> Self {
428 Self::ProcessProjectMetrics(Box::new(message))
429 }
430}
431
432impl FromMessage<ProcessBatchedMetrics> for EnvelopeProcessor {
433 type Response = NoResponse;
434
435 fn from_message(message: ProcessBatchedMetrics, _: ()) -> Self {
436 Self::ProcessBatchedMetrics(Box::new(message))
437 }
438}
439
440impl FromMessage<FlushBuckets> for EnvelopeProcessor {
441 type Response = NoResponse;
442
443 fn from_message(message: FlushBuckets, _: ()) -> Self {
444 Self::FlushBuckets(Box::new(message))
445 }
446}
447
448impl FromMessage<SubmitClientReports> for EnvelopeProcessor {
449 type Response = NoResponse;
450
451 fn from_message(message: SubmitClientReports, _: ()) -> Self {
452 Self::SubmitClientReports(Box::new(message))
453 }
454}
455
456pub type EnvelopeProcessorServicePool = AsyncPool<BoxFuture<'static, ()>>;
458
459#[derive(Clone)]
463pub struct EnvelopeProcessorService {
464 inner: Arc<InnerProcessor>,
465}
466
467pub struct Addrs {
469 pub outcome_aggregator: Addr<TrackOutcome>,
470 pub upstream_relay: Addr<UpstreamRelay>,
471 #[cfg(feature = "processing")]
472 pub objectstore: Option<Addr<Objectstore>>,
473 #[cfg(feature = "processing")]
474 pub store_forwarder: Option<Addr<Store>>,
475 pub aggregator: Addr<Aggregator>,
476}
477
478impl Default for Addrs {
479 fn default() -> Self {
480 Addrs {
481 outcome_aggregator: Addr::dummy(),
482 upstream_relay: Addr::dummy(),
483 #[cfg(feature = "processing")]
484 objectstore: None,
485 #[cfg(feature = "processing")]
486 store_forwarder: None,
487 aggregator: Addr::dummy(),
488 }
489 }
490}
491
492struct InnerProcessor {
493 pool: EnvelopeProcessorServicePool,
494 config: Arc<Config>,
495 global_config: GlobalConfigHandle,
496 project_cache: ProjectCacheHandle,
497 cogs: Cogs,
498 addrs: Addrs,
499 #[cfg(feature = "processing")]
500 rate_limiter: Option<Arc<RedisRateLimiter>>,
501 metric_outcomes: MetricOutcomes,
502 processor: RelayProcessor,
503}
504
505impl EnvelopeProcessorService {
506 #[cfg_attr(feature = "processing", expect(clippy::too_many_arguments))]
508 pub fn new(
509 pool: EnvelopeProcessorServicePool,
510 config: Arc<Config>,
511 global_config: GlobalConfigHandle,
512 project_cache: ProjectCacheHandle,
513 cogs: Cogs,
514 #[cfg(feature = "processing")] redis: Option<RedisClients>,
515 addrs: Addrs,
516 metric_outcomes: MetricOutcomes,
517 ) -> Self {
518 let c = config.current();
519
520 let geoip_lookup = c
521 .geoip_path()
522 .and_then(
523 |p| match GeoIpLookup::open(p).context(ServiceError::GeoIp) {
524 Ok(geoip) => Some(geoip),
525 Err(err) => {
526 relay_log::error!("failed to open GeoIP db {p:?}: {err:?}");
527 None
528 }
529 },
530 )
531 .unwrap_or_else(GeoIpLookup::empty);
532
533 if let Some(build_epoch) = geoip_lookup.build_epoch() {
534 relay_log::info!("Loaded GeoIP database (build: {build_epoch})");
535 }
536
537 #[cfg(feature = "processing")]
538 let rate_limiter = redis.map(|redis| {
539 RedisRateLimiter::new(redis.quotas)
540 .max_limit(c.max_rate_limit())
541 .cache(c.quota_cache_ratio(), c.quota_cache_max())
542 });
543
544 let quota_limiter = Arc::new(QuotaRateLimiter::new(
545 #[cfg(feature = "processing")]
546 project_cache.clone(),
547 #[cfg(feature = "processing")]
548 rate_limiter.clone(),
549 ));
550 #[cfg(feature = "processing")]
551 let rate_limiter = rate_limiter.map(Arc::new);
552 let inner = InnerProcessor {
553 pool,
554 global_config,
555 project_cache,
556 #[cfg(feature = "processing")]
557 rate_limiter,
558 processor: RelayProcessor::new(
559 cogs.clone(),
560 "a_limiter,
561 &geoip_lookup,
562 addrs.outcome_aggregator.clone(),
563 ),
564 cogs,
565 addrs,
566 metric_outcomes,
567 config,
568 };
569
570 Self {
571 inner: Arc::new(inner),
572 }
573 }
574
575 async fn process_envelope(
576 &self,
577 project_id: ProjectId,
578 mut envelope: ManagedEnvelope,
579 ctx: processing::Context<'_>,
580 ) -> Vec<Output<Outputs>> {
581 if let Some(sampling_state) = ctx.sampling_project_info {
583 envelope
586 .envelope_mut()
587 .parametrize_dsc_transaction(&sampling_state.config.tx_name_rules);
588 }
589
590 envelope
595 .envelope_mut()
596 .meta_mut()
597 .set_project_id(project_id);
598
599 self.inner.processor.run(envelope, ctx).await
600 }
601
602 async fn process<'a>(
608 &self,
609 mut envelope: ManagedEnvelope,
610 ctx: processing::Context<'a>,
611 ) -> Vec<Output<Outputs>> {
612 let Some(project_id) = ctx
619 .project_info
620 .project_id
621 .or_else(|| envelope.envelope().meta().project_id())
622 else {
623 relay_log::error!(
624 tags.project_key = %envelope.envelope().meta().public_key(),
625 "project info does not contain project id"
626 );
627 envelope.reject(Outcome::Invalid(DiscardReason::Internal));
628 return Vec::new();
629 };
630
631 relay_log::configure_scope(|scope| {
632 scope.set_tag("project_id", project_id);
633 });
634
635 self.process_envelope(project_id, envelope, ctx).await
636 }
637
638 async fn handle_process_envelope(&self, cogs: &mut Token, message: ProcessEnvelope) {
639 let wait_time = message.envelope.age();
640 metric!(timer(RelayTimers::EnvelopeWaitTime) = wait_time);
641
642 cogs.cancel();
645
646 let global_config = self.inner.global_config.current().unwrap_or_default();
647 let config = self.inner.config.current();
648
649 let ctx = processing::Context {
650 config: &config,
651 global_config: &global_config,
652 project_info: &message.project_info,
653 sampling_project_info: message.sampling_project_info.as_deref(),
654 rate_limits: &message.rate_limits,
655 };
656
657 let project_key = message.envelope.meta().public_key();
658 let sampling_key = ctx
662 .sampling_project_info
663 .and_then(|p| p.get_public_key_config())
664 .map(|pkc| pkc.public_key);
665
666 relay_log::configure_scope(|scope| {
667 scope.set_tag("project_key", project_key);
668 if let Some(sampling_key) = sampling_key {
669 scope.set_tag("sampling_key", sampling_key);
670 }
671 let meta = message.envelope.envelope().meta();
672 scope.set_tag("sdk_name", meta.client_name());
673 if let Some(client) = meta.client() {
674 scope.set_tag("sdk", client);
675 }
676 if let Some(user_agent) = meta.user_agent() {
677 scope.set_extra("user_agent", user_agent.into());
678 }
679 });
680
681 let mut envelopes: smallvec::SmallVec<[ManagedEnvelope; 1]> =
682 smallvec::smallvec![message.envelope];
683
684 let mut is_intermediate = false;
686
687 while let Some(envelope) = envelopes.pop() {
688 let outputs = metric!(
689 timer(RelayTimers::EnvelopeProcessingTime),
690 is_intermediate = if is_intermediate { "true" } else { "false" },
691 { self.process(envelope, ctx).await }
692 );
693
694 let ctx = ctx.to_forward();
695 for Output {
696 main,
697 metrics,
698 intermediates,
699 } in outputs
700 {
701 if let Some(metrics) = metrics {
702 let agg = &self.inner.addrs.aggregator;
703 metrics.accept(|metrics| {
704 send_metrics(metrics, project_key, agg);
705 });
706 }
707
708 if let Some(output) = main {
709 self.submit_upstream(output, ctx);
710 }
711
712 if let Some(intermediates) = intermediates {
713 envelopes.push(intermediates)
714 }
715
716 is_intermediate = true;
718 }
719 }
720 }
721
722 fn handle_process_metrics(&self, cogs: &mut Token, message: ProcessMetrics) {
723 let ProcessMetrics {
724 data,
725 project_key,
726 received_at,
727 sent_at,
728 source,
729 } = message;
730
731 let received_timestamp =
732 UnixTimestamp::from_datetime(received_at).unwrap_or(UnixTimestamp::now());
733
734 let mut buckets = data.into_buckets();
735 if buckets.is_empty() {
736 return;
737 };
738 cogs.update(relay_metrics::cogs::BySize(&buckets));
739
740 let clock_drift_processor =
741 ClockDriftProcessor::new(sent_at, received_at).at_least(MINIMUM_CLOCK_DRIFT);
742
743 buckets.retain_mut(|bucket| {
744 if let Err(error) = relay_metrics::normalize_bucket(bucket) {
745 relay_log::debug!(error = &error as &dyn Error, "dropping bucket {bucket:?}");
746 return false;
747 }
748
749 if !self::metrics::is_valid_namespace(bucket, source) {
750 relay_log::debug!("dropping bucket in invalid namespace {bucket:?}");
751 return false;
752 }
753
754 clock_drift_processor.process_timestamp(&mut bucket.timestamp);
755
756 if !matches!(source, BucketSource::Internal) {
757 bucket.metadata = BucketMetadata::new(received_timestamp);
758 }
759
760 true
761 });
762
763 let project = self.inner.project_cache.get(project_key);
764
765 let buckets = match project.state() {
768 ProjectState::Enabled(project_info) => {
769 let rate_limits = project.rate_limits().current_limits();
770 self.check_buckets(project_key, project_info, &rate_limits, buckets)
771 }
772 _ => buckets,
773 };
774
775 relay_log::trace!("merging metric buckets into the aggregator");
776 self.inner
777 .addrs
778 .aggregator
779 .send(MergeBuckets::new(project_key, buckets));
780 }
781
782 fn handle_process_batched_metrics(&self, cogs: &mut Token, message: ProcessBatchedMetrics) {
783 let ProcessBatchedMetrics {
784 payload,
785 source,
786 received_at,
787 sent_at,
788 } = message;
789
790 #[derive(serde::Deserialize)]
791 struct Wrapper {
792 buckets: HashMap<ProjectKey, Vec<Bucket>>,
793 }
794
795 let buckets = match serde_json::from_slice(&payload) {
796 Ok(Wrapper { buckets }) => buckets,
797 Err(error) => {
798 relay_log::debug!(
799 error = &error as &dyn Error,
800 "failed to parse batched metrics",
801 );
802 metric!(counter(RelayCounters::MetricBucketsParsingFailed) += 1);
803 return;
804 }
805 };
806
807 for (project_key, buckets) in buckets {
808 self.handle_process_metrics(
809 cogs,
810 ProcessMetrics {
811 data: MetricData::Parsed(buckets),
812 project_key,
813 source,
814 received_at,
815 sent_at,
816 },
817 )
818 }
819 }
820
821 fn submit_upstream(&self, output: Outputs, ctx: processing::ForwardContext<'_>) {
825 #[cfg(feature = "processing")]
826 if ctx.config.processing_enabled()
827 && let Some(store_forwarder) = &self.inner.addrs.store_forwarder
828 {
829 use crate::processing::StoreHandle;
830
831 let objectstore = self.inner.addrs.objectstore.as_ref();
832 let handle = StoreHandle::new(store_forwarder, objectstore);
833
834 output
835 .forward_store(handle, ctx)
836 .unwrap_or_else(|err| err.into_inner());
837
838 return;
839 }
840
841 match output.serialize_envelope(ctx) {
842 Ok(envelope) => {
843 let envelope = ManagedEnvelope::from(envelope);
844 self.submit_envelope_upstream(
845 envelope,
846 ctx.config,
847 ctx.project_info.upstream.clone(),
848 );
849 }
850 Err(_) => relay_log::error!("failed to serialize output to an envelope"),
851 };
852 }
853
854 fn submit_envelope_upstream(
855 &self,
856 mut envelope: ManagedEnvelope,
857 config: &ConfigSnapshot,
858 upstream: Option<UpstreamDescriptor>,
861 ) {
862 if envelope.envelope_mut().is_empty() {
863 envelope.accept();
864 return;
865 }
866
867 if config.processing_enabled() {
873 relay_log::error!(
874 "attempt to forward envelope to http upstream when processing is enabled"
875 );
876 return;
877 }
878
879 envelope.envelope_mut().set_sent_at(Utc::now());
885
886 relay_log::trace!("sending envelope to sentry endpoint");
887 let http_encoding = config.http_encoding();
888 let result = envelope.envelope().to_vec().and_then(|v| {
889 encode_payload(&v.into(), http_encoding).map_err(EnvelopeError::PayloadIoFailed)
890 });
891
892 match result {
893 Ok(body) => {
894 self.inner
895 .addrs
896 .upstream_relay
897 .send(SendRequest(SendEnvelope {
898 upstream,
899 envelope,
900 body,
901 http_encoding,
902 project_cache: self.inner.project_cache.clone(),
903 }));
904 }
905 Err(error) => {
906 relay_log::error!(
909 error = &error as &dyn Error,
910 tags.project_key = %envelope.scoping().project_key,
911 "failed to serialize envelope payload"
912 );
913
914 envelope.reject(Outcome::Invalid(DiscardReason::Internal));
915 }
916 }
917 }
918
919 fn handle_submit_client_reports(&self, message: SubmitClientReports) {
920 let SubmitClientReports {
921 client_reports,
922 scoping,
923 } = message;
924
925 relay_log::trace!(
926 "sending {} client report(s) to project id {}",
927 client_reports.len(),
928 scoping.project_id
929 );
930
931 if client_reports.is_empty() {
932 return;
933 }
934
935 let config = self.inner.config.current();
936 let upstream = config.upstream();
937 let dsn = PartialDsn::outbound(&scoping, upstream);
938
939 let mut envelope = Envelope::from_request(None, RequestMeta::outbound(dsn));
940 for client_report in client_reports {
941 match client_report.serialize() {
942 Ok(payload) => {
943 let mut item = Item::new(ItemType::ClientReport);
944 item.set_payload(ContentType::Json, payload);
945 envelope.add_item(item);
946 }
947 Err(error) => {
948 relay_log::error!(
949 error = &error as &dyn std::error::Error,
950 "failed to serialize client report"
951 );
952 }
953 }
954 }
955
956 let envelope = ManagedEnvelope::new(envelope, self.inner.addrs.outcome_aggregator.clone());
957 self.submit_envelope_upstream(envelope, &self.inner.config.current(), None);
958 }
959
960 fn check_buckets(
961 &self,
962 project_key: ProjectKey,
963 project_info: &ProjectInfo,
964 rate_limits: &RateLimits,
965 buckets: Vec<Bucket>,
966 ) -> Vec<Bucket> {
967 let Some(scoping) = project_info.scoping(project_key) else {
968 relay_log::error!(
969 tags.project_key = project_key.as_str(),
970 "there is no scoping: dropping {} buckets",
971 buckets.len(),
972 );
973 return Vec::new();
974 };
975
976 let mut buckets =
977 self::metrics::remove_invalid_namespaces(buckets, &self.inner.metric_outcomes, scoping);
978
979 let mut namespaces: BTreeSet<MetricNamespace> = buckets
980 .iter()
981 .filter_map(|bucket| bucket.name.try_namespace())
982 .collect();
983
984 namespaces.remove(&MetricNamespace::Outcomes);
986
987 for namespace in namespaces {
988 let limits = rate_limits
989 .check_with_quotas(project_info.get_quotas(), &scoping.metric_bucket(namespace));
990
991 if limits.is_limited() {
992 let rejected;
993 (buckets, rejected) = utils::split_off(buckets, |bucket| {
994 bucket.name.try_namespace() == Some(namespace)
995 });
996
997 let reason_code = limits.longest().and_then(|limit| limit.reason_code.clone());
998 self.inner.metric_outcomes.track(
999 scoping,
1000 &rejected,
1001 Outcome::RateLimited(reason_code),
1002 );
1003 }
1004 }
1005
1006 let quotas = project_info.config.quotas.clone();
1007 match MetricsLimiter::create(buckets, quotas, scoping) {
1008 Ok(mut bucket_limiter) => {
1009 bucket_limiter.enforce_limits(rate_limits, &self.inner.metric_outcomes);
1010 bucket_limiter.into_buckets()
1011 }
1012 Err(buckets) => buckets,
1013 }
1014 }
1015
1016 #[cfg(feature = "processing")]
1017 async fn rate_limit_buckets(
1018 &self,
1019 scoping: Scoping,
1020 project_info: &ProjectInfo,
1021 mut buckets: Vec<Bucket>,
1022 ) -> Vec<Bucket> {
1023 let Some(rate_limiter) = &self.inner.rate_limiter else {
1024 return buckets;
1025 };
1026
1027 let global_config = self.inner.global_config.current().unwrap_or_default();
1028 let mut namespaces = buckets
1029 .iter()
1030 .filter_map(|bucket| bucket.name.try_namespace())
1031 .counts();
1032
1033 namespaces.remove(&MetricNamespace::Outcomes);
1035
1036 let quotas = CombinedQuotas::new(&global_config, project_info.get_quotas());
1037
1038 for (namespace, quantity) in namespaces {
1039 let item_scoping = scoping.metric_bucket(namespace);
1040
1041 let limits = match rate_limiter
1042 .is_rate_limited(quotas, &item_scoping, quantity, false)
1043 .await
1044 {
1045 Ok(limits) => limits,
1046 Err(err) => {
1047 relay_log::error!(
1048 error = &err as &dyn std::error::Error,
1049 "failed to check redis rate limits"
1050 );
1051 break;
1052 }
1053 };
1054
1055 if limits.is_limited() {
1056 let rejected;
1057 (buckets, rejected) = utils::split_off(buckets, |bucket| {
1058 bucket.name.try_namespace() == Some(namespace)
1059 });
1060
1061 let reason_code = limits.longest().and_then(|limit| limit.reason_code.clone());
1062 self.inner.metric_outcomes.track(
1063 scoping,
1064 &rejected,
1065 Outcome::RateLimited(reason_code),
1066 );
1067
1068 self.inner
1069 .project_cache
1070 .get(item_scoping.scoping.project_key)
1071 .rate_limits()
1072 .merge(limits);
1073 }
1074 }
1075
1076 match MetricsLimiter::create(buckets, project_info.config.quotas.clone(), scoping) {
1077 Err(buckets) => buckets,
1078 Ok(bucket_limiter) => self.apply_other_rate_limits(bucket_limiter).await,
1079 }
1080 }
1081
1082 #[cfg(feature = "processing")]
1084 async fn apply_other_rate_limits(&self, mut bucket_limiter: MetricsLimiter) -> Vec<Bucket> {
1085 relay_log::trace!("handle_rate_limit_buckets");
1086
1087 let scoping = *bucket_limiter.scoping();
1088
1089 if let Some(rate_limiter) = self.inner.rate_limiter.as_ref() {
1090 let global_config = self.inner.global_config.current().unwrap_or_default();
1091 let quotas = CombinedQuotas::new(&global_config, bucket_limiter.quotas());
1092
1093 let over_accept_once = true;
1096 let mut rate_limits = RateLimits::new();
1097
1098 let (category, count) = bucket_limiter.count();
1099
1100 let timer = Instant::now();
1101 let mut is_limited = false;
1102
1103 if let Some(count) = count {
1104 match rate_limiter
1105 .is_rate_limited(quotas, &scoping.item(category), count, over_accept_once)
1106 .await
1107 {
1108 Ok(limits) => {
1109 is_limited = limits.is_limited();
1110 rate_limits.merge(limits)
1111 }
1112 Err(e) => {
1113 relay_log::error!(error = &e as &dyn Error, "rate limiting error")
1114 }
1115 }
1116 }
1117
1118 relay_statsd::metric!(
1119 timer(RelayTimers::RateLimitBucketsDuration) = timer.elapsed(),
1120 category = category.name(),
1121 limited = if is_limited { "true" } else { "false" },
1122 count = match count {
1123 None => "none",
1124 Some(0) => "0",
1125 Some(1) => "1",
1126 Some(1..=10) => "10",
1127 Some(1..=25) => "25",
1128 Some(1..=50) => "50",
1129 Some(51..=100) => "100",
1130 Some(101..=500) => "500",
1131 _ => "> 500",
1132 },
1133 );
1134
1135 if rate_limits.is_limited() {
1136 let was_enforced =
1137 bucket_limiter.enforce_limits(&rate_limits, &self.inner.metric_outcomes);
1138
1139 if was_enforced {
1140 self.inner
1142 .project_cache
1143 .get(scoping.project_key)
1144 .rate_limits()
1145 .merge(rate_limits);
1146 }
1147 }
1148 }
1149
1150 bucket_limiter.into_buckets()
1151 }
1152
1153 #[cfg(feature = "processing")]
1160 async fn encode_metrics_processing(
1161 &self,
1162 message: FlushBuckets,
1163 store_forwarder: &Addr<Store>,
1164 ) {
1165 use crate::constants::DEFAULT_EVENT_RETENTION;
1166 use crate::services::store::StoreMetrics;
1167
1168 for ProjectBuckets {
1169 buckets,
1170 scoping,
1171 project_info,
1172 ..
1173 } in message.buckets.into_values()
1174 {
1175 let mut buckets = self
1176 .rate_limit_buckets(scoping, &project_info, buckets)
1177 .await;
1178
1179 if buckets.is_empty() {
1180 continue;
1181 }
1182
1183 self.inner
1185 .metric_outcomes
1186 .track_accepted_outcome(scoping, &mut buckets);
1187
1188 let retention = project_info
1189 .config
1190 .event_retention
1191 .unwrap_or(DEFAULT_EVENT_RETENTION);
1192
1193 store_forwarder.send(StoreMetrics {
1196 buckets,
1197 scoping,
1198 retention,
1199 });
1200 }
1201 }
1202
1203 fn encode_metrics_envelope(&self, message: FlushBuckets) {
1213 let FlushBuckets {
1214 partition_key,
1215 buckets,
1216 } = message;
1217
1218 let config = self.inner.config.current();
1219 let batch_size = config.metrics_max_batch_size_bytes();
1220 let upstream = config.upstream();
1221
1222 for ProjectBuckets {
1223 buckets,
1224 scoping,
1225 project_info,
1226 ..
1227 } in buckets.values()
1228 {
1229 let dsn = PartialDsn::outbound(scoping, upstream);
1230
1231 relay_statsd::metric!(
1232 distribution(RelayDistributions::PartitionKeys) = u64::from(partition_key)
1233 );
1234
1235 let mut num_batches = 0;
1236 for buckets in BucketsView::from(buckets).by_size(batch_size) {
1237 let mut envelope = Envelope::from_request(None, RequestMeta::outbound(dsn.clone()));
1238
1239 let mut item = Item::new(ItemType::MetricBuckets);
1240 item.set_source_quantities(crate::metrics::extract_quantities(buckets));
1241 item.set_payload(ContentType::Json, serde_json::to_vec(&buckets).unwrap());
1242 envelope.add_item(item);
1243
1244 let mut envelope =
1245 ManagedEnvelope::new(envelope, self.inner.addrs.outcome_aggregator.clone());
1246 envelope
1247 .set_partition_key(Some(partition_key))
1248 .scope(*scoping);
1249
1250 relay_statsd::metric!(
1251 distribution(RelayDistributions::BucketsPerBatch) = buckets.len() as u64
1252 );
1253
1254 self.submit_envelope_upstream(envelope, &config, project_info.upstream.clone());
1255 num_batches += 1;
1256 }
1257
1258 relay_statsd::metric!(
1259 distribution(RelayDistributions::BatchesPerPartition) = num_batches
1260 );
1261 }
1262 }
1263
1264 fn send_global_partition(
1266 &self,
1267 upstream: Option<UpstreamDescriptor>,
1268 partition_key: u32,
1269 partition: &mut Partition<'_>,
1270 ) {
1271 if partition.is_empty() {
1272 return;
1273 }
1274
1275 let (unencoded, project_info) = partition.take();
1276 let http_encoding = self.inner.config.current().http_encoding();
1277 let encoded = match encode_payload(&unencoded, http_encoding) {
1278 Ok(payload) => payload,
1279 Err(error) => {
1280 let error = &error as &dyn std::error::Error;
1281 relay_log::error!(error, "failed to encode metrics payload");
1282 return;
1283 }
1284 };
1285
1286 let request = SendMetricsRequest {
1287 upstream,
1288 partition_key: partition_key.to_string(),
1289 unencoded,
1290 encoded,
1291 project_info,
1292 http_encoding,
1293 metric_outcomes: self.inner.metric_outcomes.clone(),
1294 };
1295
1296 self.inner.addrs.upstream_relay.send(SendRequest(request));
1297 }
1298
1299 fn encode_metrics_global(&self, message: FlushBuckets) {
1310 let FlushBuckets {
1311 partition_key,
1312 buckets,
1313 } = message;
1314
1315 let batch_size = self.inner.config.current().metrics_max_batch_size_bytes();
1316 let mut partitions = BTreeMap::new();
1317 let mut partition_splits = 0;
1318
1319 for ProjectBuckets {
1320 buckets,
1321 scoping,
1322 project_info,
1323 ..
1324 } in buckets.values()
1325 {
1326 let partition = match partitions.get_mut(&project_info.upstream) {
1327 Some(partition) => partition,
1328 None => partitions
1329 .entry(project_info.upstream.clone())
1330 .or_insert_with(|| Partition::new(batch_size)),
1331 };
1332
1333 for bucket in buckets {
1334 let mut remaining = Some(BucketView::new(bucket));
1335
1336 while let Some(bucket) = remaining.take() {
1337 if let Some(next) = partition.insert(bucket, *scoping) {
1338 self.send_global_partition(
1342 project_info.upstream.clone(),
1343 partition_key,
1344 partition,
1345 );
1346 remaining = Some(next);
1347 partition_splits += 1;
1348 }
1349 }
1350 }
1351 }
1352
1353 if partition_splits > 0 {
1354 metric!(distribution(RelayDistributions::PartitionSplits) = partition_splits);
1355 }
1356
1357 for (upstream, mut partition) in partitions {
1358 self.send_global_partition(upstream, partition_key, &mut partition);
1359 }
1360 }
1361
1362 fn encode_metrics_client_reports(&self, mut message: FlushBuckets) -> FlushBuckets {
1366 for ProjectBuckets {
1367 buckets, scoping, ..
1368 } in message.buckets.values_mut()
1369 {
1370 let client_reports = outcome::metric::extract_client_reports(buckets).collect();
1371
1372 self.handle_submit_client_reports(SubmitClientReports {
1373 client_reports,
1374 scoping: *scoping,
1375 });
1376 }
1377
1378 message
1379 }
1380
1381 async fn handle_flush_buckets(&self, mut message: FlushBuckets) {
1382 for (project_key, pb) in message.buckets.iter_mut() {
1383 let buckets = std::mem::take(&mut pb.buckets);
1384 pb.buckets =
1385 self.check_buckets(*project_key, &pb.project_info, &pb.rate_limits, buckets);
1386 }
1387
1388 let config = self.inner.config.current();
1389
1390 #[cfg(feature = "processing")]
1391 if config.processing_enabled()
1392 && let Some(ref store_forwarder) = self.inner.addrs.store_forwarder
1393 {
1394 return self
1395 .encode_metrics_processing(message, store_forwarder)
1396 .await;
1397 }
1398
1399 if config.emit_outcomes() == EmitOutcomes::AsClientReports {
1402 message = self.encode_metrics_client_reports(message);
1405 }
1406
1407 if config.http_global_metrics() {
1408 self.encode_metrics_global(message)
1409 } else {
1410 self.encode_metrics_envelope(message)
1411 }
1412 }
1413
1414 #[cfg(all(test, feature = "processing"))]
1415 fn redis_rate_limiter_enabled(&self) -> bool {
1416 self.inner.rate_limiter.is_some()
1417 }
1418
1419 async fn handle_message(self, message: EnvelopeProcessor) {
1420 let ty = message.variant();
1421 let feature_weights = self.feature_weights(&message);
1422
1423 metric!(timer(RelayTimers::ProcessMessageDuration), message = ty, {
1424 let mut cogs = self.inner.cogs.timed(ResourceId::Relay, feature_weights);
1425
1426 match message {
1427 EnvelopeProcessor::ProcessEnvelope(m) => {
1428 self.handle_process_envelope(&mut cogs, *m).await
1429 }
1430 EnvelopeProcessor::ProcessProjectMetrics(m) => {
1431 self.handle_process_metrics(&mut cogs, *m)
1432 }
1433 EnvelopeProcessor::ProcessBatchedMetrics(m) => {
1434 self.handle_process_batched_metrics(&mut cogs, *m)
1435 }
1436 EnvelopeProcessor::FlushBuckets(m) => self.handle_flush_buckets(*m).await,
1437 EnvelopeProcessor::SubmitClientReports(m) => self.handle_submit_client_reports(*m),
1438 }
1439 });
1440 }
1441
1442 fn feature_weights(&self, message: &EnvelopeProcessor) -> FeatureWeights {
1443 match message {
1444 EnvelopeProcessor::ProcessEnvelope(_) => AppFeature::Unattributed.into(),
1446 EnvelopeProcessor::ProcessProjectMetrics(_) => AppFeature::Unattributed.into(),
1447 EnvelopeProcessor::ProcessBatchedMetrics(_) => AppFeature::Unattributed.into(),
1448 EnvelopeProcessor::FlushBuckets(v) => v
1449 .buckets
1450 .values()
1451 .map(|s| {
1452 if self.inner.config.current().processing_enabled() {
1453 relay_metrics::cogs::ByCount(&s.buckets).into()
1456 } else {
1457 relay_metrics::cogs::BySize(&s.buckets).into()
1458 }
1459 })
1460 .fold(FeatureWeights::none(), FeatureWeights::merge),
1461 EnvelopeProcessor::SubmitClientReports(_) => AppFeature::ClientReports.into(),
1462 }
1463 }
1464}
1465
1466impl Service for EnvelopeProcessorService {
1467 type Interface = EnvelopeProcessor;
1468
1469 async fn run(self, mut rx: relay_system::Receiver<Self::Interface>) {
1470 while let Some(message) = rx.recv().await {
1471 let service = self.clone();
1472 let hub = relay_log::Hub::new_from_top(relay_log::Hub::current());
1474
1475 self.inner
1476 .pool
1477 .spawn_async(Box::pin(service.handle_message(message).bind_hub(hub)))
1478 .await;
1479 }
1480 }
1481}
1482
1483pub fn encode_payload(body: &Bytes, http_encoding: HttpEncoding) -> Result<Bytes, std::io::Error> {
1484 let envelope_body: Vec<u8> = match http_encoding {
1485 HttpEncoding::Identity => return Ok(body.clone()),
1486 HttpEncoding::Deflate => {
1487 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
1488 encoder.write_all(body.as_ref())?;
1489 encoder.finish()?
1490 }
1491 HttpEncoding::Gzip => {
1492 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
1493 encoder.write_all(body.as_ref())?;
1494 encoder.finish()?
1495 }
1496 HttpEncoding::Br => {
1497 let mut encoder = BrotliEncoder::new(Vec::new(), 0, 5, 22);
1499 encoder.write_all(body.as_ref())?;
1500 encoder.into_inner()
1501 }
1502 HttpEncoding::Zstd => {
1503 let mut encoder = ZstdEncoder::new(Vec::new(), 1)?;
1506 encoder.write_all(body.as_ref())?;
1507 encoder.finish()?
1508 }
1509 };
1510
1511 Ok(envelope_body.into())
1512}
1513
1514#[derive(Debug)]
1516pub struct SendEnvelope {
1517 pub upstream: Option<UpstreamDescriptor>,
1518 pub envelope: ManagedEnvelope,
1519 pub body: Bytes,
1520 pub http_encoding: HttpEncoding,
1521 pub project_cache: ProjectCacheHandle,
1522}
1523
1524impl UpstreamRequest for SendEnvelope {
1525 fn upstream(&self) -> Option<&UpstreamDescriptor> {
1526 self.upstream.as_ref()
1527 }
1528
1529 fn method(&self) -> reqwest::Method {
1530 reqwest::Method::POST
1531 }
1532
1533 fn path(&self) -> Cow<'_, str> {
1534 format!("/api/{}/envelope/", self.envelope.scoping().project_id).into()
1535 }
1536
1537 fn route(&self) -> &'static str {
1538 "envelope"
1539 }
1540
1541 fn build(&mut self, builder: &mut http::RequestBuilder) -> Result<(), http::HttpError> {
1542 let envelope_body = self.body.clone();
1543
1544 let meta = &self.envelope.meta();
1545 let shard = self.envelope.partition_key().map(|p| p.to_string());
1546 builder
1547 .content_encoding(self.http_encoding)
1548 .header_opt("Origin", meta.origin().map(|url| url.as_str()))
1549 .header_opt("User-Agent", meta.user_agent())
1550 .header("X-Sentry-Auth", meta.auth_header())
1551 .header("X-Forwarded-For", meta.forwarded_for())
1552 .header("Content-Type", envelope::CONTENT_TYPE)
1553 .header_opt("X-Sentry-Relay-Shard", shard)
1554 .body(envelope_body);
1555
1556 Ok(())
1557 }
1558
1559 fn sign(&mut self) -> Option<Sign> {
1560 Some(Sign::Optional(SignatureType::RequestSign))
1561 }
1562
1563 fn respond(
1564 self: Box<Self>,
1565 result: Result<http::Response, UpstreamRequestError>,
1566 ) -> Pin<Box<dyn Future<Output = ()> + Send + Sync>> {
1567 Box::pin(async move {
1568 let result = match result {
1569 Ok(mut response) => response.consume().await.map_err(UpstreamRequestError::Http),
1570 Err(error) => Err(error),
1571 };
1572
1573 match result {
1574 Ok(()) => self.envelope.accept(),
1575 Err(error) if error.is_received() => {
1576 let scoping = self.envelope.scoping();
1577 self.envelope.accept();
1578
1579 if let UpstreamRequestError::RateLimited(limits) = error {
1580 self.project_cache
1581 .get(scoping.project_key)
1582 .rate_limits()
1583 .merge(limits.scope(&scoping));
1584 }
1585 }
1586 Err(error) => {
1587 let mut envelope = self.envelope;
1590 envelope.reject(Outcome::Invalid(DiscardReason::Internal));
1591 relay_log::error!(
1592 error = &error as &dyn Error,
1593 tags.project_key = %envelope.scoping().project_key,
1594 "error sending envelope"
1595 );
1596 }
1597 }
1598 })
1599 }
1600}
1601
1602#[derive(Debug)]
1609struct Partition<'a> {
1610 max_size: usize,
1611 remaining: usize,
1612 views: HashMap<ProjectKey, Vec<BucketView<'a>>>,
1613 project_info: HashMap<ProjectKey, Scoping>,
1614}
1615
1616impl<'a> Partition<'a> {
1617 pub fn new(size: usize) -> Self {
1619 Self {
1620 max_size: size,
1621 remaining: size,
1622 views: HashMap::new(),
1623 project_info: HashMap::new(),
1624 }
1625 }
1626
1627 pub fn insert(&mut self, bucket: BucketView<'a>, scoping: Scoping) -> Option<BucketView<'a>> {
1638 let (current, next) = bucket.split(self.remaining, Some(self.max_size));
1639
1640 if let Some(current) = current {
1641 self.remaining = self.remaining.saturating_sub(current.estimated_size());
1642 self.views
1643 .entry(scoping.project_key)
1644 .or_default()
1645 .push(current);
1646
1647 self.project_info
1648 .entry(scoping.project_key)
1649 .or_insert(scoping);
1650 }
1651
1652 next
1653 }
1654
1655 fn is_empty(&self) -> bool {
1657 self.views.is_empty()
1658 }
1659
1660 fn take(&mut self) -> (Bytes, HashMap<ProjectKey, Scoping>) {
1664 #[derive(serde::Serialize)]
1665 struct Wrapper<'a> {
1666 buckets: &'a HashMap<ProjectKey, Vec<BucketView<'a>>>,
1667 }
1668
1669 let buckets = &self.views;
1670 let payload = serde_json::to_vec(&Wrapper { buckets }).unwrap().into();
1671
1672 let scopings = std::mem::take(&mut self.project_info);
1673
1674 self.views.clear();
1675 self.remaining = self.max_size;
1676
1677 (payload, scopings)
1678 }
1679}
1680
1681#[derive(Debug)]
1685struct SendMetricsRequest {
1686 upstream: Option<UpstreamDescriptor>,
1688 partition_key: String,
1690 unencoded: Bytes,
1692 encoded: Bytes,
1694 project_info: HashMap<ProjectKey, Scoping>,
1698 http_encoding: HttpEncoding,
1700 metric_outcomes: MetricOutcomes,
1702}
1703
1704impl SendMetricsRequest {
1705 fn create_error_outcomes(self) {
1706 #[derive(serde::Deserialize)]
1707 struct Wrapper {
1708 buckets: HashMap<ProjectKey, Vec<MinimalTrackableBucket>>,
1709 }
1710
1711 let buckets = match serde_json::from_slice(&self.unencoded) {
1712 Ok(Wrapper { buckets }) => buckets,
1713 Err(err) => {
1714 relay_log::error!(
1715 error = &err as &dyn std::error::Error,
1716 "failed to parse buckets from failed transmission"
1717 );
1718 return;
1719 }
1720 };
1721
1722 for (key, buckets) in buckets {
1723 let Some(&scoping) = self.project_info.get(&key) else {
1724 relay_log::error!("missing scoping for project key");
1725 continue;
1726 };
1727
1728 self.metric_outcomes.track(
1729 scoping,
1730 &buckets,
1731 Outcome::Invalid(DiscardReason::Internal),
1732 );
1733 }
1734 }
1735}
1736
1737impl UpstreamRequest for SendMetricsRequest {
1738 fn upstream(&self) -> Option<&UpstreamDescriptor> {
1739 self.upstream.as_ref()
1740 }
1741
1742 fn set_relay_id(&self) -> bool {
1743 true
1744 }
1745
1746 fn sign(&mut self) -> Option<Sign> {
1747 Some(Sign::Required(SignatureType::Body(self.unencoded.clone())))
1748 }
1749
1750 fn method(&self) -> reqwest::Method {
1751 reqwest::Method::POST
1752 }
1753
1754 fn path(&self) -> Cow<'_, str> {
1755 "/api/0/relays/metrics/".into()
1756 }
1757
1758 fn route(&self) -> &'static str {
1759 "global_metrics"
1760 }
1761
1762 fn build(&mut self, builder: &mut http::RequestBuilder) -> Result<(), http::HttpError> {
1763 builder
1764 .content_encoding(self.http_encoding)
1765 .header("X-Sentry-Relay-Shard", self.partition_key.as_bytes())
1766 .header(header::CONTENT_TYPE, b"application/json")
1767 .body(self.encoded.clone());
1768
1769 Ok(())
1770 }
1771
1772 fn respond(
1773 self: Box<Self>,
1774 result: Result<http::Response, UpstreamRequestError>,
1775 ) -> Pin<Box<dyn Future<Output = ()> + Send + Sync>> {
1776 Box::pin(async {
1777 match result {
1778 Ok(mut response) => {
1779 response.consume().await.ok();
1780 }
1781 Err(error) => {
1782 relay_log::error!(error = &error as &dyn Error, "Failed to send metrics batch");
1783
1784 if error.is_received() {
1787 return;
1788 }
1789
1790 self.create_error_outcomes()
1791 }
1792 }
1793 })
1794 }
1795}
1796
1797#[derive(Copy, Clone, Debug)]
1799#[cfg(feature = "processing")]
1800struct CombinedQuotas<'a> {
1801 global_quotas: &'a [Quota],
1802 project_quotas: &'a [Quota],
1803}
1804
1805#[cfg(feature = "processing")]
1806impl<'a> CombinedQuotas<'a> {
1807 pub fn new(global_config: &'a GlobalConfig, project_quotas: &'a [Quota]) -> Self {
1809 Self {
1810 global_quotas: &global_config.quotas,
1811 project_quotas,
1812 }
1813 }
1814}
1815
1816#[cfg(feature = "processing")]
1817impl<'a> IntoIterator for CombinedQuotas<'a> {
1818 type Item = &'a Quota;
1819 type IntoIter = std::iter::Chain<std::slice::Iter<'a, Quota>, std::slice::Iter<'a, Quota>>;
1820
1821 fn into_iter(self) -> Self::IntoIter {
1822 self.global_quotas.iter().chain(self.project_quotas.iter())
1823 }
1824}
1825
1826#[cfg(test)]
1827mod tests {
1828 use insta::assert_debug_snapshot;
1829 use relay_common::glob2::LazyGlob;
1830 use relay_dynamic_config::ProjectConfig;
1831 use relay_event_normalization::{
1832 NormalizationConfig, RedactionRule, TransactionNameConfig, TransactionNameRule,
1833 };
1834 use relay_event_schema::protocol::{Event, EventId, TransactionSource};
1835 use relay_pii::DataScrubbingConfig;
1836 use relay_protocol::Annotated;
1837 #[cfg(feature = "processing")]
1838 use relay_quotas::DataCategory;
1839 use similar_asserts::assert_eq;
1840
1841 use crate::testutils::{create_test_processor, create_test_processor_with_addrs};
1842
1843 #[cfg(feature = "processing")]
1844 use {
1845 relay_metrics::BucketValue,
1846 relay_quotas::{QuotaScope, ReasonCode},
1847 relay_test::mock_service,
1848 };
1849
1850 use super::*;
1851
1852 async fn process_to_single_envelope<'a>(
1853 processor: &EnvelopeProcessorService,
1854 envelope: ManagedEnvelope,
1855 ctx: processing::Context<'a>,
1856 ) -> Box<Envelope> {
1857 let mut outputs = processor.process(envelope, ctx).await;
1858 assert_eq!(outputs.len(), 1);
1859
1860 let Output {
1861 main,
1862 metrics,
1863 intermediates: _,
1864 } = outputs.pop().unwrap();
1865
1866 if let Some(metrics) = metrics {
1867 metrics.accept(drop);
1868 }
1869
1870 main.unwrap()
1871 .serialize_envelope(ctx.to_forward())
1872 .unwrap()
1873 .accept(|envelope| envelope)
1874 }
1875
1876 #[cfg(feature = "processing")]
1877 fn mock_quota(id: &str) -> Quota {
1878 Quota {
1879 id: Some(id.into()),
1880 categories: [DataCategory::MetricBucket].into(),
1881 scope: QuotaScope::Organization,
1882 scope_id: None,
1883 limit: Some(0),
1884 window: None,
1885 reason_code: None,
1886 namespace: None,
1887 group_by: None,
1888 }
1889 }
1890
1891 #[cfg(feature = "processing")]
1892 #[test]
1893 fn test_dynamic_quotas() {
1894 let global_config = relay_dynamic_config::GlobalConfig {
1895 quotas: vec![mock_quota("foo"), mock_quota("bar")],
1896 ..Default::default()
1897 };
1898
1899 let project_quotas = vec![mock_quota("baz"), mock_quota("qux")];
1900
1901 let dynamic_quotas = CombinedQuotas::new(&global_config, &project_quotas);
1902
1903 let quota_ids = dynamic_quotas.into_iter().filter_map(|q| q.id.as_deref());
1904 assert!(quota_ids.eq(["foo", "bar", "baz", "qux"]));
1905 }
1906
1907 #[cfg(feature = "processing")]
1910 #[tokio::test]
1911 async fn test_ratelimit_per_batch() {
1912 use relay_base_schema::organization::OrganizationId;
1913 use relay_protocol::FiniteF64;
1914
1915 let rate_limited_org = Scoping {
1916 organization_id: OrganizationId::new(1),
1917 project_id: ProjectId::new(21),
1918 project_key: ProjectKey::parse("00000000000000000000000000000000").unwrap(),
1919 key_id: Some(17),
1920 };
1921
1922 let not_rate_limited_org = Scoping {
1923 organization_id: OrganizationId::new(2),
1924 project_id: ProjectId::new(21),
1925 project_key: ProjectKey::parse("11111111111111111111111111111111").unwrap(),
1926 key_id: Some(17),
1927 };
1928
1929 let message = {
1930 let project_info = {
1931 let quota = Quota {
1932 id: Some("testing".into()),
1933 categories: [DataCategory::MetricBucket].into(),
1934 scope: relay_quotas::QuotaScope::Organization,
1935 scope_id: Some(rate_limited_org.organization_id.to_string().into()),
1936 limit: Some(0),
1937 window: None,
1938 reason_code: Some(ReasonCode::new("test")),
1939 namespace: None,
1940 group_by: None,
1941 };
1942
1943 let mut config = ProjectConfig::default();
1944 config.quotas.push(quota);
1945
1946 Arc::new(ProjectInfo {
1947 config,
1948 ..Default::default()
1949 })
1950 };
1951
1952 let project_metrics = |scoping| ProjectBuckets {
1953 buckets: vec![Bucket {
1954 name: "d:spans/bar".into(),
1955 value: BucketValue::Counter(FiniteF64::new(1.0).unwrap()),
1956 timestamp: UnixTimestamp::now(),
1957 tags: Default::default(),
1958 width: 10,
1959 metadata: BucketMetadata::default(),
1960 }],
1961 rate_limits: Default::default(),
1962 project_info: project_info.clone(),
1963 scoping,
1964 };
1965
1966 let buckets = hashbrown::HashMap::from([
1967 (
1968 rate_limited_org.project_key,
1969 project_metrics(rate_limited_org),
1970 ),
1971 (
1972 not_rate_limited_org.project_key,
1973 project_metrics(not_rate_limited_org),
1974 ),
1975 ]);
1976
1977 FlushBuckets {
1978 partition_key: 0,
1979 buckets,
1980 }
1981 };
1982
1983 assert_eq!(message.buckets.keys().count(), 2);
1985
1986 let config = {
1987 let config_json = serde_json::json!({
1988 "processing": {
1989 "enabled": true,
1990 "kafka_config": [],
1991 "redis": {
1992 "server": std::env::var("RELAY_REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_owned()),
1993 }
1994 }
1995 });
1996 Config::from_json_value(config_json).unwrap()
1997 };
1998
1999 let (store, handle) = {
2000 let f = |org_ids: &mut Vec<OrganizationId>, msg: Store| {
2001 let org_id = match msg {
2002 Store::Metrics(x) => x.scoping.organization_id,
2003 _ => panic!("received envelope when expecting only metrics"),
2004 };
2005 org_ids.push(org_id);
2006 };
2007
2008 mock_service("store_forwarder", vec![], f)
2009 };
2010
2011 let processor = create_test_processor(config).await;
2012 assert!(processor.redis_rate_limiter_enabled());
2013
2014 processor.encode_metrics_processing(message, &store).await;
2015
2016 drop(store);
2017 let orgs_not_ratelimited = handle.await.unwrap();
2018
2019 assert_eq!(
2020 orgs_not_ratelimited,
2021 vec![not_rate_limited_org.organization_id]
2022 );
2023 }
2024
2025 #[tokio::test]
2026 async fn test_browser_version_extraction_with_pii_like_data() {
2027 let processor = create_test_processor(Default::default()).await;
2028 let outcome_aggregator = Addr::dummy();
2029 let event_id = EventId::new();
2030
2031 let dsn = "https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42"
2032 .parse()
2033 .unwrap();
2034
2035 let request_meta = RequestMeta::new(dsn);
2036 let mut envelope = Envelope::from_request(Some(event_id), request_meta);
2037
2038 envelope.add_item({
2039 let mut item = Item::new(ItemType::Event);
2040 item.set_payload(
2041 ContentType::Json,
2042 r#"
2043 {
2044 "request": {
2045 "headers": [
2046 ["User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36"]
2047 ]
2048 }
2049 }
2050 "#,
2051 );
2052 item
2053 });
2054
2055 let mut datascrubbing_settings = DataScrubbingConfig::default();
2056 datascrubbing_settings.scrub_data = true;
2058 datascrubbing_settings.scrub_defaults = true;
2059 datascrubbing_settings.scrub_ip_addresses = true;
2060
2061 let pii_config = serde_json::from_str(r#"{"applications": {"**": ["@ip:mask"]}}"#).unwrap();
2063
2064 let config = ProjectConfig {
2065 datascrubbing_settings,
2066 pii_config: Some(pii_config),
2067 ..Default::default()
2068 };
2069
2070 let project_info = ProjectInfo {
2071 config,
2072 ..Default::default()
2073 };
2074
2075 let envelope = ManagedEnvelope::new(envelope, outcome_aggregator);
2076
2077 let ctx = processing::Context {
2078 project_info: &project_info,
2079 ..processing::Context::for_test()
2080 };
2081
2082 let new_envelope = process_to_single_envelope(&processor, envelope, ctx).await;
2083
2084 let event_item = new_envelope.items().last().unwrap();
2085 let annotated_event: Annotated<Event> =
2086 Annotated::from_json_bytes(&event_item.payload()).unwrap();
2087 let event = annotated_event.into_value().unwrap();
2088 let headers = event
2089 .request
2090 .into_value()
2091 .unwrap()
2092 .headers
2093 .into_value()
2094 .unwrap();
2095
2096 assert_eq!(
2098 Some(
2099 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/********* Safari/537.36"
2100 ),
2101 headers.get_header("User-Agent")
2102 );
2103 let contexts = event.contexts.into_value().unwrap();
2105 let browser = contexts.0.get("browser").unwrap();
2106 assert_eq!(
2107 r#"{"browser":"Chrome 103.0.0","name":"Chrome","version":"103.0.0","type":"browser"}"#,
2108 browser.to_json().unwrap()
2109 );
2110 }
2111
2112 #[tokio::test]
2113 #[cfg(feature = "processing")]
2114 async fn test_materialize_dsc() {
2115 use crate::services::projects::project::PublicKeyConfig;
2116
2117 let dsn = "https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42"
2118 .parse()
2119 .unwrap();
2120 let request_meta = RequestMeta::new(dsn);
2121 let mut envelope = Envelope::from_request(None, request_meta);
2122
2123 let dsc = r#"{
2124 "trace_id": "00000000-0000-0000-0000-000000000001",
2125 "public_key": "e12d836b15bb49d7bbf99e64295d995b",
2126 "sample_rate": "0.2"
2127 }"#;
2128 envelope.set_dsc(serde_json::from_str(dsc).unwrap());
2129
2130 let mut item = Item::new(ItemType::Event);
2131 item.set_payload(ContentType::Json, r#"{}"#);
2132 envelope.add_item(item);
2133
2134 let outcome_aggregator = Addr::dummy();
2135 let managed_envelope = ManagedEnvelope::new(envelope, outcome_aggregator);
2136
2137 let mut project_info = ProjectInfo::default();
2138 project_info.public_keys.push(PublicKeyConfig {
2139 public_key: ProjectKey::parse("e12d836b15bb49d7bbf99e64295d995b").unwrap(),
2140 numeric_id: Some(1),
2141 });
2142
2143 let config = serde_json::json!({
2144 "processing": {
2145 "enabled": true,
2146 "kafka_config": [],
2147 }
2148 });
2149
2150 let processor =
2151 create_test_processor(Config::from_json_value(config.clone()).unwrap()).await;
2152 let config = Config::from_json_value(config).unwrap().current();
2153 let ctx = processing::Context {
2154 config: &config,
2155 project_info: &project_info,
2156 sampling_project_info: Some(&project_info),
2157 ..processing::Context::for_test()
2158 };
2159
2160 let envelope = process_to_single_envelope(&processor, managed_envelope, ctx).await;
2161 let event = envelope
2162 .get_item_by(|item| item.ty() == &ItemType::Event)
2163 .unwrap();
2164
2165 let event = Annotated::<Event>::from_json_bytes(&event.payload()).unwrap();
2166 insta::assert_debug_snapshot!(event.value().unwrap()._dsc, @r###"
2167 Object(
2168 {
2169 "environment": ~,
2170 "public_key": String(
2171 "e12d836b15bb49d7bbf99e64295d995b",
2172 ),
2173 "release": ~,
2174 "replay_id": ~,
2175 "sample_rate": String(
2176 "0.2",
2177 ),
2178 "trace_id": String(
2179 "00000000000000000000000000000001",
2180 ),
2181 "transaction": ~,
2182 },
2183 )
2184 "###);
2185 }
2186
2187 fn capture_test_event(transaction_name: &str, source: TransactionSource) -> Vec<String> {
2188 let mut event = Annotated::<Event>::from_json(
2189 r#"
2190 {
2191 "type": "transaction",
2192 "transaction": "/foo/",
2193 "timestamp": 946684810.0,
2194 "start_timestamp": 946684800.0,
2195 "contexts": {
2196 "trace": {
2197 "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
2198 "span_id": "fa90fdead5f74053",
2199 "op": "http.server",
2200 "type": "trace"
2201 }
2202 },
2203 "transaction_info": {
2204 "source": "url"
2205 }
2206 }
2207 "#,
2208 )
2209 .unwrap();
2210 let e = event.value_mut().as_mut().unwrap();
2211 e.transaction.set_value(Some(transaction_name.into()));
2212
2213 e.transaction_info
2214 .value_mut()
2215 .as_mut()
2216 .unwrap()
2217 .source
2218 .set_value(Some(source));
2219
2220 relay_statsd::with_capturing_test_client(|| {
2221 utils::log_transaction_name_metrics(&mut event, |event| {
2222 let config = NormalizationConfig {
2223 transaction_name_config: TransactionNameConfig {
2224 rules: &[TransactionNameRule {
2225 pattern: LazyGlob::new("/foo/*/**".to_owned()),
2226 expiry: DateTime::<Utc>::MAX_UTC,
2227 redaction: RedactionRule::Replace {
2228 substitution: "*".to_owned(),
2229 },
2230 }],
2231 },
2232 ..Default::default()
2233 };
2234 relay_event_normalization::normalize_event(event, &config)
2235 });
2236 })
2237 }
2238
2239 #[test]
2240 fn test_log_transaction_metrics_none() {
2241 let captures = capture_test_event("/nothing", TransactionSource::Url);
2242 insta::assert_debug_snapshot!(captures, @r###"
2243 [
2244 "event.transaction_name_changes:1|c|#source_in:url,changes:none,source_out:sanitized,is_404:false",
2245 ]
2246 "###);
2247 }
2248
2249 #[test]
2250 fn test_log_transaction_metrics_rule() {
2251 let captures = capture_test_event("/foo/john/denver", TransactionSource::Url);
2252 insta::assert_debug_snapshot!(captures, @r###"
2253 [
2254 "event.transaction_name_changes:1|c|#source_in:url,changes:rule,source_out:sanitized,is_404:false",
2255 ]
2256 "###);
2257 }
2258
2259 #[test]
2260 fn test_log_transaction_metrics_pattern() {
2261 let captures = capture_test_event("/something/12345", TransactionSource::Url);
2262 insta::assert_debug_snapshot!(captures, @r###"
2263 [
2264 "event.transaction_name_changes:1|c|#source_in:url,changes:pattern,source_out:sanitized,is_404:false",
2265 ]
2266 "###);
2267 }
2268
2269 #[test]
2270 fn test_log_transaction_metrics_both() {
2271 let captures = capture_test_event("/foo/john/12345", TransactionSource::Url);
2272 insta::assert_debug_snapshot!(captures, @r###"
2273 [
2274 "event.transaction_name_changes:1|c|#source_in:url,changes:both,source_out:sanitized,is_404:false",
2275 ]
2276 "###);
2277 }
2278
2279 #[test]
2280 fn test_log_transaction_metrics_no_match() {
2281 let captures = capture_test_event("/foo/john/12345", TransactionSource::Route);
2282 insta::assert_debug_snapshot!(captures, @r###"
2283 [
2284 "event.transaction_name_changes:1|c|#source_in:route,changes:none,source_out:route,is_404:false",
2285 ]
2286 "###);
2287 }
2288
2289 #[tokio::test]
2290 async fn test_process_metrics_bucket_metadata() {
2291 let mut token = Cogs::noop().timed(ResourceId::Relay, AppFeature::Unattributed);
2292 let project_key = ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap();
2293 let received_at = Utc::now();
2294 let config = Config::default();
2295
2296 let (aggregator, mut aggregator_rx) = Addr::custom();
2297 let processor = create_test_processor_with_addrs(
2298 config,
2299 Addrs {
2300 aggregator,
2301 ..Default::default()
2302 },
2303 )
2304 .await;
2305
2306 let mut item = Item::new(ItemType::MetricBuckets);
2307 item.set_payload(
2308 ContentType::Json,
2309 serde_json::json!([{
2310 "timestamp": received_at.timestamp(),
2311 "width": 0,
2312 "name": "s:sessions/foo@none",
2313 "type": "s",
2314 "value": [3182887624u32, 4267882815u32],
2315 }])
2316 .to_string(),
2317 );
2318 for (source, expected_received_at) in [
2319 (
2320 BucketSource::External,
2321 Some(UnixTimestamp::from_datetime(received_at).unwrap()),
2322 ),
2323 (BucketSource::Internal, None),
2324 ] {
2325 let message = ProcessMetrics {
2326 data: MetricData::Raw(vec![item.clone()]),
2327 project_key,
2328 source,
2329 received_at,
2330 sent_at: Some(Utc::now()),
2331 };
2332 processor.handle_process_metrics(&mut token, message);
2333
2334 let Aggregator::MergeBuckets(merge_buckets) = aggregator_rx.recv().await.unwrap();
2335 let buckets = merge_buckets.buckets;
2336 assert_eq!(buckets.len(), 1);
2337 assert_eq!(buckets[0].metadata.received_at, expected_received_at);
2338 }
2339 }
2340
2341 #[tokio::test]
2342 async fn test_process_batched_metrics() {
2343 let mut token = Cogs::noop().timed(ResourceId::Relay, AppFeature::Unattributed);
2344 let received_at = Utc::now();
2345 let config = Config::default();
2346
2347 let (aggregator, mut aggregator_rx) = Addr::custom();
2348 let processor = create_test_processor_with_addrs(
2349 config,
2350 Addrs {
2351 aggregator,
2352 ..Default::default()
2353 },
2354 )
2355 .await;
2356
2357 let payload = r#"{
2358 "buckets": {
2359 "11111111111111111111111111111111": [
2360 {
2361 "timestamp": 1615889440,
2362 "width": 0,
2363 "name": "d:transactions/endpoint.response_time@millisecond",
2364 "type": "d",
2365 "value": [
2366 68.0
2367 ],
2368 "tags": {
2369 "route": "user_index"
2370 }
2371 }
2372 ],
2373 "22222222222222222222222222222222": [
2374 {
2375 "timestamp": 1615889440,
2376 "width": 0,
2377 "name": "d:transactions/endpoint.cache_rate@none",
2378 "type": "d",
2379 "value": [
2380 36.0
2381 ]
2382 }
2383 ]
2384 }
2385}
2386"#;
2387 let message = ProcessBatchedMetrics {
2388 payload: Bytes::from(payload),
2389 source: BucketSource::Internal,
2390 received_at,
2391 sent_at: Some(Utc::now()),
2392 };
2393 processor.handle_process_batched_metrics(&mut token, message);
2394
2395 let Aggregator::MergeBuckets(mb1) = aggregator_rx.recv().await.unwrap();
2396 let Aggregator::MergeBuckets(mb2) = aggregator_rx.recv().await.unwrap();
2397
2398 let mut messages = vec![mb1, mb2];
2399 messages.sort_by_key(|mb| mb.project_key);
2400
2401 let actual = messages
2402 .into_iter()
2403 .map(|mb| (mb.project_key, mb.buckets))
2404 .collect::<Vec<_>>();
2405
2406 assert_debug_snapshot!(actual, @r###"
2407 [
2408 (
2409 ProjectKey("11111111111111111111111111111111"),
2410 [
2411 Bucket {
2412 timestamp: UnixTimestamp(1615889440),
2413 width: 0,
2414 name: MetricName(
2415 "d:transactions/endpoint.response_time@millisecond",
2416 ),
2417 value: Distribution(
2418 [
2419 68.0,
2420 ],
2421 ),
2422 tags: {
2423 "route": "user_index",
2424 },
2425 metadata: BucketMetadata {
2426 merges: 1,
2427 received_at: None,
2428 extracted_from_indexed: false,
2429 },
2430 },
2431 ],
2432 ),
2433 (
2434 ProjectKey("22222222222222222222222222222222"),
2435 [
2436 Bucket {
2437 timestamp: UnixTimestamp(1615889440),
2438 width: 0,
2439 name: MetricName(
2440 "d:transactions/endpoint.cache_rate@none",
2441 ),
2442 value: Distribution(
2443 [
2444 36.0,
2445 ],
2446 ),
2447 tags: {},
2448 metadata: BucketMetadata {
2449 merges: 1,
2450 received_at: None,
2451 extracted_from_indexed: false,
2452 },
2453 },
2454 ],
2455 ),
2456 ]
2457 "###);
2458 }
2459}