@@ -6,10 +6,11 @@ use std::collections::{btree_map::Entry, BTreeMap, BTreeSet};
66use std:: sync:: { Arc , Mutex } ;
77use std:: time:: { SystemTime , UNIX_EPOCH } ;
88
9- use datadog_protos:: metrics:: { metric_payload:: MetricSeries , MetricPayload , SketchPayload } ;
9+ use datadog_protos:: metrics:: metric_payload:: { MetricSeries , MetricType , Resource } ;
10+ use datadog_protos:: metrics:: { MetricPayload , SketchPayload } ;
1011use serde:: { Deserialize , Serialize } ;
11- use stele :: { Metric , MetricValue } ;
12- use tracing :: warn ;
12+
13+ use crate :: lenient_decode :: V3Series ;
1314
1415const SELF_TELEMETRY_PREFIX : & str = "datadog." ;
1516
@@ -46,16 +47,23 @@ pub(crate) enum MetricKind {
4647 Rate ,
4748 Gauge ,
4849 Sketch ,
50+ /// A metric type outside the known set — an out-of-range v3 type nibble. Production keeps such a
51+ /// series and forwards its type verbatim, so the intake keeps it too rather than dropping it and
52+ /// masking a producer bug.
53+ Other ,
4954}
5055
5156impl MetricKind {
52- fn of ( metric : & Metric ) -> Option < Self > {
53- metric. values ( ) . first ( ) . map ( |( _, value) | match value {
54- MetricValue :: Count { .. } => Self :: Count ,
55- MetricValue :: Rate { .. } => Self :: Rate ,
56- MetricValue :: Gauge { .. } => Self :: Gauge ,
57- MetricValue :: Sketch { .. } => Self :: Sketch ,
58- } )
57+ /// Derives the kind from the v2 wire type field. The accessor defaults any out-of-range type to
58+ /// `UNSPECIFIED`, which maps to `Other`, keeping the series and forwarding an unknown type rather
59+ /// than dropping it and masking a producer bug, as the v3 path does for an unknown type nibble.
60+ fn of ( type_ : MetricType ) -> Self {
61+ match type_ {
62+ MetricType :: COUNT => Self :: Count ,
63+ MetricType :: RATE => Self :: Rate ,
64+ MetricType :: GAUGE => Self :: Gauge ,
65+ MetricType :: UNSPECIFIED => Self :: Other ,
66+ }
5967 }
6068}
6169
@@ -67,6 +75,11 @@ impl EpochSeconds {
6775 Self ( secs)
6876 }
6977
78+ /// The whole seconds since the Unix epoch.
79+ pub ( crate ) fn secs ( self ) -> i64 {
80+ self . 0
81+ }
82+
7083 /// The intake's current wall-clock time, or `None` if the clock predates
7184 /// the epoch or overflows.
7285 pub ( crate ) fn now ( ) -> Option < Self > {
@@ -75,6 +88,27 @@ impl EpochSeconds {
7588 }
7689}
7790
91+ /// One point value as the native decoder reads it off the wire, kind-agnostic. The intake keeps no
92+ /// curve, only whether a series carries a point that survives the backend's per-point drops, so a
93+ /// series left with none emits no context.
94+ #[ derive( Clone , Debug , PartialEq ) ]
95+ pub ( crate ) enum BucketValue {
96+ /// A count, rate, or gauge scalar.
97+ Scalar ( f64 ) ,
98+ /// A `DDSketch` point: the summary the Agent emits plus its log-grid bins.
99+ Sketch ( SketchValue ) ,
100+ }
101+
102+ /// A `DDSketch` point: the summary the Agent emits plus the log-grid bins as `(key, count)`, key-sorted.
103+ #[ derive( Clone , Debug , PartialEq ) ]
104+ pub ( crate ) struct SketchValue {
105+ pub ( crate ) count : i64 ,
106+ pub ( crate ) sum : f64 ,
107+ pub ( crate ) min : f64 ,
108+ pub ( crate ) max : f64 ,
109+ pub ( crate ) bins : Vec < ( i32 , u32 ) > ,
110+ }
111+
78112/// A metric context: name, tagset, and type.
79113#[ derive( Clone , Debug , Deserialize , Eq , Ord , PartialEq , PartialOrd , Serialize ) ]
80114pub ( crate ) struct Context {
@@ -145,12 +179,17 @@ impl State {
145179 }
146180
147181 pub ( crate ) fn record_series_v2 ( & self , target : Target , payload : MetricPayload , now : EpochSeconds ) -> usize {
148- let contexts = observe_series ( target , payload ) ;
182+ let contexts = observe_series ( payload , now . secs ( ) ) ;
149183 self . with_lanes ( |lanes| lanes. record ( target, & contexts, now) )
150184 }
151185
152186 pub ( crate ) fn record_sketches ( & self , target : Target , payload : SketchPayload , now : EpochSeconds ) -> usize {
153- let contexts = observe_sketches ( target, payload) ;
187+ let contexts = observe_sketches ( payload) ;
188+ self . with_lanes ( |lanes| lanes. record ( target, & contexts, now) )
189+ }
190+
191+ pub ( crate ) fn record_series_v3 ( & self , target : Target , series : Vec < V3Series > , now : EpochSeconds ) -> usize {
192+ let contexts = observe_series_v3 ( series, now. secs ( ) ) ;
154193 self . with_lanes ( |lanes| lanes. record ( target, & contexts, now) )
155194 }
156195
@@ -163,66 +202,156 @@ impl State {
163202 }
164203}
165204
166- /// Longest metric name propjoe stores, in bytes (`model.MaxMetricLen`).
167- const MAX_METRIC_NAME_LEN : usize = 350 ;
168- /// Most tags propjoe keeps on a series (`model.MaxTagThresh`).
169- const MAX_TAG_COUNT : usize = 100 ;
170- /// Most resources propjoe keeps on a series (`model.MaxResourceThresh`).
171- const MAX_RESOURCE_COUNT : usize = 500 ;
205+ /// Longest metric name the intake keeps, in bytes.
206+ pub ( crate ) const MAX_METRIC_NAME_LEN : usize = 350 ;
207+ /// Most tags the intake keeps on a series. The backend's tag limit is per-org (`tagLimitProvider`),
208+ /// defaulting to `model.MaxTagThresh`=100; the rig hardcodes the default, so an org with a non-default
209+ /// limit would diverge. This is a knowingly-deferred config-parity approximation, sound while the
210+ /// differential only exercises default-org limits.
211+ pub ( crate ) const MAX_TAG_COUNT : usize = 100 ;
212+ /// Most resources the intake keeps on a series. Per-org in the backend (`resourceLimitProvider`),
213+ /// defaulting to `model.MaxResourceThresh`=500; the rig hardcodes the default, same deferral as
214+ /// `MAX_TAG_COUNT`.
215+ pub ( crate ) const MAX_RESOURCE_COUNT : usize = 500 ;
216+ /// Longest `host` resource name the intake keeps on a series, in bytes.
217+ pub ( crate ) const MAX_HOST_NAME_LEN : usize = 255 ;
218+ /// How far past the intake's receipt clock a scalar point may sit before it is dropped, in seconds.
219+ /// Matches the backend's `payload.MaxSecondsInFuture` (intake/payload/normalizer.go:32, ten minutes).
220+ const MAX_SECONDS_IN_FUTURE : i64 = 600 ;
221+
222+ /// Whether a scalar point is kept, mirroring the backend's per-point drops (v2
223+ /// api_series_v2_handler_helpers.go:264-275, v3 validatePoint api_series_v3_handler.go:549-557): a NaN
224+ /// value is dropped and a timestamp more than `MAX_SECONDS_IN_FUTURE` past the receipt clock is dropped.
225+ /// Past timestamps are kept, since late points are accepted downstream. Sketch points carry no scalar
226+ /// value and are not filtered here, matching the scalar-only scope of the backend's point checks.
227+ fn scalar_point_kept ( value : & BucketValue , bucket_start : u64 , now_secs : i64 ) -> bool {
228+ match value {
229+ BucketValue :: Scalar ( v) => {
230+ !v. is_nan ( ) && i128:: from ( bucket_start) <= i128:: from ( now_secs) + i128:: from ( MAX_SECONDS_IN_FUTURE )
231+ }
232+ BucketValue :: Sketch ( _) => true ,
233+ }
234+ }
172235
173- /// Whether propjoe's v2 ingest keeps this series. It drops any series with an invalid metric
174- /// name (`ValidateMetricName`: empty, over `MaxMetricLen` bytes, or no ASCII-alphabetic byte),
175- /// more than `MaxTagThresh` tags, or more than `MaxResourceThresh` resources. Matching keeps
176- /// our captured context set equal to what production would store.
236+ /// Whether the intake keeps this metric name: non-empty, at most the max name length in bytes, and
237+ /// carrying at least one ASCII-alphabetic byte. Shared by the v2 and v3 drop rules.
238+ pub ( crate ) fn metric_name_kept ( name : & str ) -> bool {
239+ !name. is_empty ( ) && name. len ( ) <= MAX_METRIC_NAME_LEN && name. bytes ( ) . any ( |b| b. is_ascii_alphabetic ( ) )
240+ }
241+
242+ /// Whether the intake's v2 ingest keeps this series. It drops any series with an invalid metric name
243+ /// (empty, over the max name length, or no ASCII-alphabetic byte), more than the max tag count, more
244+ /// than the max resource count, or a `host` resource whose name exceeds the max host length. Matching
245+ /// keeps our captured context set equal to what production would store, and keeps the two lanes' drop
246+ /// rules identical to the v3 path.
177247pub ( crate ) fn series_kept_by_intake ( series : & MetricSeries ) -> bool {
178- let name = series. metric . as_str ( ) ;
179- let name_ok =
180- !name. is_empty ( ) && name. len ( ) <= MAX_METRIC_NAME_LEN && name. bytes ( ) . any ( |b| b. is_ascii_alphabetic ( ) ) ;
181- name_ok && series. tags . len ( ) <= MAX_TAG_COUNT && series. resources . len ( ) <= MAX_RESOURCE_COUNT
248+ let host_ok = series
249+ . resources
250+ . iter ( )
251+ . find ( |r| r. type_ ( ) == "host" )
252+ . is_none_or ( |host| host. name ( ) . len ( ) <= MAX_HOST_NAME_LEN ) ;
253+ metric_name_kept ( series. metric . as_str ( ) )
254+ && series. tags . len ( ) <= MAX_TAG_COUNT
255+ && series. resources . len ( ) <= MAX_RESOURCE_COUNT
256+ && host_ok
257+ }
258+
259+ /// The tagset a series carries, its wire tags plus its `host` resource folded into a `host:<name>` tag,
260+ /// matching the fold the v3 lane applies.
261+ fn tagset_with_host ( tags : & [ String ] , host : Option < & str > ) -> BTreeSet < String > {
262+ let mut tagset: BTreeSet < String > = tags. iter ( ) . cloned ( ) . collect ( ) ;
263+ if let Some ( host) = host {
264+ if !host. is_empty ( ) {
265+ tagset. insert ( format ! ( "host:{host}" ) ) ;
266+ }
267+ }
268+ tagset
182269}
183270
184- /// Decodes a `/api/v2/series` payload into contexts with stele's `Metric::try_from_series_v2`.
185- fn observe_series ( target : Target , payload : MetricPayload ) -> Vec < Context > {
271+ /// Reads a `/api/v2/series` `MetricPayload` straight off the wire into contexts, no stele in the path.
272+ /// It applies the same `series_kept_by_intake` drop rules and the same `host` resource fold as the v3
273+ /// lane, and derives the kind from the wire type field. A series whose every point is dropped by the
274+ /// backend's per-point NaN and too-far-future checks (keyed on `now_secs`, the intake's receipt clock)
275+ /// emits no context, matching the backend's all-points-dropped series drop.
276+ fn observe_series ( payload : MetricPayload , now_secs : i64 ) -> Vec < Context > {
186277 let mut contexts = Vec :: new ( ) ;
187278 for series in payload. series {
188279 if !series_kept_by_intake ( & series) {
189280 continue ;
190281 }
191- let mut single = MetricPayload :: new ( ) ;
192- single. series . push ( series) ;
193- match Metric :: try_from_series_v2 ( single) {
194- Ok ( metrics) => contexts. extend ( metrics. iter ( ) . filter_map ( context_of) ) ,
195- Err ( error) => {
196- warn ! ( target = target. as_str( ) , %error, "skipped a series that did not convert to a stele metric" ) ;
197- }
282+ let host = series
283+ . resources
284+ . iter ( )
285+ . find ( |r| r. type_ ( ) == "host" )
286+ . map ( Resource :: name) ;
287+ let has_point = series. points . iter ( ) . any ( |point| {
288+ u64:: try_from ( point. timestamp )
289+ . is_ok_and ( |ts| scalar_point_kept ( & BucketValue :: Scalar ( point. value ) , ts, now_secs) )
290+ } ) ;
291+ if !has_point {
292+ continue ;
198293 }
294+ contexts. push ( Context {
295+ name : series. metric . clone ( ) ,
296+ tagset : tagset_with_host ( & series. tags , host) ,
297+ kind : MetricKind :: of ( series. type_ ( ) ) ,
298+ } ) ;
199299 }
200300 contexts
201301}
202302
203- /// Decodes a sketch payload into contexts.
204- fn observe_sketches ( target : Target , payload : SketchPayload ) -> Vec < Context > {
303+ /// Reads an `/api/beta/sketches` `SketchPayload` straight off the wire into contexts, no stele in the
304+ /// path. Each kept sketch is one `Sketch`-kind context; its `host` folds into a `host:<name>` tag as
305+ /// the v2 series path folds its host resource. A sketch left with no point whose timestamp fits a
306+ /// `u64` bucket-start emits no context.
307+ ///
308+ /// The backend's `NormalizeDistributionReq` (intake/payload/normalizer.go:459-503) drops a distribution
309+ /// whose host exceeds the host-length cap, whose tag count exceeds the tag cap, or whose metric name is
310+ /// invalid, keeping the rest. This applies the same per-sketch keep predicate. The backend additionally
311+ /// REWRITES kept metric names (`NormMetricNameParse`) and tags (`NormalizeTags`); that normalization is
312+ /// a separate fidelity gap the sketch, v2, and v3 lanes all share and is not modeled here.
313+ fn observe_sketches ( payload : SketchPayload ) -> Vec < Context > {
205314 let mut contexts = Vec :: new ( ) ;
206315 for sketch in payload. sketches {
207- let mut single = SketchPayload :: new ( ) ;
208- single. sketches . push ( sketch) ;
209- match Metric :: try_from_sketch ( single) {
210- Ok ( metrics) => contexts. extend ( metrics. iter ( ) . filter_map ( context_of) ) ,
211- Err ( error) => {
212- warn ! ( target = target. as_str( ) , %error, "skipped a sketch that did not convert to a stele metric" ) ;
213- }
316+ // Per-distribution keep rules, matching the backend. Resource count has no sketch analogue.
317+ if !metric_name_kept ( sketch. metric ( ) )
318+ || sketch. tags . len ( ) > MAX_TAG_COUNT
319+ || sketch. host ( ) . len ( ) > MAX_HOST_NAME_LEN
320+ {
321+ continue ;
214322 }
323+ let has_point = sketch. dogsketches . iter ( ) . any ( |d| u64:: try_from ( d. ts ) . is_ok ( ) )
324+ || sketch. distributions . iter ( ) . any ( |d| u64:: try_from ( d. ts ) . is_ok ( ) ) ;
325+ if !has_point {
326+ continue ;
327+ }
328+ contexts. push ( Context {
329+ name : sketch. metric . clone ( ) ,
330+ tagset : tagset_with_host ( & sketch. tags , Some ( sketch. host ( ) ) ) ,
331+ kind : MetricKind :: Sketch ,
332+ } ) ;
215333 }
216334 contexts
217335}
218336
219- fn context_of ( metric : & Metric ) -> Option < Context > {
220- let kind = MetricKind :: of ( metric) ?;
221- Some ( Context {
222- name : metric. context ( ) . name ( ) . to_string ( ) ,
223- tagset : metric. context ( ) . tags ( ) . iter ( ) . cloned ( ) . collect ( ) ,
224- kind,
225- } )
337+ /// Maps the natively decoded v3 series into contexts. The native decoder in `lenient_decode` already
338+ /// applied the two-tier failure model, the production intake's per-series validation, and the `host`
339+ /// resource fold; this applies the backend's per-point NaN and too-far-future drops (validatePoint),
340+ /// keyed on `now_secs`, and emits a context for each series left with at least one surviving point.
341+ fn observe_series_v3 ( series : Vec < V3Series > , now_secs : i64 ) -> Vec < Context > {
342+ series
343+ . into_iter ( )
344+ . filter ( |s| {
345+ s. points
346+ . iter ( )
347+ . any ( |( ts, value) | scalar_point_kept ( value, * ts, now_secs) )
348+ } )
349+ . map ( |s| Context {
350+ name : s. name ,
351+ tagset : s. tags . into_iter ( ) . collect ( ) ,
352+ kind : s. kind ,
353+ } )
354+ . collect ( )
226355}
227356
228357#[ cfg( test) ]
0 commit comments