Skip to content

Commit 66abc6e

Browse files
committed
chore(antithesis): Intro Intake V3
This commit introduces a v3 intake into antithesis harness to support symmetric difference with ADP-on -- v2 by default -- and ADP-off -- v3 by default.
1 parent e34c659 commit 66abc6e

19 files changed

Lines changed: 2791 additions & 367 deletions

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/antithesis/intake/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ anyhow = { workspace = true, features = ["std"] }
1919
axum = { workspace = true, features = ["http1", "json", "tokio", "tracing"] }
2020
clap = { workspace = true, features = ["derive", "env", "error-context", "help", "std", "usage"] }
2121
datadog-protos = { workspace = true }
22+
harness = { path = "../harness" }
2223
headers = { workspace = true }
2324
mime = { workspace = true }
2425
protobuf = { workspace = true }
2526
serde = { workspace = true, features = ["derive"] }
2627
serde_json = { workspace = true }
27-
stele = { workspace = true }
2828
tokio = { workspace = true, features = [
29+
"io-util",
2930
"macros",
3031
"net",
3132
"rt",

test/antithesis/intake/README.md

Lines changed: 133 additions & 47 deletions
Large diffs are not rendered by default.

test/antithesis/intake/src/capture.rs

Lines changed: 180 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ use std::collections::{btree_map::Entry, BTreeMap, BTreeSet};
66
use std::sync::{Arc, Mutex};
77
use 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};
1011
use serde::{Deserialize, Serialize};
11-
use stele::{Metric, MetricValue};
12-
use tracing::warn;
12+
13+
use crate::lenient_decode::V3Series;
1314

1415
const 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

5156
impl 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)]
80114
pub(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.
177247
pub(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

Comments
 (0)