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