Skip to content

Commit 23ad8e3

Browse files
authored
feat(lading): add record policy to allowlist recorded series in datadog blackhole (#1911)
* feat(datadog blackhole): add record policy to filter capture tags The Datadog intake blackhole re-emits every received series into lading's capture system, preserving all payload tags. A target that stamps an unbounded tag (e.g. a per-event host) produces one capture series per unique tag-set, inflating the capture file without bound and OOM-ing downstream analysis. Add an optional `record` policy on the datadog blackhole config: - `all` (default): unchanged behaviour, record every series with every tag. - `disabled`: record no series; still decode payloads and count bytes. - `tags: { keep: [...] }`: record series but retain only the allowlisted tag keys, bounding capture-series cardinality by construction. Defaults to `all`, so existing configs are unaffected. * refactor(datadog blackhole): make tag filter a denylist Flip the `tags` record variant from an allowlist (`keep`) to a denylist (`drop`): record every tag except the listed keys. This matches the common case of stripping a single known high-cardinality tag (e.g. `host`) while keeping the rest of the series intact. An empty `drop` set is a no-op, equivalent to `all`. * refactor(datadog blackhole): use TagsToDrop newtype variant Replace the `Tags { drop }` struct variant with a self-describing `TagsToDrop(BTreeSet<String>)` newtype, giving a flat `tags_to_drop: [...]` config. Note in the docs that an empty list behaves like `all`, only slower. * refactor(datadog blackhole): simplify record policy plumbing Drop the custom deserialize_record fn in favour of `with = "singleton_map_recursive"`, hoist the disabled check out of the recording loop, and remove a redundant `#[inline]`. * chore(datadog blackhole): trim added tests and changelog wording * refactor(datadog blackhole): allowlist recorded series by metric name Replace the tag denylist with an allowlist keyed by series (metric) name: `series_to_keep: [...]` records only the named series, with all their tags. This keeps the lading config's recorded outputs explicit and avoids accidentally forwarding generator/fuzz series to org2.
1 parent 8999bd7 commit 23ad8e3

2 files changed

Lines changed: 113 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## Unreleased
8+
- Datadog blackhole now accepts a `record` policy (`all` / `disabled` /
9+
`series_to_keep: [...]`) controlling which received series are recorded as
10+
capture metrics.
811
- OpenTelemetry metric payloads now prefix generated metric names with their
912
metric kind to simplify intake debugging.
1013
- OpenTelemetry cumulative metric generation now updates cumulative sums using

lading/src/blackhole/datadog.rs

Lines changed: 110 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ use lading_capture::{counter_incr, gauge_set};
3434
use metrics::counter;
3535
use prost::Message;
3636
use serde::{Deserialize, Serialize};
37+
use serde_yaml::with::singleton_map_recursive;
3738
use std::{
3839
borrow::Cow,
40+
collections::BTreeSet,
3941
io,
4042
net::SocketAddr,
4143
sync::Arc,
@@ -91,13 +93,52 @@ pub enum Variant {
9193
},
9294
}
9395

94-
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
96+
/// Controls which received series this blackhole records as lading capture
97+
/// metrics.
98+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
99+
#[serde(rename_all = "snake_case")]
100+
#[serde(deny_unknown_fields)]
101+
pub enum RecordPolicy {
102+
/// Default behaviour, record every received series with all of its tags.
103+
All,
104+
/// Record no series at all. Aggregate `bytes_received` / `requests_received`
105+
/// counters are still emitted and payloads are still decoded for
106+
/// accounting.
107+
Disabled,
108+
/// Record only series whose metric name is listed. An empty list behaves
109+
/// like `Disabled`.
110+
SeriesToKeep(BTreeSet<String>),
111+
}
112+
113+
impl Default for RecordPolicy {
114+
fn default() -> Self {
115+
Self::All
116+
}
117+
}
118+
119+
impl RecordPolicy {
120+
/// Whether a series with the given metric name is recorded as a capture
121+
/// metric.
122+
fn records_series(&self, metric: &str) -> bool {
123+
match self {
124+
Self::All => true,
125+
Self::Disabled => false,
126+
Self::SeriesToKeep(names) => names.contains(metric),
127+
}
128+
}
129+
}
130+
131+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
95132
#[serde(deny_unknown_fields)]
96133
/// Configuration for [`Datadog`].
97134
pub struct Config {
98135
/// The Datadog API variant to use
99136
#[serde(flatten)]
100137
pub variant: Variant,
138+
/// Which received series to record as lading capture metrics. Defaults to
139+
/// [`RecordPolicy::All`].
140+
#[serde(default, with = "singleton_map_recursive")]
141+
pub record: RecordPolicy,
101142
}
102143

103144
#[derive(Debug)]
@@ -106,11 +147,13 @@ pub struct Datadog {
106147
binding_addr: SocketAddr,
107148
shutdown: lading_signal::Watcher,
108149
metric_labels: Vec<(String, String)>,
150+
record: RecordPolicy,
109151
}
110152

111153
#[derive(Clone)]
112154
struct AppState {
113155
metric_labels: Arc<[(String, String)]>,
156+
record: RecordPolicy,
114157
}
115158

116159
impl Datadog {
@@ -133,6 +176,7 @@ impl Datadog {
133176
binding_addr,
134177
shutdown,
135178
metric_labels,
179+
record: config.record,
136180
}
137181
}
138182

@@ -146,7 +190,10 @@ impl Datadog {
146190
/// Function will return an error if the server fails to start.
147191
pub async fn run(self) -> Result<(), Error> {
148192
let metric_labels: Arc<[(String, String)]> = Arc::from(self.metric_labels);
149-
let state = Arc::new(AppState { metric_labels });
193+
let state = Arc::new(AppState {
194+
metric_labels,
195+
record: self.record,
196+
});
150197

151198
let listener = TcpListener::bind(self.binding_addr).await?;
152199
info!(
@@ -241,7 +288,7 @@ async fn handle_request(
241288

242289
let status = match (path.as_str(), content_type) {
243290
("/api/v2/series", "application/x-protobuf") => {
244-
handle_v2_protobuf(&whole_body, content_encoding, &path, labels).await
291+
handle_v2_protobuf(&whole_body, content_encoding, &path, labels, &state.record).await
245292
}
246293
_ => StatusCode::ACCEPTED,
247294
};
@@ -299,6 +346,7 @@ async fn handle_v2_protobuf(
299346
content_encoding: &str,
300347
path: &str,
301348
labels: &[(String, String)],
349+
record: &RecordPolicy,
302350
) -> StatusCode {
303351
let decompressed = match decompress_if_needed(body, content_encoding) {
304352
Ok(data) => data,
@@ -318,67 +366,67 @@ async fn handle_v2_protobuf(
318366
payload.series.len()
319367
);
320368

321-
for series in &payload.series {
322-
if series.points.is_empty() {
323-
continue;
324-
}
325-
326-
// Parse Datadog tags (format: "key:value" or "key") into label pairs.
327-
// Key-only tags are represented with an empty value.
328-
let tag_pairs: Vec<(&str, &str)> = series
329-
.tags
330-
.iter()
331-
.map(|tag| tag.split_once(':').unwrap_or((tag.as_str(), "")))
332-
.collect();
333-
334-
// Metric types from the agent_payload.proto:
335-
//
336-
// - COUNT (1): Delta count over the interval
337-
// - RATE (2): Per-second rate, converted into a counter by
338-
// multiplication with the interval.
339-
// - GAUGE (3): Point-in-time value
340-
//
341-
// For COUNT/RATE we use counter_incr, for GAUGE we use
342-
// gauge_set. Timestamps are Unix epoch.
343-
for point in &series.points {
344-
let timestamp = unix_to_instant(point.timestamp);
345-
346-
let metrics_res = match series.r#type {
347-
1 => {
348-
// COUNT
349-
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
350-
let value = point.value.round() as u64;
351-
counter_incr(&series.metric, &tag_pairs, value, timestamp).await
352-
}
353-
2 => {
354-
// RATE
355-
let interval = series.interval;
356-
if interval <= 0 {
357-
warn!(
358-
"RATE with non-positive interval for {metric}, ignoring",
359-
metric = series.metric
360-
);
361-
continue;
369+
if !matches!(record, RecordPolicy::Disabled) {
370+
for series in payload.series.iter().filter(|series| {
371+
!series.points.is_empty() && record.records_series(&series.metric)
372+
}) {
373+
// Parse Datadog tags (format: "key:value" or "key") into label pairs.
374+
// Key-only tags are represented with an empty value.
375+
let tag_pairs: Vec<(&str, &str)> = series
376+
.tags
377+
.iter()
378+
.map(|tag| tag.split_once(':').unwrap_or((tag.as_str(), "")))
379+
.collect();
380+
381+
// Metric types from the agent_payload.proto:
382+
//
383+
// - COUNT (1): Delta count over the interval
384+
// - RATE (2): Per-second rate, converted into a counter by
385+
// multiplication with the interval.
386+
// - GAUGE (3): Point-in-time value
387+
//
388+
// For COUNT/RATE we use counter_incr, for GAUGE we use
389+
// gauge_set. Timestamps are Unix epoch.
390+
for point in &series.points {
391+
let timestamp = unix_to_instant(point.timestamp);
392+
393+
let metrics_res = match series.r#type {
394+
1 => {
395+
// COUNT
396+
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
397+
let value = point.value.round() as u64;
398+
counter_incr(&series.metric, &tag_pairs, value, timestamp).await
362399
}
363-
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
364-
let val = (point.value * interval as f64).round() as u64;
365-
counter_incr(&series.metric, &tag_pairs, val, timestamp).await
366-
}
367-
3 => {
368-
// GAUGE
369-
gauge_set(&series.metric, &tag_pairs, point.value, timestamp).await
370-
}
371-
i => {
372-
warn!("Unknown metric type, skipping: {i}");
373-
Ok(())
400+
2 => {
401+
// RATE
402+
let interval = series.interval;
403+
if interval <= 0 {
404+
warn!(
405+
"RATE with non-positive interval for {metric}, ignoring",
406+
metric = series.metric
407+
);
408+
continue;
409+
}
410+
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
411+
let val = (point.value * interval as f64).round() as u64;
412+
counter_incr(&series.metric, &tag_pairs, val, timestamp).await
413+
}
414+
3 => {
415+
// GAUGE
416+
gauge_set(&series.metric, &tag_pairs, point.value, timestamp).await
417+
}
418+
i => {
419+
warn!("Unknown metric type, skipping: {i}");
420+
Ok(())
421+
}
422+
};
423+
if let Err(e) = metrics_res {
424+
warn!(
425+
"Failed to record metric {metric} at timestamp {ts}: {e}",
426+
metric = series.metric,
427+
ts = point.timestamp
428+
);
374429
}
375-
};
376-
if let Err(e) = metrics_res {
377-
warn!(
378-
"Failed to record metric {metric} at timestamp {ts}: {e}",
379-
metric = series.metric,
380-
ts = point.timestamp
381-
);
382430
}
383431
}
384432
}

0 commit comments

Comments
 (0)