saluki_components/transforms/trace_sampler/
mod.rs

1//! Trace sampling transform.
2//!
3//! This transform implements agent-side head sampling for traces, supporting:
4//! - Probabilistic sampling based on trace ID
5//! - User-set priority preservation
6//! - Error-based sampling as a safety net
7//! - OTLP trace ingestion with proper sampling decision handling
8//!
9//! TODO:
10//!
11//! - add trace metrics: datadog-agent/pkg/trace/sampler/metrics.go
12//! - adding missing samplers (priority, nopriority)
13//! - add error tracking standalone mode
14
15use agent_data_plane_config::domains;
16use async_trait::async_trait;
17use saluki_common::collections::FastHashMap;
18use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
19use saluki_core::{
20    components::{transforms::*, ComponentContext},
21    data_model::event::{
22        trace::{AttributeValue, Span, Trace},
23        Event,
24    },
25    topology::EventsBuffer,
26};
27use saluki_error::GenericError;
28use stringtheory::MetaString;
29use tracing::debug;
30
31mod catalog;
32mod core_sampler;
33mod errors;
34mod priority_sampler;
35mod probabilistic;
36mod rare_sampler;
37mod score_sampler;
38mod signature;
39
40use self::probabilistic::PROB_RATE_KEY;
41use crate::common::datadog::{
42    sample_by_rate, DECISION_MAKER_MANUAL, DECISION_MAKER_PROBABILISTIC, OTEL_TRACE_ID_META_KEY,
43    SAMPLING_PRIORITY_METRIC_KEY, TAG_DECISION_MAKER,
44};
45
46// Sampling priority constants (matching datadog-agent)
47const PRIORITY_AUTO_DROP: i32 = 0;
48const PRIORITY_AUTO_KEEP: i32 = 1;
49const PRIORITY_USER_KEEP: i32 = 2;
50
51const ERROR_SAMPLE_RATE: f64 = 1.0; // Default extra sample rate (matches agent's ExtraSampleRate)
52
53// Single Span Sampling and Analytics Events keys
54const KEY_SPAN_SAMPLING_MECHANISM: &str = "_dd.span_sampling.mechanism";
55const KEY_ANALYZED_SPANS: &str = "_dd.analyzed";
56
57// Decision maker values for `_dd.p.dm` (matching datadog-agent).
58
59fn normalize_sampling_rate(rate: f64) -> f64 {
60    if rate <= 0.0 || rate >= 1.0 {
61        1.0
62    } else {
63        rate
64    }
65}
66
67/// Configuration for the trace sampler transform.
68#[derive(Debug)]
69pub struct TraceSamplerConfiguration {
70    probabilistic_sampler_enabled: bool,
71    sampling_percentage: f64,
72    error_sampling_enabled: bool,
73    error_tracking_standalone: bool,
74    errors_per_second: f64,
75    target_traces_per_second: f64,
76    default_env: MetaString,
77    rare_sampler_enabled: bool,
78    rare_sampler_tps: f64,
79    rare_sampler_cooldown_secs: f64,
80    rare_sampler_cardinality: usize,
81    otlp_sampling_rate: f64,
82}
83
84impl TraceSamplerConfiguration {
85    /// Creates a new `TraceSamplerConfiguration` from the resolved traces domain.
86    ///
87    /// The OTLP trace settings live in their own domain, so they arrive as a separate slice rather
88    /// than through the traces domain.
89    pub fn from_configuration(traces: &domains::traces::Domain, otlp_traces: &domains::otlp::Traces) -> Self {
90        let otlp_sampling_rate = normalize_sampling_rate(otlp_traces.probabilistic_sampler_sampling_percentage / 100.0);
91        Self {
92            probabilistic_sampler_enabled: traces.probabilistic_sampler.enabled,
93            sampling_percentage: traces.probabilistic_sampler.sampling_percentage,
94            error_sampling_enabled: traces.error_sampling_enabled,
95            error_tracking_standalone: traces.error_tracking_standalone_enabled,
96            errors_per_second: traces.errors_per_second,
97            target_traces_per_second: traces.target_traces_per_second,
98            default_env: MetaString::from(traces.default_env.clone()),
99            rare_sampler_enabled: traces.enable_rare_sampler,
100            rare_sampler_tps: traces.rare_sampler.tps,
101            rare_sampler_cooldown_secs: traces.rare_sampler.cooldown,
102            rare_sampler_cardinality: traces.rare_sampler.cardinality,
103            otlp_sampling_rate,
104        }
105    }
106}
107
108#[async_trait]
109impl SynchronousTransformBuilder for TraceSamplerConfiguration {
110    async fn build(&self, _context: ComponentContext) -> Result<Box<dyn SynchronousTransform + Send>, GenericError> {
111        // TODO: Need to support remote configuration changing these at runtime
112        // See https://github.com/DataDog/saluki/issues/1326
113        let sampler = TraceSampler {
114            sampling_rate: self.sampling_percentage / 100.0,
115            error_sampling_enabled: self.error_sampling_enabled,
116            error_tracking_standalone: self.error_tracking_standalone,
117            probabilistic_sampler_enabled: self.probabilistic_sampler_enabled,
118            otlp_sampling_rate: self.otlp_sampling_rate,
119            error_sampler: errors::ErrorsSampler::new(self.errors_per_second, ERROR_SAMPLE_RATE),
120            priority_sampler: priority_sampler::PrioritySampler::new(
121                self.default_env.clone(),
122                ERROR_SAMPLE_RATE,
123                self.target_traces_per_second,
124            ),
125            no_priority_sampler: score_sampler::NoPrioritySampler::new(
126                self.target_traces_per_second,
127                ERROR_SAMPLE_RATE,
128            ),
129            rare_sampler: rare_sampler::RareSampler::new(
130                self.rare_sampler_enabled,
131                self.rare_sampler_tps,
132                std::time::Duration::from_secs_f64(self.rare_sampler_cooldown_secs),
133                self.rare_sampler_cardinality,
134            ),
135        };
136
137        Ok(Box::new(sampler))
138    }
139}
140
141impl MemoryBounds for TraceSamplerConfiguration {
142    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
143        builder.minimum().with_single_value::<TraceSampler>("component struct");
144    }
145}
146
147pub struct TraceSampler {
148    sampling_rate: f64,
149    error_tracking_standalone: bool,
150    error_sampling_enabled: bool,
151    probabilistic_sampler_enabled: bool,
152    otlp_sampling_rate: f64,
153    error_sampler: errors::ErrorsSampler,
154    priority_sampler: priority_sampler::PrioritySampler,
155    no_priority_sampler: score_sampler::NoPrioritySampler,
156    rare_sampler: rare_sampler::RareSampler,
157}
158
159impl TraceSampler {
160    // TODO: merge this with the other duplicate "find root span of trace" functions
161    /// Find the root span index of a trace.
162    fn get_root_span_index(&self, trace: &Trace) -> Option<usize> {
163        // logic taken from here: https://github.com/DataDog/datadog-agent/blob/main/pkg/trace/traceutil/trace.go#L36
164        let spans = trace.spans();
165        if spans.is_empty() {
166            return None;
167        }
168        let length = spans.len();
169        // General case: go over all spans and check for one without a matching parent.
170        // This intentionally mirrors `datadog-agent/pkg/trace/traceutil/trace.go:GetRoot`:
171        // - Fast-path: return the last span with `parent_id == 0` (some clients report the root last)
172        // - Otherwise: build a map of `parent_id -> child_span_index`, delete entries whose parent
173        //   exists in the trace, and pick any remaining "orphan" child span.
174        let mut parent_id_to_child: FastHashMap<u64, usize> = FastHashMap::default();
175
176        for i in 0..length {
177            // Common case optimization: check for span with parent_id == 0, starting from the end,
178            // since some clients report the root last.
179            let j = length - 1 - i;
180            if spans[j].parent_id() == 0 {
181                return Some(j);
182            }
183            parent_id_to_child.insert(spans[j].parent_id(), j);
184        }
185
186        for span in spans.iter() {
187            parent_id_to_child.remove(&span.span_id());
188        }
189
190        // Here, if the trace is valid, we should have `len(parent_id_to_child) == 1`.
191        if parent_id_to_child.len() != 1 {
192            debug!(
193                "Didn't reliably find the root span for traceID:{:016x}{:016x}",
194                trace.trace_id_high, trace.trace_id_low,
195            );
196        }
197
198        // Have a safe behavior if that's not the case.
199        // Pick a random span without its parent.
200        if let Some((_, child_idx)) = parent_id_to_child.iter().next() {
201            return Some(*child_idx);
202        }
203
204        // Gracefully fail with the last span of the trace.
205        Some(length - 1)
206    }
207
208    /// Check for user-set sampling priority in trace
209    fn get_user_priority(&self, trace: &Trace, root_span_idx: usize) -> Option<i32> {
210        // First check trace-level sampling priority (last-seen priority from OTLP ingest)
211        if let Some(priority) = trace.priority {
212            return Some(priority);
213        }
214
215        if trace.spans().is_empty() {
216            return None;
217        }
218
219        // Fall back to checking spans (for compatibility with non-OTLP traces)
220        // Prefer the root span (common case), but fall back to scanning all spans to be robust to ordering.
221        if let Some(root) = trace.spans().get(root_span_idx) {
222            if let Some(p) = root
223                .attributes
224                .get(SAMPLING_PRIORITY_METRIC_KEY)
225                .and_then(AttributeValue::as_num)
226            {
227                return Some(p as i32);
228            }
229        }
230        let spans = trace.spans();
231        spans.iter().find_map(|span| {
232            span.attributes
233                .get(SAMPLING_PRIORITY_METRIC_KEY)
234                .and_then(AttributeValue::as_num)
235                .map(|p| p as i32)
236        })
237    }
238
239    /// Returns `true` if the given trace ID should be probabilistically sampled.
240    fn sample_probabilistic(&self, trace_id: u64) -> bool {
241        probabilistic::ProbabilisticSampler::sample(trace_id, self.sampling_rate)
242    }
243
244    fn is_otlp_trace(&self, trace: &Trace, root_span_idx: usize) -> bool {
245        trace
246            .spans()
247            .get(root_span_idx)
248            .map(|span| {
249                span.attributes
250                    .contains_key(&MetaString::from_static(OTEL_TRACE_ID_META_KEY))
251            })
252            .unwrap_or(false)
253    }
254
255    /// Returns `true` if the trace contains a span with an error.
256    fn trace_contains_error(&self, trace: &Trace, consider_exception_span_events: bool) -> bool {
257        trace.spans().iter().any(|span| {
258            span.error() != 0 || (consider_exception_span_events && self.span_contains_exception_span_event(span))
259        })
260    }
261
262    /// Returns `true` if the span has exception span events.
263    ///
264    /// This checks for the `_dd.span_events.has_exception` meta field set to `"true"`.
265    fn span_contains_exception_span_event(&self, span: &Span) -> bool {
266        if let Some(has_exception) = span
267            .attributes
268            .get("_dd.span_events.has_exception")
269            .and_then(AttributeValue::as_string)
270        {
271            return has_exception == "true";
272        }
273        false
274    }
275
276    /// Computes the OTLP pre-sampling priority and decision maker for a trace, mirroring
277    /// `OTLPReceiver.createChunks` in DDA which runs before `runSamplersV1`.
278    ///
279    /// Returns `Some((priority, dm))` for OTLP traces when the probabilistic sampler is disabled,
280    /// or `None` if pre-sampling doesn't apply.
281    ///
282    /// See: https://github.com/DataDog/datadog-agent/blob/be33ac1490c4a34602cbc65a211406b73ad6d00b/pkg/trace/api/otlp.go#L561-L585
283    fn otlp_pre_sample(&mut self, trace: &mut Trace, root_span_idx: usize) -> Option<(i32, &'static str)> {
284        if self.probabilistic_sampler_enabled || !self.is_otlp_trace(trace, root_span_idx) {
285            return None;
286        }
287        let (priority, dm) = if let Some(user_priority) = self.get_user_priority(trace, root_span_idx) {
288            (user_priority, DECISION_MAKER_MANUAL)
289        } else {
290            let root_trace_id = trace.trace_id_low;
291            if sample_by_rate(root_trace_id, self.otlp_sampling_rate) {
292                (PRIORITY_AUTO_KEEP, DECISION_MAKER_PROBABILISTIC)
293            } else {
294                (PRIORITY_AUTO_DROP, DECISION_MAKER_PROBABILISTIC)
295            }
296        };
297        if priority == PRIORITY_AUTO_KEEP {
298            if let Some(root_span) = trace.spans_mut().get_mut(root_span_idx) {
299                root_span.attributes.remove(PROB_RATE_KEY);
300            }
301        }
302        Some((priority, dm))
303    }
304
305    /// Apply analyzed span sampling to the trace.
306    ///
307    /// Returns `true` if the trace was modified.
308    fn analyzed_span_sampling(&self, trace: &mut Trace) -> bool {
309        let retained = trace.retain_spans(|_, span| span.attributes.contains_key(KEY_ANALYZED_SPANS));
310        if retained > 0 {
311            trace.dropped_trace = false;
312            trace.priority = Some(PRIORITY_USER_KEEP);
313            trace.otlp_sampling_rate = Some(self.sampling_rate);
314            true
315        } else {
316            false
317        }
318    }
319
320    /// Returns `true` if the given trace has any analyzed spans.
321    fn has_analyzed_spans(&self, trace: &Trace) -> bool {
322        trace
323            .spans()
324            .iter()
325            .any(|span| span.attributes.contains_key(KEY_ANALYZED_SPANS))
326    }
327
328    /// Apply Single Span Sampling to the trace
329    /// Returns true if the trace was modified
330    fn single_span_sampling(&self, trace: &mut Trace) -> bool {
331        let retained = trace.retain_spans(|_, span| span.attributes.contains_key(KEY_SPAN_SAMPLING_MECHANISM));
332        if retained > 0 {
333            trace.dropped_trace = false;
334            trace.priority = Some(PRIORITY_USER_KEEP);
335            trace.otlp_sampling_rate = Some(self.sampling_rate);
336            true
337        } else {
338            false
339        }
340    }
341
342    /// Evaluates the given trace against all configured samplers.
343    ///
344    /// Return a tuple containing whether or not the trace should be kept, the decision maker tag (which sampler is responsible),
345    /// and the index of the root span used for evaluation.
346    fn run_samplers(&mut self, trace: &mut Trace) -> (bool, i32, &'static str, Option<usize>) {
347        // logic taken from: https://github.com/DataDog/datadog-agent/blob/main/pkg/trace/agent/agent.go#L1066
348        // Empty trace check
349        if trace.spans().is_empty() {
350            return (false, PRIORITY_AUTO_DROP, "", None);
351        }
352
353        let now = std::time::SystemTime::now();
354        let Some(root_span_idx) = self.get_root_span_index(trace) else {
355            return (false, PRIORITY_AUTO_DROP, "", None);
356        };
357
358        // ETS: only sample traces containing errors (including exception span events); skip all other samplers.
359        // logic taken from: https://github.com/DataDog/datadog-agent/blob/be33ac1490c4a34602cbc65a211406b73ad6d00b/pkg/trace/agent/agent.go#L1068
360        if self.error_tracking_standalone {
361            let otlp_pre_sample = self.otlp_pre_sample(trace, root_span_idx);
362            if self.trace_contains_error(trace, true) {
363                let keep = self.error_sampler.sample_error(now, trace, root_span_idx);
364                let default_priority = if keep { PRIORITY_AUTO_KEEP } else { PRIORITY_AUTO_DROP };
365                let (priority, dm) = otlp_pre_sample.unwrap_or((default_priority, ""));
366                return (keep, priority, dm, Some(root_span_idx));
367            }
368            let (pre_priority, pre_dm) = otlp_pre_sample.unwrap_or((PRIORITY_AUTO_DROP, ""));
369            return (false, pre_priority, pre_dm, Some(root_span_idx));
370        }
371
372        let contains_error = self.trace_contains_error(trace, false);
373
374        // Run the rare sampler early, before all other samplers. This mirrors the Go agent behavior
375        // where the rare sampler runs first to catch traces that would otherwise be dropped entirely.
376        // logic taken from: https://github.com/DataDog/datadog-agent/blob/main/pkg/trace/agent/agent.go#L1078
377        let rare = self.rare_sampler.sample(trace, root_span_idx);
378
379        // Modern path: ProbabilisticSamplerEnabled = true
380        if self.probabilistic_sampler_enabled {
381            let mut prob_keep = false;
382            let mut decision_maker = "";
383
384            if rare {
385                // Rare sampler wins over probabilistic sampling.
386                prob_keep = true;
387            } else {
388                // Run probabilistic sampler - use trace ID
389                let root_trace_id = trace.trace_id_low;
390                if self.sample_probabilistic(root_trace_id) {
391                    decision_maker = DECISION_MAKER_PROBABILISTIC;
392                    prob_keep = true;
393
394                    if let Some(root_span) = trace.spans_mut().get_mut(root_span_idx) {
395                        root_span.attributes.insert(
396                            MetaString::from(PROB_RATE_KEY),
397                            AttributeValue::Float(self.sampling_rate),
398                        );
399                    }
400                } else if self.error_sampling_enabled && contains_error {
401                    prob_keep = self.error_sampler.sample_error(now, trace, root_span_idx);
402                }
403            }
404
405            let priority = if prob_keep {
406                PRIORITY_AUTO_KEEP
407            } else {
408                PRIORITY_AUTO_DROP
409            };
410
411            return (prob_keep, priority, decision_maker, Some(root_span_idx));
412        }
413
414        let user_priority = self.get_user_priority(trace, root_span_idx);
415        if let Some(priority) = user_priority {
416            if priority < PRIORITY_AUTO_DROP {
417                // Manual drop: short-circuit and skip other samplers.
418                return (false, priority, "", Some(root_span_idx));
419            }
420
421            if rare {
422                return (true, priority, "", Some(root_span_idx));
423            }
424
425            if self.priority_sampler.sample(now, trace, root_span_idx, priority, 0.0) {
426                return (true, priority, "", Some(root_span_idx));
427            }
428        } else if self.is_otlp_trace(trace, root_span_idx) {
429            // Rare check mirrors agent behavior: https://github.com/DataDog/datadog-agent/blob/main/pkg/trace/agent/agent.go#L1129-L1140
430            if rare {
431                return (true, PRIORITY_AUTO_KEEP, "", Some(root_span_idx));
432            }
433
434            // some sampling happens upstream in the otlp receiver in the agent: https://github.com/DataDog/datadog-agent/blob/main/pkg/trace/api/otlp.go#L572
435            let root_trace_id = trace.trace_id_low;
436            if sample_by_rate(root_trace_id, self.otlp_sampling_rate) {
437                if let Some(root_span) = trace.spans_mut().get_mut(root_span_idx) {
438                    root_span.attributes.remove(PROB_RATE_KEY);
439                }
440                return (
441                    true,
442                    PRIORITY_AUTO_KEEP,
443                    DECISION_MAKER_PROBABILISTIC,
444                    Some(root_span_idx),
445                );
446            }
447        } else {
448            if rare {
449                return (true, PRIORITY_AUTO_KEEP, "", Some(root_span_idx));
450            }
451            if self.no_priority_sampler.sample(now, trace, root_span_idx) {
452                return (true, PRIORITY_AUTO_KEEP, "", Some(root_span_idx));
453            }
454        }
455
456        if self.error_sampling_enabled && contains_error {
457            let keep = self.error_sampler.sample_error(now, trace, root_span_idx);
458            if keep {
459                return (true, PRIORITY_AUTO_KEEP, "", Some(root_span_idx));
460            }
461        }
462
463        // Default: drop the trace
464        (false, PRIORITY_AUTO_DROP, "", Some(root_span_idx))
465    }
466
467    /// Apply sampling metadata to the trace in-place.
468    ///
469    /// The `root_span_id` parameter identifies which span should receive the sampling metadata.
470    /// This avoids recalculating the root span since it was already found in `run_samplers`.
471    fn apply_sampling_metadata(
472        &self, trace: &mut Trace, keep: bool, priority: i32, decision_maker: &str, root_span_idx: usize,
473    ) {
474        let is_otlp = self.is_otlp_trace(trace, root_span_idx);
475        let root_span_value = match trace.spans_mut().get_mut(root_span_idx) {
476            Some(span) => span,
477            None => return,
478        };
479
480        // Add tag for the decision maker
481        let existing_decision_maker = if decision_maker.is_empty() {
482            root_span_value
483                .attributes
484                .get(TAG_DECISION_MAKER)
485                .and_then(AttributeValue::as_string)
486                .cloned()
487        } else {
488            None
489        };
490        let decision_maker_meta = if decision_maker.is_empty() {
491            existing_decision_maker
492        } else {
493            Some(MetaString::from(decision_maker))
494        };
495
496        // When the APM-level probabilistic sampler is used with OTLP traces, the DD Agent writes
497        // _dd.p.dm to trace chunk tags only (not span meta). For the legacy OTLP sampling path,
498        // it is written to both. We match that behavior by skipping the span meta write only when
499        // both conditions hold; the DM value still flows through trace fields to the encoder.
500        if priority > 0 && !(is_otlp && self.probabilistic_sampler_enabled) {
501            if let Some(dm) = decision_maker_meta.as_ref() {
502                root_span_value
503                    .attributes
504                    .insert(MetaString::from(TAG_DECISION_MAKER), AttributeValue::String(dm.clone()));
505            }
506        }
507
508        // Now set sampling metadata directly on the trace.
509        trace.dropped_trace = !keep;
510        trace.priority = Some(priority);
511        trace.decision_maker = if priority > 0 { decision_maker_meta } else { None };
512        trace.otlp_sampling_rate = Some(if is_otlp {
513            self.otlp_sampling_rate
514        } else {
515            self.sampling_rate
516        });
517    }
518
519    fn process_trace(&mut self, trace: &mut Trace) -> bool {
520        // keep is a boolean that indicates if the trace should be kept or dropped
521        // priority is the sampling priority
522        // decision_maker is the tag that indicates the decision maker (probabilistic, error, etc.)
523        // root_span_idx is the index of the root span of the trace
524        let (keep, priority, decision_maker, root_span_idx) = self.run_samplers(trace);
525
526        // Apply sampling metadata and forward if kept, or if ETS (dropped non-error traces are
527        // forwarded with DroppedTrace=true, suppressing SSS/analytics).
528        if keep || self.error_tracking_standalone {
529            if let Some(root_idx) = root_span_idx {
530                self.apply_sampling_metadata(trace, keep, priority, decision_maker, root_idx);
531            }
532            return true;
533        }
534
535        // logic taken from here: https://github.com/DataDog/datadog-agent/blob/main/pkg/trace/agent/agent.go#L980-L990
536        // try single span sampling (keeps spans marked for sampling when trace would be dropped)
537        let modified = self.single_span_sampling(trace);
538        if !modified {
539            // Fall back to analytics events if no SSS spans
540            if self.analyzed_span_sampling(trace) {
541                return true;
542            }
543        } else if self.has_analyzed_spans(trace) {
544            // Warn about both SSS and analytics events
545            debug!(
546                "Detected both analytics events AND single span sampling in the same trace. Single span sampling wins because App Analytics is deprecated."
547            );
548            return true;
549        }
550
551        // If we modified the trace with SSS, send it
552        if modified {
553            return true;
554        }
555
556        // Neither SSS nor analytics events found, drop the trace
557        debug!("Dropping trace with priority {}", priority);
558        false
559    }
560}
561
562impl SynchronousTransform for TraceSampler {
563    fn transform_buffer(&mut self, buffer: &mut EventsBuffer) {
564        buffer.remove_if(|event| match event {
565            Event::Trace(trace) => !self.process_trace(trace),
566            _ => false,
567        });
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use std::collections::HashMap;
574
575    use saluki_core::data_model::event::trace::{AttributeValue, Span as DdSpan, Trace};
576    const PRIORITY_USER_DROP: i32 = -1;
577
578    use super::*;
579    fn create_test_sampler() -> TraceSampler {
580        TraceSampler {
581            sampling_rate: 1.0,
582            error_sampling_enabled: true,
583            error_tracking_standalone: false,
584            probabilistic_sampler_enabled: true,
585            otlp_sampling_rate: 1.0,
586            error_sampler: errors::ErrorsSampler::new(10.0, 1.0),
587            priority_sampler: priority_sampler::PrioritySampler::new(MetaString::from("agent-env"), 1.0, 10.0),
588            no_priority_sampler: score_sampler::NoPrioritySampler::new(10.0, 1.0),
589            rare_sampler: rare_sampler::RareSampler::new(false, 5.0, std::time::Duration::from_secs(300), 200),
590        }
591    }
592
593    fn create_test_span(span_id: u64, error: i32) -> DdSpan {
594        DdSpan::new(
595            MetaString::from("test-service"),
596            MetaString::from("test-operation"),
597            MetaString::from("test-resource"),
598            MetaString::from("test-type"),
599            span_id,
600            0,    // parent_id
601            0,    // start
602            1000, // duration
603            error,
604        )
605    }
606
607    fn create_test_span_with_metrics(span_id: u64, metrics: HashMap<String, f64>) -> DdSpan {
608        let attrs: saluki_common::collections::FastHashMap<MetaString, AttributeValue> = metrics
609            .into_iter()
610            .map(|(k, v)| (MetaString::from(k), AttributeValue::Float(v)))
611            .collect();
612        create_test_span(span_id, 0).with_attributes(attrs)
613    }
614
615    #[allow(dead_code)]
616    fn create_test_span_with_meta(span_id: u64, meta: HashMap<String, String>) -> DdSpan {
617        let attrs: saluki_common::collections::FastHashMap<MetaString, AttributeValue> = meta
618            .into_iter()
619            .map(|(k, v)| (MetaString::from(k), AttributeValue::String(MetaString::from(v))))
620            .collect();
621        create_test_span(span_id, 0).with_attributes(attrs)
622    }
623
624    fn create_test_trace(spans: Vec<DdSpan>) -> Trace {
625        Trace::new(spans)
626    }
627
628    #[test]
629    fn user_priority_detection() {
630        let sampler = create_test_sampler();
631
632        // Test trace with user-set priority = 2 (UserKeep)
633        let mut metrics = HashMap::new();
634        metrics.insert(SAMPLING_PRIORITY_METRIC_KEY.to_string(), 2.0);
635        let span = create_test_span_with_metrics(1, metrics);
636        let trace = create_test_trace(vec![span]);
637        let root_idx = sampler.get_root_span_index(&trace).unwrap();
638
639        assert_eq!(sampler.get_user_priority(&trace, root_idx), Some(2));
640
641        // Test trace with user-set priority = -1 (UserDrop)
642        let mut metrics = HashMap::new();
643        metrics.insert(SAMPLING_PRIORITY_METRIC_KEY.to_string(), -1.0);
644        let span = create_test_span_with_metrics(1, metrics);
645        let trace = create_test_trace(vec![span]);
646        let root_idx = sampler.get_root_span_index(&trace).unwrap();
647
648        assert_eq!(sampler.get_user_priority(&trace, root_idx), Some(-1));
649
650        // Test trace without user priority
651        let span = create_test_span(1, 0);
652        let trace = create_test_trace(vec![span]);
653        let root_idx = sampler.get_root_span_index(&trace).unwrap();
654
655        assert_eq!(sampler.get_user_priority(&trace, root_idx), None);
656    }
657
658    #[test]
659    fn trace_level_priority_takes_precedence() {
660        let sampler = create_test_sampler();
661
662        // Test trace-level priority overrides span priorities (last-seen priority)
663        // Create spans with different priorities - root has 0, later span has 2
664        let mut metrics_root = HashMap::new();
665        metrics_root.insert(SAMPLING_PRIORITY_METRIC_KEY.to_string(), 0.0);
666        let root_span = create_test_span_with_metrics(1, metrics_root);
667
668        let mut metrics_later = HashMap::new();
669        metrics_later.insert(SAMPLING_PRIORITY_METRIC_KEY.to_string(), 1.0);
670        let later_span = create_test_span_with_metrics(2, metrics_later).with_parent_id(1);
671
672        let mut trace = create_test_trace(vec![root_span, later_span]);
673        let root_idx = sampler.get_root_span_index(&trace).unwrap();
674
675        // Without trace-level priority, should get priority from root (0)
676        assert_eq!(sampler.get_user_priority(&trace, root_idx), Some(0));
677
678        // Now set trace-level priority to 2 (simulating last-seen priority from OTLP translator)
679        trace.priority = Some(2);
680
681        // Trace-level priority should take precedence
682        assert_eq!(sampler.get_user_priority(&trace, root_idx), Some(2));
683
684        // Test that trace-level priority is used even when no span has priority
685        let span_no_priority = create_test_span(3, 0);
686        let mut trace_only_trace_level = create_test_trace(vec![span_no_priority]);
687        trace_only_trace_level.priority = Some(1);
688        let root_idx = sampler.get_root_span_index(&trace_only_trace_level).unwrap();
689
690        assert_eq!(sampler.get_user_priority(&trace_only_trace_level, root_idx), Some(1));
691    }
692
693    #[test]
694    fn manual_keep_with_trace_level_priority() {
695        let mut sampler = create_test_sampler();
696        sampler.probabilistic_sampler_enabled = false; // Use legacy path that checks user priority
697
698        // Test that manual keep (priority = 2) works via trace-level priority
699        let span = create_test_span(1, 0);
700        let mut trace = create_test_trace(vec![span]);
701        trace.priority = Some(PRIORITY_USER_KEEP);
702
703        let (keep, priority, decision_maker, _) = sampler.run_samplers(&mut trace);
704        assert!(keep);
705        assert_eq!(priority, PRIORITY_USER_KEEP);
706        assert_eq!(decision_maker, "");
707
708        // Test manual drop (priority = -1) via trace-level priority
709        let span = create_test_span(1, 0);
710        let mut trace = create_test_trace(vec![span]);
711        trace.priority = Some(PRIORITY_USER_DROP);
712
713        let (keep, priority, _, _) = sampler.run_samplers(&mut trace);
714        assert!(!keep); // Should not keep when user drops
715        assert_eq!(priority, PRIORITY_USER_DROP);
716
717        // Test that priority = 1 (auto keep) via trace-level is also respected
718        let span = create_test_span(1, 0);
719        let mut trace = create_test_trace(vec![span]);
720        trace.priority = Some(PRIORITY_AUTO_KEEP);
721
722        let (keep, priority, decision_maker, _) = sampler.run_samplers(&mut trace);
723        assert!(keep);
724        assert_eq!(priority, PRIORITY_AUTO_KEEP);
725        assert_eq!(decision_maker, "");
726    }
727
728    #[test]
729    fn probabilistic_sampling_known_decisions() {
730        // The bucketed probabilistic sampler is fully deterministic: it hashes the trace ID into one of 0x4000
731        // buckets and keeps the trace when `bucket < (rate * 0x4000)`. These cases pin the exact keep/drop decision
732        // for known trace IDs at known rates, so a regression in the hash, the bucket mask, or the comparison is
733        // caught (a determinism-only check would not catch any of those).
734        //
735        // Expected values were computed directly from the FNV-1a bucket math in `ProbabilisticSampler::sample`
736        // (mirrors datadog-agent/pkg/trace/sampler/probabilistic.go). For reference, the trace IDs below hash to
737        // these buckets (out of 0x4000 = 16384): 0x1234567890ABCDEF -> 1764, 0x0 -> 9301, u64::MAX -> 12365.
738        struct Case {
739            trace_id: u64,
740            rate: f64,
741            expected_keep: bool,
742        }
743
744        let cases = [
745            // rate 1.0 keeps every trace (the maximum bucket, 16383, is always below 16384).
746            Case {
747                trace_id: 0x1234567890ABCDEF,
748                rate: 1.0,
749                expected_keep: true,
750            },
751            // rate 0.0 drops every trace (no bucket is below 0).
752            Case {
753                trace_id: 0x1234567890ABCDEF,
754                rate: 0.0,
755                expected_keep: false,
756            },
757            // Same trace ID (bucket 1764) flips from drop to keep as the rate crosses its bucket ratio (~0.108).
758            Case {
759                trace_id: 0x1234567890ABCDEF,
760                rate: 0.10,
761                expected_keep: false,
762            },
763            Case {
764                trace_id: 0x1234567890ABCDEF,
765                rate: 0.20,
766                expected_keep: true,
767            },
768            // Trace ID 0 (bucket 9301) straddles rate 0.5 (scaled bucket 8192) vs 0.6 (scaled bucket 9830).
769            Case {
770                trace_id: 0,
771                rate: 0.50,
772                expected_keep: false,
773            },
774            Case {
775                trace_id: 0,
776                rate: 0.60,
777                expected_keep: true,
778            },
779            // u64::MAX (bucket 12365) straddles rate 0.5 vs 0.8.
780            Case {
781                trace_id: u64::MAX,
782                rate: 0.50,
783                expected_keep: false,
784            },
785            Case {
786                trace_id: u64::MAX,
787                rate: 0.80,
788                expected_keep: true,
789            },
790        ];
791
792        for case in cases {
793            let mut sampler = create_test_sampler();
794            sampler.sampling_rate = case.rate;
795            assert_eq!(
796                sampler.sample_probabilistic(case.trace_id),
797                case.expected_keep,
798                "trace_id={:#018x} rate={}",
799                case.trace_id,
800                case.rate
801            );
802        }
803    }
804
805    #[test]
806    fn probabilistic_sampling_is_deterministic() {
807        // Determinism is a documented property of `ProbabilisticSampler::sample` (same trace ID + rate always yields
808        // the same decision). This is intentionally a determinism-only check; correctness is covered by
809        // `test_probabilistic_sampling_known_decisions`.
810        let sampler = create_test_sampler();
811        let trace_id = 0x1234567890ABCDEF_u64;
812        assert_eq!(
813            sampler.sample_probabilistic(trace_id),
814            sampler.sample_probabilistic(trace_id)
815        );
816    }
817
818    #[test]
819    fn error_detection() {
820        let sampler = create_test_sampler();
821
822        // Test trace with error field set
823        let span_with_error = create_test_span(1, 1);
824        let trace = create_test_trace(vec![span_with_error]);
825        assert!(sampler.trace_contains_error(&trace, false));
826
827        // Test trace without error
828        let span_without_error = create_test_span(1, 0);
829        let trace = create_test_trace(vec![span_without_error]);
830        assert!(!sampler.trace_contains_error(&trace, false));
831    }
832
833    #[test]
834    fn sampling_priority_order() {
835        // Test modern path: error sampler overrides probabilistic drop
836        let mut sampler = create_test_sampler();
837        sampler.sampling_rate = 0.5; // 50% sampling rate
838        sampler.probabilistic_sampler_enabled = true;
839
840        // Create trace with error that would be dropped by probabilistic
841        // Using a trace ID that we know will be dropped at 50% rate
842        let span_with_error = create_test_span(1, 1);
843        let mut trace = create_test_trace(vec![span_with_error]);
844        trace.trace_id_low = u64::MAX - 1;
845
846        let (keep, priority, decision_maker, _) = sampler.run_samplers(&mut trace);
847        assert!(keep);
848        assert_eq!(priority, PRIORITY_AUTO_KEEP);
849        assert_eq!(decision_maker, ""); // Error sampler doesn't set decision_maker
850
851        // Test legacy path: user priority is respected
852        let mut sampler = create_test_sampler();
853        sampler.probabilistic_sampler_enabled = false; // Use legacy path
854
855        let mut metrics = HashMap::new();
856        metrics.insert(SAMPLING_PRIORITY_METRIC_KEY.to_string(), 2.0);
857        let span = create_test_span_with_metrics(1, metrics);
858        let mut trace = create_test_trace(vec![span]);
859
860        let (keep, priority, decision_maker, _) = sampler.run_samplers(&mut trace);
861        assert!(keep);
862        assert_eq!(priority, 2); // UserKeep
863        assert_eq!(decision_maker, "");
864    }
865
866    #[test]
867    fn empty_trace_handling() {
868        let mut sampler = create_test_sampler();
869        let mut trace = create_test_trace(vec![]);
870
871        let (keep, priority, _, _) = sampler.run_samplers(&mut trace);
872        assert!(!keep);
873        assert_eq!(priority, PRIORITY_AUTO_DROP);
874    }
875
876    #[test]
877    fn root_span_detection() {
878        let sampler = create_test_sampler();
879
880        // Test 1: Root span with parent_id = 0 (common case)
881        let root_span = DdSpan::new(
882            MetaString::from("service"),
883            MetaString::from("operation"),
884            MetaString::from("resource"),
885            MetaString::from("type"),
886            1,
887            0, // parent_id = 0 indicates root
888            0,
889            1000,
890            0,
891        );
892        let child_span = DdSpan::new(
893            MetaString::from("service"),
894            MetaString::from("child_op"),
895            MetaString::from("resource"),
896            MetaString::from("type"),
897            2,
898            1, // parent_id = 1 (points to root)
899            100,
900            500,
901            0,
902        );
903        // Put root span second to test that we find it even when not first
904        let trace = create_test_trace(vec![child_span.clone(), root_span.clone()]);
905        let root_idx = sampler.get_root_span_index(&trace).unwrap();
906        assert_eq!(trace.spans()[root_idx].span_id(), 1);
907
908        // Test 2: Orphaned span (parent not in trace)
909        let orphan_span = DdSpan::new(
910            MetaString::from("service"),
911            MetaString::from("orphan"),
912            MetaString::from("resource"),
913            MetaString::from("type"),
914            3,
915            999, // parent_id = 999 (doesn't exist in trace)
916            200,
917            300,
918            0,
919        );
920        let trace = create_test_trace(vec![orphan_span]);
921        let root_idx = sampler.get_root_span_index(&trace).unwrap();
922        assert_eq!(trace.spans()[root_idx].span_id(), 3);
923
924        // Test 3: Multiple root candidates: should return the last one found (index 1)
925        let span1 = create_test_span(1, 0);
926        let span2 = create_test_span(2, 0);
927        let trace = create_test_trace(vec![span1, span2]);
928        // Both have parent_id = 0, should return the last one found (span_id = 2)
929        let root_idx = sampler.get_root_span_index(&trace).unwrap();
930        assert_eq!(trace.spans()[root_idx].span_id(), 2);
931    }
932
933    #[test]
934    fn single_span_sampling() {
935        let mut sampler = create_test_sampler();
936
937        // Test 1: Trace with SSS tags should be kept even when probabilistic would drop it
938        sampler.sampling_rate = 0.0; // 0% sampling rate - should drop everything
939        sampler.probabilistic_sampler_enabled = true;
940
941        // Create span with SSS metric
942        let mut attrs_map = saluki_common::collections::FastHashMap::default();
943        attrs_map.insert(
944            MetaString::from(KEY_SPAN_SAMPLING_MECHANISM),
945            AttributeValue::Float(8.0),
946        );
947        let sss_span = create_test_span(1, 0).with_attributes(attrs_map.clone());
948
949        // Create regular span without SSS
950        let regular_span = create_test_span(2, 0);
951
952        let mut trace = create_test_trace(vec![sss_span.clone(), regular_span]);
953
954        // Apply SSS
955        let modified = sampler.single_span_sampling(&mut trace);
956        assert!(modified);
957        assert_eq!(trace.spans().len(), 1); // Only SSS span kept
958        assert_eq!(trace.spans()[0].span_id(), 1); // It's the SSS span
959
960        // Check that trace has been marked as kept with high priority
961        assert_eq!(trace.priority, Some(PRIORITY_USER_KEEP));
962
963        // Test 2: Trace without SSS tags should not be modified
964        let trace_without_sss = create_test_trace(vec![create_test_span(3, 0)]);
965        let mut trace_copy = trace_without_sss.clone();
966        let modified = sampler.single_span_sampling(&mut trace_copy);
967        assert!(!modified);
968        assert_eq!(trace_copy.spans().len(), trace_without_sss.spans().len());
969    }
970
971    #[test]
972    fn analytics_events() {
973        let sampler = create_test_sampler();
974
975        // Test 1: Trace with analyzed spans
976        let mut attrs_map = saluki_common::collections::FastHashMap::default();
977        attrs_map.insert(MetaString::from(KEY_ANALYZED_SPANS), AttributeValue::Float(1.0));
978        let analyzed_span = create_test_span(1, 0).with_attributes(attrs_map.clone());
979        let regular_span = create_test_span(2, 0);
980
981        let mut trace = create_test_trace(vec![analyzed_span.clone(), regular_span]);
982
983        let analyzed_span_ids: Vec<u64> = trace
984            .spans()
985            .iter()
986            .filter(|span| span.attributes.contains_key(KEY_ANALYZED_SPANS))
987            .map(|span| span.span_id())
988            .collect();
989        assert_eq!(analyzed_span_ids, vec![1]);
990
991        assert!(sampler.has_analyzed_spans(&trace));
992        let modified = sampler.analyzed_span_sampling(&mut trace);
993        assert!(modified);
994        assert_eq!(trace.spans().len(), 1);
995        assert_eq!(trace.spans()[0].span_id(), 1);
996        assert_eq!(trace.priority, Some(PRIORITY_USER_KEEP));
997
998        // Test 2: Trace without analyzed spans
999        let trace_no_analytics = create_test_trace(vec![create_test_span(3, 0)]);
1000        let mut trace_no_analytics_copy = trace_no_analytics.clone();
1001        let analyzed_span_ids: Vec<u64> = trace_no_analytics
1002            .spans()
1003            .iter()
1004            .filter(|span| span.attributes.contains_key(KEY_ANALYZED_SPANS))
1005            .map(|span| span.span_id())
1006            .collect();
1007        assert!(analyzed_span_ids.is_empty());
1008        assert!(!sampler.has_analyzed_spans(&trace_no_analytics));
1009        let modified = sampler.analyzed_span_sampling(&mut trace_no_analytics_copy);
1010        assert!(!modified);
1011        assert_eq!(trace_no_analytics_copy.spans().len(), trace_no_analytics.spans().len());
1012    }
1013
1014    #[test]
1015    fn probabilistic_sampling_with_prob_rate_key() {
1016        let mut sampler = create_test_sampler();
1017        sampler.sampling_rate = 0.75; // 75% sampling rate
1018        sampler.probabilistic_sampler_enabled = true;
1019
1020        // Use a trace ID that we know will be sampled
1021        let trace_id = 12345_u64;
1022        let root_span = DdSpan::new(
1023            MetaString::from("service"),
1024            MetaString::from("operation"),
1025            MetaString::from("resource"),
1026            MetaString::from("type"),
1027            1,
1028            0, // parent_id = 0 indicates root
1029            0,
1030            1000,
1031            0,
1032        );
1033        let mut trace = create_test_trace(vec![root_span]);
1034        trace.trace_id_low = trace_id;
1035
1036        let (keep, priority, decision_maker, root_span_idx) = sampler.run_samplers(&mut trace);
1037
1038        if keep && decision_maker == DECISION_MAKER_PROBABILISTIC {
1039            // If sampled probabilistically, check that probRateKey was already added
1040            assert_eq!(priority, PRIORITY_AUTO_KEEP);
1041            assert_eq!(decision_maker, DECISION_MAKER_PROBABILISTIC); // probabilistic sampling marker
1042
1043            // Check that the root span already has the probRateKey (it should have been added in run_samplers)
1044            let root_idx = root_span_idx.unwrap_or(0);
1045            let root_span = &trace.spans()[root_idx];
1046            assert!(root_span.attributes.contains_key(PROB_RATE_KEY));
1047            assert_eq!(
1048                root_span
1049                    .attributes
1050                    .get(PROB_RATE_KEY)
1051                    .and_then(AttributeValue::as_float),
1052                Some(0.75)
1053            );
1054
1055            // Test that apply_sampling_metadata still works correctly for other metadata
1056            let mut trace_with_metadata = trace.clone();
1057            sampler.apply_sampling_metadata(&mut trace_with_metadata, keep, priority, decision_maker, root_idx);
1058
1059            // Check that decision maker tag was added
1060            let modified_root = &trace_with_metadata.spans()[root_idx];
1061            assert!(modified_root.attributes.contains_key(TAG_DECISION_MAKER));
1062            assert_eq!(
1063                modified_root
1064                    .attributes
1065                    .get(TAG_DECISION_MAKER)
1066                    .and_then(AttributeValue::as_string),
1067                Some(&MetaString::from(DECISION_MAKER_PROBABILISTIC))
1068            );
1069        }
1070    }
1071
1072    // ── Rare-sampler interaction tests ──────────────────────────────────────────
1073    // Adapted from datadog-agent/pkg/trace/agent/agent_test.go TestSampling cases:
1074    // "rare-sampler-catch-unsampled", "rare-sampler-catch-sampled",
1075    // "rare-sampler-disabled", and related probabilistic path interactions.
1076
1077    /// Create a top-level span eligible for rare sampling.
1078    ///
1079    /// The rare sampler only considers spans that have `_top_level=1` or `_dd.measured=1`.
1080    /// This helper sets `_top_level=1` so that the rare sampler can consider the span.
1081    fn create_top_level_span(span_id: u64) -> DdSpan {
1082        let mut attrs = saluki_common::collections::FastHashMap::default();
1083        attrs.insert(MetaString::from("_top_level"), AttributeValue::Float(1.0));
1084        create_test_span(span_id, 0).with_attributes(attrs)
1085    }
1086
1087    /// Create a `TraceSampler` with the rare sampler enabled and a very high TPS limit so it
1088    /// freely samples first occurrences, plus a long TTL so second occurrences stay within TTL.
1089    fn create_sampler_with_rare_enabled() -> TraceSampler {
1090        TraceSampler {
1091            rare_sampler: rare_sampler::RareSampler::new(true, 1000.0, std::time::Duration::from_secs(300), 200),
1092            ..create_test_sampler()
1093        }
1094    }
1095
1096    /// Adapted from Go "rare-sampler-catch-unsampled":
1097    ///
1098    /// Rare is enabled + probabilistic would drop → rare catches it (first occurrence).
1099    #[test]
1100    fn rare_sampler_catches_unsampled_trace() {
1101        let mut sampler = create_sampler_with_rare_enabled();
1102        sampler.sampling_rate = 0.0; // probabilistic drops everything
1103        sampler.probabilistic_sampler_enabled = true;
1104
1105        let span = create_top_level_span(1);
1106        let mut trace = create_test_trace(vec![span]);
1107
1108        let (keep, priority, decision_maker, _) = sampler.run_samplers(&mut trace);
1109        assert!(keep, "rare sampler should catch first occurrence");
1110        assert_eq!(priority, PRIORITY_AUTO_KEEP);
1111        assert_eq!(decision_maker, "", "rare sampler does not set _dd.p.dm");
1112    }
1113
1114    /// Adapted from Go "rare-sampler-catch-sampled" (first trace):
1115    ///
1116    /// Rare is enabled, first occurrence—trace is kept and `_dd.rare` is set on the span.
1117    #[test]
1118    fn rare_sampler_sets_rare_metric_on_first_occurrence() {
1119        let mut sampler = create_sampler_with_rare_enabled();
1120        sampler.sampling_rate = 0.0;
1121        sampler.probabilistic_sampler_enabled = true;
1122
1123        let span = create_top_level_span(1);
1124        let mut trace = create_test_trace(vec![span]);
1125
1126        let (keep, _, _, root_idx) = sampler.run_samplers(&mut trace);
1127        assert!(keep);
1128        let root = &trace.spans()[root_idx.unwrap()];
1129        assert_eq!(
1130            root.attributes
1131                .get(rare_sampler::RARE_KEY)
1132                .and_then(AttributeValue::as_float),
1133            Some(1.0),
1134            "_dd.rare should be 1 on first occurrence"
1135        );
1136    }
1137
1138    /// Adapted from Go "rare-sampler-catch-sampled" (second trace same signature):
1139    ///
1140    /// Within the TTL, the same signature is no longer "rare" and rare doesn't re-sample it.
1141    /// With probabilistic at 0%, the trace should be dropped.
1142    #[test]
1143    fn rare_sampler_does_not_resample_within_ttl() {
1144        let mut sampler = create_sampler_with_rare_enabled();
1145        sampler.sampling_rate = 0.0;
1146        sampler.probabilistic_sampler_enabled = true;
1147
1148        // First trace: rare catches it.
1149        let span1 = create_top_level_span(1);
1150        let mut trace1 = create_test_trace(vec![span1]);
1151        let (keep1, _, _, _) = sampler.run_samplers(&mut trace1);
1152        assert!(keep1, "first occurrence should be kept by rare sampler");
1153
1154        // Second trace: same signature (same service/operation/resource on the top-level span),
1155        // still within TTL → rare won't catch it; probabilistic at 0% drops it.
1156        let span2 = create_top_level_span(2);
1157        let mut trace2 = create_test_trace(vec![span2]);
1158        let (keep2, priority2, _, _) = sampler.run_samplers(&mut trace2);
1159        assert!(!keep2, "second occurrence within TTL should be dropped");
1160        assert_eq!(priority2, PRIORITY_AUTO_DROP);
1161    }
1162
1163    /// Adapted from Go "rare-sampler-disabled":
1164    ///
1165    /// Rare is disabled + probabilistic at 0% → trace is dropped.
1166    #[test]
1167    fn rare_sampler_disabled_does_not_catch_unsampled() {
1168        let mut sampler = create_test_sampler(); // rare disabled by default
1169        sampler.sampling_rate = 0.0;
1170        sampler.probabilistic_sampler_enabled = true;
1171
1172        let span = create_top_level_span(1);
1173        let mut trace = create_test_trace(vec![span]);
1174
1175        let (keep, priority, _, _) = sampler.run_samplers(&mut trace);
1176        assert!(!keep, "rare disabled should not catch the trace");
1177        assert_eq!(priority, PRIORITY_AUTO_DROP);
1178    }
1179
1180    /// Rare + non-probabilistic path (priority path): rare catches `PriorityAutoDrop` on first
1181    /// occurrence, preserving the tracer-set priority rather than upgrading to AutoKeep.
1182    #[test]
1183    fn rare_sampler_catches_priority_auto_drop_in_legacy_path() {
1184        let mut sampler = create_sampler_with_rare_enabled();
1185        sampler.probabilistic_sampler_enabled = false;
1186
1187        let mut attrs = saluki_common::collections::FastHashMap::default();
1188        attrs.insert(MetaString::from("_top_level"), AttributeValue::Float(1.0));
1189        attrs.insert(
1190            MetaString::from(SAMPLING_PRIORITY_METRIC_KEY),
1191            AttributeValue::Float(PRIORITY_AUTO_DROP as f64),
1192        );
1193        let span = create_test_span(1, 0).with_attributes(attrs);
1194        let mut trace = create_test_trace(vec![span]);
1195
1196        let (keep, priority, decision_maker, _) = sampler.run_samplers(&mut trace);
1197        assert!(keep, "rare sampler should catch PriorityAutoDrop on first occurrence");
1198        assert_eq!(priority, PRIORITY_AUTO_DROP, "tracer-set priority should be preserved");
1199        assert_eq!(decision_maker, "");
1200    }
1201
1202    /// Rare + non-probabilistic path (priority path): UserKeep priority is preserved, not
1203    /// downgraded to AutoKeep. Mirrors Go agent behavior at agent.go#L1129-1131.
1204    #[test]
1205    fn rare_sampler_preserves_user_keep_priority_in_legacy_path() {
1206        let mut sampler = create_sampler_with_rare_enabled();
1207        sampler.probabilistic_sampler_enabled = false;
1208
1209        let mut attrs = saluki_common::collections::FastHashMap::default();
1210        attrs.insert(MetaString::from("_top_level"), AttributeValue::Float(1.0));
1211        attrs.insert(
1212            MetaString::from(SAMPLING_PRIORITY_METRIC_KEY),
1213            AttributeValue::Float(2.0),
1214        ); // UserKeep
1215        let span = create_test_span(1, 0).with_attributes(attrs);
1216        let mut trace = create_test_trace(vec![span]);
1217
1218        let (keep, priority, _, _) = sampler.run_samplers(&mut trace);
1219        assert!(keep);
1220        assert_eq!(priority, 2, "UserKeep priority must not be downgraded to AutoKeep");
1221    }
1222
1223    /// Probabilistic path with 100% rate and rare disabled: keep with `_dd.p.dm = "-9"`.
1224    #[test]
1225    fn probabilistic_100_percent_keeps_trace_with_decision_maker() {
1226        let mut sampler = create_test_sampler(); // rare disabled
1227        sampler.sampling_rate = 1.0;
1228        sampler.probabilistic_sampler_enabled = true;
1229
1230        let span = create_top_level_span(1);
1231        let mut trace = create_test_trace(vec![span]);
1232
1233        let (keep, priority, decision_maker, _) = sampler.run_samplers(&mut trace);
1234        assert!(keep);
1235        assert_eq!(priority, PRIORITY_AUTO_KEEP);
1236        assert_eq!(decision_maker, DECISION_MAKER_PROBABILISTIC);
1237    }
1238
1239    /// Probabilistic path with 0% rate and rare disabled: drop.
1240    #[test]
1241    fn probabilistic_0_percent_drops_trace() {
1242        let mut sampler = create_test_sampler(); // rare disabled
1243        sampler.sampling_rate = 0.0;
1244        sampler.probabilistic_sampler_enabled = true;
1245        sampler.error_sampling_enabled = false;
1246
1247        let span = create_top_level_span(1);
1248        let mut trace = create_test_trace(vec![span]);
1249
1250        let (keep, priority, _, _) = sampler.run_samplers(&mut trace);
1251        assert!(!keep);
1252        assert_eq!(priority, PRIORITY_AUTO_DROP);
1253    }
1254
1255    /// Rare sampler should catch OTLP traces without a sampling priority on their first occurrence,
1256    /// matching the Go agent behavior: https://github.com/DataDog/datadog-agent/blob/main/pkg/trace/agent/agent.go#L1129-L1140
1257    #[test]
1258    fn rare_sampler_catches_otlp_no_priority_trace() {
1259        let mut sampler = create_sampler_with_rare_enabled();
1260        sampler.probabilistic_sampler_enabled = false;
1261        sampler.error_sampling_enabled = false;
1262        sampler.otlp_sampling_rate = 0.0;
1263
1264        let mut span = create_top_level_span(1);
1265        span.attributes.insert(
1266            MetaString::from_static(OTEL_TRACE_ID_META_KEY),
1267            AttributeValue::String(MetaString::from("00000000000000000000000000000001")),
1268        );
1269        let mut trace = create_test_trace(vec![span]);
1270
1271        let (keep, priority, decision_maker, root_idx) = sampler.run_samplers(&mut trace);
1272        assert!(
1273            keep,
1274            "rare sampler should keep OTLP trace with no priority on first occurrence"
1275        );
1276        assert_eq!(priority, PRIORITY_AUTO_KEEP);
1277        assert_eq!(decision_maker, "");
1278        assert_eq!(
1279            trace.spans()[root_idx.unwrap()]
1280                .attributes
1281                .get(rare_sampler::RARE_KEY)
1282                .and_then(AttributeValue::as_float),
1283            Some(1.0),
1284            "_dd.rare should be set to 1 on first occurrence"
1285        );
1286    }
1287
1288    /// Adapted from Go "probabilistic-rare-100":
1289    ///
1290    /// Rare fires before probabilistic is consulted, so even at 100% sampling rate the decision
1291    /// maker tag isn't set—the trace is attributed to rare, not probabilistic.
1292    #[test]
1293    fn rare_wins_over_probabilistic_no_decision_maker_tag() {
1294        let mut sampler = create_sampler_with_rare_enabled();
1295        sampler.sampling_rate = 1.0;
1296        sampler.probabilistic_sampler_enabled = true;
1297
1298        let span = create_top_level_span(1);
1299        let mut trace = create_test_trace(vec![span]);
1300
1301        let (keep, priority, decision_maker, _) = sampler.run_samplers(&mut trace);
1302        assert!(keep);
1303        assert_eq!(priority, PRIORITY_AUTO_KEEP);
1304        assert_eq!(decision_maker, "", "rare takes precedence—_dd.p.dm must not be set");
1305    }
1306
1307    /// Adapted from Go "error-sampled-prio-unsampled":
1308    ///
1309    /// Rare fires before the error sampler is reached. A no-priority error trace on its first
1310    /// occurrence is kept by rare, not by the error sampler.
1311    #[test]
1312    fn rare_catches_error_trace_before_error_sampler() {
1313        let mut sampler = create_sampler_with_rare_enabled();
1314        sampler.probabilistic_sampler_enabled = false;
1315        sampler.error_sampling_enabled = true;
1316
1317        let span = create_top_level_span(1);
1318        let error_span = create_test_span(2, 1); // error=1
1319        let mut trace = create_test_trace(vec![span, error_span]);
1320
1321        let (keep, priority, decision_maker, _) = sampler.run_samplers(&mut trace);
1322        assert!(keep, "rare should catch the trace before the error sampler");
1323        assert_eq!(priority, PRIORITY_AUTO_KEEP);
1324        assert_eq!(decision_maker, "");
1325    }
1326
1327    /// Adapted from Go manual-drop short-circuit behavior:
1328    ///
1329    /// UserDrop (-1) priority is checked before rare runs in the priority path. A UserDrop trace
1330    /// must be dropped even when rare is enabled and would otherwise match.
1331    #[test]
1332    fn manual_drop_short_circuits_before_rare() {
1333        let mut sampler = create_sampler_with_rare_enabled();
1334        sampler.probabilistic_sampler_enabled = false;
1335
1336        let mut attrs = saluki_common::collections::FastHashMap::default();
1337        attrs.insert(MetaString::from("_top_level"), AttributeValue::Float(1.0));
1338        attrs.insert(
1339            MetaString::from(SAMPLING_PRIORITY_METRIC_KEY),
1340            AttributeValue::Float(-1.0),
1341        ); // UserDrop
1342        let span = create_test_span(1, 0).with_attributes(attrs);
1343        let mut trace = create_test_trace(vec![span]);
1344
1345        let (keep, priority, _, _) = sampler.run_samplers(&mut trace);
1346        assert!(!keep, "UserDrop must be dropped even when rare would match");
1347        assert_eq!(priority, -1);
1348    }
1349
1350    // ── Error Tracking Standalone tests ─────────────────────────────────────────
1351    // Adapted from datadog-agent/pkg/trace/agent/agent.go runSamplers ETS block.
1352
1353    fn create_sampler_with_ets() -> TraceSampler {
1354        TraceSampler {
1355            error_tracking_standalone: true,
1356            ..create_test_sampler()
1357        }
1358    }
1359
1360    /// ETS enabled + trace with error → kept by error sampler.
1361    #[test]
1362    fn ets_keeps_trace_with_error() {
1363        let mut sampler = create_sampler_with_ets();
1364
1365        let span = create_test_span(1, 1); // error=1
1366        let mut trace = create_test_trace(vec![span]);
1367
1368        let (keep, priority, decision_maker, _) = sampler.run_samplers(&mut trace);
1369        assert!(keep, "ETS should keep traces with errors");
1370        assert_eq!(priority, PRIORITY_AUTO_KEEP);
1371        assert_eq!(decision_maker, "", "ETS does not set a decision maker");
1372    }
1373
1374    /// ETS enabled + trace without error → dropped; rare/probabilistic/priority not consulted.
1375    #[test]
1376    fn ets_drops_trace_without_error() {
1377        let mut sampler = create_sampler_with_ets();
1378
1379        let span = create_test_span(1, 0); // error=0
1380        let mut trace = create_test_trace(vec![span]);
1381
1382        let (keep, priority, _, _) = sampler.run_samplers(&mut trace);
1383        assert!(!keep, "ETS should drop traces without errors");
1384        assert_eq!(priority, PRIORITY_AUTO_DROP);
1385    }
1386
1387    /// ETS enabled + non-error trace → forwarded with DroppedTrace=true; SSS/analytics suppressed.
1388    #[test]
1389    fn ets_forwards_dropped_trace_with_dropped_flag() {
1390        let mut sampler = create_sampler_with_ets();
1391
1392        // Span with SSS metric — would trigger single span sampling in non-ETS mode.
1393        let mut attrs = saluki_common::collections::FastHashMap::default();
1394        attrs.insert(
1395            MetaString::from(KEY_SPAN_SAMPLING_MECHANISM),
1396            AttributeValue::Float(8.0),
1397        );
1398        let span = create_test_span(1, 0).with_attributes(attrs);
1399        let mut trace = create_test_trace(vec![span]);
1400
1401        let forwarded = sampler.process_trace(&mut trace);
1402        assert!(forwarded, "ETS should forward non-error traces to intake");
1403        assert!(trace.dropped_trace, "non-error ETS trace should have DroppedTrace=true");
1404    }
1405
1406    /// ETS enabled + trace with exception span event → kept (exception events count as errors in ETS).
1407    #[test]
1408    fn ets_keeps_trace_with_exception_span_event() {
1409        let mut sampler = create_sampler_with_ets();
1410
1411        // Span with error=0 but exception span event metadata.
1412        let mut attrs = saluki_common::collections::FastHashMap::default();
1413        attrs.insert(
1414            MetaString::from("_dd.span_events.has_exception"),
1415            AttributeValue::String(MetaString::from("true")),
1416        );
1417        let span = create_test_span(1, 0).with_attributes(attrs);
1418        let mut trace = create_test_trace(vec![span]);
1419
1420        let (keep, _, _, _) = sampler.run_samplers(&mut trace);
1421        assert!(keep, "ETS should treat exception span events as errors");
1422    }
1423
1424    /// ETS disabled → normal sampling path (probabilistic) is used.
1425    #[test]
1426    fn ets_disabled_uses_normal_sampling() {
1427        let mut sampler = create_test_sampler(); // ETS disabled
1428        sampler.sampling_rate = 1.0;
1429        sampler.probabilistic_sampler_enabled = true;
1430
1431        let span = create_test_span(1, 0); // no error
1432        let mut trace = create_test_trace(vec![span]);
1433
1434        let (keep, _, decision_maker, _) = sampler.run_samplers(&mut trace);
1435        assert!(keep, "normal probabilistic sampling should keep the trace");
1436        assert_eq!(decision_maker, DECISION_MAKER_PROBABILISTIC);
1437    }
1438
1439    // ── ETS + OTLP pre-sampling tests ────────────────────────────────────────────
1440    // These mirror DDA's OTLPReceiver.createChunks behavior which pre-assigns
1441    // priority/dm before runSamplersV1, so ETS sees those values even when it
1442    // short-circuits. See: pkg/trace/api/otlp.go#L561-L585.
1443
1444    fn create_otlp_test_span(span_id: u64, error: i32) -> DdSpan {
1445        let mut attrs = saluki_common::collections::FastHashMap::default();
1446        attrs.insert(
1447            MetaString::from_static(OTEL_TRACE_ID_META_KEY),
1448            AttributeValue::String(MetaString::from("0000000000000000deadbeefcafebabe")),
1449        );
1450        create_test_span(span_id, error).with_attributes(attrs)
1451    }
1452
1453    fn create_sampler_with_ets_legacy() -> TraceSampler {
1454        TraceSampler {
1455            error_tracking_standalone: true,
1456            probabilistic_sampler_enabled: false,
1457            otlp_sampling_rate: 1.0,
1458            ..create_test_sampler()
1459        }
1460    }
1461
1462    /// ETS + OTLP non-error trace (legacy sampler path): pre-sampling sets priority=AutoKeep and dm=-9.
1463    /// Mirrors DDA `OTLPReceiver` assigning priority=1 + dm=-9 before ETS returns early.
1464    #[test]
1465    fn ets_otlp_non_error_gets_presample_priority_and_dm() {
1466        let mut sampler = create_sampler_with_ets_legacy();
1467
1468        let span = create_otlp_test_span(1, 0); // no error
1469        let mut trace = create_test_trace(vec![span]);
1470
1471        let (keep, priority, dm, _) = sampler.run_samplers(&mut trace);
1472        assert!(!keep, "ETS should drop non-error OTLP traces");
1473        assert_eq!(
1474            priority, PRIORITY_AUTO_KEEP,
1475            "OTLP pre-sampling sets priority=AutoKeep even for ETS-dropped traces"
1476        );
1477        assert_eq!(dm, DECISION_MAKER_PROBABILISTIC, "OTLP pre-sampling sets dm=-9");
1478    }
1479
1480    /// ETS + OTLP error trace (legacy sampler path): pre-sampling sets priority=AutoKeep and dm=-9.
1481    #[test]
1482    fn ets_otlp_error_gets_presample_priority_and_dm() {
1483        let mut sampler = create_sampler_with_ets_legacy();
1484
1485        let span = create_otlp_test_span(1, 1); // error=1
1486        let mut trace = create_test_trace(vec![span]);
1487
1488        let (keep, priority, dm, _) = sampler.run_samplers(&mut trace);
1489        assert!(keep, "ETS should keep error OTLP traces");
1490        assert_eq!(priority, PRIORITY_AUTO_KEEP, "OTLP pre-sampling sets priority=AutoKeep");
1491        assert_eq!(dm, DECISION_MAKER_PROBABILISTIC, "OTLP pre-sampling sets dm=-9");
1492    }
1493
1494    /// ETS + OTLP + probabilistic_sampler_enabled=true: `OTLPReceiver` defers, no pre-sampling.
1495    /// DDA's `OTLPReceiver` sets PriorityNone and skips when ProbabilisticSamplerEnabled.
1496    #[test]
1497    fn ets_otlp_probabilistic_path_skips_presample() {
1498        let mut sampler = create_sampler_with_ets_legacy();
1499        sampler.probabilistic_sampler_enabled = true; // override to prob path
1500
1501        let span = create_otlp_test_span(1, 0); // no error
1502        let mut trace = create_test_trace(vec![span]);
1503
1504        let (keep, priority, dm, _) = sampler.run_samplers(&mut trace);
1505        assert!(!keep, "ETS should drop non-error traces");
1506        assert_eq!(
1507            priority, PRIORITY_AUTO_DROP,
1508            "no pre-sampling when probabilistic path active"
1509        );
1510        assert_eq!(dm, "", "no dm when probabilistic path active");
1511    }
1512
1513    /// ETS + non-OTLP trace (legacy sampler path): behavior unchanged—no pre-sampling.
1514    #[test]
1515    fn ets_non_otlp_unaffected_by_presample() {
1516        let mut sampler = create_sampler_with_ets_legacy();
1517
1518        let span = create_test_span(1, 0); // no error, no OTLP meta
1519        let mut trace = create_test_trace(vec![span]);
1520
1521        let (keep, priority, dm, _) = sampler.run_samplers(&mut trace);
1522        assert!(!keep, "ETS should drop non-error non-OTLP traces");
1523        assert_eq!(priority, PRIORITY_AUTO_DROP, "non-OTLP traces use default ETS priority");
1524        assert_eq!(dm, "", "non-OTLP traces get no dm");
1525    }
1526
1527    /// ETS + OTLP trace with user-set priority: dm="-4" (manual sampling), matching DDA.
1528    #[test]
1529    fn ets_otlp_user_priority_gets_manual_dm() {
1530        let mut sampler = create_sampler_with_ets_legacy();
1531
1532        let mut span = create_otlp_test_span(1, 0);
1533        span.attributes.insert(
1534            MetaString::from(SAMPLING_PRIORITY_METRIC_KEY),
1535            AttributeValue::Float(2.0),
1536        ); // UserKeep
1537        let mut trace = create_test_trace(vec![span]);
1538
1539        let (keep, priority, dm, _) = sampler.run_samplers(&mut trace);
1540        assert!(!keep, "ETS drops non-error traces regardless of user priority");
1541        assert_eq!(priority, PRIORITY_USER_KEEP, "user priority is preserved");
1542        assert_eq!(dm, DECISION_MAKER_MANUAL, "user-set priority gets dm=-4");
1543    }
1544}