Skip to content

Commit ca16da5

Browse files
committed
fix(correctness): decode V3 metric resources
1 parent f27ed86 commit ca16da5

7 files changed

Lines changed: 375 additions & 9 deletions

File tree

bin/correctness/stele/src/metrics.rs

Lines changed: 153 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -411,13 +411,21 @@ impl Metric {
411411
let names_dict = parse_dict_strings(&data.dictNameStr)?;
412412
let tags_dict = parse_dict_strings(&data.dictTagStr)?;
413413
let tagsets_dict = parse_tagsets(&data.dictTagsets, &tags_dict)?;
414+
let resources_dict = parse_resources(
415+
&data.dictResourceLen,
416+
&data.dictResourceType,
417+
&data.dictResourceName,
418+
&parse_dict_strings(&data.dictResourceStr)?,
419+
)?;
414420

415421
// Delta-decode index arrays.
416422
let mut name_refs = data.nameRefs;
417423
let mut tagset_refs = data.tagsetRefs;
424+
let mut resources_refs = data.resourcesRefs;
418425
let mut timestamps = data.timestamps;
419426
delta_decode(&mut name_refs);
420427
delta_decode(&mut tagset_refs);
428+
delta_decode(&mut resources_refs);
421429
delta_decode(&mut timestamps);
422430

423431
// Delta-decode sketch bin keys (per-sketch sequences are individually delta-encoded,
@@ -446,7 +454,7 @@ impl Metric {
446454

447455
// Resolve tags (1-based index).
448456
let tagset_ref = tagset_refs[i] as usize;
449-
let tags = if tagset_ref == 0 {
457+
let mut tags = if tagset_ref == 0 {
450458
Vec::new()
451459
} else {
452460
tagsets_dict
@@ -457,6 +465,23 @@ impl Metric {
457465
.clone()
458466
};
459467

468+
let resource_ref = resources_refs.get(i).copied().unwrap_or(0) as usize;
469+
if resource_ref != 0 {
470+
let resources = resources_dict.get(resource_ref - 1).ok_or_else(|| {
471+
generic_error!(
472+
"Invalid resource ref {} (dict size {})",
473+
resource_ref,
474+
resources_dict.len()
475+
)
476+
})?;
477+
if let Some((_, host_name)) = resources
478+
.iter()
479+
.find(|(resource_type, resource_name)| resource_type == "host" && !resource_name.is_empty())
480+
{
481+
tags.push(format!("host:{}", host_name));
482+
}
483+
}
484+
460485
let mut values = Vec::with_capacity(num_points);
461486

462487
if metric_type == V3_METRIC_TYPE_SKETCH {
@@ -468,14 +493,8 @@ impl Metric {
468493
let timestamp = u64::try_from(ts).map_err(|_| generic_error!("Invalid timestamp: {}", ts))?;
469494
cursors.timestamp += 1;
470495

471-
// Sketch count is always in valsSint64.
472-
let cnt = *data
473-
.valsSint64
474-
.get(cursors.sint64)
475-
.ok_or_else(|| generic_error!("Ran out of sint64 values for sketch count"))?;
476-
cursors.sint64 += 1;
477-
478-
// Sum, min, max are stored as 3 consecutive values based on value_type.
496+
// The Agent writes sketch summaries as sum, min, max, then count. Count is always in valsSint64,
497+
// but integer summaries can share that column, so count must be read after the summary values.
479498
let sum = read_value(
480499
value_type,
481500
&mut cursors,
@@ -497,6 +516,11 @@ impl Metric {
497516
&data.valsFloat32,
498517
&data.valsFloat64,
499518
)?;
519+
let cnt = *data
520+
.valsSint64
521+
.get(cursors.sint64)
522+
.ok_or_else(|| generic_error!("Ran out of sint64 values for sketch count"))?;
523+
cursors.sint64 += 1;
500524
let avg = if cnt != 0 { sum / cnt as f64 } else { 0.0 };
501525

502526
// Read bin data.
@@ -669,6 +693,52 @@ fn parse_tagsets(dict_tagsets: &[i64], tags_dict: &[String]) -> Result<Vec<Vec<S
669693
Ok(tagsets)
670694
}
671695

696+
/// Parse resource sets from V3 resource dictionaries.
697+
///
698+
/// Each resource set is encoded as one length entry plus that many locally delta-encoded type/name dictionary indexes.
699+
fn parse_resources(
700+
dict_resource_len: &[i64], dict_resource_type: &[i64], dict_resource_name: &[i64], resource_strings: &[String],
701+
) -> Result<Vec<Vec<(String, String)>>, GenericError> {
702+
let mut resources = Vec::with_capacity(dict_resource_len.len());
703+
let mut offset = 0;
704+
705+
for &count in dict_resource_len {
706+
let count = usize::try_from(count).map_err(|_| generic_error!("Invalid negative resource count: {}", count))?;
707+
if offset + count > dict_resource_type.len() || offset + count > dict_resource_name.len() {
708+
return Err(generic_error!("Resource set extends past resource dictionary arrays"));
709+
}
710+
711+
let mut type_indices = dict_resource_type[offset..offset + count].to_vec();
712+
let mut name_indices = dict_resource_name[offset..offset + count].to_vec();
713+
delta_decode(&mut type_indices);
714+
delta_decode(&mut name_indices);
715+
716+
let mut resource_set = Vec::with_capacity(count);
717+
for (&type_idx, &name_idx) in type_indices.iter().zip(name_indices.iter()) {
718+
let resource_type = resource_strings
719+
.get(resource_index(type_idx)?)
720+
.ok_or_else(|| generic_error!("Invalid resource type index {}", type_idx))?
721+
.clone();
722+
let resource_name = resource_strings
723+
.get(resource_index(name_idx)?)
724+
.ok_or_else(|| generic_error!("Invalid resource name index {}", name_idx))?
725+
.clone();
726+
resource_set.push((resource_type, resource_name));
727+
}
728+
729+
resources.push(resource_set);
730+
offset += count;
731+
}
732+
733+
Ok(resources)
734+
}
735+
736+
fn resource_index(idx: i64) -> Result<usize, GenericError> {
737+
let idx = usize::try_from(idx).map_err(|_| generic_error!("Invalid negative resource index: {}", idx))?;
738+
idx.checked_sub(1)
739+
.ok_or_else(|| generic_error!("Invalid zero resource index"))
740+
}
741+
672742
/// Read the next f64 value from the appropriate value array based on `value_type`.
673743
fn read_value(
674744
value_type: u64, cursors: &mut V3ValueCursors, vals_sint64: &[i64], vals_float32: &[f32], vals_float64: &[f64],
@@ -834,4 +904,78 @@ mod tests {
834904
assert!(metrics[0].context.tags.contains(&"host:server-1".to_string()));
835905
assert!(metrics[0].context.tags.contains(&"env:prod".to_string()));
836906
}
907+
908+
#[test]
909+
fn try_from_v3_folds_host_resource_into_tags() {
910+
use datadog_protos::metrics::v3::{MetricData, Payload};
911+
912+
let mut data = MetricData::new();
913+
data.dictNameStr = length_prefixed_strings(["my.metric"]);
914+
data.dictTagStr = length_prefixed_strings(["env:prod"]);
915+
data.dictTagsets = vec![1, 1];
916+
data.dictResourceStr = length_prefixed_strings(["host", "server-1", "device", "eth0"]);
917+
data.dictResourceLen = vec![2];
918+
data.dictResourceType = vec![1, 2];
919+
data.dictResourceName = vec![2, 2];
920+
data.types = vec![V3_METRIC_TYPE_COUNT | V3_VALUE_TYPE_ZERO];
921+
data.nameRefs = vec![1];
922+
data.tagsetRefs = vec![1];
923+
data.resourcesRefs = vec![1];
924+
data.intervals = vec![0];
925+
data.numPoints = vec![1];
926+
data.timestamps = vec![1];
927+
928+
let mut payload = Payload::new();
929+
payload.metricData = Some(data).into();
930+
931+
let metrics = Metric::try_from_v3(payload).expect("parse should succeed");
932+
assert_eq!(metrics.len(), 1);
933+
assert!(metrics[0].context.tags.contains(&"env:prod".to_string()));
934+
assert!(metrics[0].context.tags.contains(&"host:server-1".to_string()));
935+
assert!(!metrics[0].context.tags.iter().any(|tag| tag.starts_with("device:")));
936+
}
937+
938+
#[test]
939+
fn try_from_v3_decodes_integer_sketch_summary_order() {
940+
use datadog_protos::metrics::v3::{MetricData, Payload};
941+
942+
let mut data = MetricData::new();
943+
data.dictNameStr = length_prefixed_strings(["my.sketch"]);
944+
data.types = vec![V3_METRIC_TYPE_SKETCH | V3_VALUE_TYPE_SINT64];
945+
data.nameRefs = vec![1];
946+
data.tagsetRefs = vec![0];
947+
data.resourcesRefs = vec![0];
948+
data.intervals = vec![0];
949+
data.numPoints = vec![1];
950+
data.timestamps = vec![123];
951+
// Agent V3 sketch ordering is sum, min, max, count when integer summaries share valsSint64.
952+
data.valsSint64 = vec![10, 1, 4, 4];
953+
data.sketchNumBins = vec![1];
954+
data.sketchBinKeys = vec![0];
955+
data.sketchBinCnts = vec![4];
956+
957+
let mut payload = Payload::new();
958+
payload.metricData = Some(data).into();
959+
960+
let metrics = Metric::try_from_v3(payload).expect("parse should succeed");
961+
assert_eq!(metrics.len(), 1);
962+
963+
let MetricValue::Sketch { sketch } = &metrics[0].values[0].1 else {
964+
panic!("expected sketch value");
965+
};
966+
assert_eq!(sketch.count(), 4);
967+
assert_eq!(sketch.sum(), Some(10.0));
968+
assert_eq!(sketch.min(), Some(1.0));
969+
assert_eq!(sketch.max(), Some(4.0));
970+
assert_eq!(sketch.avg(), Some(2.5));
971+
}
972+
973+
fn length_prefixed_strings(strings: impl IntoIterator<Item = &'static str>) -> Vec<u8> {
974+
let mut bytes = Vec::new();
975+
for s in strings {
976+
bytes.push(s.len() as u8);
977+
bytes.extend_from_slice(s.as_bytes());
978+
}
979+
bytes
980+
}
837981
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
type: correctness
2+
runtime: docker
3+
analysis_mode: metrics
4+
baseline:
5+
image: saluki-images/datadog-agent:testing-release
6+
files:
7+
- datadog.yaml:/etc/datadog-agent/datadog.yaml
8+
additional_env_vars:
9+
- DD_API_KEY=correctness-test
10+
comparison:
11+
image: saluki-images/datadog-agent:testing-release
12+
files:
13+
- datadog.yaml:/etc/datadog-agent/datadog.yaml
14+
additional_env_vars:
15+
- DD_API_KEY=correctness-test
16+
- DD_DATA_PLANE_ENABLED=true
17+
- DD_DATA_PLANE_STANDALONE_MODE=true
18+
- DD_DATA_PLANE_DOGSTATSD_ENABLED=true
19+
- DD_AGGREGATE_CONTEXT_LIMIT=500000
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Using a fixed hostname is both required to avoid errors, and also will ensure consistent tags between DSD/ADP.
2+
hostname: "correctness-testing"
3+
4+
# Dummy API key.
5+
api_key: dummy-api-key-correctness-testing
6+
7+
# We have to specifically configure the health port to use.
8+
health_port: 5555
9+
10+
# Point ourselves at the datadog-intake service.
11+
dd_url: "http://datadog-intake:2049"
12+
13+
# Turn off UDP and listen on a UDS socket instead.
14+
dogstatsd_port: 0
15+
dogstatsd_socket: /airlock/metrics.sock
16+
17+
# Ensure origin detection is disabled since we can't support it with ADP in standalone mode.
18+
dogstatsd_origin_detection: false
19+
20+
# Gauges can be processed out-of-order when multiple workers are used, while ADP does not use multiple workers, so ADP
21+
# always ends up with the correct (last seen) value, while DSD might return the last seen value... or the value seen
22+
# four updates ago, etc etc.
23+
dogstatsd_workers_count: 1
24+
25+
# Enable V3 metrics encoding in validation mode: both V2 and V3 payloads are sent simultaneously,
26+
# paired by X-Metrics-Request-ID. V3 payloads are counted in the metrics dump; V2 payloads are
27+
# used only for comparison against V3 to validate encoding correctness.
28+
serializer_experimental_use_v3_api:
29+
series:
30+
endpoints:
31+
- "http://datadog-intake:2049"
32+
validate: true
33+
sketches:
34+
endpoints:
35+
- "http://datadog-intake:2049"
36+
validate: true
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
seed: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131]
2+
target: "unixgram:///$GROUP-airlock/metrics.sock"
3+
aggregation_bucket_width_secs: 10
4+
volume: 10000
5+
corpus:
6+
# TODO: This is a little confusing, because we're specifying the number of metrics to generate (which we _will_
7+
# honor faithfully) but since we're specifying the contexts count in the payload definition, we might not
8+
# actually generate 10,000 unique contexts, but instead somewhere below 3,000, where each of them is repeated a
9+
# few times to reach the total count.
10+
#
11+
# We need to figure that out, since the intent is that specifying a fixed count should lead to that many metrics
12+
# (and no more) being generated, such that you could depend on that for testing purposes.
13+
size: 10000
14+
payload:
15+
dogstatsd:
16+
contexts:
17+
constant: 3000
18+
name_length:
19+
inclusive:
20+
min: 1
21+
max: 32
22+
tag_length:
23+
inclusive:
24+
min: 3
25+
max: 16
26+
tags_per_msg:
27+
inclusive:
28+
min: 2
29+
max: 8
30+
value:
31+
float_probability: 0.5
32+
range:
33+
inclusive:
34+
min: -9999999
35+
max: 9999999
36+
multivalue_count:
37+
inclusive:
38+
min: 2
39+
max: 32
40+
multivalue_pack_probability: 0.08
41+
kind_weights:
42+
metric: 100
43+
event: 0
44+
service_check: 0
45+
# Weights based on analyzing internal Datadog usage data of metric type for metrics sent to the Agent over DogStatsD.
46+
metric_weights:
47+
count: 208
48+
gauge: 66
49+
timer: 0
50+
distribution: 72
51+
# We specifically _don't_ want to generate sets, because we can't assert their correctness once they've been
52+
# aggregated: a gauge is generated for each aggregator flush that represents the unique number of values in a
53+
# given set, but in general, gauges are meant to be last-write-wins, so unless the metric names/tags can
54+
# indicate that they're for a set, we can't know that it's safe for us to _aggregate_ the gauge values, and with
55+
# our default behavior of taking the latest gauge value... we end up with non-deterministic results.
56+
set: 0
57+
histogram: 1
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
type: correctness
2+
runtime: docker
3+
analysis_mode: metrics
4+
baseline:
5+
image: saluki-images/datadog-agent:testing-release
6+
files:
7+
- datadog.yaml:/etc/datadog-agent/datadog.yaml
8+
additional_env_vars:
9+
- DD_API_KEY=correctness-test
10+
comparison:
11+
image: saluki-images/datadog-agent:testing-release
12+
files:
13+
- datadog.yaml:/etc/datadog-agent/datadog.yaml
14+
additional_env_vars:
15+
- DD_API_KEY=correctness-test
16+
- DD_DATA_PLANE_ENABLED=true
17+
- DD_DATA_PLANE_STANDALONE_MODE=true
18+
- DD_DATA_PLANE_DOGSTATSD_ENABLED=true
19+
- DD_AGGREGATE_CONTEXT_LIMIT=500000
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Using a fixed hostname is both required to avoid errors, and also will ensure consistent tags between DSD/ADP.
2+
hostname: "correctness-testing"
3+
4+
# Dummy API key.
5+
api_key: dummy-api-key-correctness-testing
6+
7+
# We have to specifically configure the health port to use.
8+
health_port: 5555
9+
10+
# Point ourselves at the datadog-intake service.
11+
dd_url: "http://datadog-intake:2049"
12+
13+
# Turn off UDP and listen on a UDS socket instead.
14+
dogstatsd_port: 0
15+
dogstatsd_socket: /airlock/metrics.sock
16+
17+
# Ensure origin detection is disabled since we can't support it with ADP in standalone mode.
18+
dogstatsd_origin_detection: false
19+
20+
# Gauges can be processed out-of-order when multiple workers are used, while ADP does not use multiple workers, so ADP
21+
# always ends up with the correct (last seen) value, while DSD might return the last seen value... or the value seen
22+
# four updates ago, etc etc.
23+
dogstatsd_workers_count: 1
24+
25+
# Enable V3 metrics encoding for all endpoints.
26+
#
27+
# Validation mode is off here so this case compares Agent V3 output against ADP V3 output directly.
28+
serializer_experimental_use_v3_api:
29+
series:
30+
endpoints:
31+
- "http://datadog-intake:2049"
32+
sketches:
33+
endpoints:
34+
- "http://datadog-intake:2049"

0 commit comments

Comments
 (0)