@@ -403,6 +403,15 @@ impl DatadogMetricsConfiguration {
403403 self . additional_tags = Some ( additional_tags) ;
404404 self
405405 }
406+
407+ fn v3_payload_limits ( & self ) -> V3PayloadLimits {
408+ V3PayloadLimits :: new (
409+ self . max_series_payload_size ,
410+ self . max_series_uncompressed_payload_size ,
411+ self . max_metrics_per_payload ,
412+ self . max_series_points_per_payload ,
413+ )
414+ }
406415}
407416
408417#[ async_trait]
@@ -432,12 +441,7 @@ impl EncoderBuilder for DatadogMetricsConfiguration {
432441 V3_SERIES_ENDPOINT_URI . to_string ( )
433442 } ;
434443 let shadow_series_endpoint_uri = self . v3_api . series . beta_route . clone ( ) ;
435- let payload_limits = V3PayloadLimits :: new (
436- self . max_series_payload_size ,
437- self . max_series_uncompressed_payload_size ,
438- self . max_metrics_per_payload ,
439- self . max_series_points_per_payload ,
440- ) ;
444+ let payload_limits = self . v3_payload_limits ( ) ;
441445
442446 let v2_endpoint_config = EndpointConfiguration :: new (
443447 v2_compression_scheme,
@@ -710,6 +714,8 @@ async fn run_request_builder(
710714 let mut v3_series_metrics =
711715 ( series_mode. needs_v3 ( ) || v3_runtime_config. series_shadow_config . is_enabled ( ) ) . then ( Vec :: < Metric > :: new) ;
712716 let mut v3_sketch_metrics = sketches_mode. needs_v3 ( ) . then ( Vec :: < Metric > :: new) ;
717+ let mut v3_series_points = 0usize ;
718+ let mut v3_sketch_points = 0usize ;
713719
714720 let mut series_batch_id = None ;
715721 let mut sketches_batch_id = None ;
@@ -748,20 +754,23 @@ async fn run_request_builder(
748754
749755 // Figure out which endpoint the metric belongs to, and grab the relevant V2 builder/V3 storage.
750756 let endpoint = MetricsEndpoint :: from_metric( & metric) ;
751- let ( endpoint_mode, maybe_v2_builder, maybe_v3_metrics, batch_id) = match endpoint {
757+ let ( endpoint_mode, maybe_v2_builder, maybe_v3_metrics, v3_points , batch_id) = match endpoint {
752758 MetricsEndpoint :: SeriesV1 | MetricsEndpoint :: SeriesV2 => (
753759 series_mode,
754760 & mut v2_series_builder,
755761 & mut v3_series_metrics,
762+ & mut v3_series_points,
756763 & mut series_batch_id,
757764 ) ,
758765 MetricsEndpoint :: Sketches => (
759766 sketches_mode,
760767 & mut v2_sketch_builder,
761768 & mut v3_sketch_metrics,
769+ & mut v3_sketch_points,
762770 & mut sketches_batch_id,
763771 ) ,
764772 } ;
773+ let metric_point_count = metric. values( ) . len( ) ;
765774 let is_series = matches!( endpoint, MetricsEndpoint :: SeriesV1 | MetricsEndpoint :: SeriesV2 ) ;
766775 let series_shadow_active = is_series
767776 && matches!( endpoint_mode, MetricsEncoderMode :: V2Only )
@@ -780,6 +789,7 @@ async fn run_request_builder(
780789 if should_buffer_v3 {
781790 if let Some ( metrics) = maybe_v3_metrics {
782791 metrics. push( metric. clone( ) ) ;
792+ * v3_points += metric_point_count;
783793 }
784794 }
785795
@@ -810,9 +820,13 @@ async fn run_request_builder(
810820 let result =
811821 encode_v2_metrics( builder, metric, & telemetry, & mut payloads_tx, active_batch_id, v2_payload_info) . await ?;
812822 v2_encoded = result. encoded( ) ;
813- if should_buffer_v3 && !result. encoded( ) {
823+ if should_buffer_v3
824+ && !result. encoded( )
825+ && !matches!( endpoint_mode, MetricsEncoderMode :: V3Enabled )
826+ {
814827 if let Some ( metrics) = maybe_v3_metrics {
815828 let _ = metrics. pop( ) ;
829+ * v3_points = v3_points. saturating_sub( metric_point_count) ;
816830 }
817831 }
818832
@@ -821,8 +835,8 @@ async fn run_request_builder(
821835 false
822836 } ;
823837
824- // If we flushed via V2, or we've hit our max metrics per payload limit in pure V3 mode, we need to flush our V3 metrics
825- // as well .
838+ // Validation and shadow payloads must keep V2/ V3 batch boundaries aligned. Authoritative V3 can
839+ // batch independently, so it flushes on V3-specific limits instead of following V2 flushes .
826840 let v3_payload_info = match endpoint {
827841 MetricsEndpoint :: SeriesV1 | MetricsEndpoint :: SeriesV2 => {
828842 if series_shadow_active {
@@ -838,15 +852,33 @@ async fn run_request_builder(
838852 let should_flush_v3 = match endpoint_mode {
839853 MetricsEncoderMode :: V2Only => series_shadow_active && v2_flushed,
840854 MetricsEncoderMode :: V3Enabled => {
841- v2_flushed || v3_flush_context. payload_limits. should_flush_metric_count_limit( v3_metrics)
855+ if v3_flush_context. payload_limits. point_count_exceeds_limit( * v3_points)
856+ && v3_metrics. len( ) > 1
857+ {
858+ v3_flush_context
859+ . serializer_telemetry
860+ . record_split_reason( V3PayloadSplitReason :: MaxPoints ) ;
861+ split_metric = v3_metrics. pop( ) ;
862+ * v3_points = v3_points. saturating_sub( metric_point_count) ;
863+ true
864+ } else {
865+ v3_flush_context. payload_limits. should_flush_point_count_limit( * v3_points)
866+ || v3_flush_context
867+ . payload_limits
868+ . should_flush_metric_count_limit( v3_metrics)
869+ }
842870 }
843871 MetricsEncoderMode :: Validation => v2_flushed,
844872 } ;
845873 if should_flush_v3 {
846- // V2 flushes the previous batch without the current metric (the metric
847- // that triggered the flush is re-encoded into the next V2 batch). Pop it
848- // from V3 before flushing so both batches cover the same set of metrics.
849- split_metric = if v2_flushed { v3_metrics. pop( ) } else { None } ;
874+ if !matches!( endpoint_mode, MetricsEncoderMode :: V3Enabled ) {
875+ // V2 flushes the previous batch without the current metric. Pop it
876+ // from V3 before flushing so both batches cover the same set of metrics.
877+ split_metric = if v2_flushed { v3_metrics. pop( ) } else { None } ;
878+ if split_metric. is_some( ) {
879+ * v3_points = v3_points. saturating_sub( metric_point_count) ;
880+ }
881+ }
850882 let flush_context = if series_shadow_active {
851883 v3_shadow_flush_context
852884 } else {
@@ -861,6 +893,7 @@ async fn run_request_builder(
861893 v3_payload_info,
862894 )
863895 . await ?;
896+ * v3_points = 0 ;
864897 true
865898 } else {
866899 false
@@ -869,12 +902,24 @@ async fn run_request_builder(
869902 false
870903 } ;
871904
872- // A validation flush completes the current V2/V3 pair. If V2 carried the current metric into the
873- // next batch, keep the V3 copy and assign that next pair a fresh validation ID.
874- if endpoint_mode. needs_batch_id( ) && ( v2_flushed || v3_flushed) {
905+ if matches!( endpoint_mode, MetricsEncoderMode :: V3Enabled ) {
906+ // Authoritative V3 may flush the previous point-limit batch while keeping the current metric
907+ // buffered for the next V3 payload.
908+ if let Some ( m) = split_metric. take( ) {
909+ let point_count = m. values( ) . len( ) ;
910+ if let Some ( metrics) = maybe_v3_metrics {
911+ metrics. push( m) ;
912+ * v3_points += point_count;
913+ }
914+ }
915+ } else if endpoint_mode. needs_batch_id( ) && ( v2_flushed || v3_flushed) {
916+ // A validation flush completes the current V2/V3 pair. If V2 carried the current metric into
917+ // the next batch, keep the V3 copy and assign that next pair a fresh validation ID.
875918 * batch_id = if let Some ( m) = split_metric. take( ) {
919+ let point_count = m. values( ) . len( ) ;
876920 if let Some ( metrics) = maybe_v3_metrics {
877921 metrics. push( m) ;
922+ * v3_points += point_count;
878923 }
879924 Some ( Uuid :: now_v7( ) )
880925 } else {
@@ -886,8 +931,10 @@ async fn run_request_builder(
886931 series_shadow_state. reset( ) ;
887932 * batch_id = if let Some ( m) = split_metric. take( ) {
888933 if series_shadow_state. ensure_decision( series_shadow_config) {
934+ let point_count = m. values( ) . len( ) ;
889935 if let Some ( metrics) = maybe_v3_metrics {
890936 metrics. push( m) ;
937+ * v3_points += point_count;
891938 }
892939 Some ( Uuid :: now_v7( ) )
893940 } else {
@@ -908,8 +955,10 @@ async fn run_request_builder(
908955 if v2_encoded {
909956 if let Some ( m) = metric_for_next_shadow_batch {
910957 if series_shadow_state. ensure_decision( series_shadow_config) {
958+ let point_count = m. values( ) . len( ) ;
911959 if let Some ( metrics) = maybe_v3_metrics {
912960 metrics. push( m) ;
961+ * v3_points += point_count;
913962 }
914963 * batch_id = Some ( Uuid :: now_v7( ) ) ;
915964 }
@@ -958,7 +1007,7 @@ async fn run_request_builder(
9581007 tag_series. then( MetricsPayloadInfo :: v3_series)
9591008 } ;
9601009 if let Some ( metrics) = & mut v3_series_metrics {
961- if v2_series_flush_succeeded {
1010+ if v2_series_flush_succeeded || matches! ( series_mode , MetricsEncoderMode :: V3Enabled ) {
9621011 // Shadow series use the V3 beta route; normal V3 series use the configured authoritative route.
9631012 let flush_context = if series_shadow_active {
9641013 v3_shadow_flush_context
@@ -976,10 +1025,12 @@ async fn run_request_builder(
9761025 {
9771026 error!( error = %e, "Failed to flush V3 series metrics: {}" , e) ;
9781027 }
1028+ v3_series_points = 0 ;
9791029 } else {
9801030 // Validation/shadow V3 must not outlive a failed V2 baseline flush.
9811031 warn!( "Failed to flush V2 series metrics, skipping V3 series flush." ) ;
9821032 metrics. clear( ) ;
1033+ v3_series_points = 0 ;
9831034 }
9841035 }
9851036 if series_mode. needs_batch_id( ) {
@@ -1003,7 +1054,7 @@ async fn run_request_builder(
10031054
10041055 let v3_sketches_payload_info = tag_sketches. then( MetricsPayloadInfo :: v3_sketches) ;
10051056 if let Some ( metrics) = & mut v3_sketch_metrics {
1006- if v2_sketches_flush_succeeded {
1057+ if v2_sketches_flush_succeeded || matches! ( sketches_mode , MetricsEncoderMode :: V3Enabled ) {
10071058 if let Err ( e) = encode_and_flush_v3_sketch_metrics(
10081059 v3_flush_context,
10091060 metrics,
@@ -1015,9 +1066,11 @@ async fn run_request_builder(
10151066 {
10161067 error!( error = %e, "Failed to flush V3 sketch metrics: {}" , e) ;
10171068 }
1069+ v3_sketch_points = 0 ;
10181070 } else {
10191071 warn!( "Failed to flush V2 sketch metrics, skipping V3 sketch flush." ) ;
10201072 metrics. clear( ) ;
1073+ v3_sketch_points = 0 ;
10211074 }
10221075 }
10231076 if sketches_mode. needs_batch_id( ) {
@@ -2738,6 +2791,7 @@ mod use_v2_api_series_default {
27382791 . expect ( "config should load" ) ;
27392792 let parsed: DatadogMetricsConfiguration = cfg. as_typed ( ) . expect ( "should deserialize" ) ;
27402793 assert_eq ! ( parsed. max_series_points_per_payload, 10_000 ) ;
2794+ assert_eq ! ( parsed. v3_payload_limits( ) . max_points_per_payload, 10_000 ) ;
27412795
27422796 // Explicit value should round-trip.
27432797 let cfg = ConfigurationLoader :: default ( )
@@ -2750,6 +2804,7 @@ mod use_v2_api_series_default {
27502804 . expect ( "config should load" ) ;
27512805 let parsed: DatadogMetricsConfiguration = cfg. as_typed ( ) . expect ( "should deserialize" ) ;
27522806 assert_eq ! ( parsed. max_series_points_per_payload, 500 ) ;
2807+ assert_eq ! ( parsed. v3_payload_limits( ) . max_points_per_payload, 500 ) ;
27532808 }
27542809
27552810 #[ test]
0 commit comments