Skip to main content

libdd_trace_utils/stats_payload_encoder/
mod.rs

1// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4//! Encoding utilities for the top-level [`pb::StatsPayload`] message.
5//!
6//! Wraps a [`pb::ClientStatsPayload`] into a [`pb::StatsPayload`] and serializes
7//! it as msgpack, matching the Agent's `/api/v0.2/stats` wire format. Payloads
8//! over [`MAX_GROUPED_STATS_PER_PAYLOAD`] entries are split via
9//! [`split_stats_buckets`] and sent one per group.
10
11use libdd_trace_protobuf::pb;
12
13/// Max [`pb::ClientGroupedStats`] entries per stats payload before splitting,
14/// matching the Agent (`pkg/trace/writer/stats.go`).
15pub const MAX_GROUPED_STATS_PER_PAYLOAD: usize = 4000;
16
17/// Wrap a client stats payload into a top-level [`pb::StatsPayload`].
18///
19/// Set `split_payload` when this is one fragment of a split flush.
20pub fn build_stats_payload(
21    payload: pb::ClientStatsPayload,
22    hostname: String,
23    env: String,
24    version: String,
25    split_payload: bool,
26) -> pb::StatsPayload {
27    pb::StatsPayload {
28        agent_hostname: hostname,
29        agent_env: env,
30        stats: vec![payload],
31        agent_version: version,
32        // `client_computed` is always set to `true` since the stats were computed by the
33        // tracer/client, not the Agent
34        client_computed: true,
35        split_payload,
36    }
37}
38
39/// Split buckets into groups of at most `max_entries` [`pb::ClientGroupedStats`].
40/// An oversized bucket is split across output buckets sharing its `start`,
41/// `duration` and `agent_time_shift`. Empty buckets are dropped, so all-empty
42/// input yields no groups.
43pub fn split_stats_buckets(
44    buckets: Vec<pb::ClientStatsBucket>,
45    max_entries: usize,
46) -> Vec<Vec<pb::ClientStatsBucket>> {
47    let max_entries = max_entries.max(1);
48    let mut groups: Vec<Vec<pb::ClientStatsBucket>> = Vec::new();
49    let mut current: Vec<pb::ClientStatsBucket> = Vec::new();
50    let mut current_count = 0usize;
51
52    for bucket in buckets {
53        let pb::ClientStatsBucket {
54            start,
55            duration,
56            agent_time_shift,
57            mut stats,
58        } = bucket;
59        while !stats.is_empty() {
60            if current_count == max_entries {
61                groups.push(std::mem::take(&mut current));
62                current_count = 0;
63            }
64            let take = (max_entries - current_count).min(stats.len());
65            let rest = stats.split_off(take);
66            let chunk = std::mem::replace(&mut stats, rest);
67            current.push(pb::ClientStatsBucket {
68                start,
69                duration,
70                agent_time_shift,
71                stats: chunk,
72            });
73            current_count += take;
74        }
75    }
76
77    if !current.is_empty() {
78        groups.push(current);
79    }
80    groups
81}
82
83/// Serialize a [`pb::StatsPayload`] as msgpack (with named fields), matching the
84/// encoding accepted by the `/api/v0.2/stats` intake.
85pub fn encode_stats_payload_msgpack(
86    payload: &pb::StatsPayload,
87) -> Result<Vec<u8>, rmp_serde::encode::Error> {
88    rmp_serde::to_vec_named(payload)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use libdd_trace_protobuf::pb;
95
96    fn sample_client_payload() -> pb::ClientStatsPayload {
97        pb::ClientStatsPayload {
98            hostname: "client-host".to_string(),
99            env: "test".to_string(),
100            version: "1.0.0".to_string(),
101            stats: vec![pb::ClientStatsBucket {
102                start: 0,
103                duration: 10_000_000_000,
104                stats: vec![pb::ClientGroupedStats {
105                    service: "svc".to_string(),
106                    name: "op".to_string(),
107                    resource: "res".to_string(),
108                    hits: 3,
109                    top_level_hits: 3,
110                    duration: 42,
111                    ..Default::default()
112                }],
113                agent_time_shift: 0,
114            }],
115            lang: "rust".to_string(),
116            tracer_version: "0.0.0".to_string(),
117            runtime_id: "00000000-0000-0000-0000-000000000000".to_string(),
118            sequence: 1,
119            ..Default::default()
120        }
121    }
122
123    #[test]
124    fn build_wraps_single_payload() {
125        let client = sample_client_payload();
126        let payload = build_stats_payload(
127            client.clone(),
128            "host-a".to_string(),
129            "prod".to_string(),
130            "1.2.3-libdatadog".to_string(),
131            false,
132        );
133
134        assert_eq!(payload.agent_hostname, "host-a");
135        assert_eq!(payload.agent_env, "prod");
136        assert_eq!(payload.agent_version, "1.2.3-libdatadog");
137        assert!(payload.client_computed);
138        assert!(!payload.split_payload);
139        assert_eq!(payload.stats.len(), 1);
140        assert_eq!(payload.stats[0], client);
141    }
142
143    #[test]
144    fn encode_roundtrips_through_msgpack() {
145        let payload = build_stats_payload(
146            sample_client_payload(),
147            "host-a".to_string(),
148            "prod".to_string(),
149            "1.2.3-libdatadog".to_string(),
150            false,
151        );
152
153        let encoded = encode_stats_payload_msgpack(&payload).expect("encode should succeed");
154        let decoded: pb::StatsPayload =
155            rmp_serde::from_slice(&encoded).expect("decode should succeed");
156
157        assert_eq!(decoded, payload);
158    }
159
160    fn grouped(resource: &str) -> pb::ClientGroupedStats {
161        pb::ClientGroupedStats {
162            resource: resource.to_string(),
163            ..Default::default()
164        }
165    }
166
167    fn bucket(start: u64, count: usize) -> pb::ClientStatsBucket {
168        pb::ClientStatsBucket {
169            start,
170            duration: 10,
171            agent_time_shift: 0,
172            stats: (0..count).map(|i| grouped(&i.to_string())).collect(),
173        }
174    }
175
176    fn total_stats(groups: &[Vec<pb::ClientStatsBucket>]) -> usize {
177        groups
178            .iter()
179            .flat_map(|g| g.iter())
180            .map(|b| b.stats.len())
181            .sum()
182    }
183
184    #[test]
185    fn split_keeps_single_group_when_under_limit() {
186        let groups = split_stats_buckets(vec![bucket(0, 3), bucket(10, 2)], 4000);
187        assert_eq!(groups.len(), 1);
188        assert_eq!(groups[0].len(), 2);
189        assert_eq!(total_stats(&groups), 5);
190    }
191
192    #[test]
193    fn split_breaks_across_multiple_groups() {
194        // 5 entries across two buckets, max 2 per payload -> 3 groups (2, 2, 1).
195        let groups = split_stats_buckets(vec![bucket(0, 3), bucket(10, 2)], 2);
196        assert_eq!(groups.len(), 3);
197        for group in &groups {
198            let count: usize = group.iter().map(|b| b.stats.len()).sum();
199            assert!(count <= 2);
200        }
201        assert_eq!(total_stats(&groups), 5);
202    }
203
204    #[test]
205    fn split_splits_an_oversized_bucket_preserving_metadata() {
206        // A single bucket of 5 entries, max 2 -> split into 3 output buckets that
207        // all share the original start/duration.
208        let groups = split_stats_buckets(vec![bucket(42, 5)], 2);
209        assert_eq!(groups.len(), 3);
210        for group in &groups {
211            for b in group {
212                assert_eq!(b.start, 42);
213                assert_eq!(b.duration, 10);
214            }
215        }
216        assert_eq!(total_stats(&groups), 5);
217    }
218
219    #[test]
220    fn split_drops_empty_buckets() {
221        let groups = split_stats_buckets(vec![bucket(0, 0), bucket(10, 0)], 2);
222        assert!(groups.is_empty());
223    }
224
225    #[test]
226    fn split_handles_zero_max_as_one() {
227        let groups = split_stats_buckets(vec![bucket(0, 2)], 0);
228        assert_eq!(groups.len(), 2);
229        assert_eq!(total_stats(&groups), 2);
230    }
231}