Skip to main content

saluki_components/encoders/datadog/metrics/
mod.rs

1use std::{collections::HashMap, collections::VecDeque, ops::Range, sync::Arc, time::Duration};
2
3use agent_data_plane_config::shared::{SharedConfiguration, V3SeriesMode};
4use async_trait::async_trait;
5use ddsketch::DDSketch;
6use http::{HeaderValue, Method, Request};
7use protobuf::{rt::WireType, CodedOutputStream};
8use saluki_common::{
9    buf::{ChunkedBytesBuffer, FrozenChunkedBytesBuffer},
10    iter::ReusableDeduplicator,
11};
12use saluki_core::{
13    accounting::{MemoryBounds, MemoryBoundsBuilder},
14    components::{encoders::*, BuildContext},
15    data_model::{
16        event::{
17            metric::{Metric, MetricOrigin, MetricValues},
18            EventType,
19        },
20        payload::{HttpPayload, Payload, PayloadMetadata, PayloadType},
21        tags::{SharedTagSet, Tag},
22    },
23    observability::ComponentMetricsExt as _,
24    runtime,
25    topology::{EventsBuffer, PayloadsBuffer, PayloadsDispatcher},
26};
27use saluki_error::{generic_error, ErrorContext as _, GenericError};
28use saluki_io::compression::{CompressionScheme, Compressor};
29use saluki_metrics::MetricsBuilder;
30use tokio::{io::AsyncWriteExt as _, select, sync::mpsc, time::sleep};
31use tracing::{debug, error, warn};
32use url::Url;
33
34use self::v3::{
35    V3EncodedMetrics, V3EncodedRequest, V3EncoderStats, V3MetricType, V3PayloadLimits, V3PayloadRequest,
36    V3PayloadSplitReason, V3SerializerTelemetry, V3Writer,
37};
38use crate::{
39    common::datadog::{
40        clamp_payload_limits,
41        config::OpwMetricsConfiguration,
42        endpoints::{
43            resolve_additional_endpoints, series_v3_config_can_enable_v3, EndpointV3Settings, ResolvedEndpoint,
44            V3EndpointConfig,
45        },
46        io::RB_BUFFER_CHUNK_SIZE,
47        protocol::{MetricsPayloadInfo, UseV3ApiConfig, UseV3ApiSeriesConfig, V3ApiConfig},
48        request_builder::{RequestBuilder, RequestBuilderError},
49        routing::{MetricsRoutingTargets, RoutingTargetCatalog, RoutingTargetKind, RoutingTargetSet},
50        telemetry::ComponentTelemetry,
51        DEFAULT_SERIALIZER_COMPRESSED_SIZE_LIMIT, DEFAULT_SERIALIZER_UNCOMPRESSED_SIZE_LIMIT, METRICS_SERIES_V3_PATH,
52        METRICS_SKETCHES_V3_PATH,
53    },
54    encoders::datadog::metrics::v2::MetricsEndpointEncoder,
55};
56
57mod endpoint;
58use self::endpoint::{EndpointConfiguration, MetricsEndpoint};
59
60mod v1;
61mod v2;
62mod v3;
63
64const V3_SKETCHES_ENDPOINT_URI: &str = METRICS_SKETCHES_V3_PATH;
65
66/// Encoding mode for a metrics endpoint.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68enum MetricsEncoderMode {
69    /// Send V2 payloads only.
70    V2Only,
71    /// V3 is enabled for at least one endpoint; generate tagged V2 and V3 payloads so each endpoint
72    /// receives the protocol version configured for it.
73    V3Enabled,
74}
75
76impl MetricsEncoderMode {
77    fn from_config(use_v3: bool) -> Self {
78        match use_v3 {
79            false => Self::V2Only,
80            true => Self::V3Enabled,
81        }
82    }
83
84    fn needs_v3(self) -> bool {
85        matches!(self, Self::V3Enabled)
86    }
87
88    fn needs_tagging(self) -> bool {
89        matches!(self, Self::V3Enabled)
90    }
91}
92
93fn metrics_encoder_mode_for_config(use_v3: bool, metrics_v3_disabled_by_compressor: bool) -> MetricsEncoderMode {
94    let use_v3 = use_v3 && !metrics_v3_disabled_by_compressor;
95    MetricsEncoderMode::from_config(use_v3)
96}
97
98fn selected_metrics_primary_v3_override(opw_metrics: &OpwMetricsConfiguration) -> Option<bool> {
99    selected_metrics_primary_endpoint(opw_metrics).map(|(_, use_v3_series)| use_v3_series)
100}
101
102fn selected_metrics_primary_endpoint(opw_metrics: &OpwMetricsConfiguration) -> Option<(&str, bool)> {
103    let selected = opw_metrics.selected_endpoint()?;
104    let url = selected.url.trim();
105    metrics_primary_url_can_resolve(url).then_some((url, selected.use_v3_series))
106}
107
108fn metrics_primary_url_can_resolve(url: &str) -> bool {
109    if url.is_empty() {
110        return false;
111    }
112    if url.starts_with("http://") || url.starts_with("https://") {
113        Url::parse(url).is_ok_and(|url| url.host_str().is_some())
114    } else {
115        Url::parse(&format!("https://{url}")).is_ok_and(|url| url.host_str().is_some())
116    }
117}
118
119fn series_v3_can_be_enabled_for_config(
120    use_v2_api_series: bool, metrics_primary_v3_override: Option<bool>, has_additional_endpoints: bool,
121    series_config: &UseV3ApiSeriesConfig,
122) -> bool {
123    use_v2_api_series
124        && (metrics_primary_v3_override == Some(true)
125            || ((metrics_primary_v3_override != Some(false) || has_additional_endpoints)
126                && series_v3_config_can_enable_v3(series_config)))
127}
128
129/// Datadog Metrics encoder.
130///
131/// Generates Datadog metrics payloads for the Datadog platform.
132#[derive(Clone)]
133#[cfg_attr(test, derive(Debug, PartialEq))]
134pub struct DatadogMetricsConfiguration {
135    /// Maximum number of input metrics to encode into a single request payload.
136    ///
137    /// This applies both to the series and sketches endpoints.
138    max_metrics_per_payload: usize,
139
140    /// Maximum compressed size, in bytes, of generic payloads.
141    ///
142    /// This applies to V1 JSON series payloads and sketch payloads, matching the Datadog Agent's generic payload
143    /// builder. V2 series payloads use the series-specific limit instead. The effective value is clamped to
144    /// the Agent's default intake-safe limit of 2,621,440 bytes, so larger configured values do not allow payloads that
145    /// intake may reject. If set to `0`, every non-empty compressed payload exceeds the limit and is dropped during
146    /// flush.
147    max_payload_size: usize,
148
149    /// Maximum uncompressed size, in bytes, of generic payloads.
150    ///
151    /// This applies to V1 JSON series payloads and sketch payloads, matching the Datadog Agent's generic payload
152    /// builder. V2 series payloads use the series-specific limit instead. The effective value
153    /// is clamped to the Agent's default intake-safe limit of 4,194,304 bytes, so larger configured values do not allow
154    /// payloads that intake may reject. Values smaller than the minimum endpoint framing size prevent the request
155    /// builder from starting.
156    max_uncompressed_payload_size: usize,
157
158    /// Maximum compressed size, in bytes, of a V2 series payload.
159    ///
160    /// This applies only when the V2 series API is in use. V1 series and sketches use the generic payload limit
161    /// instead. The effective value is clamped to the V2 series API limit of 512,000 bytes, so larger configured values
162    /// do not allow payloads that intake would reject. If set to `0`, every non-empty compressed payload exceeds the
163    /// limit and is dropped during flush.
164    max_series_payload_size: usize,
165
166    /// Maximum uncompressed size, in bytes, of a V2 series payload.
167    ///
168    /// This applies only when the V2 series API is in use. V1 series and sketches use the generic uncompressed payload
169    /// limit instead. The effective value is clamped to the V2 series API limit of 5,242,880 bytes, so larger configured
170    /// values do not allow payloads that intake would reject. This limit protects the encoder before compression, so
171    /// compressed payload size may still force a separate flush. Values smaller than the minimum endpoint framing size
172    /// prevent the request builder from starting.
173    max_series_uncompressed_payload_size: usize,
174
175    /// Maximum number of data points, across all series, to encode into a single series request payload.
176    ///
177    /// This applies only to series metrics (counters, gauges, rates, sets) and not to sketch metrics (histograms,
178    /// distributions). A single metric series may contribute multiple data points when it carries more than one
179    /// timestamp/value pair. When encoding an input would cause the running data point total to exceed this limit, the
180    /// current payload is flushed first and the input is placed in the next payload.
181    max_series_points_per_payload: usize,
182
183    /// Flush timeout for pending requests.
184    ///
185    /// When the destination has written metrics to the in-flight request payload, but it has not yet reached the
186    /// payload size limits that would force the payload to be flushed, the destination will wait for a period of time
187    /// before flushing the in-flight request payload. This allows for the possibility of other events to be processed
188    /// and written into the request payload, thereby maximizing the payload size and reducing the number of requests
189    /// generated and sent overall.
190    flush_timeout: Duration,
191
192    /// Compression kind to use for the request payloads.
193    compressor_kind: String,
194
195    /// Effective zstd compression level for the request payloads.
196    zstd_compressor_level: i32,
197
198    /// Whether to use the V2 API for series metrics.
199    ///
200    /// When `true`, series metrics are sent to the V2 protobuf endpoint (`/api/v2/series`). When `false`, series
201    /// metrics are sent to the legacy V1 JSON endpoint (`/api/v1/series`). Sketch metrics always use the V2 endpoint
202    /// (`/api/beta/sketches`) regardless of this setting.
203    use_v2_series_api: bool,
204
205    /// Whether to log metric payload contents before encoding.
206    ///
207    /// This logs decoded metric objects, not the encoded JSON/protobuf HTTP body.
208    log_payloads: bool,
209
210    /// Additional tags to apply to all forwarded metrics.
211    additional_tags: Option<SharedTagSet>,
212
213    /// V3 API configuration for per-endpoint V3 support.
214    ///
215    /// Configures which endpoints receive V3 payloads.
216    v3_api: V3ApiConfig,
217
218    /// Agent-compatible V3 API configuration.
219    use_v3_api: UseV3ApiConfig,
220
221    /// Metrics routing to an alternate intake.
222    opw_metrics: OpwMetricsConfiguration,
223
224    /// The primary metrics endpoint, as configured and not altered in any way.
225    primary_endpoint: String,
226
227    /// Additional endpoints that metrics may be dual-shipped to, keyed by endpoint URL with their API keys.
228    additional_endpoints: HashMap<String, Vec<String>>,
229
230    /// Routing targets that series and sketch payloads emitted by this encoder are addressed to.
231    ///
232    /// When unset, payloads are not addressed to specific targets and reach every endpoint of the forwarder.
233    routing: Option<MetricsRouting>,
234}
235
236/// The routing targets an encoder addresses its payloads to, and the catalog they belong to.
237#[derive(Clone)]
238#[cfg_attr(test, derive(Debug, PartialEq))]
239struct MetricsRouting {
240    catalog: Arc<RoutingTargetCatalog>,
241    targets: Arc<MetricsRoutingTargets>,
242}
243
244impl DatadogMetricsConfiguration {
245    /// Creates a new `DatadogMetricsConfiguration` from the resolved shared configuration.
246    pub fn from_configuration(shared: &SharedConfiguration) -> Self {
247        let endpoints = &shared.endpoints;
248        let metrics = &shared.metrics_encoding;
249
250        Self {
251            max_metrics_per_payload: metrics.max_metrics_per_payload,
252            max_payload_size: metrics.max_payload_size,
253            max_uncompressed_payload_size: metrics.max_uncompressed_payload_size,
254            max_series_payload_size: metrics.max_series_payload_size,
255            max_series_uncompressed_payload_size: metrics.max_series_uncompressed_payload_size,
256            max_series_points_per_payload: metrics.max_series_points_per_payload,
257            flush_timeout: metrics.flush_timeout,
258            compressor_kind: endpoints.compression.compressor_kind.clone(),
259            zstd_compressor_level: endpoints.compression.effective_zstd_level(),
260            use_v2_series_api: metrics.use_v2_series_api,
261            log_payloads: metrics.log_payloads,
262            additional_tags: None,
263            v3_api: (&metrics.v3_api).into(),
264            use_v3_api: UseV3ApiConfig { series: metrics.into() },
265            opw_metrics: OpwMetricsConfiguration::from_configuration(endpoints),
266            primary_endpoint: endpoints.primary_endpoint(),
267            additional_endpoints: endpoints.additional_endpoints.clone(),
268            routing: None,
269        }
270    }
271
272    /// Sets additional tags to be applied uniformly to all metrics forwarded by this destination.
273    pub fn with_additional_tags(mut self, additional_tags: SharedTagSet) -> Self {
274        self.additional_tags = Some(additional_tags);
275        self
276    }
277
278    /// Addresses series and sketch payloads to the given routing targets.
279    ///
280    /// The forwarder these payloads are sent to must share `catalog`. Protocol selection then considers only the
281    /// targeted endpoints: for example, V2 series payloads are not encoded when every targeted endpoint accepts V3.
282    pub fn with_routing(mut self, catalog: Arc<RoutingTargetCatalog>, targets: RoutingTargetSet) -> Self {
283        self.routing = Some(MetricsRouting {
284            catalog,
285            targets: Arc::new(MetricsRoutingTargets::new(targets)),
286        });
287        self
288    }
289
290    /// Restricts endpoint-aware protocol selection to a single overridden metrics endpoint.
291    ///
292    /// This mirrors a forwarder branch that replaces the normal primary endpoint and removes additional and
293    /// alternate-intake endpoints, such as Multi-Region Failover.
294    pub fn with_metrics_endpoint_override(mut self, dd_url: String) -> Self {
295        self.primary_endpoint = dd_url;
296        self.additional_endpoints = HashMap::new();
297        self.opw_metrics.disable();
298        self
299    }
300
301    /// Forces series metrics to use V2.
302    ///
303    /// This is used for local destinations that only accept the V2 series protocol, such as the Cluster Agent.
304    pub fn with_v2_series_only(mut self) -> Self {
305        self.use_v3_api.series.enabled = V3SeriesMode::Disabled;
306        self.use_v3_api.series.endpoints.clear();
307        self.opw_metrics.clear_v3_series_overrides();
308        self
309    }
310
311    fn v3_payload_limits(&self) -> V3PayloadLimits {
312        V3PayloadLimits::new(
313            self.max_series_payload_size,
314            self.max_series_uncompressed_payload_size,
315            self.max_metrics_per_payload,
316            self.max_series_points_per_payload,
317        )
318    }
319
320    fn endpoint_v3_settings(
321        &self, configured_endpoint: &str, metrics_primary_v3_override: Option<bool>,
322    ) -> EndpointV3Settings {
323        EndpointV3Settings::from_v3_config(V3EndpointConfig {
324            configured_endpoint,
325            series_config: &self.use_v3_api.series,
326            metrics_primary_v3_override,
327            serializer_v3_sketches_endpoints: &self.v3_api.sketches.endpoints,
328        })
329    }
330
331    /// Returns the V3 settings of the endpoint that receives the primary stream.
332    ///
333    /// An alternate metrics intake replaces the primary endpoint for metrics, so it receives the primary stream with its
334    /// own protocol settings.
335    fn primary_stream_v3_settings(&self) -> Result<EndpointV3Settings, GenericError> {
336        if let Some((metrics_primary_url, metrics_primary_v3_override)) =
337            selected_metrics_primary_endpoint(&self.opw_metrics)
338        {
339            let metrics_primary = ResolvedEndpoint::from_raw_endpoint(metrics_primary_url, "")
340                .error_context("Failed parsing/resolving the metrics primary destination endpoint.")?;
341            Ok(self.endpoint_v3_settings(metrics_primary.configured_endpoint(), Some(metrics_primary_v3_override)))
342        } else {
343            let primary = ResolvedEndpoint::from_raw_endpoint(&self.primary_endpoint, "")
344                .error_context("Failed parsing/resolving the primary destination endpoint.")?;
345            Ok(self.endpoint_v3_settings(primary.configured_endpoint(), None))
346        }
347    }
348
349    fn any_series_endpoint_matches(
350        &self, mut predicate: impl FnMut(&EndpointV3Settings) -> bool,
351    ) -> Result<bool, GenericError> {
352        if let Some(routing) = &self.routing {
353            for target in routing.targets.targets().iter() {
354                let Some(target) = routing.catalog.get(target) else {
355                    continue;
356                };
357                let settings = match target.kind() {
358                    RoutingTargetKind::Primary => self.primary_stream_v3_settings()?,
359                    RoutingTargetKind::Additional => self.endpoint_v3_settings(target.configured_endpoint(), None),
360                };
361                if predicate(&settings) {
362                    return Ok(true);
363                }
364            }
365
366            return Ok(false);
367        }
368
369        if predicate(&self.primary_stream_v3_settings()?) {
370            return Ok(true);
371        }
372
373        for endpoint in resolve_additional_endpoints(&self.additional_endpoints)
374            .error_context("Failed parsing/resolving the additional destination endpoints.")?
375        {
376            let settings = self.endpoint_v3_settings(endpoint.configured_endpoint(), None);
377            if predicate(&settings) {
378                return Ok(true);
379            }
380        }
381
382        Ok(false)
383    }
384
385    fn requires_v2_series(&self, metrics_v3_disabled_by_compressor: bool) -> Result<bool, GenericError> {
386        if !self.use_v2_series_api || metrics_v3_disabled_by_compressor {
387            return Ok(true);
388        }
389
390        self.any_series_endpoint_matches(|settings| !settings.use_v3_series)
391    }
392
393    fn requires_v3_series(&self, metrics_v3_disabled_by_compressor: bool) -> Result<bool, GenericError> {
394        if metrics_v3_disabled_by_compressor {
395            return Ok(false);
396        }
397
398        let metrics_primary_v3_override = selected_metrics_primary_v3_override(&self.opw_metrics);
399        if !series_v3_can_be_enabled_for_config(
400            self.use_v2_series_api,
401            metrics_primary_v3_override,
402            !self.additional_endpoints.is_empty(),
403            &self.use_v3_api.series,
404        ) {
405            return Ok(false);
406        }
407
408        self.any_series_endpoint_matches(|settings| settings.use_v3_series)
409    }
410}
411
412#[async_trait]
413impl EncoderBuilder for DatadogMetricsConfiguration {
414    fn input_event_type(&self) -> EventType {
415        EventType::Metric
416    }
417
418    fn output_payload_type(&self) -> PayloadType {
419        PayloadType::Http
420    }
421
422    async fn build(&self, context: BuildContext) -> Result<Box<dyn Encoder + Send>, GenericError> {
423        let metrics_builder = MetricsBuilder::from_component_context(context.component_context());
424        let telemetry = ComponentTelemetry::from_builder(&metrics_builder);
425        let v3_serializer_telemetry = V3SerializerTelemetry::from_builder(&metrics_builder);
426
427        let v2_compression_scheme = CompressionScheme::new(&self.compressor_kind, self.zstd_compressor_level);
428        let v3_compression_scheme = if self.v3_api.compression_level > 0 {
429            CompressionScheme::new(&self.compressor_kind, self.v3_api.compression_level)
430        } else {
431            v2_compression_scheme
432        };
433        let series_endpoint_uri = METRICS_SERIES_V3_PATH.to_string();
434        let payload_limits = self.v3_payload_limits();
435
436        let v2_endpoint_config = EndpointConfiguration::new(
437            v2_compression_scheme,
438            self.max_metrics_per_payload,
439            self.max_series_points_per_payload,
440            self.additional_tags.clone(),
441        );
442        let endpoint_config = EndpointConfiguration::new(
443            v3_compression_scheme,
444            self.max_metrics_per_payload,
445            // Actually enforced by V3PayloadLimits, required for the
446            // constructor shared between V1/V2/V3.
447            usize::MAX,
448            self.additional_tags.clone(),
449        );
450
451        // Derive the encoding mode for each metric type from the configuration.
452        let metrics_v3_disabled_by_compressor = matches!(v3_compression_scheme, CompressionScheme::Zlib(_));
453        let use_v3_series = self.requires_v3_series(metrics_v3_disabled_by_compressor)?;
454        let series_mode = metrics_encoder_mode_for_config(use_v3_series, metrics_v3_disabled_by_compressor);
455        let sketches_mode =
456            metrics_encoder_mode_for_config(self.v3_api.use_v3_sketches(), metrics_v3_disabled_by_compressor);
457        let series_endpoint = if self.use_v2_series_api {
458            MetricsEndpoint::SeriesV2
459        } else {
460            MetricsEndpoint::SeriesV1
461        };
462        let v3_runtime_config = V3RuntimeConfig {
463            endpoint_config,
464            payload_limits,
465            series_endpoint_uri,
466            serializer_telemetry: v3_serializer_telemetry,
467        };
468        let generic_payload_limits = clamp_payload_limits(
469            self.max_uncompressed_payload_size,
470            self.max_payload_size,
471            DEFAULT_SERIALIZER_UNCOMPRESSED_SIZE_LIMIT,
472            DEFAULT_SERIALIZER_COMPRESSED_SIZE_LIMIT,
473        );
474        let (series_uncompressed_limit, series_compressed_limit) = if series_endpoint == MetricsEndpoint::SeriesV2 {
475            clamp_payload_limits(
476                self.max_series_uncompressed_payload_size,
477                self.max_series_payload_size,
478                v2::SERIES_V2_UNCOMPRESSED_SIZE_LIMIT,
479                v2::SERIES_V2_COMPRESSED_SIZE_LIMIT,
480            )
481        } else {
482            generic_payload_limits
483        };
484        let v2_series_builder = if self.requires_v2_series(metrics_v3_disabled_by_compressor)? {
485            let mut builder = v2::create_v2_request_builder(series_endpoint, &v2_endpoint_config)
486                .await
487                .error_context("Failed to create V2 series request builder.")?;
488            builder.with_len_limits(series_uncompressed_limit, series_compressed_limit)?;
489            Some(builder)
490        } else {
491            debug!("All metrics series endpoints use authoritative V3; disabling V2 series encoding.");
492            None
493        };
494
495        let (sketches_uncompressed_limit, sketches_compressed_limit) = generic_payload_limits;
496        let mut v2_sketch_builder = v2::create_v2_request_builder(MetricsEndpoint::Sketches, &v2_endpoint_config)
497            .await
498            .error_context("Failed to create V2 sketches request builder.")?;
499        v2_sketch_builder.with_len_limits(sketches_uncompressed_limit, sketches_compressed_limit)?;
500        let v2_sketch_builder = Some(v2_sketch_builder);
501
502        let flush_timeout = if self.flush_timeout.is_zero() {
503            // We always give ourselves a minimum flush timeout of 10ms to allow for some very minimal amount of
504            // batching, while still practically flushing things almost immediately.
505            Duration::from_millis(10)
506        } else {
507            self.flush_timeout
508        };
509
510        if series_mode.needs_v3() || sketches_mode.needs_v3() {
511            debug!(
512                ?series_mode,
513                ?sketches_mode,
514                v3_sketches_endpoints = ?self.v3_api.sketches.endpoints,
515                "V3 encoding support is enabled."
516            );
517        }
518
519        Ok(Box::new(DatadogMetrics {
520            v2_series_builder,
521            v2_sketch_builder,
522            series_mode,
523            sketches_mode,
524            v3_runtime_config,
525            telemetry,
526            flush_timeout,
527            log_payloads: self.log_payloads,
528            routing_targets: self.routing.as_ref().map(|routing| Arc::clone(&routing.targets)),
529        }))
530    }
531}
532
533impl MemoryBounds for DatadogMetricsConfiguration {
534    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
535        // TODO: How do we properly represent the requests we can generate that may be sitting around in-flight?
536        //
537        // Theoretically, we'll end up being limited by the size of the downstream forwarder's interconnect, and however
538        // many payloads it will buffer internally... so realistically the firm limit boils down to the forwarder itself
539        // but we'll have a hard time in the forwarder knowing the maximum size of any given payload being sent in, which
540        // then makes it hard to calculate a proper firm bound even though we know the rest of the values required to
541        // calculate the firm bound.
542        builder
543            .minimum()
544            .with_single_value::<DatadogMetrics>("component struct")
545            .with_array::<EventsBuffer>("request builder events channel", 8)
546            .with_array::<PayloadsBuffer>("request builder payloads channel", 8);
547
548        builder
549            .firm()
550            // Capture the size of the "split re-encode" buffers in the request builders, which is where we keep owned
551            // versions of metrics that we encode in case we need to actually re-encode them during a split operation.
552            .with_array::<Metric>("series metrics split re-encode buffer", self.max_metrics_per_payload)
553            .with_array::<Metric>("sketch metrics split re-encode buffer", self.max_metrics_per_payload);
554    }
555}
556
557pub struct DatadogMetrics {
558    v2_series_builder: Option<RequestBuilder<v2::MetricsEndpointEncoder>>,
559    v2_sketch_builder: Option<RequestBuilder<v2::MetricsEndpointEncoder>>,
560    series_mode: MetricsEncoderMode,
561    sketches_mode: MetricsEncoderMode,
562    v3_runtime_config: V3RuntimeConfig,
563    telemetry: ComponentTelemetry,
564    flush_timeout: Duration,
565    log_payloads: bool,
566    routing_targets: Option<Arc<MetricsRoutingTargets>>,
567}
568
569struct V3RuntimeConfig {
570    endpoint_config: EndpointConfiguration,
571    payload_limits: V3PayloadLimits,
572    series_endpoint_uri: String,
573    serializer_telemetry: V3SerializerTelemetry,
574}
575
576#[async_trait]
577impl Encoder for DatadogMetrics {
578    async fn run(mut self: Box<Self>, mut context: EncoderContext) -> Result<(), GenericError> {
579        let Self {
580            v2_series_builder,
581            v2_sketch_builder,
582            series_mode,
583            sketches_mode,
584            v3_runtime_config,
585            telemetry,
586            flush_timeout,
587            log_payloads,
588            routing_targets,
589        } = *self;
590
591        let mut health = context.take_health_handle();
592
593        let (events_tx, events_rx) = mpsc::channel(8);
594        let (payloads_tx, mut payloads_rx) = mpsc::channel(8);
595
596        // Run our request builder task on the worker pool.
597        //
598        // The request builder task ignores the shutdown signal on purpose: it drains its incoming event buffer channel
599        // until the channel closes, which is what guarantees every buffered metric is encoded and dispatched.
600        let request_builder_fut = run_request_builder(
601            v2_series_builder,
602            v2_sketch_builder,
603            series_mode,
604            sketches_mode,
605            v3_runtime_config,
606            telemetry,
607            events_rx,
608            payloads_tx,
609            flush_timeout,
610            log_payloads,
611        );
612        runtime::worker("request_builder", request_builder_fut)
613            .on_runtime(context.topology_context().global_thread_pool().clone())
614            .needed()
615            .spawn();
616
617        health.mark_ready();
618        debug!("Datadog Metrics encoder started.");
619
620        loop {
621            select! {
622                biased;
623
624                _ = health.live() => continue,
625                maybe_payload = payloads_rx.recv() => match maybe_payload {
626                    Some(payload) => {
627                        apply_routing_and_dispatch(context.dispatcher(), payload, routing_targets.as_ref()).await;
628                    }
629                    None => break,
630                },
631                maybe_event_buffer = context.events().next() => match maybe_event_buffer {
632                    Some(event_buffer) => {
633                        // Both channels between this task and the request builder task are bounded, and each task is
634                        // the producer on one and the consumer on the other. Blocking outright on `events_tx` would
635                        // stop us draining `payloads_rx`, which can deadlock: the builder task blocks sending a
636                        // payload once `payloads_tx` is full, so it never returns to receive from `events_rx`, so
637                        // capacity never frees up here. Keep draining payloads while we wait for capacity so that at
638                        // least one of the two channels is always making progress.
639                        let permit = loop {
640                            select! {
641                                biased;
642
643                                permit = events_tx.reserve() => break permit
644                                    .error_context("Failed to reserve capacity for event buffer.")?,
645                                maybe_payload = payloads_rx.recv() => match maybe_payload {
646                                    Some(payload) => {
647                                        apply_routing_and_dispatch(context.dispatcher(), payload, routing_targets.as_ref()).await;
648                                    },
649
650                                    // Our payloads channel is gone, which means our request builder task went away unexpectedly.
651                                    None => return Err(generic_error!("Request builder task stopped before accepting event buffer.")),
652                                }
653                            }
654                        };
655
656                        permit.send(event_buffer);
657                    }
658                    None => break,
659                },
660            }
661        }
662
663        // Drop the events sender, which signals the request builder task to stop.
664        drop(events_tx);
665
666        // Continue draining the payloads receiver until it is closed.
667        while let Some(payload) = payloads_rx.recv().await {
668            apply_routing_and_dispatch(context.dispatcher(), payload, routing_targets.as_ref()).await;
669        }
670
671        // Draining `payloads_rx` to completion already implies the request builder finished: it owns the only sender,
672        // so the channel only closes once that child's future has run to completion (or been dropped).
673        debug!("Datadog Metrics encoder stopped.");
674
675        Ok(())
676    }
677}
678
679/// Addresses a payload to the encoder's routing targets, if it has any, and dispatches it.
680///
681/// Every payload the encoder emits goes through here, so that none can leave without its routing targets.
682async fn apply_routing_and_dispatch(
683    dispatcher: &PayloadsDispatcher, payload: Payload, routing_targets: Option<&Arc<MetricsRoutingTargets>>,
684) {
685    let payload = apply_routing_targets(payload, routing_targets);
686    if let Err(e) = dispatcher.dispatch(payload).await {
687        error!("Failed to dispatch payload: {}", e);
688    }
689}
690
691fn apply_routing_targets(payload: Payload, routing_targets: Option<&Arc<MetricsRoutingTargets>>) -> Payload {
692    let Some(routing_targets) = routing_targets else {
693        return payload;
694    };
695
696    match payload {
697        Payload::Http(http_payload) => {
698            let (mut metadata, request) = http_payload.into_parts();
699            metadata.set(Arc::clone(routing_targets));
700            Payload::Http(HttpPayload::new(metadata, request))
701        }
702        payload => payload,
703    }
704}
705
706/// Logs the decoded contents of a metric prior to encoding.
707///
708/// This logs the metric object itself, not the encoded JSON/protobuf HTTP body.
709fn log_metric_payload(metric: &Metric) {
710    match metric.values() {
711        MetricValues::Counter(..) | MetricValues::Rate(..) | MetricValues::Gauge(..) | MetricValues::Set(..) => {
712            debug!(?metric, "Flushing series metric.")
713        }
714        MetricValues::Histogram(..) | MetricValues::Distribution(..) => {
715            debug!(?metric, "Flushing sketch metric.")
716        }
717    }
718}
719
720#[allow(clippy::too_many_arguments)]
721async fn run_request_builder(
722    mut v2_series_builder: Option<RequestBuilder<v2::MetricsEndpointEncoder>>,
723    mut v2_sketch_builder: Option<RequestBuilder<v2::MetricsEndpointEncoder>>, series_mode: MetricsEncoderMode,
724    sketches_mode: MetricsEncoderMode, v3_runtime_config: V3RuntimeConfig, telemetry: ComponentTelemetry,
725    mut events_rx: mpsc::Receiver<EventsBuffer>, mut payloads_tx: mpsc::Sender<PayloadsBuffer>,
726    flush_timeout: Duration, log_payloads: bool,
727) -> Result<(), GenericError> {
728    let mut pending_flush = false;
729    let pending_flush_timeout = sleep(flush_timeout);
730    tokio::pin!(pending_flush_timeout);
731
732    let mut v3_series_metrics = series_mode.needs_v3().then(Vec::<Metric>::new);
733    let mut v3_sketch_metrics = sketches_mode.needs_v3().then(Vec::<Metric>::new);
734    let mut v3_series_points = 0usize;
735    let mut v3_sketch_points = 0usize;
736    let mut v3_series_ratio = V3CompressionRatio::default();
737    let mut v3_sketch_ratio = V3CompressionRatio::default();
738
739    let tag_series = series_mode.needs_tagging();
740    let tag_sketches = sketches_mode.needs_tagging();
741    let v3_flush_context = V3FlushContext {
742        endpoint_config: &v3_runtime_config.endpoint_config,
743        payload_limits: v3_runtime_config.payload_limits,
744        series_endpoint_uri: &v3_runtime_config.series_endpoint_uri,
745        serializer_telemetry: &v3_runtime_config.serializer_telemetry,
746        telemetry: &telemetry,
747    };
748
749    loop {
750        select! {
751            Some(event_buffer) = events_rx.recv() => {
752                for event in event_buffer {
753                    let metric = match event.try_into_metric() {
754                        Some(metric) => metric,
755                        None => continue,
756                    };
757
758                    if log_payloads {
759                        log_metric_payload(&metric);
760                    }
761
762                    // A series metric whose points are all non-finite would encode to a series with no points, which
763                    // intake rejects as an empty value set. Drop it whole rather than emit an empty series.
764                    if !v1::has_emittable_point(&metric) {
765                        debug!(metric = %metric.context().name(), "Dropping series metric with no finite points.");
766                        telemetry.events_dropped_encoder().increment(1);
767                        continue;
768                    }
769
770                    // Figure out which endpoint the metric belongs to, and grab the relevant V2 builder/V3 storage.
771                    let endpoint = MetricsEndpoint::from_metric(&metric);
772                    let (endpoint_mode, maybe_v2_builder, maybe_v3_metrics, v3_points) = match endpoint {
773                        MetricsEndpoint::SeriesV1 | MetricsEndpoint::SeriesV2 => (
774                            series_mode,
775                            &mut v2_series_builder,
776                            &mut v3_series_metrics,
777                            &mut v3_series_points,
778                        ),
779                        MetricsEndpoint::Sketches => (
780                            sketches_mode,
781                            &mut v2_sketch_builder,
782                            &mut v3_sketch_metrics,
783                            &mut v3_sketch_points,
784                        ),
785                    };
786                    let metric_point_count = metric.values().len();
787                    let should_buffer_v3 = endpoint_mode.needs_v3();
788
789                    // Store a copy of the metric in `maybe_v3_metrics` if it's present.
790                    //
791                    // We have to do this before encoding because `RequestBuilder::encode` consumes the metric. This also means we'll
792                    // need to _remove_ the metric if encoding fails.
793                    if should_buffer_v3 {
794                        if let Some(metrics) = maybe_v3_metrics {
795                            metrics.push(metric.clone());
796                            *v3_points += metric_point_count;
797                        }
798                    }
799
800                    // Attempt encoding the metric for V2 if configured.
801                    //
802                    // If the metric could not be encoded, remove its buffered V3 copy when this endpoint also requires
803                    // a V2 encoding.
804                    let v2_payload_info = match endpoint {
805                        MetricsEndpoint::SeriesV1 | MetricsEndpoint::SeriesV2 => {
806                            tag_series.then(MetricsPayloadInfo::v2_series)
807                        }
808                        MetricsEndpoint::Sketches => tag_sketches.then(MetricsPayloadInfo::v2_sketches),
809                    };
810                    let _v2_flushed = if let Some(builder) = maybe_v2_builder {
811                        let result = encode_v2_metrics(builder, metric, &telemetry, &mut payloads_tx, v2_payload_info).await?;
812                        if should_buffer_v3
813                            && !result.encoded()
814                            && !matches!(endpoint_mode, MetricsEncoderMode::V3Enabled)
815                        {
816                            if let Some(metrics) = maybe_v3_metrics {
817                                let _ = metrics.pop();
818                                *v3_points = v3_points.saturating_sub(metric_point_count);
819                            }
820                        }
821                        result.flushed()
822                    } else {
823                        false
824                    };
825
826                    let v3_payload_info = match endpoint {
827                        MetricsEndpoint::SeriesV1 | MetricsEndpoint::SeriesV2 => {
828                            tag_series.then(MetricsPayloadInfo::v3_series)
829                        }
830                        MetricsEndpoint::Sketches => tag_sketches.then(MetricsPayloadInfo::v3_sketches),
831                    };
832                    let mut split_metric = None;
833                    let _v3_flushed = if let Some(v3_metrics) = maybe_v3_metrics {
834                        let should_flush_v3 = match endpoint_mode {
835                            MetricsEncoderMode::V2Only => false,
836                            MetricsEncoderMode::V3Enabled => {
837                                if v3_flush_context.payload_limits.point_count_fits(metric_point_count)
838                                    && v3_flush_context.payload_limits.point_count_exceeds_limit(*v3_points)
839                                    && v3_metrics.len() > 1
840                                {
841                                    v3_flush_context
842                                        .serializer_telemetry
843                                        .record_split_reason(V3PayloadSplitReason::MaxPoints);
844                                    if let Some(metric) = v3_metrics.pop() {
845                                        *v3_points = v3_points.saturating_sub(metric.values().len());
846                                        split_metric = Some(metric);
847                                    }
848                                    true
849                                } else {
850                                    v3_flush_context.payload_limits.should_flush_point_count_limit(*v3_points)
851                                        || v3_flush_context
852                                            .payload_limits
853                                            .should_flush_metric_count_limit(v3_metrics)
854                                }
855                            }
856                        };
857                        if should_flush_v3 {
858                            encode_and_flush_v3_metrics(
859                                endpoint,
860                                v3_flush_context,
861                                v3_metrics,
862                                &mut v3_series_ratio,
863                                &mut v3_sketch_ratio,
864                                &mut payloads_tx,
865                                v3_payload_info,
866                            )
867                            .await?;
868                            *v3_points = 0;
869                            true
870                        } else {
871                            false
872                        }
873                    } else {
874                        false
875                    };
876
877                    if matches!(endpoint_mode, MetricsEncoderMode::V3Enabled) {
878                        if let Some(m) = split_metric.take() {
879                            let point_count = m.values().len();
880                            if let Some(metrics) = maybe_v3_metrics {
881                                metrics.push(m);
882                                *v3_points += point_count;
883                            }
884                        }
885                    }
886                }
887
888                debug!("Processed event buffer.");
889
890                // If we're not already pending a flush, we'll start the countdown.
891                if !pending_flush {
892                    pending_flush_timeout.as_mut().reset(tokio::time::Instant::now() + flush_timeout);
893                    pending_flush = true;
894                }
895            },
896            _ = &mut pending_flush_timeout, if pending_flush => {
897                debug!("Flushing pending request(s).");
898
899                pending_flush = false;
900
901                // Flush any pending series metrics.
902                let v2_series_payload_info = tag_series.then(MetricsPayloadInfo::v2_series);
903                let mut v2_series_flush_succeeded = true;
904                if let Some(builder) = &mut v2_series_builder {
905                    if let Err(e) = flush_v2_metrics(builder, &mut payloads_tx, v2_series_payload_info).await {
906                        error!(error = %e, "Failed to flush V2 series metrics: {}", e);
907                        v2_series_flush_succeeded = false;
908                    }
909                }
910
911                let v3_series_payload_info = tag_series.then(MetricsPayloadInfo::v3_series);
912                if let Some(metrics) = &mut v3_series_metrics {
913                    if v2_series_flush_succeeded || matches!(series_mode, MetricsEncoderMode::V3Enabled) {
914                        if let Err(e) = encode_and_flush_v3_series_metrics(
915                            v3_flush_context,
916                            metrics,
917                            &mut v3_series_ratio,
918                            &mut payloads_tx,
919                            v3_series_payload_info,
920                        )
921                        .await
922                        {
923                            error!(error = %e, "Failed to flush V3 series metrics: {}", e);
924                        }
925                        v3_series_points = 0;
926                    } else {
927                        warn!("Failed to flush V2 series metrics, skipping V3 series flush.");
928                        metrics.clear();
929                        v3_series_points = 0;
930                    }
931                }
932
933                // Flush any pending sketch metrics.
934                let v2_sketches_payload_info = tag_sketches.then(MetricsPayloadInfo::v2_sketches);
935                let mut v2_sketches_flush_succeeded = true;
936                if let Some(builder) = &mut v2_sketch_builder {
937                    if let Err(e) = flush_v2_metrics(builder, &mut payloads_tx, v2_sketches_payload_info).await {
938                        error!(error = %e, "Failed to flush V2 sketch metrics: {}", e);
939                        v2_sketches_flush_succeeded = false;
940                    }
941                }
942
943                let v3_sketches_payload_info = tag_sketches.then(MetricsPayloadInfo::v3_sketches);
944                if let Some(metrics) = &mut v3_sketch_metrics {
945                    if v2_sketches_flush_succeeded || matches!(sketches_mode, MetricsEncoderMode::V3Enabled) {
946                        if let Err(e) = encode_and_flush_v3_sketch_metrics(
947                            v3_flush_context,
948                            metrics,
949                            &mut v3_sketch_ratio,
950                            &mut payloads_tx,
951                            v3_sketches_payload_info,
952                        )
953                        .await
954                        {
955                            error!(error = %e, "Failed to flush V3 sketch metrics: {}", e);
956                        }
957                        v3_sketch_points = 0;
958                    } else {
959                        warn!("Failed to flush V2 sketch metrics, skipping V3 sketch flush.");
960                        metrics.clear();
961                        v3_sketch_points = 0;
962                    }
963                }
964
965                debug!("All flushed requests sent to I/O task. Waiting for next event buffer...");
966            },
967
968            // Event buffers channel has been closed, and we have no pending flushing, so we're all done.
969            else => break,
970        }
971    }
972
973    Ok(())
974}
975
976struct EncodeResult {
977    encoded: bool,
978    flushed: bool,
979}
980
981impl EncodeResult {
982    pub const fn new(encoded: bool, flushed: bool) -> Self {
983        Self { encoded, flushed }
984    }
985
986    pub const fn encoded(&self) -> bool {
987        self.encoded
988    }
989
990    pub const fn flushed(&self) -> bool {
991        self.flushed
992    }
993}
994
995async fn encode_v2_metrics(
996    request_builder: &mut RequestBuilder<v2::MetricsEndpointEncoder>, metric: Metric, telemetry: &ComponentTelemetry,
997    payloads_tx: &mut mpsc::Sender<Payload>, payload_info: Option<MetricsPayloadInfo>,
998) -> Result<EncodeResult, GenericError> {
999    // Encode the metric. If we get it back, that means the current request is full, and we need to
1000    // flush it before we can try to encode the metric again... so we'll hold on to it in that case
1001    // before flushing and trying to encode it again.
1002    let metric_to_retry = match request_builder.encode(metric).await {
1003        Ok(None) => return Ok(EncodeResult::new(true, false)),
1004        Ok(Some(metric)) => metric,
1005        Err(RequestBuilderError::InvalidInput { input }) => {
1006            debug!(metric_name = %input.context().name(), "Dropping metric with no emittable values.");
1007            telemetry.events_dropped_encoder().increment(1);
1008            return Ok(EncodeResult::new(false, false));
1009        }
1010        Err(e) => {
1011            error!(error = %e, "Failed to encode metric.");
1012            telemetry.events_dropped_encoder().increment(1);
1013            return Ok(EncodeResult::new(false, false));
1014        }
1015    };
1016
1017    flush_v2_metrics(request_builder, payloads_tx, payload_info).await?;
1018
1019    // Now try to encode the metric again. If it fails again, we'll just log it because it shouldn't
1020    // be possible to fail at this point, otherwise we would have already caught that the first
1021    // time.
1022    match request_builder.encode(metric_to_retry).await {
1023        Ok(None) => Ok(EncodeResult::new(true, true)),
1024        Ok(Some(_)) => unreachable!(
1025            "failure to encode due to size should never occur after flush for metrics which aren't unencodable"
1026        ),
1027        Err(e) => {
1028            error!(error = %e, "Failed to encode metric.");
1029            telemetry.events_dropped_encoder().increment(1);
1030            Ok(EncodeResult::new(false, true))
1031        }
1032    }
1033}
1034
1035async fn flush_v2_metrics(
1036    request_builder: &mut RequestBuilder<MetricsEndpointEncoder>, payloads_tx: &mut mpsc::Sender<Payload>,
1037    payload_info: Option<MetricsPayloadInfo>,
1038) -> Result<usize, GenericError> {
1039    let mut requests_flushed = 0;
1040
1041    let maybe_requests = request_builder.flush().await;
1042    for maybe_request in maybe_requests {
1043        match maybe_request {
1044            Ok((events, data_points, request)) => {
1045                requests_flushed += 1;
1046
1047                flush_payload(request, events, data_points, payloads_tx, payload_info).await?;
1048            }
1049
1050            // TODO: Increment a counter here that metrics were dropped due to a flush failure.
1051            Err(e) => {
1052                if !e.is_recoverable() {
1053                    return Err(GenericError::from(e).context("Failed to flush request."));
1054                }
1055            }
1056        }
1057    }
1058
1059    Ok(requests_flushed)
1060}
1061
1062#[derive(Clone, Copy)]
1063struct V3FlushContext<'a> {
1064    endpoint_config: &'a EndpointConfiguration,
1065    payload_limits: V3PayloadLimits,
1066    series_endpoint_uri: &'a str,
1067    serializer_telemetry: &'a V3SerializerTelemetry,
1068    telemetry: &'a ComponentTelemetry,
1069}
1070
1071#[allow(clippy::too_many_arguments)]
1072async fn encode_and_flush_v3_metrics(
1073    endpoint: MetricsEndpoint, context: V3FlushContext<'_>, metrics: &mut Vec<Metric>,
1074    series_ratio: &mut V3CompressionRatio, sketches_ratio: &mut V3CompressionRatio,
1075    payloads_tx: &mut mpsc::Sender<Payload>, payload_info: Option<MetricsPayloadInfo>,
1076) -> Result<(), GenericError> {
1077    match endpoint {
1078        MetricsEndpoint::SeriesV1 | MetricsEndpoint::SeriesV2 => {
1079            encode_and_flush_v3_series_metrics(context, metrics, series_ratio, payloads_tx, payload_info).await
1080        }
1081        MetricsEndpoint::Sketches => {
1082            encode_and_flush_v3_sketch_metrics(context, metrics, sketches_ratio, payloads_tx, payload_info).await
1083        }
1084    }
1085}
1086
1087async fn encode_and_flush_v3_series_metrics(
1088    context: V3FlushContext<'_>, metrics: &mut Vec<Metric>, ratio: &mut V3CompressionRatio,
1089    payloads_tx: &mut mpsc::Sender<Payload>, payload_info: Option<MetricsPayloadInfo>,
1090) -> Result<(), GenericError> {
1091    if metrics.is_empty() {
1092        return Ok(());
1093    }
1094    let metrics_to_flush = std::mem::take(metrics);
1095
1096    encode_and_flush_v3_payload_requests(
1097        context.series_endpoint_uri,
1098        &metrics_to_flush,
1099        context,
1100        "series",
1101        ratio,
1102        payloads_tx,
1103        payload_info,
1104    )
1105    .await
1106}
1107
1108async fn encode_and_flush_v3_sketch_metrics(
1109    context: V3FlushContext<'_>, metrics: &mut Vec<Metric>, ratio: &mut V3CompressionRatio,
1110    payloads_tx: &mut mpsc::Sender<Payload>, payload_info: Option<MetricsPayloadInfo>,
1111) -> Result<(), GenericError> {
1112    if metrics.is_empty() {
1113        return Ok(());
1114    }
1115    let metrics_to_flush = std::mem::take(metrics);
1116
1117    encode_and_flush_v3_payload_requests(
1118        V3_SKETCHES_ENDPOINT_URI,
1119        &metrics_to_flush,
1120        context,
1121        "sketches",
1122        ratio,
1123        payloads_tx,
1124        payload_info,
1125    )
1126    .await
1127}
1128
1129/// Smallest compression ratio used when translating the compressed size limit into an uncompressed byte target.
1130///
1131/// Guards against dividing by a pathologically small ratio and producing an enormous target.
1132const V3_MIN_COMPRESSION_RATIO: f64 = 0.02;
1133
1134/// Fraction of the derived byte target that a batch is actually cut at.
1135///
1136/// Leaves headroom so that ordinary variation between batches does not push the finalized payload over the real limit
1137/// and force the expensive re-encode path.
1138const V3_BATCH_TARGET_MARGIN: f64 = 0.9;
1139
1140/// Weight given to the newest observation when updating the compression ratio.
1141const V3_RATIO_SMOOTHING: f64 = 0.25;
1142
1143/// Tracks the compression ratio observed on emitted V3 payloads, so batches can be sized against the compressed limit.
1144///
1145/// V3 payloads are columnar and only compressed once the whole batch is finalized, so unlike the V2 request builder
1146/// there is no live compressor to consult with a [`CompressionEstimator`] while batching. Instead we remember how well
1147/// recent payloads compressed and use that to turn the compressed size limit (which is the limit that actually binds
1148/// for metrics intake) into an uncompressed byte target for the next batch.
1149#[derive(Default)]
1150struct V3CompressionRatio {
1151    ratio: Option<f64>,
1152}
1153
1154impl V3CompressionRatio {
1155    /// Returns the uncompressed byte target that a batch should be cut at.
1156    fn uncompressed_target(&self, limits: V3PayloadLimits) -> usize {
1157        // Until a payload has been observed, assume the data will not compress at all. That errs towards batches that
1158        // are too small, which costs an extra payload, rather than too large, which costs a full re-encode.
1159        let ratio = self.ratio.unwrap_or(1.0).clamp(V3_MIN_COMPRESSION_RATIO, 1.0);
1160        let target_from_compressed_limit = (limits.max_compressed_size as f64 / ratio) as usize;
1161        let target = target_from_compressed_limit.min(limits.max_uncompressed_size);
1162
1163        // Always leave room for at least one metric, however small the limits are.
1164        (((target as f64) * V3_BATCH_TARGET_MARGIN) as usize).max(1)
1165    }
1166
1167    /// Records the uncompressed and compressed sizes of an emitted payload.
1168    fn record(&mut self, uncompressed_len: usize, compressed_len: usize) {
1169        if uncompressed_len == 0 {
1170            return;
1171        }
1172
1173        let observed = compressed_len as f64 / uncompressed_len as f64;
1174        self.ratio = Some(match self.ratio {
1175            Some(current) => (current * (1.0 - V3_RATIO_SMOOTHING)) + (observed * V3_RATIO_SMOOTHING),
1176            None => observed,
1177        });
1178    }
1179}
1180
1181/// A contiguous run of metrics encoded into a single V3 payload.
1182struct V3Batch {
1183    encoded: V3EncodedMetrics,
1184    event_count: usize,
1185    data_point_count: usize,
1186}
1187
1188/// Encodes metrics starting at `*idx` into a single V3 payload, stopping once a payload limit or
1189/// `target_uncompressed_len` is reached, and advances `*idx` past everything consumed.
1190///
1191/// Sizing the batch while encoding it is what keeps this to a single encode pass. The alternative (encoding a large
1192/// batch and halving it whenever the result is too big) re-encodes and re-compresses the same metrics once per level of
1193/// splitting, which is dominated by dictionary rebuilding on high-cardinality data
1194fn build_v3_batch(
1195    metrics: &[Metric], idx: &mut usize, context: V3FlushContext<'_>, payload_kind: &'static str,
1196    target_uncompressed_len: usize,
1197) -> Option<V3Batch> {
1198    let mut writer = V3Writer::new();
1199    let mut tags_deduplicator = ReusableDeduplicator::new();
1200    let additional_tags = context.endpoint_config.additional_tags();
1201    let mut data_point_count = 0usize;
1202    let mut cut_for_size = false;
1203
1204    while *idx < metrics.len() {
1205        let metric = &metrics[*idx];
1206
1207        if !metric_has_emittable_values(metric) {
1208            debug!(metric_name = %metric.context().name(), "Dropping metric with no emittable values.");
1209            context.telemetry.events_dropped_encoder().increment(1);
1210            *idx += 1;
1211            continue;
1212        }
1213
1214        let metric_points = metric.values().len();
1215        if !context.payload_limits.point_count_fits(metric_points) {
1216            // This metric exceeds the point limit on its own, so it cannot fit in any payload.
1217            context.serializer_telemetry.record_item_too_big();
1218            context
1219                .serializer_telemetry
1220                .record_split_reason(V3PayloadSplitReason::ItemTooBig);
1221            warn!(
1222                payload_kind,
1223                data_points = metric_points,
1224                point_limit = context.payload_limits.max_points_per_payload,
1225                "Dropping oversized V3 metric that exceeds the point-count limit."
1226            );
1227            context.telemetry.events_dropped_encoder().increment(1);
1228            *idx += 1;
1229            continue;
1230        }
1231
1232        // Limits that have to be honoured before the metric goes in. The first metric of a batch is always admitted,
1233        // since it has already been checked to fit by itself.
1234        if writer.metric_count() > 0 {
1235            if !context
1236                .payload_limits
1237                .point_count_fits(data_point_count + metric_points)
1238            {
1239                context
1240                    .serializer_telemetry
1241                    .record_split_reason(V3PayloadSplitReason::MaxPoints);
1242                break;
1243            }
1244
1245            if context.payload_limits.metric_count_reached(writer.metric_count()) {
1246                break;
1247            }
1248        }
1249
1250        write_metric_to_v3(&mut writer, metric, additional_tags, &mut tags_deduplicator);
1251        data_point_count += metric_points;
1252        *idx += 1;
1253
1254        // The size contribution of a metric is only knowable once it is written, since dictionary deduplication makes
1255        // it depend on what came before. Cutting after the fact can overshoot by one metric, which the target's
1256        // headroom absorbs; the finalized payload is checked against the real limits regardless.
1257        if writer.estimated_uncompressed_len() >= target_uncompressed_len {
1258            cut_for_size = true;
1259            break;
1260        }
1261    }
1262
1263    // Only count this as a payload boundary if metrics actually remain: a batch that ends on the last metric was not
1264    // split by anything.
1265    if cut_for_size && *idx < metrics.len() {
1266        context
1267            .serializer_telemetry
1268            .record_split_reason(V3PayloadSplitReason::PayloadFull);
1269    }
1270
1271    let event_count = writer.metric_count();
1272    if event_count == 0 {
1273        return None;
1274    }
1275
1276    match writer.finalize() {
1277        Ok(encoded) => Some(V3Batch {
1278            encoded,
1279            event_count,
1280            data_point_count,
1281        }),
1282        Err(e) => {
1283            error!(error = %e, payload_kind, events = event_count, "Failed to encode V3 metrics payload request.");
1284            context.telemetry.events_dropped_encoder().increment(event_count as u64);
1285            None
1286        }
1287    }
1288}
1289
1290/// Turns an encoded batch into a payload request, returning `None` if it does not fit the payload limits.
1291async fn create_v3_batch_request(
1292    endpoint_uri: &str, batch: V3Batch, context: V3FlushContext<'_>, payload_kind: &'static str,
1293    ratio: &mut V3CompressionRatio,
1294) -> Option<V3PayloadRequest> {
1295    let V3Batch {
1296        encoded,
1297        event_count,
1298        data_point_count,
1299    } = batch;
1300
1301    let mut encoded_request =
1302        match create_v3_request(endpoint_uri, encoded, context.endpoint_config.compression_scheme()).await {
1303            Ok(request) => request,
1304            Err(e) => {
1305                error!(error = %e, payload_kind, events = event_count, "Failed to create V3 metrics request.");
1306                context.telemetry.events_dropped_encoder().increment(event_count as u64);
1307                return None;
1308            }
1309        };
1310
1311    // Feed the observation back even when the request is too big: an oversized batch is exactly the case where the
1312    // target needs correcting.
1313    ratio.record(encoded_request.uncompressed_len, encoded_request.compressed_len);
1314
1315    if !context.payload_limits.request_fits(&encoded_request) {
1316        return None;
1317    }
1318
1319    // Per-column compressed sizes are measured only for requests we actually emit. Measuring them eagerly in
1320    // `create_v3_request` would compress every column of every oversized attempt that gets discarded, which is pure
1321    // waste.
1322    if let Err(e) =
1323        measure_v3_column_compressed_sizes(&mut encoded_request.stats, context.endpoint_config.compression_scheme())
1324            .await
1325    {
1326        error!(error = %e, payload_kind, "Failed to measure V3 column compressed sizes.");
1327    } else {
1328        record_v3_serializer_stats(context.serializer_telemetry, &encoded_request.stats);
1329    }
1330
1331    Some(V3PayloadRequest {
1332        request: encoded_request.request,
1333        event_count,
1334        data_point_count,
1335    })
1336}
1337
1338/// Re-encodes an oversized range as progressively smaller ranges until each one fits.
1339///
1340/// This is the fallback for a batch that was sized against the byte target but still exceeded the real payload limits.
1341/// It re-encodes and re-compresses at every level of splitting, so it is deliberately only reached when the target
1342/// guessed wrong.
1343async fn split_and_encode_oversized_v3_range(
1344    endpoint_uri: &str, metrics: &[Metric], range: Range<usize>, context: V3FlushContext<'_>,
1345    payload_kind: &'static str, ratio: &mut V3CompressionRatio,
1346) -> Vec<V3PayloadRequest> {
1347    // `build_v3_batch` advances past metrics it drops. Preserve that decision when the accepted batch has to be
1348    // re-encoded: retrying the raw source range would otherwise resurrect those metrics.
1349    let accepted_metrics = metrics[range]
1350        .iter()
1351        .filter(|metric| {
1352            metric_has_emittable_values(metric) && context.payload_limits.point_count_fits(metric.values().len())
1353        })
1354        .collect::<Vec<_>>();
1355
1356    let mut requests = Vec::new();
1357    let mut pending_ranges = VecDeque::new();
1358    pending_ranges.push_back(0..accepted_metrics.len());
1359
1360    while let Some(range) = pending_ranges.pop_front() {
1361        if range.is_empty() {
1362            continue;
1363        }
1364
1365        let metrics_in_range = &accepted_metrics[range.clone()];
1366        let event_count = metrics_in_range.len();
1367        let data_point_count = metrics_in_range.iter().map(|metric| metric.values().len()).sum();
1368
1369        let encoded = match encode_v3_metrics_batch(
1370            metrics_in_range.iter().copied(),
1371            context.endpoint_config.additional_tags(),
1372        ) {
1373            Ok(encoded) => encoded,
1374            Err(e) => {
1375                error!(error = %e, payload_kind, events = event_count, "Failed to encode V3 metrics payload request.");
1376                context.telemetry.events_dropped_encoder().increment(event_count as u64);
1377                continue;
1378            }
1379        };
1380        let batch = V3Batch {
1381            encoded,
1382            event_count,
1383            data_point_count,
1384        };
1385
1386        if let Some(request) = create_v3_batch_request(endpoint_uri, batch, context, payload_kind, ratio).await {
1387            requests.push(request);
1388            continue;
1389        }
1390
1391        if range.len() == 1 {
1392            // The encoded request is too large and this range cannot be split any further.
1393            context.serializer_telemetry.record_item_too_big();
1394            context
1395                .serializer_telemetry
1396                .record_split_reason(V3PayloadSplitReason::ItemTooBig);
1397            warn!(
1398                payload_kind,
1399                compressed_limit = context.payload_limits.max_compressed_size,
1400                uncompressed_limit = context.payload_limits.max_uncompressed_size,
1401                "Dropping oversized V3 metric that cannot be split further."
1402            );
1403            context.telemetry.events_dropped_encoder().increment(1);
1404            continue;
1405        }
1406
1407        // Retry this oversized range as two smaller ranges, preserving the original metric order.
1408        context
1409            .serializer_telemetry
1410            .record_split_reason(V3PayloadSplitReason::PayloadFull);
1411        let pivot = range.start + range.len() / 2;
1412        pending_ranges.push_front(pivot..range.end);
1413        pending_ranges.push_front(range.start..pivot);
1414    }
1415
1416    requests
1417}
1418
1419#[allow(clippy::too_many_arguments)]
1420async fn encode_and_flush_v3_payload_requests(
1421    endpoint_uri: &str, metrics: &[Metric], context: V3FlushContext<'_>, payload_kind: &'static str,
1422    ratio: &mut V3CompressionRatio, payloads_tx: &mut mpsc::Sender<Payload>, payload_info: Option<MetricsPayloadInfo>,
1423) -> Result<(), GenericError> {
1424    let target_uncompressed_len = ratio.uncompressed_target(context.payload_limits);
1425    let mut idx = 0;
1426
1427    while idx < metrics.len() {
1428        let batch_start = idx;
1429        let Some(batch) = build_v3_batch(metrics, &mut idx, context, payload_kind, target_uncompressed_len) else {
1430            // `build_v3_batch` always consumes at least one metric when one is available, but guard against spinning
1431            // on a batch that somehow produced nothing.
1432            if idx == batch_start {
1433                error!(
1434                    payload_kind,
1435                    "V3 batching made no progress; dropping remaining metrics."
1436                );
1437                context
1438                    .telemetry
1439                    .events_dropped_encoder()
1440                    .increment((metrics.len() - idx) as u64);
1441                break;
1442            }
1443            continue;
1444        };
1445
1446        let requests = match create_v3_batch_request(endpoint_uri, batch, context, payload_kind, ratio).await {
1447            Some(request) => vec![request],
1448            // The batch we sized against the byte target still came out over the real limits, so fall back to
1449            // re-encoding it as progressively smaller ranges. The target self-corrects from the ratio we record on
1450            // every emitted payload, so this should be rare.
1451            None => {
1452                split_and_encode_oversized_v3_range(
1453                    endpoint_uri,
1454                    metrics,
1455                    batch_start..idx,
1456                    context,
1457                    payload_kind,
1458                    ratio,
1459                )
1460                .await
1461            }
1462        };
1463
1464        for payload_request in requests {
1465            flush_payload(
1466                payload_request.request,
1467                payload_request.event_count,
1468                payload_request.data_point_count,
1469                payloads_tx,
1470                payload_info,
1471            )
1472            .await?;
1473            debug!(
1474                payload_kind,
1475                events = payload_request.event_count,
1476                data_points = payload_request.data_point_count,
1477                "Sent V3 payload."
1478            );
1479        }
1480    }
1481
1482    Ok(())
1483}
1484
1485fn record_v3_serializer_stats(telemetry: &V3SerializerTelemetry, stats: &V3EncoderStats) {
1486    telemetry.record_values_count(stats.value_encoding_stats);
1487
1488    for column in &stats.columns {
1489        let uncompressed_size = column.bytes.len() as u64;
1490        let compressed_size = column.compressed_len as u64;
1491        telemetry.record_column_size(column.field_number, uncompressed_size, compressed_size);
1492    }
1493}
1494
1495/// Measures the compressed size of each V3 column, for telemetry purposes.
1496///
1497/// This is deliberately separate from building the request: it is only worth paying for columns belonging to a request
1498/// that will actually be sent, since oversized requests are discarded and re-encoded as smaller ranges.
1499async fn measure_v3_column_compressed_sizes(
1500    stats: &mut V3EncoderStats, compression_scheme: CompressionScheme,
1501) -> Result<(), GenericError> {
1502    for column in &mut stats.columns {
1503        column.compressed_len = compressed_v3_len(&column.bytes, compression_scheme)
1504            .await
1505            .error_context("Failed to measure V3 column compressed size.")?;
1506    }
1507
1508    Ok(())
1509}
1510
1511async fn compressed_v3_len(bytes: &[u8], compression_scheme: CompressionScheme) -> Result<usize, GenericError> {
1512    if matches!(compression_scheme, CompressionScheme::Noop) {
1513        return Ok(bytes.len());
1514    }
1515
1516    let buffer = ChunkedBytesBuffer::new(RB_BUFFER_CHUNK_SIZE);
1517    let mut compressor = Compressor::from_scheme(compression_scheme, buffer);
1518    compressor
1519        .write_all(bytes)
1520        .await
1521        .error_context("Failed to compress V3 bytes.")?;
1522    compressor
1523        .flush()
1524        .await
1525        .error_context("Failed to flush V3 compressor.")?;
1526    compressor
1527        .shutdown()
1528        .await
1529        .error_context("Failed to shutdown V3 compressor.")?;
1530
1531    Ok(compressor.into_inner().freeze().len())
1532}
1533
1534async fn flush_payload(
1535    request: Request<FrozenChunkedBytesBuffer>, event_count: usize, data_point_count: usize,
1536    payloads_tx: &mut mpsc::Sender<Payload>, payload_info: Option<MetricsPayloadInfo>,
1537) -> Result<(), GenericError> {
1538    let mut payload_meta = PayloadMetadata::from_event_and_data_point_count(event_count, data_point_count);
1539    if let Some(info) = payload_info {
1540        payload_meta = payload_meta.with(info);
1541    }
1542    let http_payload = HttpPayload::new(payload_meta, request);
1543    let payload = Payload::Http(http_payload);
1544
1545    payloads_tx
1546        .send(payload)
1547        .await
1548        .error_context("Failed to send payload.")?;
1549
1550    Ok(())
1551}
1552
1553// Encodes a batch of metrics to V3 columnar format.
1554fn encode_v3_metrics_batch<'a>(
1555    metrics: impl IntoIterator<Item = &'a Metric>, additional_tags: &SharedTagSet,
1556) -> Result<V3EncodedMetrics, GenericError> {
1557    let mut writer = V3Writer::new();
1558    let mut tags_deduplicator = ReusableDeduplicator::new();
1559
1560    for metric in metrics {
1561        write_metric_to_v3(&mut writer, metric, additional_tags, &mut tags_deduplicator);
1562    }
1563
1564    writer
1565        .finalize()
1566        .map_err(|e| generic_error!("Failed to serialize V3 payload: {}", e))
1567}
1568
1569/// Writes a single metric to the V3 writer.
1570pub(super) fn sketch_has_emittable_values(sketch: &DDSketch) -> bool {
1571    !sketch.is_empty() || !sketch.bins().is_empty()
1572}
1573
1574pub(super) fn metric_has_emittable_values(metric: &Metric) -> bool {
1575    match metric.values() {
1576        MetricValues::Counter(points) | MetricValues::Rate(points, _) | MetricValues::Gauge(points) => {
1577            points.into_iter().any(|(_, value)| emittable_scalar_point(value))
1578        }
1579        MetricValues::Set(points) => points.into_iter().any(|(_, value)| emittable_scalar_point(value)),
1580        MetricValues::Distribution(sketches) => sketches
1581            .into_iter()
1582            .any(|(_, sketch)| sketch_has_emittable_values(sketch)),
1583        MetricValues::Histogram(points) => points.into_iter().any(|(_, histogram)| !histogram.samples().is_empty()),
1584    }
1585}
1586
1587fn write_metric_to_v3(
1588    writer: &mut V3Writer, metric: &Metric, additional_tags: &SharedTagSet,
1589    tags_deduplicator: &mut ReusableDeduplicator<Tag>,
1590) {
1591    if !metric_has_emittable_values(metric) {
1592        debug!(metric_name = %metric.context().name(), "Dropping metric with no emittable values.");
1593        return;
1594    }
1595
1596    let metric_type = match metric.values() {
1597        MetricValues::Counter(..) => V3MetricType::Count,
1598        MetricValues::Rate(..) => V3MetricType::Rate,
1599        MetricValues::Gauge(..) | MetricValues::Set(..) => V3MetricType::Gauge,
1600        MetricValues::Histogram(..) | MetricValues::Distribution(..) => V3MetricType::Sketch,
1601    };
1602    let is_sketch = metric_type == V3MetricType::Sketch;
1603
1604    let mut builder = writer.write(metric_type, metric.context().name());
1605
1606    // Tags - chain instrumented + additional + origin tags
1607    let chained_tags = metric
1608        .context()
1609        .tags()
1610        .into_iter()
1611        .chain(additional_tags)
1612        .chain(metric.context().origin_tags());
1613    let all_tags = tags_deduplicator
1614        .deduplicated(chained_tags)
1615        .filter(|t| is_sketch || !is_v3_series_resource_tag(t) && !is_v3_series_device_tag(t))
1616        .map(|t| t.as_str());
1617    builder.set_tags(all_tags);
1618
1619    // Resources - extract host and, for series, promoted resource tags.
1620    let mut resources = Vec::new();
1621    if let Some(host) = metric.context().host().filter(|host| !host.is_empty()) {
1622        resources.push(("host", host));
1623    }
1624    if !is_sketch {
1625        let mut device_resource = None;
1626        let chained_tags = metric
1627            .context()
1628            .origin_tags()
1629            .into_iter()
1630            .chain(metric.context().tags())
1631            .chain(additional_tags);
1632        for tag in tags_deduplicator.deduplicated(chained_tags) {
1633            if is_v3_series_device_tag(tag) {
1634                device_resource = tag.value().filter(|device| !device.is_empty());
1635            } else if is_v3_series_resource_tag(tag) {
1636                if let Some((rtype, rname)) = tag.value().and_then(|value| value.split_once(':')) {
1637                    if !rtype.is_empty() && !rname.is_empty() {
1638                        resources.push((rtype, rname));
1639                    }
1640                }
1641            }
1642        }
1643        if let Some(device) = device_resource {
1644            let device_idx = usize::from(metric.context().host().is_some_and(|host| !host.is_empty()));
1645            resources.insert(device_idx, ("device", device));
1646        }
1647    }
1648    builder.set_resources(&resources);
1649
1650    // Origin metadata
1651    if let Some(origin) = metric.metadata().origin() {
1652        match origin {
1653            MetricOrigin::SourceType(source_type) => {
1654                builder.set_source_type(source_type.as_ref());
1655            }
1656            MetricOrigin::OriginMetadata {
1657                product,
1658                subproduct,
1659                product_detail,
1660            } => {
1661                builder.set_origin(*product, *subproduct, *product_detail, false);
1662            }
1663        }
1664    }
1665
1666    if metric_type != V3MetricType::Sketch {
1667        if let Some(unit) = metric.metadata().unit() {
1668            builder.set_unit(unit);
1669        }
1670    }
1671
1672    // Points based on metric type
1673    match metric.values() {
1674        MetricValues::Counter(points) | MetricValues::Gauge(points) => {
1675            for (ts, val) in points {
1676                if !emittable_scalar_point(val) {
1677                    continue;
1678                }
1679                let timestamp = ts.map(|t| t.get() as i64).unwrap_or(0);
1680                builder.add_point(timestamp, val);
1681            }
1682        }
1683        MetricValues::Rate(points, interval) => {
1684            builder.set_interval(interval.as_secs());
1685            for (ts, val) in points {
1686                // Scale by interval as done in V2. A zero interval is an unnormalized rate.
1687                let scaled = if interval.is_zero() {
1688                    val
1689                } else {
1690                    val / interval.as_secs_f64()
1691                };
1692                if !emittable_scalar_point(scaled) {
1693                    continue;
1694                }
1695                let timestamp = ts.map(|t| t.get() as i64).unwrap_or(0);
1696                builder.add_point(timestamp, scaled);
1697            }
1698        }
1699        MetricValues::Set(points) => {
1700            // Set values are already converted to count in the iterator
1701            for (ts, count) in points {
1702                if !emittable_scalar_point(count) {
1703                    continue;
1704                }
1705                let timestamp = ts.map(|t| t.get() as i64).unwrap_or(0);
1706                builder.add_point(timestamp, count);
1707            }
1708        }
1709        MetricValues::Distribution(sketches) => {
1710            for (ts, sketch) in sketches {
1711                let timestamp = ts.map(|t| t.get() as i64).unwrap_or(0);
1712                if sketch_has_emittable_values(sketch) {
1713                    let bin_keys: Vec<i32> = sketch.bins().iter().map(|b| b.key()).collect();
1714                    let bin_counts: Vec<u32> = sketch.bins().iter().map(|b| b.count()).collect();
1715                    builder.add_sketch(
1716                        timestamp,
1717                        sketch.count() as i64,
1718                        sketch.stored_sum(),
1719                        sketch.stored_min(),
1720                        sketch.stored_max(),
1721                        &bin_keys,
1722                        &bin_counts,
1723                    );
1724                }
1725            }
1726        }
1727        MetricValues::Histogram(histograms) => {
1728            for (ts, histogram) in histograms {
1729                let timestamp = ts.map(|t| t.get() as i64).unwrap_or(0);
1730                // Convert histogram to DDSketch
1731                let mut sketch = DDSketch::default();
1732                for sample in histogram.samples() {
1733                    sketch.insert_n(sample.value.into_inner(), sample.weight.0 as u64);
1734                }
1735                if !sketch.is_empty() {
1736                    let bin_keys: Vec<i32> = sketch.bins().iter().map(|b| b.key()).collect();
1737                    let bin_counts: Vec<u32> = sketch.bins().iter().map(|b| b.count()).collect();
1738                    builder.add_sketch(
1739                        timestamp,
1740                        sketch.count() as i64,
1741                        sketch.sum().unwrap_or(0.0),
1742                        sketch.min().unwrap_or(0.0),
1743                        sketch.max().unwrap_or(0.0),
1744                        &bin_keys,
1745                        &bin_counts,
1746                    );
1747                }
1748            }
1749        }
1750    }
1751
1752    builder.close();
1753}
1754
1755#[inline]
1756fn emittable_scalar_point(point: f64) -> bool {
1757    point.is_finite()
1758}
1759
1760fn is_v3_series_device_tag(tag: &Tag) -> bool {
1761    tag.name() == "device" && tag.value().is_some()
1762}
1763
1764fn is_v3_series_resource_tag(tag: &Tag) -> bool {
1765    tag.name() == "dd.internal.resource" && tag.value().is_some()
1766}
1767
1768/// Creates a V3 HTTP request from encoded payload data.
1769async fn create_v3_request(
1770    endpoint_uri: &str, encoded: V3EncodedMetrics, compression_scheme: CompressionScheme,
1771) -> Result<V3EncodedRequest, GenericError> {
1772    // Keep the wire payload as one continuous compressed stream. Per-column compressed sizes are measured
1773    // independently for telemetry.
1774    let mut header_buf = [0; 16];
1775    let header_len = {
1776        let mut header_writer = CodedOutputStream::bytes(&mut header_buf);
1777        header_writer.write_tag(3, WireType::LengthDelimited)?;
1778        header_writer.write_uint64_no_tag(encoded.payload.len() as u64)?;
1779        header_writer.flush()?;
1780        header_writer.total_bytes_written() as usize
1781    };
1782
1783    let uncompressed_len = header_len + encoded.payload.len();
1784
1785    let buffer = ChunkedBytesBuffer::new(RB_BUFFER_CHUNK_SIZE);
1786    let mut compressor = Compressor::from_scheme(compression_scheme, buffer);
1787    compressor
1788        .write_all(&header_buf[..header_len])
1789        .await
1790        .error_context("Failed to compress V3 payload.")?;
1791    compressor
1792        .write_all(&encoded.payload)
1793        .await
1794        .error_context("Failed to compress V3 payload.")?;
1795    compressor
1796        .flush()
1797        .await
1798        .error_context("Failed to flush V3 compressor.")?;
1799    compressor
1800        .shutdown()
1801        .await
1802        .error_context("Failed to shutdown V3 compressor.")?;
1803
1804    let compressed_buf = compressor.into_inner().freeze();
1805    let compressed_len = compressed_buf.len();
1806
1807    let mut builder = Request::builder()
1808        .method(Method::POST)
1809        .uri(endpoint_uri)
1810        .header(http::header::CONTENT_TYPE, "application/x-protobuf");
1811
1812    if let Some(encoding) = content_encoding_for_scheme(compression_scheme) {
1813        builder = builder.header(http::header::CONTENT_ENCODING, encoding);
1814    }
1815
1816    let request = builder
1817        .body(compressed_buf)
1818        .map_err(|e| generic_error!("Failed to build V3 request: {}", e))?;
1819
1820    Ok(V3EncodedRequest {
1821        request,
1822        compressed_len,
1823        uncompressed_len,
1824        stats: encoded.stats,
1825    })
1826}
1827
1828fn content_encoding_for_scheme(compression_scheme: CompressionScheme) -> Option<HeaderValue> {
1829    match compression_scheme {
1830        CompressionScheme::Noop => None,
1831        CompressionScheme::Gzip(_) => Some(HeaderValue::from_static("gzip")),
1832        CompressionScheme::Zlib(_) => Some(HeaderValue::from_static("deflate")),
1833        CompressionScheme::Zstd(_) => Some(HeaderValue::from_static("zstd")),
1834    }
1835}
1836
1837#[cfg(test)]
1838mod tests {
1839    use std::{collections::HashMap, io::Cursor};
1840
1841    use agent_data_plane_config::{
1842        shared::{
1843            AltMetricsIntake, MetricsEncoding as TypedMetricsEncoding, V3ApiSettings as TypedV3ApiSettings,
1844            V3SeriesMode,
1845        },
1846        ConfigValue,
1847    };
1848    use bytes::Bytes;
1849    use datadog_protos::metrics::v3::MetricData as V3MetricData;
1850    use protobuf::Message as _;
1851    use saluki_core::components::test_util::TestComponentDriver;
1852    use saluki_core::data_model::{
1853        event::{
1854            metric::{context::Context, MetricMetadata},
1855            Event,
1856        },
1857        payload::Payload,
1858        tags::{Tag, TagSet},
1859    };
1860    use saluki_metrics::test::TestRecorder;
1861    use stringtheory::MetaString;
1862    use tokio::time::timeout;
1863
1864    use super::*;
1865    use crate::common::datadog::test_util::shared_configuration;
1866
1867    /// Returns an encoder built from shared configuration shaped like a translated Agent configuration.
1868    fn metrics_config_from(shared: &SharedConfiguration) -> DatadogMetricsConfiguration {
1869        DatadogMetricsConfiguration::from_configuration(shared)
1870    }
1871
1872    /// Returns the Agent-compatible V3 series settings a default configuration resolves to.
1873    fn agent_series_config() -> UseV3ApiSeriesConfig {
1874        (&TypedMetricsEncoding::default()).into()
1875    }
1876
1877    /// Returns a catalog built from the endpoints of `shared`, the way the topology builds the one it shares.
1878    fn catalog_for(shared: &SharedConfiguration) -> Arc<RoutingTargetCatalog> {
1879        Arc::new(
1880            RoutingTargetCatalog::new(
1881                &shared.endpoints.primary_endpoint(),
1882                &shared.endpoints.additional_endpoints,
1883            )
1884            .expect("catalog should build"),
1885        )
1886    }
1887
1888    #[test]
1889    fn routing_targets_address_series_and_sketch_payloads() {
1890        use crate::common::datadog::{
1891            METRICS_SERIES_V1_PATH, METRICS_SERIES_V2_PATH, METRICS_SERIES_V3_BETA_PATH, METRICS_SKETCHES_PATH,
1892        };
1893
1894        let catalog = catalog_for(&shared_configuration());
1895        for targets in [RoutingTargetSet::new(), catalog.select(|_| true)] {
1896            let routing_targets = Arc::new(MetricsRoutingTargets::new(targets));
1897            for (path, info) in [
1898                (METRICS_SERIES_V1_PATH, MetricsPayloadInfo::v2_series()),
1899                (METRICS_SERIES_V2_PATH, MetricsPayloadInfo::v2_series()),
1900                (METRICS_SERIES_V3_BETA_PATH, MetricsPayloadInfo::v3_series()),
1901                (METRICS_SERIES_V3_PATH, MetricsPayloadInfo::v3_series()),
1902                (METRICS_SKETCHES_PATH, MetricsPayloadInfo::v2_sketches()),
1903                (METRICS_SKETCHES_V3_PATH, MetricsPayloadInfo::v3_sketches()),
1904            ] {
1905                for tagged in [false, true] {
1906                    let mut metadata = PayloadMetadata::from_event_count(1);
1907                    if tagged {
1908                        metadata.set(info);
1909                    }
1910                    let body = ChunkedBytesBuffer::new(RB_BUFFER_CHUNK_SIZE).freeze();
1911                    let request = Request::builder().uri(path).body(body).unwrap();
1912                    let payload = Payload::Http(HttpPayload::new(metadata, request));
1913                    let routed = apply_routing_targets(payload, Some(&routing_targets))
1914                        .try_into_http_payload()
1915                        .unwrap();
1916                    let (metadata, _) = routed.into_parts();
1917                    let payload_targets = metadata.get::<Arc<MetricsRoutingTargets>>();
1918                    assert!(Arc::ptr_eq(payload_targets.unwrap(), &routing_targets));
1919                    assert_eq!(metadata.get::<MetricsPayloadInfo>(), tagged.then_some(&info));
1920                }
1921            }
1922        }
1923    }
1924
1925    // TODO: Build a routed encoder and run it end to end, asserting that every payload it dispatches carries the
1926    // configured routing targets. Nothing covers `build` handing `routing_targets` to the running encoder: if it passed
1927    // `None` instead, every payload would leave unaddressed and reach every endpoint, and every test would still pass.
1928
1929    // TODO: The same end-to-end test should cover the run loop sending each payload through
1930    // `apply_routing_and_dispatch`, including payloads drained at shutdown. A dispatch that bypassed it would also
1931    // leave payloads unaddressed, and nothing here would notice.
1932
1933    #[test]
1934    fn v3_api_settings_come_from_resolved_configuration() {
1935        let mut shared = shared_configuration();
1936        shared.metrics_encoding.v3_api.compression_level = 7;
1937        shared.metrics_encoding.v3_api.sketches = TypedV3ApiSettings {
1938            endpoints: vec!["https://app.datadoghq.eu".to_string()],
1939        };
1940
1941        let config = metrics_config_from(&shared);
1942
1943        assert_eq!(7, config.v3_api.compression_level);
1944        assert_eq!(
1945            Some("https://app.datadoghq.eu"),
1946            config.v3_api.sketches.endpoints.first().map(String::as_str)
1947        );
1948    }
1949
1950    #[test]
1951    fn metrics_routing_comes_from_resolved_configuration() {
1952        let mut shared = shared_configuration();
1953        shared.endpoints.compression.compressor_kind = "zstd".to_string();
1954        shared.endpoints.opw_intake = AltMetricsIntake {
1955            enabled: true,
1956            url: "https://opw.example.com".to_string(),
1957            use_v3_series: true,
1958        };
1959        shared.metrics_encoding.use_v2_series_api = false;
1960        shared.metrics_encoding.v3_api.compression_level = 7;
1961        shared.metrics_encoding.v3_series_mode = V3SeriesMode::Disabled;
1962        shared.metrics_encoding.v3_series_endpoint_modes =
1963            HashMap::from([("https://app.datadoghq.com".to_string(), V3SeriesMode::Enabled)]);
1964
1965        let config = metrics_config_from(&shared);
1966
1967        assert_eq!("zstd", config.compressor_kind);
1968        assert!(!config.use_v2_series_api);
1969        assert_eq!(7, config.v3_api.compression_level);
1970        assert_eq!(V3SeriesMode::Disabled, config.use_v3_api.series.enabled);
1971        assert_eq!(
1972            Some(&V3SeriesMode::Enabled),
1973            config.use_v3_api.series.endpoints.get("https://app.datadoghq.com")
1974        );
1975        // A selected endpoint means alternate-intake routing is enabled; it carries the URL and V3 override.
1976        let opw = config
1977            .opw_metrics
1978            .selected_endpoint()
1979            .expect("alternate intake routing should be selected");
1980        assert_eq!("https://opw.example.com", opw.url);
1981        assert!(opw.use_v3_series);
1982    }
1983
1984    #[test]
1985    fn metrics_v3_disabled_by_compressor_uses_v2_only() {
1986        assert_eq!(MetricsEncoderMode::V2Only, metrics_encoder_mode_for_config(true, true));
1987        assert_eq!(
1988            MetricsEncoderMode::V3Enabled,
1989            metrics_encoder_mode_for_config(true, false)
1990        );
1991    }
1992
1993    #[test]
1994    fn mixed_v2_and_v3_endpoints_require_both_series_encoders() {
1995        // The primary Datadog endpoint is V3-authoritative under `datadog_only`, while the additional
1996        // endpoint is not a Datadog URL and stays on V2.
1997        let mut shared = shared_configuration();
1998        shared.metrics_encoding.v3_series_mode = V3SeriesMode::DatadogOnly;
1999        shared.endpoints.additional_endpoints = HashMap::from([(
2000            "https://custom.example.com".to_string(),
2001            vec!["additional-api-key".to_string()],
2002        )]);
2003        let config = metrics_config_from(&shared);
2004
2005        assert!(config.requires_v2_series(false).expect("endpoints should resolve"));
2006        assert!(config.requires_v3_series(false).expect("endpoints should resolve"));
2007    }
2008
2009    #[test]
2010    fn routing_targets_limit_protocol_selection_to_payload_targets() {
2011        // The primary Datadog endpoint is V3-authoritative under `datadog_only`, while the custom additional endpoint
2012        // stays on V2. Each target set therefore proves that primary and additional endpoints can both be selected or
2013        // excluded.
2014        let mut shared = shared_configuration();
2015        shared.metrics_encoding.v3_series_mode = V3SeriesMode::DatadogOnly;
2016        shared.endpoints.additional_endpoints = HashMap::from([(
2017            "https://custom.example.com".to_string(),
2018            vec!["additional-api-key".to_string()],
2019        )]);
2020        let catalog = catalog_for(&shared);
2021        let primary = catalog.select(|target| target.kind() == RoutingTargetKind::Primary);
2022        let additional = catalog.select(|target| target.kind() == RoutingTargetKind::Additional);
2023
2024        for (targets, expects_v2, expects_v3) in [
2025            (primary, false, true),
2026            (additional, true, false),
2027            (catalog.select(|_| true), true, true),
2028            (RoutingTargetSet::new(), false, false),
2029        ] {
2030            let config = metrics_config_from(&shared).with_routing(Arc::clone(&catalog), targets);
2031            assert_eq!(config.requires_v2_series(false).unwrap(), expects_v2);
2032            assert_eq!(config.requires_v3_series(false).unwrap(), expects_v3);
2033        }
2034    }
2035
2036    #[test]
2037    fn alternate_metrics_intakes_inherit_the_primary_target_but_keep_their_protocol() {
2038        for use_vector in [false, true] {
2039            for use_v3_series in [false, true] {
2040                let mut shared = shared_configuration();
2041                // Give ordinary endpoints the opposite protocol so using the wrong destination is observable.
2042                shared.metrics_encoding.v3_series_mode = if use_v3_series {
2043                    V3SeriesMode::Disabled
2044                } else {
2045                    V3SeriesMode::Enabled
2046                };
2047                let alternate = AltMetricsIntake {
2048                    enabled: true,
2049                    url: "https://alternate.example.com".to_string(),
2050                    use_v3_series,
2051                };
2052                if use_vector {
2053                    shared.endpoints.vector_intake = alternate;
2054                } else {
2055                    shared.endpoints.opw_intake = alternate;
2056                }
2057                shared.endpoints.additional_endpoints = HashMap::from([(
2058                    "https://alternate.example.com".to_string(),
2059                    vec!["additional-key".to_string()],
2060                )]);
2061                let catalog = catalog_for(&shared);
2062                let primary = catalog.select(|target| target.kind() == RoutingTargetKind::Primary);
2063                let additional = catalog.select(|target| target.kind() == RoutingTargetKind::Additional);
2064                for (targets, expects_v3) in [(primary, use_v3_series), (additional, !use_v3_series)] {
2065                    let config = metrics_config_from(&shared).with_routing(Arc::clone(&catalog), targets);
2066                    assert_eq!(config.requires_v2_series(false).unwrap(), !expects_v3);
2067                    assert_eq!(config.requires_v3_series(false).unwrap(), expects_v3);
2068                }
2069            }
2070        }
2071    }
2072
2073    #[test]
2074    fn all_v2_endpoints_do_not_require_v3_series() {
2075        let mut shared = shared_configuration();
2076        shared.metrics_encoding.v3_series_mode = V3SeriesMode::DatadogOnly;
2077        shared.endpoints.dd_url = ConfigValue::explicit("http://127.0.0.1:9091".to_string());
2078        let config = metrics_config_from(&shared);
2079
2080        assert!(config.requires_v2_series(false).expect("endpoints should resolve"));
2081        assert!(!config.requires_v3_series(false).expect("endpoints should resolve"));
2082    }
2083
2084    #[test]
2085    fn enabled_v3_series_mode_requires_only_v3_series() {
2086        let mut shared = shared_configuration();
2087        shared.metrics_encoding.v3_series_mode = V3SeriesMode::Enabled;
2088        let config = metrics_config_from(&shared);
2089
2090        assert!(!config.requires_v2_series(false).expect("endpoints should resolve"));
2091        assert!(config.requires_v3_series(false).expect("endpoints should resolve"));
2092    }
2093
2094    #[test]
2095    fn v1_series_configuration_takes_precedence_over_v3() {
2096        let mut shared = shared_configuration();
2097        shared.metrics_encoding.use_v2_series_api = false;
2098        shared.metrics_encoding.v3_series_mode = V3SeriesMode::Enabled;
2099        let config = metrics_config_from(&shared);
2100
2101        let series_v3_can_be_enabled =
2102            series_v3_can_be_enabled_for_config(config.use_v2_series_api, None, false, &config.use_v3_api.series);
2103
2104        assert!(!series_v3_can_be_enabled);
2105
2106        assert_eq!(
2107            MetricsEncoderMode::V2Only,
2108            metrics_encoder_mode_for_config(series_v3_can_be_enabled, false)
2109        );
2110
2111        assert!(
2112            config.requires_v2_series(false).expect("endpoint should resolve"),
2113            "V1 configuration must retain the legacy series builder even when V3 is enabled"
2114        );
2115    }
2116
2117    #[test]
2118    fn endpoint_override_uses_the_overridden_endpoint_protocol() {
2119        let mut shared = shared_configuration();
2120        shared.metrics_encoding.v3_series_mode = V3SeriesMode::Disabled;
2121        shared.endpoints.dd_url = ConfigValue::explicit("https://primary.example.com".to_string());
2122        shared.metrics_encoding.v3_series_endpoint_modes =
2123            HashMap::from([("https://v3-mrf.example.com".to_string(), V3SeriesMode::Enabled)]);
2124        let config = metrics_config_from(&shared);
2125
2126        let v2_mrf_config = config
2127            .clone()
2128            .with_metrics_endpoint_override("https://v2-mrf.example.com".to_string());
2129        let v3_mrf_config = config.with_metrics_endpoint_override("https://v3-mrf.example.com".to_string());
2130
2131        assert!(v2_mrf_config
2132            .requires_v2_series(false)
2133            .expect("V2 MRF endpoint should resolve"));
2134        assert!(!v2_mrf_config
2135            .requires_v3_series(false)
2136            .expect("V2 MRF endpoint should resolve"));
2137        assert!(!v3_mrf_config
2138            .requires_v2_series(false)
2139            .expect("V3 MRF endpoint should resolve"));
2140        assert!(v3_mrf_config
2141            .requires_v3_series(false)
2142            .expect("V3 MRF endpoint should resolve"));
2143    }
2144
2145    #[test]
2146    fn an_endpoint_override_drops_the_configured_additional_and_alternate_endpoints() {
2147        let mut shared = shared_configuration();
2148        shared.endpoints.dd_url = ConfigValue::explicit("https://primary.example.com".to_string());
2149        shared.endpoints.additional_endpoints = HashMap::from([(
2150            "https://additional.example.com".to_string(),
2151            vec!["additional-api-key".to_string()],
2152        )]);
2153        shared.endpoints.opw_intake = AltMetricsIntake {
2154            enabled: true,
2155            url: "https://opw.example.com".to_string(),
2156            use_v3_series: true,
2157        };
2158
2159        let config = metrics_config_from(&shared).with_metrics_endpoint_override("https://mrf.example.com".to_string());
2160
2161        assert_eq!("https://mrf.example.com", config.primary_endpoint);
2162        assert!(config.additional_endpoints.is_empty());
2163        assert_eq!(None, config.opw_metrics.selected_endpoint().map(|opw| opw.url));
2164    }
2165
2166    #[test]
2167    fn v2_series_only_override_requires_only_v2_series() {
2168        let mut shared = shared_configuration();
2169        shared.metrics_encoding.v3_series_mode = V3SeriesMode::Enabled;
2170        let config = metrics_config_from(&shared).with_v2_series_only();
2171
2172        assert_eq!(V3SeriesMode::Disabled, config.use_v3_api.series.enabled);
2173        assert!(config.use_v3_api.series.endpoints.is_empty());
2174        assert!(config.requires_v2_series(false).expect("endpoint should resolve"));
2175        assert!(!config.requires_v3_series(false).expect("endpoint should resolve"));
2176    }
2177
2178    #[test]
2179    fn agent_default_v3_does_not_enable_opw_only_encoder_mode() {
2180        let series_config = agent_series_config();
2181        // An alternate intake whose URL cannot resolve does not fall through to the Vector intake.
2182        let mut shared = shared_configuration();
2183        shared.endpoints.opw_intake = AltMetricsIntake {
2184            enabled: true,
2185            url: "http://[::1".to_string(),
2186            use_v3_series: false,
2187        };
2188        shared.endpoints.vector_intake = AltMetricsIntake {
2189            enabled: true,
2190            url: "http://vector.example.com".to_string(),
2191            use_v3_series: false,
2192        };
2193        let opw_metrics = OpwMetricsConfiguration::from_configuration(&shared.endpoints);
2194        let invalid_metrics_primary_override = selected_metrics_primary_v3_override(&opw_metrics);
2195
2196        assert!(!series_v3_can_be_enabled_for_config(
2197            true,
2198            Some(false),
2199            false,
2200            &series_config
2201        ));
2202        assert!(series_v3_can_be_enabled_for_config(
2203            true,
2204            Some(true),
2205            false,
2206            &series_config
2207        ));
2208        assert!(series_v3_can_be_enabled_for_config(
2209            true,
2210            Some(false),
2211            true,
2212            &series_config
2213        ));
2214        assert_eq!(None, invalid_metrics_primary_override);
2215        assert!(series_v3_can_be_enabled_for_config(
2216            true,
2217            invalid_metrics_primary_override,
2218            false,
2219            &series_config
2220        ));
2221    }
2222
2223    #[test]
2224    fn v3_encodes_zero_count_distribution_with_bins() {
2225        let mut sketch = DDSketch::default();
2226        sketch.insert_n(42.0, 10);
2227        sketch.set_count(0);
2228        sketch.set_min(40.0);
2229        sketch.set_max(45.0);
2230        sketch.set_sum(123.0);
2231        sketch.set_avg(f64::NAN);
2232
2233        let metric = Metric::from_parts(
2234            Context::from_static_parts("zero.count.distribution", &[]),
2235            MetricValues::distribution((123_u64, sketch)),
2236            MetricMetadata::default(),
2237        );
2238
2239        let encoded = encode_v3_metrics_batch(&[metric], &SharedTagSet::default())
2240            .expect("zero-count distribution should encode to V3");
2241        let metric_data = V3MetricData::parse_from_bytes(&encoded.payload).expect("V3 metric data should decode");
2242
2243        assert_eq!(metric_data.numPoints, vec![1]);
2244    }
2245
2246    #[test]
2247    fn v3_skips_empty_distribution() {
2248        let metric = Metric::from_parts(
2249            Context::from_static_parts("empty.distribution", &[]),
2250            MetricValues::distribution((123_u64, DDSketch::default())),
2251            MetricMetadata::default(),
2252        );
2253
2254        let encoded =
2255            encode_v3_metrics_batch(&[metric], &SharedTagSet::default()).expect("empty distribution should encode");
2256        let metric_data = V3MetricData::parse_from_bytes(&encoded.payload).expect("V3 metric data should decode");
2257
2258        assert!(metric_data.numPoints.is_empty());
2259    }
2260
2261    #[tokio::test]
2262    async fn create_v3_request_uses_configured_endpoint_uri() {
2263        let encoded = V3Writer::new().finalize().expect("empty V3 payload should encode");
2264        let request = create_v3_request("/api/intake/metrics/custom/series", encoded, CompressionScheme::noop())
2265            .await
2266            .expect("request should be created");
2267
2268        assert_eq!("/api/intake/metrics/custom/series", request.request.uri());
2269    }
2270
2271    #[tokio::test]
2272    async fn create_v3_request_uses_single_stream_body_and_column_telemetry() {
2273        let metrics = vec![Metric::counter("v3.single.stream", 42.0)];
2274        let encoded = encode_v3_metrics_batch(&metrics, &SharedTagSet::default()).expect("metrics should encode to V3");
2275        let expected_payload = encoded.payload.clone();
2276
2277        let mut request = create_v3_request(METRICS_SERIES_V3_PATH, encoded, CompressionScheme::noop())
2278            .await
2279            .expect("request should be created");
2280        measure_v3_column_compressed_sizes(&mut request.stats, CompressionScheme::noop())
2281            .await
2282            .expect("column compressed sizes should be measured");
2283
2284        for column in &request.stats.columns {
2285            assert_eq!(column.compressed_len, column.bytes.len());
2286        }
2287
2288        let mut expected_body = Vec::new();
2289        {
2290            let mut os = CodedOutputStream::vec(&mut expected_body);
2291            os.write_tag(3, WireType::LengthDelimited).unwrap();
2292            os.write_uint64_no_tag(expected_payload.len() as u64).unwrap();
2293            os.flush().unwrap();
2294        }
2295        expected_body.extend_from_slice(&expected_payload);
2296
2297        assert_eq!(request.request.into_body().into_bytes(), Bytes::from(expected_body));
2298    }
2299
2300    #[tokio::test]
2301    async fn create_v3_request_zstd_body_decodes_to_metric_payload() {
2302        let metrics = vec![Metric::counter("v3.single.stream.zstd", 42.0)];
2303        let encoded = encode_v3_metrics_batch(&metrics, &SharedTagSet::default()).expect("metrics should encode to V3");
2304        let expected_payload = encoded.payload.clone();
2305
2306        let mut request = create_v3_request(METRICS_SERIES_V3_PATH, encoded, CompressionScheme::zstd_default())
2307            .await
2308            .expect("request should be created");
2309        measure_v3_column_compressed_sizes(&mut request.stats, CompressionScheme::zstd_default())
2310            .await
2311            .expect("column compressed sizes should be measured");
2312
2313        for column in &request.stats.columns {
2314            let expected_compressed_len = compressed_v3_len(&column.bytes, CompressionScheme::zstd_default())
2315                .await
2316                .expect("column compressed size should be measured");
2317            assert_eq!(column.compressed_len, expected_compressed_len);
2318        }
2319
2320        let mut expected_body = Vec::new();
2321        {
2322            let mut os = CodedOutputStream::vec(&mut expected_body);
2323            os.write_tag(3, WireType::LengthDelimited).unwrap();
2324            os.write_uint64_no_tag(expected_payload.len() as u64).unwrap();
2325            os.flush().unwrap();
2326        }
2327        expected_body.extend_from_slice(&expected_payload);
2328
2329        let decoded_body = zstd::stream::decode_all(Cursor::new(request.request.into_body().into_bytes()))
2330            .expect("compressed V3 body should decode");
2331        assert_eq!(decoded_body, expected_body);
2332    }
2333
2334    #[test]
2335    fn v3_drops_non_finite_scalar_points() {
2336        const NUM_POINTS_FIELD_NUMBER: u32 = 15;
2337        const VALUE_SINT64_FIELD_NUMBER: u32 = 17;
2338
2339        let metrics = vec![
2340            Metric::from_parts(
2341                Context::from_static_parts("v3.finite.counter", &[]),
2342                MetricValues::counter([(1, 1.0_f64), (2, f64::NAN), (3, f64::INFINITY), (4, 2.0)]),
2343                MetricMetadata::default(),
2344            ),
2345            Metric::from_parts(
2346                Context::from_static_parts("v3.finite.gauge", &[]),
2347                MetricValues::gauge([(1, 3.0_f64), (2, f64::NAN), (3, 4.0)]),
2348                MetricMetadata::default(),
2349            ),
2350            Metric::from_parts(
2351                Context::from_static_parts("v3.finite.rate", &[]),
2352                MetricValues::rate(
2353                    [(1, 30.0_f64), (2, f64::INFINITY), (3, f64::NAN)],
2354                    Duration::from_secs(10),
2355                ),
2356                MetricMetadata::default(),
2357            ),
2358            Metric::set("v3.finite.set", "alpha"),
2359        ];
2360
2361        let encoded = encode_v3_metrics_batch(&metrics, &SharedTagSet::default()).expect("metrics should encode to V3");
2362        let column = |field_number| {
2363            encoded
2364                .stats
2365                .columns
2366                .iter()
2367                .find(|column| column.field_number == field_number)
2368                .map(|column| column.bytes.as_slice())
2369        };
2370
2371        assert_eq!(column(NUM_POINTS_FIELD_NUMBER), Some(&[2, 2, 1, 1][..]));
2372        // Values are 1, 2, 3, 4, 3 (rate scaled by 10s), and 1 (set cardinality), encoded as sint64.
2373        assert_eq!(column(VALUE_SINT64_FIELD_NUMBER), Some(&[2, 4, 6, 8, 6, 2][..]));
2374        assert_eq!(encoded.stats.value_encoding_stats.sint64, 6);
2375        assert_eq!(encoded.stats.value_encoding_stats.float64, 0);
2376    }
2377
2378    #[test]
2379    fn v3_encodes_zero_interval_rate_without_scaling() {
2380        const NUM_POINTS_FIELD_NUMBER: u32 = 15;
2381        const VALUE_SINT64_FIELD_NUMBER: u32 = 17;
2382
2383        let metrics = vec![Metric::rate("v3.unnormalized.rate", 42.0, Duration::ZERO)];
2384        let encoded = encode_v3_metrics_batch(&metrics, &SharedTagSet::default()).expect("rate should encode to V3");
2385        let column = |field_number| {
2386            encoded
2387                .stats
2388                .columns
2389                .iter()
2390                .find(|column| column.field_number == field_number)
2391                .map(|column| column.bytes.as_slice())
2392        };
2393
2394        assert_eq!(column(NUM_POINTS_FIELD_NUMBER), Some(&[1][..]));
2395        assert_eq!(column(VALUE_SINT64_FIELD_NUMBER), Some(&[84][..]));
2396    }
2397
2398    #[tokio::test]
2399    async fn v3_serializer_stats_record_agent_style_value_and_column_counters() {
2400        const VALUE_SINT64_FIELD_NUMBER: u32 = 17;
2401
2402        let recorder = TestRecorder::default();
2403        let _local = metrics::set_default_local_recorder(&recorder);
2404        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2405        let metrics = vec![
2406            Metric::gauge("v3.telemetry.zero", [(123, 0.0), (124, 0.0)]),
2407            Metric::counter("v3.telemetry.sint64", [(123, 100.0), (124, 200.0)]),
2408            Metric::gauge("v3.telemetry.float32", [(123, 1.5), (124, 2.25)]),
2409            Metric::gauge("v3.telemetry.float64", [(123, (1i64 << 30) as f64), (124, 1.5)]),
2410        ];
2411        let encoded = encode_v3_metrics_batch(&metrics, &SharedTagSet::default()).expect("metrics should encode to V3");
2412        let mut request = create_v3_request(METRICS_SERIES_V3_PATH, encoded, CompressionScheme::noop())
2413            .await
2414            .expect("request should be created");
2415        measure_v3_column_compressed_sizes(&mut request.stats, CompressionScheme::noop())
2416            .await
2417            .expect("column compressed sizes should be measured");
2418
2419        let value_sint64_column = request
2420            .stats
2421            .columns
2422            .iter()
2423            .find(|column| column.field_number == VALUE_SINT64_FIELD_NUMBER)
2424            .expect("sint64 value column should be present");
2425        let value_sint64_len = value_sint64_column.bytes.len() as u64;
2426        assert_eq!(value_sint64_column.compressed_len as u64, value_sint64_len);
2427
2428        record_v3_serializer_stats(&serializer_telemetry, &request.stats);
2429
2430        assert_eq!(
2431            recorder.counter(("serializer.v3_values_count", &[("type", "zero")])),
2432            Some(2)
2433        );
2434        assert_eq!(
2435            recorder.counter(("serializer.v3_values_count", &[("type", "sint64")])),
2436            Some(2)
2437        );
2438        assert_eq!(
2439            recorder.counter(("serializer.v3_values_count", &[("type", "float32")])),
2440            Some(2)
2441        );
2442        assert_eq!(
2443            recorder.counter(("serializer.v3_values_count", &[("type", "float64")])),
2444            Some(2)
2445        );
2446        assert_eq!(
2447            recorder.counter((
2448                "serializer.v3_column_size",
2449                &[("column", "ValueSint64"), ("compressed", "uncompressed")]
2450            )),
2451            Some(value_sint64_len)
2452        );
2453        assert_eq!(
2454            recorder.counter((
2455                "serializer.v3_column_size",
2456                &[("column", "ValueSint64"), ("compressed", "compressed")]
2457            )),
2458            Some(value_sint64_len)
2459        );
2460    }
2461
2462    async fn create_v3_test_request(metrics: &[Metric]) -> V3EncodedRequest {
2463        let encoded = encode_v3_metrics_batch(metrics, &SharedTagSet::default()).expect("metrics should encode to V3");
2464        create_v3_request(METRICS_SERIES_V3_PATH, encoded, CompressionScheme::noop())
2465            .await
2466            .expect("request should be created")
2467    }
2468
2469    fn test_v3_flush_context<'a>(
2470        ep_config: &'a EndpointConfiguration, payload_limits: V3PayloadLimits,
2471        serializer_telemetry: &'a V3SerializerTelemetry, telemetry: &'a ComponentTelemetry,
2472    ) -> V3FlushContext<'a> {
2473        V3FlushContext {
2474            endpoint_config: ep_config,
2475            payload_limits,
2476            series_endpoint_uri: METRICS_SERIES_V3_PATH,
2477            serializer_telemetry,
2478            telemetry,
2479        }
2480    }
2481
2482    /// Collected form of a payload emitted by `encode_and_flush_v3_payload_requests`.
2483    struct CollectedV3Payload {
2484        event_count: usize,
2485        data_point_count: usize,
2486        request: Request<FrozenChunkedBytesBuffer>,
2487    }
2488
2489    /// Drives `encode_and_flush_v3_payload_requests` and collects the payloads it emits.
2490    ///
2491    /// The channel is sized generously because nothing drains it concurrently here; production drains it from the
2492    /// encoder's main task.
2493    async fn collect_v3_payload_requests(metrics: &[Metric], context: V3FlushContext<'_>) -> Vec<CollectedV3Payload> {
2494        let (mut payloads_tx, mut payloads_rx) = tokio::sync::mpsc::channel(64);
2495        let mut ratio = V3CompressionRatio::default();
2496        encode_and_flush_v3_payload_requests(
2497            METRICS_SERIES_V3_PATH,
2498            metrics,
2499            context,
2500            "series",
2501            &mut ratio,
2502            &mut payloads_tx,
2503            None,
2504        )
2505        .await
2506        .expect("payload requests should encode and flush");
2507        drop(payloads_tx);
2508
2509        let mut collected = Vec::new();
2510        while let Some(payload) = payloads_rx.recv().await {
2511            let Payload::Http(http_payload) = payload else {
2512                panic!("expected HTTP payload");
2513            };
2514            let (metadata, request) = http_payload.into_parts();
2515            collected.push(CollectedV3Payload {
2516                event_count: metadata.event_count(),
2517                data_point_count: metadata.data_point_count(),
2518                request,
2519            });
2520        }
2521
2522        collected
2523    }
2524
2525    #[tokio::test]
2526    async fn v3_payload_requests_split_by_compressed_size_limit() {
2527        let metrics = vec![
2528            Metric::counter("v3.compressed.split.one", 1.0),
2529            Metric::counter("v3.compressed.split.two", 2.0),
2530        ];
2531        let single_request = create_v3_test_request(&metrics[..1]).await;
2532        let combined_request = create_v3_test_request(&metrics).await;
2533        assert!(combined_request.compressed_len > single_request.compressed_len);
2534
2535        let limits = V3PayloadLimits::new(single_request.compressed_len, usize::MAX, 10_000, 10_000);
2536        let ep_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, usize::MAX, None);
2537        let telemetry = ComponentTelemetry::from_builder(&MetricsBuilder::default());
2538        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2539
2540        let context = test_v3_flush_context(&ep_config, limits, &serializer_telemetry, &telemetry);
2541        let requests = collect_v3_payload_requests(&metrics, context).await;
2542
2543        assert_eq!(2, requests.len());
2544        assert_eq!(
2545            vec![1, 1],
2546            requests.iter().map(|request| request.event_count).collect::<Vec<_>>()
2547        );
2548        assert!(requests
2549            .iter()
2550            .all(|request| request.request.body().len() <= limits.max_compressed_size));
2551    }
2552
2553    #[tokio::test]
2554    async fn v3_byte_fallback_does_not_resurrect_metric_over_point_limit() {
2555        let point_oversized_metric = Metric::counter("v3.fallback.over.point.limit", [(123, 1.0), (124, 2.0)]);
2556
2557        let byte_oversized_metric = Metric::counter(
2558            concat!(
2559                "v3.fallback.over.byte.limit.",
2560                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2561                "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
2562                "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
2563                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
2564            ),
2565            3.0,
2566        );
2567
2568        let point_oversized_request = create_v3_test_request(std::slice::from_ref(&point_oversized_metric)).await;
2569        let byte_oversized_request = create_v3_test_request(std::slice::from_ref(&byte_oversized_metric)).await;
2570
2571        assert!(byte_oversized_request.compressed_len > point_oversized_request.compressed_len);
2572
2573        let limits = V3PayloadLimits::new(point_oversized_request.compressed_len, usize::MAX, 10_000, 1);
2574
2575        let ep_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, usize::MAX, None);
2576        let telemetry = ComponentTelemetry::from_builder(&MetricsBuilder::default());
2577        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2578        let context = test_v3_flush_context(&ep_config, limits, &serializer_telemetry, &telemetry);
2579
2580        let requests = collect_v3_payload_requests(&[point_oversized_metric, byte_oversized_metric], context).await;
2581
2582        let emitted_point_counts = requests
2583            .iter()
2584            .map(|request| request.data_point_count)
2585            .collect::<Vec<_>>();
2586
2587        assert!(
2588            emitted_point_counts
2589                .iter()
2590                .all(|point_count| *point_count <= limits.max_points_per_payload),
2591            "fallback emitted request point counts {emitted_point_counts:?} above limit {}",
2592            limits.max_points_per_payload
2593        );
2594    }
2595
2596    #[tokio::test]
2597    async fn v3_serializer_stats_record_payload_full_split_reason() {
2598        let metrics = vec![
2599            Metric::counter("v3.telemetry.payload_full.one", 1.0),
2600            Metric::counter("v3.telemetry.payload_full.two", 2.0),
2601        ];
2602        let single_request = create_v3_test_request(&metrics[..1]).await;
2603        let limits = V3PayloadLimits::new(single_request.compressed_len, usize::MAX, 10_000, 10_000);
2604        let ep_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, usize::MAX, None);
2605        let recorder = TestRecorder::default();
2606        let _local = metrics::set_default_local_recorder(&recorder);
2607        let telemetry = ComponentTelemetry::from_builder(&MetricsBuilder::default());
2608        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2609
2610        let context = test_v3_flush_context(&ep_config, limits, &serializer_telemetry, &telemetry);
2611        let requests = collect_v3_payload_requests(&metrics, context).await;
2612
2613        assert_eq!(2, requests.len());
2614        assert_eq!(
2615            recorder.counter(("serializer.v3_payload_split_reason", &[("reason", "payload_full")])),
2616            Some(1)
2617        );
2618    }
2619
2620    #[tokio::test]
2621    async fn v3_serializer_stats_record_item_too_big_split_reason() {
2622        let metrics = vec![Metric::counter("v3.telemetry.item_too_big", 1.0)];
2623        let request = create_v3_test_request(&metrics).await;
2624        let limits = V3PayloadLimits::new(request.compressed_len - 1, usize::MAX, 10_000, 10_000);
2625        let ep_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, usize::MAX, None);
2626        let recorder = TestRecorder::default();
2627        let _local = metrics::set_default_local_recorder(&recorder);
2628        let telemetry = ComponentTelemetry::from_builder(&MetricsBuilder::default());
2629        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2630
2631        let context = test_v3_flush_context(&ep_config, limits, &serializer_telemetry, &telemetry);
2632        let requests = collect_v3_payload_requests(&metrics, context).await;
2633
2634        assert!(requests.is_empty());
2635        assert_eq!(recorder.counter("serializer.v3_item_too_big"), Some(1));
2636        assert_eq!(
2637            recorder.counter(("serializer.v3_payload_split_reason", &[("reason", "item_too_big")])),
2638            Some(1)
2639        );
2640    }
2641
2642    #[tokio::test]
2643    async fn v3_payload_requests_split_by_uncompressed_size_limit() {
2644        let metrics = vec![
2645            Metric::counter("v3.uncompressed.split.one", 1.0),
2646            Metric::counter("v3.uncompressed.split.two", 2.0),
2647        ];
2648        let single_request = create_v3_test_request(&metrics[..1]).await;
2649        let combined_request = create_v3_test_request(&metrics).await;
2650        assert!(combined_request.uncompressed_len > single_request.uncompressed_len);
2651
2652        let limits = V3PayloadLimits::new(usize::MAX, single_request.uncompressed_len, 10_000, 10_000);
2653        let ep_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, usize::MAX, None);
2654        let telemetry = ComponentTelemetry::from_builder(&MetricsBuilder::default());
2655        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2656
2657        let context = test_v3_flush_context(&ep_config, limits, &serializer_telemetry, &telemetry);
2658        let requests = collect_v3_payload_requests(&metrics, context).await;
2659
2660        assert_eq!(2, requests.len());
2661        assert_eq!(
2662            vec![1, 1],
2663            requests.iter().map(|request| request.event_count).collect::<Vec<_>>()
2664        );
2665    }
2666
2667    /// Drives `build_v3_batch` to exhaustion, returning the metric range and event count of each batch it cut.
2668    fn collect_v3_batches(
2669        metrics: &[Metric], context: V3FlushContext<'_>, target_uncompressed_len: usize,
2670    ) -> Vec<(Range<usize>, usize)> {
2671        let mut batches = Vec::new();
2672        let mut idx = 0;
2673        while idx < metrics.len() {
2674            let start = idx;
2675            match build_v3_batch(metrics, &mut idx, context, "series", target_uncompressed_len) {
2676                Some(batch) => batches.push((start..idx, batch.event_count)),
2677                None if idx == start => break,
2678                None => {}
2679            }
2680        }
2681
2682        batches
2683    }
2684
2685    #[test]
2686    fn v3_batches_are_cut_at_the_point_limit() {
2687        let metrics = vec![
2688            Metric::counter("v3.points.split.one", [(123, 1.0), (124, 2.0)]),
2689            Metric::counter("v3.points.split.two", [(123, 3.0), (124, 4.0)]),
2690            Metric::counter("v3.points.split.three", 5.0),
2691        ];
2692        let limits = V3PayloadLimits::new(usize::MAX, usize::MAX, 10_000, 3);
2693        let ep_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, usize::MAX, None);
2694        let telemetry = ComponentTelemetry::from_builder(&MetricsBuilder::default());
2695        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2696        let context = test_v3_flush_context(&ep_config, limits, &serializer_telemetry, &telemetry);
2697
2698        let batches = collect_v3_batches(&metrics, context, usize::MAX);
2699
2700        assert_eq!(vec![(0..1, 1), (1..3, 2)], batches);
2701    }
2702
2703    #[test]
2704    fn v3_batches_are_cut_at_the_metric_limit() {
2705        let metrics = vec![
2706            Metric::counter("v3.metric.limit.a", 1.0),
2707            Metric::counter("v3.metric.limit.b", 2.0),
2708            Metric::counter("v3.metric.limit.c", 3.0),
2709            Metric::counter("v3.metric.limit.d", 4.0),
2710            Metric::counter("v3.metric.limit.e", 5.0),
2711        ];
2712        let limits = V3PayloadLimits::new(usize::MAX, usize::MAX, 2, 10_000);
2713        let ep_config = EndpointConfiguration::new(CompressionScheme::noop(), 2, usize::MAX, None);
2714        let telemetry = ComponentTelemetry::from_builder(&MetricsBuilder::default());
2715        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2716        let context = test_v3_flush_context(&ep_config, limits, &serializer_telemetry, &telemetry);
2717
2718        let batches = collect_v3_batches(&metrics, context, usize::MAX);
2719
2720        assert_eq!(vec![(0..2, 2), (2..4, 2), (4..5, 1)], batches);
2721    }
2722
2723    #[test]
2724    fn v3_batches_are_cut_at_the_uncompressed_byte_target() {
2725        let metrics = vec![
2726            Metric::counter("v3.byte.target.a", 1.0),
2727            Metric::counter("v3.byte.target.b", 2.0),
2728            Metric::counter("v3.byte.target.c", 3.0),
2729            Metric::counter("v3.byte.target.d", 4.0),
2730        ];
2731        let limits = V3PayloadLimits::new(usize::MAX, usize::MAX, 10_000, 10_000);
2732        let ep_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, usize::MAX, None);
2733        let telemetry = ComponentTelemetry::from_builder(&MetricsBuilder::default());
2734        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2735        let context = test_v3_flush_context(&ep_config, limits, &serializer_telemetry, &telemetry);
2736
2737        // A target of one byte forces a cut after every metric, since the estimate is non-zero once anything is
2738        // written. This is the mechanism that keeps high-cardinality batches from being encoded and then re-split.
2739        let batches = collect_v3_batches(&metrics, context, 1);
2740
2741        assert_eq!(4, batches.len());
2742        assert!(batches.iter().all(|(_, event_count)| *event_count == 1));
2743    }
2744
2745    #[test]
2746    fn v3_serializer_stats_record_max_points_split_reason() {
2747        let metrics = vec![
2748            Metric::counter("v3.telemetry.max_points.one", [(123, 1.0), (124, 2.0)]),
2749            Metric::counter("v3.telemetry.max_points.two", [(123, 3.0), (124, 4.0)]),
2750        ];
2751        let limits = V3PayloadLimits::new(usize::MAX, usize::MAX, 10_000, 2);
2752        let ep_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, usize::MAX, None);
2753        let recorder = TestRecorder::default();
2754        let _local = metrics::set_default_local_recorder(&recorder);
2755        let telemetry = ComponentTelemetry::from_builder(&MetricsBuilder::default());
2756        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2757        let context = test_v3_flush_context(&ep_config, limits, &serializer_telemetry, &telemetry);
2758
2759        let batches = collect_v3_batches(&metrics, context, usize::MAX);
2760
2761        assert_eq!(vec![(0..1, 1), (1..2, 1)], batches);
2762        assert_eq!(
2763            recorder.counter(("serializer.v3_payload_split_reason", &[("reason", "max_points")])),
2764            Some(1)
2765        );
2766    }
2767
2768    #[test]
2769    fn v3_batches_drop_oversized_metric_and_keep_batching() {
2770        let metrics = vec![
2771            Metric::counter("v3.points.oversized.before", [(123, 1.0), (124, 2.0)]),
2772            Metric::counter(
2773                "v3.points.oversized.too_big",
2774                [(123, 3.0), (124, 4.0), (125, 5.0), (126, 6.0)],
2775            ),
2776            Metric::counter("v3.points.oversized.after", 7.0),
2777        ];
2778        let limits = V3PayloadLimits::new(usize::MAX, usize::MAX, 10_000, 3);
2779        let ep_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, usize::MAX, None);
2780        let recorder = TestRecorder::default();
2781        let _local = metrics::set_default_local_recorder(&recorder);
2782        let telemetry = ComponentTelemetry::from_builder(&MetricsBuilder::default());
2783        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2784        let context = test_v3_flush_context(&ep_config, limits, &serializer_telemetry, &telemetry);
2785
2786        let batches = collect_v3_batches(&metrics, context, usize::MAX);
2787
2788        // Dropping the oversized metric no longer forces a payload boundary: the metrics either side of it share a
2789        // batch, since together they still fit the point limit.
2790        assert_eq!(vec![(0..3, 2)], batches);
2791        assert_eq!(recorder.counter("serializer.v3_item_too_big"), Some(1));
2792    }
2793
2794    #[test]
2795    fn v3_batches_skip_zero_point_metrics_without_splitting() {
2796        let metrics = vec![
2797            Metric::counter("v3.points.zero.before", 1.0),
2798            Metric::counter("v3.points.zero.empty", &[] as &[f64]),
2799            Metric::counter("v3.points.zero.after", 2.0),
2800        ];
2801        let limits = V3PayloadLimits::new(usize::MAX, usize::MAX, 10_000, 10_000);
2802        let ep_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, usize::MAX, None);
2803        let telemetry = ComponentTelemetry::from_builder(&MetricsBuilder::default());
2804        let serializer_telemetry = V3SerializerTelemetry::from_builder(&MetricsBuilder::default());
2805        let context = test_v3_flush_context(&ep_config, limits, &serializer_telemetry, &telemetry);
2806
2807        let batches = collect_v3_batches(&metrics, context, usize::MAX);
2808
2809        assert_eq!(vec![(0..3, 2)], batches);
2810    }
2811
2812    #[test]
2813    fn v3_compression_ratio_targets_the_compressed_limit() {
2814        let limits = V3PayloadLimits::new(500_000, 5_000_000, 10_000, 10_000);
2815        let mut ratio = V3CompressionRatio::default();
2816
2817        // With nothing observed yet, assume no compression: the target tracks the compressed limit directly so the
2818        // first batch cannot wildly overshoot.
2819        let initial_target = ratio.uncompressed_target(limits);
2820        assert_eq!((500_000.0 * V3_BATCH_TARGET_MARGIN) as usize, initial_target);
2821
2822        // After observing 4:1 compression, the target grows towards the uncompressed budget those bytes imply.
2823        ratio.record(4_000_000, 1_000_000);
2824        let observed_target = ratio.uncompressed_target(limits);
2825        assert!(
2826            observed_target > initial_target,
2827            "target should grow once compression is observed: {observed_target} vs {initial_target}"
2828        );
2829
2830        // It is still capped by the uncompressed limit, however well the data compresses.
2831        ratio.record(4_000_000, 40_000);
2832        ratio.record(4_000_000, 40_000);
2833        ratio.record(4_000_000, 40_000);
2834        ratio.record(4_000_000, 40_000);
2835        ratio.record(4_000_000, 40_000);
2836        assert!(ratio.uncompressed_target(limits) <= limits.max_uncompressed_size);
2837    }
2838
2839    #[test]
2840    fn v3_series_metric_unit_refs_are_encoded_sparsely() {
2841        let context = Context::from_static_parts("my.timer.avg", &[]);
2842        let metadata = MetricMetadata::default().with_unit(MetaString::from_static("millisecond"));
2843        let gauge = Metric::from_parts(context, MetricValues::gauge([1.0_f64]), metadata);
2844        let context = Context::from_static_parts("my.counter", &[]);
2845        let no_unit = Metric::from_parts(context, MetricValues::gauge([2.0_f64]), MetricMetadata::default());
2846        let context = Context::from_static_parts("my.timer.max", &[]);
2847        let metadata = MetricMetadata::default().with_unit(MetaString::from_static("millisecond"));
2848        let same_unit = Metric::from_parts(context, MetricValues::gauge([3.0_f64]), metadata);
2849
2850        let payload = encode_v3_metrics_batch(&[gauge, no_unit, same_unit], &SharedTagSet::default())
2851            .expect("V3 metric should encode successfully")
2852            .payload;
2853
2854        let expected_unit_dict = [
2855            0xca, 0x01, // field 25, length-delimited.
2856            0x0c, // field payload length: varint string length + string bytes.
2857            0x0b, b'm', b'i', b'l', b'l', b'i', b's', b'e', b'c', b'o', b'n', b'd',
2858        ];
2859        assert!(
2860            payload
2861                .windows(expected_unit_dict.len())
2862                .any(|window| window == expected_unit_dict),
2863            "V3 payload should contain DictUnitStr field for 'millisecond', got bytes: {:?}",
2864            payload
2865        );
2866
2867        let expected_unit_ref = [
2868            0xd2, 0x01, // field 26, length-delimited.
2869            0x02, // packed field payload length.
2870            0x02, 0x00, // sparse unit refs for metrics 1 and 3 only: refs [1, 1] -> deltas [1, 0].
2871        ];
2872        assert!(
2873            payload
2874                .windows(expected_unit_ref.len())
2875                .any(|window| window == expected_unit_ref),
2876            "V3 payload should contain UnitRef field for 'millisecond', got bytes: {:?}",
2877            payload
2878        );
2879    }
2880
2881    #[test]
2882    fn v3_sketch_metric_unit_not_encoded() {
2883        let context = Context::from_static_parts("my.histogram", &[]);
2884        let metadata = MetricMetadata::default().with_unit(MetaString::from_static("millisecond"));
2885        let histogram = Metric::from_parts(context, MetricValues::histogram([1.0_f64]), metadata);
2886
2887        let payload = encode_v3_metrics_batch(&[histogram], &SharedTagSet::default())
2888            .expect("V3 sketch metric should encode successfully")
2889            .payload;
2890
2891        assert!(
2892            !payload
2893                .windows(b"millisecond".len())
2894                .any(|window| window == b"millisecond"),
2895            "V3 sketch payload should not contain unit bytes, matching the Agent V3 sketch builder: {:?}",
2896            payload
2897        );
2898    }
2899
2900    #[test]
2901    fn v3_series_promotes_device_and_internal_resource_tags_to_resources() {
2902        let context = Context::from_static_parts(
2903            "series.resources",
2904            &[
2905                "env:prod",
2906                "device:switch1",
2907                "dd.internal.resource:pod:pod-a",
2908                "dd.internal.resource:malformed",
2909            ],
2910        );
2911        let context = context.with_host(Some(MetaString::from_static("host-a")));
2912        let metadata = MetricMetadata::default();
2913        let metric = Metric::from_parts(context, MetricValues::gauge([1.0_f64]), metadata);
2914
2915        let payload = encode_v3_metrics_batch(&[metric], &SharedTagSet::default())
2916            .expect("V3 series should encode successfully")
2917            .payload;
2918
2919        assert_contains_bytes(&payload, b"env:prod");
2920        assert!(!contains_bytes(&payload, b"device:switch1"));
2921        assert!(!contains_bytes(&payload, b"dd.internal.resource:pod:pod-a"));
2922        assert!(!contains_bytes(&payload, b"dd.internal.resource:malformed"));
2923
2924        let expected_resource_dict = [
2925            0x22, // field 4, length-delimited.
2926            0x25, // field payload length.
2927            0x04, b'h', b'o', b's', b't', 0x06, b'h', b'o', b's', b't', b'-', b'a', 0x06, b'd', b'e', b'v', b'i', b'c',
2928            b'e', 0x07, b's', b'w', b'i', b't', b'c', b'h', b'1', 0x03, b'p', b'o', b'd', 0x05, b'p', b'o', b'd', b'-',
2929            b'a',
2930        ];
2931        assert_contains_bytes(&payload, &expected_resource_dict);
2932    }
2933
2934    #[test]
2935    fn v3_series_promotes_additional_and_origin_resource_tags_without_empty_host() {
2936        let context = Context::from_static_parts("series.additional_origin_resources", &["env:prod"])
2937            .with_origin_tags(tag_set(["dd.internal.resource:pod:pod-origin"]));
2938        let additional_tags = SharedTagSet::from(tag_set([
2939            "team:core",
2940            "device:switch1",
2941            "dd.internal.resource:container:container-a",
2942        ]));
2943        let context = context.with_host(Some(MetaString::empty()));
2944        let metadata = MetricMetadata::default();
2945        let metric = Metric::from_parts(context, MetricValues::gauge([1.0_f64]), metadata);
2946
2947        let payload = encode_v3_metrics_batch(&[metric], &additional_tags)
2948            .expect("V3 series should encode successfully")
2949            .payload;
2950
2951        assert_contains_bytes(&payload, b"env:prod");
2952        assert_contains_bytes(&payload, b"team:core");
2953        assert!(!contains_bytes(&payload, b"device:switch1"));
2954        assert!(!contains_bytes(&payload, b"dd.internal.resource:container:container-a"));
2955        assert!(!contains_bytes(&payload, b"dd.internal.resource:pod:pod-origin"));
2956
2957        let expected_resource_dict = [
2958            0x22, // field 4, length-delimited.
2959            0x34, // field payload length.
2960            0x06, b'd', b'e', b'v', b'i', b'c', b'e', 0x07, b's', b'w', b'i', b't', b'c', b'h', b'1', 0x03, b'p', b'o',
2961            b'd', 0x0a, b'p', b'o', b'd', b'-', b'o', b'r', b'i', b'g', b'i', b'n', 0x09, b'c', b'o', b'n', b't', b'a',
2962            b'i', b'n', b'e', b'r', 0x0b, b'c', b'o', b'n', b't', b'a', b'i', b'n', b'e', b'r', b'-', b'a',
2963        ];
2964        assert_contains_bytes(&payload, &expected_resource_dict);
2965        assert!(!contains_bytes(&payload, b"host"));
2966    }
2967
2968    #[test]
2969    fn v3_sketch_keeps_device_and_internal_resource_tags_as_tags() {
2970        let context = Context::from_static_parts(
2971            "sketch.resources",
2972            &["env:prod", "device:switch1", "dd.internal.resource:pod:pod-a"],
2973        );
2974        let context = context.with_host(Some(MetaString::from_static("host-a")));
2975        let metadata = MetricMetadata::default();
2976        let metric = Metric::from_parts(context, MetricValues::histogram([1.0_f64]), metadata);
2977
2978        let payload = encode_v3_metrics_batch(&[metric], &SharedTagSet::default())
2979            .expect("V3 sketch should encode successfully")
2980            .payload;
2981
2982        assert_contains_bytes(&payload, b"env:prod");
2983        assert_contains_bytes(&payload, b"device:switch1");
2984        assert_contains_bytes(&payload, b"dd.internal.resource:pod:pod-a");
2985
2986        let expected_resource_dict = [
2987            0x22, // field 4, length-delimited.
2988            0x0c, // field payload length.
2989            0x04, b'h', b'o', b's', b't', 0x06, b'h', b'o', b's', b't', b'-', b'a',
2990        ];
2991        assert_contains_bytes(&payload, &expected_resource_dict);
2992    }
2993
2994    /// Distinct inputs for a [`run_request_builder`] integration test.
2995    struct RequestBuilderScenario {
2996        series_mode: MetricsEncoderMode,
2997        sketches_mode: MetricsEncoderMode,
2998        payload_limits: V3PayloadLimits,
2999        flush_timeout: Duration,
3000    }
3001
3002    impl RequestBuilderScenario {
3003        /// Creates a scenario with unbounded V3 size/point limits and a 10 ms flush timeout.
3004        fn new(series_mode: MetricsEncoderMode, sketches_mode: MetricsEncoderMode) -> Self {
3005            Self {
3006                series_mode,
3007                sketches_mode,
3008                payload_limits: V3PayloadLimits::new(usize::MAX, usize::MAX, 10_000, 10_000),
3009                flush_timeout: Duration::from_millis(10),
3010            }
3011        }
3012
3013        /// Sets the V3 per-payload point limit that drives point-count split flushes.
3014        fn with_max_points_per_payload(mut self, max_points_per_payload: usize) -> Self {
3015            self.payload_limits = V3PayloadLimits::new(usize::MAX, usize::MAX, 10_000, max_points_per_payload);
3016            self
3017        }
3018
3019        /// Overrides the flush timeout used to bound pending flushes.
3020        fn with_flush_timeout(mut self, flush_timeout: Duration) -> Self {
3021            self.flush_timeout = flush_timeout;
3022            self
3023        }
3024
3025        /// Spawns [`run_request_builder`] for this scenario, returning a harness for pushing metrics and draining
3026        /// flushed payloads.
3027        async fn spawn(self) -> RequestBuilderHarness {
3028            let v3_endpoint_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, usize::MAX, None);
3029            let metrics_builder = MetricsBuilder::default();
3030            let telemetry = ComponentTelemetry::from_builder(&metrics_builder);
3031            let serializer_telemetry = V3SerializerTelemetry::from_builder(&metrics_builder);
3032            let (events_tx, events_rx) = tokio::sync::mpsc::channel(1);
3033            let (payloads_tx, payloads_rx) = tokio::sync::mpsc::channel(8);
3034
3035            let handle = tokio::spawn(run_request_builder(
3036                None,
3037                None,
3038                self.series_mode,
3039                self.sketches_mode,
3040                V3RuntimeConfig {
3041                    endpoint_config: v3_endpoint_config,
3042                    payload_limits: self.payload_limits,
3043                    series_endpoint_uri: METRICS_SERIES_V3_PATH.to_string(),
3044                    serializer_telemetry,
3045                },
3046                telemetry,
3047                events_rx,
3048                payloads_tx,
3049                self.flush_timeout,
3050                false,
3051            ));
3052
3053            RequestBuilderHarness {
3054                events_tx,
3055                payloads_rx,
3056                handle,
3057            }
3058        }
3059    }
3060
3061    /// A spawned [`run_request_builder`] task plus its input/output channels.
3062    struct RequestBuilderHarness {
3063        events_tx: tokio::sync::mpsc::Sender<EventsBuffer>,
3064        payloads_rx: tokio::sync::mpsc::Receiver<PayloadsBuffer>,
3065        handle: tokio::task::JoinHandle<Result<(), GenericError>>,
3066    }
3067
3068    impl RequestBuilderHarness {
3069        /// Sends a single event buffer carrying `metrics` to the request builder.
3070        async fn push_metrics(&self, metrics: impl IntoIterator<Item = Metric>) {
3071            let mut events = EventsBuffer::default();
3072            for metric in metrics {
3073                assert!(
3074                    events.try_push(Event::Metric(metric)).is_none(),
3075                    "event buffer should accept metric"
3076                );
3077            }
3078            self.events_tx
3079                .send(events)
3080                .await
3081                .expect("events should be sent to request builder");
3082        }
3083
3084        /// Receives the next flushed payload, unwrapping it into its HTTP metadata and request.
3085        async fn next_http_request(&mut self) -> (PayloadMetadata, Request<FrozenChunkedBytesBuffer>) {
3086            let payload = timeout(Duration::from_secs(1), self.payloads_rx.recv())
3087                .await
3088                .expect("payload should arrive before timeout")
3089                .expect("payload channel should remain open");
3090            match payload {
3091                Payload::Http(http_payload) => http_payload.into_parts(),
3092                _ => panic!("expected HTTP payload"),
3093            }
3094        }
3095
3096        /// Drops the events sender and waits for the request builder task to stop cleanly.
3097        async fn shutdown(self) {
3098            drop(self.events_tx);
3099            self.handle
3100                .await
3101                .expect("request builder task should complete")
3102                .expect("request builder should stop cleanly");
3103        }
3104    }
3105
3106    #[tokio::test]
3107    async fn authoritative_v3_flushes_previous_point_limit_batch() {
3108        // Authoritative V3 batches independently of V2. With a 3-point limit, the first two-point metric fits but
3109        // the second would exceed it, so the first flushes as a point-limit split and the second flushes on the
3110        // pending-flush timeout.
3111        let recorder = TestRecorder::default();
3112        let _local = metrics::set_default_local_recorder(&recorder);
3113
3114        // A lone Datadog endpoint under `datadog_only` is authoritative V3, so there's no V2 series encoder.
3115        let mut shared = shared_configuration();
3116        shared.metrics_encoding.v3_series_mode = V3SeriesMode::DatadogOnly;
3117        let mut config = metrics_config_from(&shared);
3118        config.max_series_points_per_payload = 3;
3119        config.flush_timeout = Duration::from_millis(250);
3120        assert!(!config.requires_v2_series(false).expect("endpoints should resolve"));
3121
3122        let (control, mut payloads) = TestComponentDriver::options()
3123            .with_component_id("metrics")
3124            .encoder(config)
3125            .await
3126            .expect("metrics encoder should build");
3127        let mut next_request_uri = async || {
3128            let payload = payloads.next().await.expect("the encoder should flush a payload");
3129            let (_, request) = payload
3130                .try_into_http_payload()
3131                .expect("metrics are sent over HTTP")
3132                .into_parts();
3133            request.uri().clone()
3134        };
3135
3136        control
3137            .send_events([
3138                Event::Metric(Metric::counter("authoritative.v3.points.one", [(123, 1.0), (124, 2.0)])),
3139                Event::Metric(Metric::counter("authoritative.v3.points.two", [(123, 3.0), (124, 4.0)])),
3140            ])
3141            .await;
3142
3143        // Point-count split flushes the first metric before the second exceeds the limit.
3144        assert_eq!(METRICS_SERIES_V3_PATH, next_request_uri().await);
3145        assert!(
3146            timeout(Duration::from_millis(50), next_request_uri()).await.is_err(),
3147            "the carried-over metric should wait for the pending-flush timeout"
3148        );
3149
3150        // The carried-over metric flushes when the pending-flush timeout fires.
3151        assert_eq!(METRICS_SERIES_V3_PATH, next_request_uri().await);
3152        assert_eq!(
3153            recorder.counter((
3154                "serializer.v3_payload_split_reason",
3155                &[
3156                    ("component_id", "metrics"),
3157                    ("component_type", "encoder"),
3158                    ("reason", "max_points")
3159                ]
3160            )),
3161            Some(1)
3162        );
3163
3164        control.shutdown().await.expect("metrics encoder should stop cleanly");
3165        assert!(payloads.collect().await.is_empty(), "nothing should be left to flush");
3166    }
3167
3168    #[tokio::test]
3169    async fn authoritative_v3_sketches_flush_previous_point_limit_batch() {
3170        // The same authoritative point-limit split applies to sketches, which always target the V3 sketches
3171        // endpoint: the first distribution flushes as a point-limit split, the second on the pending-flush timeout.
3172        let recorder = TestRecorder::default();
3173        let _local = metrics::set_default_local_recorder(&recorder);
3174
3175        let mut harness = RequestBuilderScenario::new(MetricsEncoderMode::V2Only, MetricsEncoderMode::V3Enabled)
3176            .with_max_points_per_payload(3)
3177            .with_flush_timeout(Duration::from_millis(250))
3178            .spawn()
3179            .await;
3180
3181        harness
3182            .push_metrics([
3183                Metric::distribution("authoritative.v3.sketch.points.one", [(123, 1.0), (124, 2.0)]),
3184                Metric::distribution("authoritative.v3.sketch.points.two", [(123, 3.0), (124, 4.0)]),
3185            ])
3186            .await;
3187
3188        for stage in ["point-limit split", "timeout"] {
3189            let (_, request) = harness.next_http_request().await;
3190            assert_eq!(
3191                V3_SKETCHES_ENDPOINT_URI,
3192                request.uri(),
3193                "{stage} sketches payload should target the V3 sketches endpoint"
3194            );
3195        }
3196        assert_eq!(
3197            recorder.counter(("serializer.v3_payload_split_reason", &[("reason", "max_points")])),
3198            Some(1)
3199        );
3200
3201        harness.shutdown().await;
3202    }
3203
3204    fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
3205        haystack.windows(needle.len()).any(|window| window == needle)
3206    }
3207
3208    fn assert_contains_bytes(haystack: &[u8], needle: &[u8]) {
3209        assert!(
3210            contains_bytes(haystack, needle),
3211            "expected payload to contain bytes {:?}, got {:?}",
3212            needle,
3213            haystack
3214        );
3215    }
3216
3217    fn tag_set<const N: usize>(tags: [&'static str; N]) -> TagSet {
3218        tags.into_iter().map(Tag::from_static).collect()
3219    }
3220
3221    // Regression test to ensure the V2 series request builder enforces `max_series_points_per_payload`.
3222    //
3223    // The test encodes more total points than the configured limit and asserts the builder splits them across
3224    // multiple payloads without any payload exceeding the limit.
3225    #[tokio::test]
3226    async fn v2_series_builder_enforces_max_series_points_per_payload() {
3227        let v2_endpoint_config = EndpointConfiguration::new(CompressionScheme::noop(), 10_000, 10_000, None);
3228        let mut builder = v2::create_v2_request_builder(MetricsEndpoint::SeriesV2, &v2_endpoint_config)
3229            .await
3230            .expect("V2 request builder should be created");
3231        builder
3232            .with_len_limits(usize::MAX, usize::MAX)
3233            .expect("byte limits should be accepted");
3234
3235        let mut total_points = 0;
3236        for i in 0..6_000u32 {
3237            let metric = Metric::gauge(
3238                Context::from_parts(MetaString::from(format!("g{i}")), TagSet::default()),
3239                [(1u64, 1.0), (2u64, 2.0)],
3240            );
3241            let mut pending = Some(metric);
3242            while let Some(metric) = pending.take() {
3243                if let Some(returned) = builder.encode(metric).await.expect("encode should not error") {
3244                    for request in builder.flush().await {
3245                        let (_, points, _) = request.expect("request should build");
3246                        assert!(
3247                            points <= 10_000,
3248                            "payload carried {} points, over the 10000 limit",
3249                            points
3250                        );
3251                        total_points += points;
3252                    }
3253                    pending = Some(returned);
3254                }
3255            }
3256        }
3257        for request in builder.flush().await {
3258            let (_, points, _) = request.expect("request should build");
3259            assert!(
3260                points <= 10_000,
3261                "final payload carried {} points, over the 10000 limit",
3262                points
3263            );
3264            total_points += points;
3265        }
3266
3267        assert_eq!(
3268            total_points, 12_000,
3269            "all points should be emitted across the split payloads"
3270        );
3271    }
3272}
3273
3274#[cfg(test)]
3275mod payload_limits {
3276    use super::{v2, DatadogMetricsConfiguration};
3277    use crate::common::datadog::{clamp_payload_limits, test_util::shared_configuration};
3278
3279    #[test]
3280    fn payload_limits_come_from_resolved_configuration() {
3281        let mut shared = shared_configuration();
3282        shared.metrics_encoding.max_payload_size = 4321;
3283        shared.metrics_encoding.max_uncompressed_payload_size = 8765;
3284        shared.metrics_encoding.max_series_payload_size = 1234;
3285        shared.metrics_encoding.max_series_uncompressed_payload_size = 5678;
3286        shared.metrics_encoding.max_series_points_per_payload = 500;
3287        shared.metrics_encoding.max_metrics_per_payload = 42;
3288
3289        let config = DatadogMetricsConfiguration::from_configuration(&shared);
3290
3291        assert_eq!(4321, config.max_payload_size);
3292        assert_eq!(8765, config.max_uncompressed_payload_size);
3293        assert_eq!(1234, config.max_series_payload_size);
3294        assert_eq!(5678, config.max_series_uncompressed_payload_size);
3295        assert_eq!(500, config.max_series_points_per_payload);
3296        assert_eq!(500, config.v3_payload_limits().max_points_per_payload);
3297        assert_eq!(42, config.max_metrics_per_payload);
3298    }
3299
3300    #[test]
3301    fn clamps_series_payload_limit_keys_to_api_limits() {
3302        let (uncompressed_limit, compressed_limit) = clamp_payload_limits(
3303            v2::SERIES_V2_UNCOMPRESSED_SIZE_LIMIT + 1,
3304            v2::SERIES_V2_COMPRESSED_SIZE_LIMIT + 1,
3305            v2::SERIES_V2_UNCOMPRESSED_SIZE_LIMIT,
3306            v2::SERIES_V2_COMPRESSED_SIZE_LIMIT,
3307        );
3308        assert_eq!(uncompressed_limit, v2::SERIES_V2_UNCOMPRESSED_SIZE_LIMIT);
3309        assert_eq!(compressed_limit, v2::SERIES_V2_COMPRESSED_SIZE_LIMIT);
3310
3311        let (uncompressed_limit, compressed_limit) = clamp_payload_limits(
3312            5678,
3313            1234,
3314            v2::SERIES_V2_UNCOMPRESSED_SIZE_LIMIT,
3315            v2::SERIES_V2_COMPRESSED_SIZE_LIMIT,
3316        );
3317        assert_eq!(uncompressed_limit, 5678);
3318        assert_eq!(compressed_limit, 1234);
3319    }
3320}