Skip to main content

plecto_host/
otlp.rs

1//! ADR 000040: the OTLP trace-export half of host observability. Two pieces, both
2//! dependency-less (the same reasoning as the hand-written Prometheus exposition — the trace
3//! protos are Stable/frozen in opentelemetry-proto, so field numbers never change):
4//!
5//! - [`OtlpBuffer`] — a bounded, pull-based span queue. It IS a [`TelemetrySink`] (filter spans
6//!   arrive through the existing seam) and exposes [`push`](OtlpBuffer::push) for the fast
7//!   path's request span + [`drain`](OtlpBuffer::drain) for an external pump. The producer side
8//!   never blocks and never grows unboundedly: at capacity the incoming span is dropped and
9//!   counted (drop-newest, the shape every OTel SDK batch processor converges on).
10//! - The wire encoding — a hand-written protobuf encoder for
11//!   `ExportTraceServiceRequest` (opentelemetry-proto v1, trace signal) and a fail-soft decoder
12//!   for the response's `partial_success`. Verified in tests against the official generated
13//!   types (`opentelemetry-proto`, dev-dependency only).
14//!
15//! The network pump itself (batching tick, retry, shutdown flush) lives in the fast-path server
16//! — the host stays runtime-free; this module is pure data + bytes.
17
18use std::collections::VecDeque;
19use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
20use std::time::SystemTime;
21
22use opentelemetry::trace::{Event, SpanId, SpanKind, Status, TraceId};
23use opentelemetry::{Array, KeyValue, Value};
24use parking_lot::Mutex;
25
26use crate::observe::{FilterSpan, TelemetrySink};
27
28/// OTel batch-processor spec default (`OTEL_BSP_MAX_QUEUE_SIZE`).
29pub const DEFAULT_QUEUE_CAPACITY: usize = 2048;
30
31/// One span in the OTel data model, ready for OTLP encoding — the union shape behind both a
32/// [`FilterSpan`] (INTERNAL, always a local parent) and the fast path's request span (SERVER,
33/// possibly a remote parent from an inbound `traceparent`).
34#[derive(Debug, Clone)]
35pub struct SpanRecord {
36    pub trace_id: TraceId,
37    pub span_id: SpanId,
38    /// `None` for a trace root (the OTLP field is then omitted entirely).
39    pub parent_span_id: Option<SpanId>,
40    pub name: String,
41    pub kind: SpanKind,
42    pub start_time: SystemTime,
43    pub end_time: SystemTime,
44    pub status: Status,
45    pub attributes: Vec<KeyValue>,
46    pub events: Vec<Event>,
47    /// Whether the parent context arrived over the wire (inbound `traceparent`) — sets the
48    /// `Span.flags` CONTEXT_IS_REMOTE bit so backends render the service boundary correctly.
49    pub remote_parent: bool,
50    pub sampled: bool,
51}
52
53impl SpanRecord {
54    /// Build the fast path's request (SERVER) span — the root every filter span and the
55    /// upstream's own spans nest under (ADR 000040). Semconv HTTP-server shape: named by the
56    /// method, `Error` status only for 5xx (a 4xx is the client's fault, not the server's).
57    #[allow(clippy::too_many_arguments)]
58    pub fn request_span(
59        trace: &crate::observe::RequestTrace,
60        method: &str,
61        path: &str,
62        scheme: &str,
63        status_code: u16,
64        start_time: SystemTime,
65        duration: std::time::Duration,
66    ) -> Self {
67        let status = if status_code >= 500 {
68            Status::Error {
69                description: std::borrow::Cow::Borrowed(""),
70            }
71        } else {
72            Status::Unset
73        };
74        Self {
75            trace_id: trace.trace_id(),
76            span_id: trace.request_span_id(),
77            parent_span_id: trace.parent_span_id(),
78            name: method.to_string(),
79            kind: SpanKind::Server,
80            start_time,
81            end_time: start_time + duration,
82            status,
83            attributes: vec![
84                KeyValue::new("http.request.method", method.to_string()),
85                KeyValue::new("url.path", path.to_string()),
86                KeyValue::new("url.scheme", scheme.to_string()),
87                KeyValue::new("http.response.status_code", i64::from(status_code)),
88            ],
89            events: vec![],
90            remote_parent: trace.parent_span_id().is_some(),
91            sampled: trace.is_sampled(),
92        }
93    }
94}
95
96impl From<&FilterSpan> for SpanRecord {
97    fn from(span: &FilterSpan) -> Self {
98        Self {
99            trace_id: span.trace_id,
100            span_id: span.span_id,
101            parent_span_id: Some(span.parent_span_id),
102            name: span.name.clone(),
103            kind: span.kind.clone(),
104            start_time: span.start_time,
105            end_time: span.start_time + span.duration,
106            status: span.status(),
107            attributes: span.attributes.clone(),
108            events: span.events.clone(),
109            remote_parent: false,
110            sampled: span.sampled,
111        }
112    }
113}
114
115/// The bounded span queue between the sync data plane and the async OTLP pump. Producers
116/// ([`TelemetrySink::export`] for filter spans, [`push`](Self::push) for the request span) take
117/// one uncontended mutex acquisition; the pump [`drain`](Self::drain)s in batches under the same
118/// lock and does all encoding/IO outside it. At capacity the INCOMING span is dropped and
119/// tallied — the data plane is never back-pressured by a slow collector (fail-open for
120/// telemetry, the inverse of the filter chain's fail-closed).
121pub struct OtlpBuffer {
122    queue: Mutex<VecDeque<SpanRecord>>,
123    capacity: usize,
124    dropped: AtomicU64,
125    drop_logged: AtomicBool,
126}
127
128impl OtlpBuffer {
129    pub fn new(capacity: usize) -> Self {
130        Self {
131            // Pre-allocate the FULL configured capacity, not `DEFAULT_QUEUE_CAPACITY` — `push`
132            // enforces `capacity` itself (below), so under-allocating here just means the queue
133            // reallocates mid-flight the first time a burst pushes it past 2048, for any operator
134            // who configured a larger capacity to ride out a longer collector outage.
135            queue: Mutex::new(VecDeque::with_capacity(capacity)),
136            capacity,
137            dropped: AtomicU64::new(0),
138            drop_logged: AtomicBool::new(false),
139        }
140    }
141
142    /// Enqueue one span; at capacity the span is dropped and counted (never blocks). Logs a
143    /// single warning on the first drop (the otel-rust convention — one line, not one per span),
144    /// leaving the running total to the dropped-spans counter.
145    pub fn push(&self, record: SpanRecord) {
146        {
147            let mut queue = self.queue.lock();
148            if queue.len() < self.capacity {
149                queue.push_back(record);
150                return;
151            }
152        }
153        self.dropped.fetch_add(1, Ordering::Relaxed);
154        if !self.drop_logged.swap(true, Ordering::Relaxed) {
155            tracing::warn!(
156                capacity = self.capacity,
157                "OTLP span queue full; dropping spans (further drops are counted, not logged)"
158            );
159        }
160    }
161
162    /// Dequeue up to `max` spans, FIFO. O(batch) under the lock — encoding and network IO
163    /// belong to the caller, outside it.
164    pub fn drain(&self, max: usize) -> Vec<SpanRecord> {
165        let mut queue = self.queue.lock();
166        let n = queue.len().min(max);
167        queue.drain(..n).collect()
168    }
169
170    pub fn len(&self) -> usize {
171        self.queue.lock().len()
172    }
173
174    pub fn is_empty(&self) -> bool {
175        self.queue.lock().is_empty()
176    }
177
178    /// Spans lost so far: queue-full drops plus batches the pump gave up on (`record_dropped`).
179    pub fn dropped_spans(&self) -> u64 {
180        self.dropped.load(Ordering::Relaxed)
181    }
182
183    /// Tally spans the pump dropped after export gave up (non-retryable failure / retries
184    /// exhausted), so one counter covers every loss path.
185    pub fn record_dropped(&self, n: u64) {
186        self.dropped.fetch_add(n, Ordering::Relaxed);
187    }
188}
189
190impl Default for OtlpBuffer {
191    fn default() -> Self {
192        Self::new(DEFAULT_QUEUE_CAPACITY)
193    }
194}
195
196impl TelemetrySink for OtlpBuffer {
197    fn export(&self, span: &FilterSpan) {
198        // Honour the W3C sampled flag: an unsampled transaction exports nothing (the caller's
199        // SDK already decided). Tally sinks (metrics) see every span; only export skips.
200        if !span.sampled {
201            return;
202        }
203        self.push(span.into());
204    }
205}
206
207// --- OTLP protobuf encoding ------------------------------------------------------------------
208//
209// Hand-written proto3 wire format against opentelemetry-proto's STABLE trace/common/resource
210// protos (field types, numbers and names are frozen — the crate's maturity guarantee). Field
211// numbers below cite trace.proto / common.proto / resource.proto / trace_service.proto @ v1.
212// proto3 rules honoured here: scalar fields at their default (0 / "" / empty bytes) are
213// omitted, EXCEPT inside a oneof (`AnyValue.value`), where presence is explicit.
214
215const WIRE_VARINT: u32 = 0;
216const WIRE_I64: u32 = 1;
217const WIRE_LEN: u32 = 2;
218const WIRE_I32: u32 = 5;
219
220fn put_varint(buf: &mut Vec<u8>, mut v: u64) {
221    loop {
222        let byte = (v & 0x7f) as u8;
223        v >>= 7;
224        if v == 0 {
225            buf.push(byte);
226            return;
227        }
228        buf.push(byte | 0x80);
229    }
230}
231
232fn put_tag(buf: &mut Vec<u8>, field: u32, wire: u32) {
233    put_varint(buf, u64::from((field << 3) | wire));
234}
235
236/// A length-delimited field (string / bytes / embedded message). Emits even when empty — callers
237/// that want proto3 default-omission guard themselves.
238fn put_len(buf: &mut Vec<u8>, field: u32, bytes: &[u8]) {
239    put_tag(buf, field, WIRE_LEN);
240    put_varint(buf, bytes.len() as u64);
241    buf.extend_from_slice(bytes);
242}
243
244fn put_str(buf: &mut Vec<u8>, field: u32, s: &str) {
245    if !s.is_empty() {
246        put_len(buf, field, s.as_bytes());
247    }
248}
249
250fn put_u64(buf: &mut Vec<u8>, field: u32, v: u64) {
251    if v != 0 {
252        put_tag(buf, field, WIRE_VARINT);
253        put_varint(buf, v);
254    }
255}
256
257fn put_fixed64(buf: &mut Vec<u8>, field: u32, v: u64) {
258    if v != 0 {
259        put_tag(buf, field, WIRE_I64);
260        buf.extend_from_slice(&v.to_le_bytes());
261    }
262}
263
264fn put_fixed32(buf: &mut Vec<u8>, field: u32, v: u32) {
265    if v != 0 {
266        put_tag(buf, field, WIRE_I32);
267        buf.extend_from_slice(&v.to_le_bytes());
268    }
269}
270
271fn unix_nanos(t: SystemTime) -> u64 {
272    t.duration_since(SystemTime::UNIX_EPOCH)
273        .map(|d| d.as_nanos() as u64)
274        .unwrap_or(0)
275}
276
277/// common.proto `AnyValue`: oneof value { string_value = 1; bool_value = 2; int_value = 3;
278/// double_value = 4; array_value = 5; ... }. Oneof presence is explicit — defaults ARE emitted.
279fn encode_any_value(value: &Value) -> Vec<u8> {
280    let mut buf = Vec::new();
281    match value {
282        Value::String(s) => put_len(&mut buf, 1, s.as_str().as_bytes()),
283        Value::Bool(b) => {
284            put_tag(&mut buf, 2, WIRE_VARINT);
285            put_varint(&mut buf, u64::from(*b));
286        }
287        Value::I64(i) => {
288            put_tag(&mut buf, 3, WIRE_VARINT);
289            put_varint(&mut buf, *i as u64);
290        }
291        Value::F64(f) => {
292            put_tag(&mut buf, 4, WIRE_I64);
293            buf.extend_from_slice(&f.to_bits().to_le_bytes());
294        }
295        Value::Array(array) => {
296            // ArrayValue { repeated AnyValue values = 1; }
297            let mut inner = Vec::new();
298            let one = |v: Value| encode_any_value(&v);
299            match array {
300                Array::Bool(items) => {
301                    for b in items {
302                        put_len(&mut inner, 1, &one(Value::Bool(*b)));
303                    }
304                }
305                Array::I64(items) => {
306                    for i in items {
307                        put_len(&mut inner, 1, &one(Value::I64(*i)));
308                    }
309                }
310                Array::F64(items) => {
311                    for f in items {
312                        put_len(&mut inner, 1, &one(Value::F64(*f)));
313                    }
314                }
315                Array::String(items) => {
316                    for s in items {
317                        put_len(&mut inner, 1, &one(Value::String(s.clone())));
318                    }
319                }
320                // `Array` is #[non_exhaustive] upstream; a future variant encodes as an empty
321                // array rather than breaking the build (telemetry is fail-open).
322                _ => {}
323            }
324            put_len(&mut buf, 5, &inner);
325        }
326        // `Value` is #[non_exhaustive] upstream; encode unknown kinds as their debug string
327        // rather than dropping the attribute silently.
328        other => put_len(&mut buf, 1, format!("{other:?}").as_bytes()),
329    }
330    buf
331}
332
333/// common.proto `KeyValue { string key = 1; AnyValue value = 2; }`.
334fn encode_key_value(kv: &KeyValue) -> Vec<u8> {
335    let mut buf = Vec::new();
336    put_str(&mut buf, 1, kv.key.as_str());
337    put_len(&mut buf, 2, &encode_any_value(&kv.value));
338    buf
339}
340
341/// trace.proto `Span.Event { fixed64 time_unix_nano = 1; string name = 2;
342/// repeated KeyValue attributes = 3; }`.
343fn encode_event(event: &Event) -> Vec<u8> {
344    let mut buf = Vec::new();
345    put_fixed64(&mut buf, 1, unix_nanos(event.timestamp));
346    put_str(&mut buf, 2, &event.name);
347    for kv in &event.attributes {
348        put_len(&mut buf, 3, &encode_key_value(kv));
349    }
350    buf
351}
352
353/// trace.proto `SpanKind`: UNSPECIFIED = 0, INTERNAL = 1, SERVER = 2, CLIENT = 3,
354/// PRODUCER = 4, CONSUMER = 5.
355fn kind_value(kind: &SpanKind) -> u64 {
356    match kind {
357        SpanKind::Internal => 1,
358        SpanKind::Server => 2,
359        SpanKind::Client => 3,
360        SpanKind::Producer => 4,
361        SpanKind::Consumer => 5,
362    }
363}
364
365/// trace.proto `Span.flags` (fixed32): low byte = W3C trace flags (bit 0 sampled); bit 8 =
366/// "is_remote is known"; bit 9 = "parent is remote". The host always knows remoteness, so bit 8
367/// is always set.
368fn span_flags(record: &SpanRecord) -> u32 {
369    let sampled = u32::from(record.sampled);
370    let remote = if record.remote_parent { 0x200 } else { 0 };
371    0x100 | remote | sampled
372}
373
374/// trace.proto `Status { string message = 2; StatusCode code = 3; }` (field 1 is reserved).
375/// `Unset` yields `None` — the whole Status message is omitted.
376fn encode_status(status: &Status) -> Option<Vec<u8>> {
377    let mut buf = Vec::new();
378    match status {
379        Status::Unset => return None,
380        Status::Ok => put_u64(&mut buf, 3, 1),
381        Status::Error { description } => {
382            put_str(&mut buf, 2, description);
383            put_u64(&mut buf, 3, 2);
384        }
385    }
386    Some(buf)
387}
388
389/// trace.proto `Span`: trace_id = 1, span_id = 2, parent_span_id = 4, name = 5, kind = 6,
390/// start_time_unix_nano = 7 (fixed64), end_time_unix_nano = 8 (fixed64), attributes = 9,
391/// events = 11, status = 15, flags = 16 (fixed32).
392fn encode_span(record: &SpanRecord) -> Vec<u8> {
393    let mut buf = Vec::new();
394    put_len(&mut buf, 1, &record.trace_id.to_bytes());
395    put_len(&mut buf, 2, &record.span_id.to_bytes());
396    if let Some(parent) = record.parent_span_id {
397        put_len(&mut buf, 4, &parent.to_bytes());
398    }
399    put_str(&mut buf, 5, &record.name);
400    put_u64(&mut buf, 6, kind_value(&record.kind));
401    put_fixed64(&mut buf, 7, unix_nanos(record.start_time));
402    put_fixed64(&mut buf, 8, unix_nanos(record.end_time));
403    for kv in &record.attributes {
404        put_len(&mut buf, 9, &encode_key_value(kv));
405    }
406    for event in &record.events {
407        put_len(&mut buf, 11, &encode_event(event));
408    }
409    if let Some(status) = encode_status(&record.status) {
410        put_len(&mut buf, 15, &status);
411    }
412    put_fixed32(&mut buf, 16, span_flags(record));
413    buf
414}
415
416fn string_attr(key: &str, value: &str) -> Vec<u8> {
417    encode_key_value(&KeyValue::new(key.to_string(), value.to_string()))
418}
419
420/// Encode one `ExportTraceServiceRequest` (trace_service.proto: `repeated ResourceSpans
421/// resource_spans = 1`) carrying every span under a single Resource + InstrumentationScope.
422///
423/// Resource identity (semconv): `service.name` is the operator-visible name; `telemetry.sdk.*`
424/// identifies this hand-written exporter as its own SDK (semconv forbids claiming
425/// `opentelemetry` for a non-official implementation).
426pub fn encode_traces_request(service_name: &str, spans: &[SpanRecord]) -> Vec<u8> {
427    // resource.proto Resource { repeated KeyValue attributes = 1; }
428    let mut resource = Vec::new();
429    put_len(&mut resource, 1, &string_attr("service.name", service_name));
430    put_len(
431        &mut resource,
432        1,
433        &string_attr("telemetry.sdk.name", "plecto"),
434    );
435    put_len(
436        &mut resource,
437        1,
438        &string_attr("telemetry.sdk.language", "rust"),
439    );
440    put_len(
441        &mut resource,
442        1,
443        &string_attr("telemetry.sdk.version", env!("CARGO_PKG_VERSION")),
444    );
445
446    // common.proto InstrumentationScope { string name = 1; string version = 2; }
447    let mut scope = Vec::new();
448    put_str(&mut scope, 1, "plecto");
449    put_str(&mut scope, 2, env!("CARGO_PKG_VERSION"));
450
451    // trace.proto ScopeSpans { scope = 1; repeated Span spans = 2; }
452    let mut scope_spans = Vec::new();
453    put_len(&mut scope_spans, 1, &scope);
454    for record in spans {
455        put_len(&mut scope_spans, 2, &encode_span(record));
456    }
457
458    // trace.proto ResourceSpans { resource = 1; repeated ScopeSpans scope_spans = 2; }
459    let mut resource_spans = Vec::new();
460    put_len(&mut resource_spans, 1, &resource);
461    put_len(&mut resource_spans, 2, &scope_spans);
462
463    let mut request = Vec::new();
464    put_len(&mut request, 1, &resource_spans);
465    request
466}
467
468/// A collector's partial rejection, from `ExportTraceServiceResponse.partial_success`
469/// (trace_service.proto: `partial_success = 1 { int64 rejected_spans = 1;
470/// string error_message = 2; }`). Per the OTLP spec the client MUST NOT retry these — log and
471/// move on.
472#[derive(Debug, Clone, PartialEq, Eq)]
473pub struct PartialSuccess {
474    pub rejected_spans: i64,
475    pub error_message: String,
476}
477
478/// Fail-soft decode of a 200 response body: `Some` only when the collector populated
479/// `partial_success` with actual content. Malformed bytes yield `None` (an exporter never
480/// panics on what a network peer sent).
481pub fn decode_export_partial_success(body: &[u8]) -> Option<PartialSuccess> {
482    let inner = read_fields(body, |field, payload| match (field, payload) {
483        (1, FieldPayload::Len(bytes)) => Some(bytes.to_vec()),
484        _ => None,
485    })?;
486    let inner = inner?;
487    let mut rejected: i64 = 0;
488    let mut message = String::new();
489    read_fields(&inner, |field, payload| {
490        match (field, payload) {
491            (1, FieldPayload::Varint(v)) => rejected = v as i64,
492            (2, FieldPayload::Len(bytes)) => {
493                message = String::from_utf8_lossy(bytes).into_owned();
494            }
495            _ => {}
496        }
497        None::<()>
498    })?;
499    if rejected == 0 && message.is_empty() {
500        return None;
501    }
502    Some(PartialSuccess {
503        rejected_spans: rejected,
504        error_message: message,
505    })
506}
507
508enum FieldPayload<'a> {
509    Varint(u64),
510    Len(&'a [u8]),
511}
512
513/// Walk one protobuf message's fields, calling `visit` per field; returns the first `Some` the
514/// visitor yields (wrapped), or `Some(None)` after a clean walk, or `None` on malformed input.
515fn read_fields<'a, T>(
516    mut bytes: &'a [u8],
517    mut visit: impl FnMut(u32, FieldPayload<'a>) -> Option<T>,
518) -> Option<Option<T>> {
519    while !bytes.is_empty() {
520        let (tag, rest) = read_varint(bytes)?;
521        let field = (tag >> 3) as u32;
522        let wire = (tag & 0x7) as u32;
523        bytes = rest;
524        let payload = match wire {
525            WIRE_VARINT => {
526                let (v, rest) = read_varint(bytes)?;
527                bytes = rest;
528                FieldPayload::Varint(v)
529            }
530            WIRE_I64 => {
531                let v = u64::from_le_bytes(bytes.get(..8)?.try_into().ok()?);
532                bytes = bytes.get(8..)?;
533                FieldPayload::Varint(v)
534            }
535            WIRE_LEN => {
536                let (len, rest) = read_varint(bytes)?;
537                let len = usize::try_from(len).ok()?;
538                let payload = rest.get(..len)?;
539                bytes = rest.get(len..)?;
540                FieldPayload::Len(payload)
541            }
542            WIRE_I32 => {
543                let v = u32::from_le_bytes(bytes.get(..4)?.try_into().ok()?);
544                bytes = bytes.get(4..)?;
545                FieldPayload::Varint(u64::from(v))
546            }
547            _ => return None,
548        };
549        if let Some(t) = visit(field, payload) {
550            return Some(Some(t));
551        }
552    }
553    Some(None)
554}
555
556fn read_varint(bytes: &[u8]) -> Option<(u64, &[u8])> {
557    let mut value: u64 = 0;
558    for (i, byte) in bytes.iter().enumerate().take(10) {
559        value |= u64::from(byte & 0x7f) << (7 * i);
560        if byte & 0x80 == 0 {
561            return Some((value, bytes.get(i + 1..)?));
562        }
563    }
564    None
565}
566
567#[cfg(test)]
568mod tests {
569    use std::time::Duration;
570
571    use opentelemetry_proto::tonic::collector::trace::v1::{
572        ExportTracePartialSuccess, ExportTraceServiceRequest, ExportTraceServiceResponse,
573    };
574    use opentelemetry_proto::tonic::common::v1::any_value::Value as ProtoValue;
575    use opentelemetry_proto::tonic::trace::v1::status::StatusCode as ProtoStatusCode;
576    use prost::Message;
577
578    use super::*;
579    use crate::observe::{Hook, RequestTrace, SpanOutcome, build_filter_span};
580    use crate::{Isolation, LogLevel, LogLine};
581
582    fn record(name: &str) -> SpanRecord {
583        let start = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
584        SpanRecord {
585            trace_id: TraceId::from_bytes([1; 16]),
586            span_id: SpanId::from_bytes([2; 8]),
587            parent_span_id: None,
588            name: name.to_string(),
589            kind: SpanKind::Server,
590            start_time: start,
591            end_time: start + Duration::from_millis(5),
592            status: Status::Unset,
593            attributes: vec![],
594            events: vec![],
595            remote_parent: false,
596            sampled: true,
597        }
598    }
599
600    /// The oracle: decode our hand-encoded bytes with the OFFICIAL generated types.
601    fn decode(bytes: &[u8]) -> ExportTraceServiceRequest {
602        ExportTraceServiceRequest::decode(bytes).expect("official decoder accepts our bytes")
603    }
604
605    #[test]
606    fn round_trips_a_full_span_through_the_official_decoder() {
607        let start = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
608        let rec = SpanRecord {
609            trace_id: TraceId::from_bytes([0xAB; 16]),
610            span_id: SpanId::from_bytes([0xCD; 8]),
611            parent_span_id: Some(SpanId::from_bytes([0xEF; 8])),
612            name: "GET".to_string(),
613            kind: SpanKind::Server,
614            start_time: start,
615            end_time: start + Duration::from_millis(12),
616            status: Status::Error {
617                description: "upstream 502".into(),
618            },
619            attributes: vec![
620                KeyValue::new("http.request.method", "GET"),
621                KeyValue::new("http.response.status_code", 502_i64),
622                KeyValue::new("plecto.pi", 3.5_f64),
623                KeyValue::new("plecto.flag", true),
624                KeyValue::new(
625                    "plecto.list",
626                    Value::Array(Array::String(vec!["a".into(), "b".into()])),
627                ),
628            ],
629            events: vec![Event::new(
630                "auth denied",
631                start,
632                vec![KeyValue::new("log.level", "warn")],
633                0,
634            )],
635            remote_parent: true,
636            sampled: true,
637        };
638
639        let decoded = decode(&encode_traces_request("plecto", &[rec]));
640
641        let resource = decoded.resource_spans[0].resource.as_ref().unwrap();
642        let attr = |key: &str| {
643            resource
644                .attributes
645                .iter()
646                .find(|kv| kv.key == key)
647                .and_then(|kv| kv.value.as_ref())
648                .and_then(|v| v.value.as_ref())
649                .cloned()
650        };
651        assert_eq!(
652            attr("service.name"),
653            Some(ProtoValue::StringValue("plecto".into()))
654        );
655        assert_eq!(
656            attr("telemetry.sdk.name"),
657            Some(ProtoValue::StringValue("plecto".into())),
658            "a hand-written exporter must not claim to be the official SDK"
659        );
660
661        let scope_spans = &decoded.resource_spans[0].scope_spans[0];
662        assert_eq!(scope_spans.scope.as_ref().unwrap().name, "plecto");
663        let span = &scope_spans.spans[0];
664        assert_eq!(span.trace_id, vec![0xAB; 16]);
665        assert_eq!(span.span_id, vec![0xCD; 8]);
666        assert_eq!(span.parent_span_id, vec![0xEF; 8]);
667        assert_eq!(span.name, "GET");
668        assert_eq!(span.kind, 2, "SERVER");
669        assert_eq!(span.start_time_unix_nano, 1_700_000_000_000_000_000);
670        assert_eq!(span.end_time_unix_nano, 1_700_000_000_012_000_000);
671        assert_eq!(
672            span.flags,
673            0x300 | 0x01,
674            "remote parent: has_is_remote + is_remote + sampled"
675        );
676
677        let span_attr = |key: &str| {
678            span.attributes
679                .iter()
680                .find(|kv| kv.key == key)
681                .and_then(|kv| kv.value.as_ref())
682                .and_then(|v| v.value.as_ref())
683                .cloned()
684        };
685        assert_eq!(
686            span_attr("http.request.method"),
687            Some(ProtoValue::StringValue("GET".into()))
688        );
689        assert_eq!(
690            span_attr("http.response.status_code"),
691            Some(ProtoValue::IntValue(502))
692        );
693        assert_eq!(span_attr("plecto.pi"), Some(ProtoValue::DoubleValue(3.5)));
694        assert_eq!(span_attr("plecto.flag"), Some(ProtoValue::BoolValue(true)));
695        match span_attr("plecto.list") {
696            Some(ProtoValue::ArrayValue(arr)) => {
697                let items: Vec<_> = arr
698                    .values
699                    .iter()
700                    .filter_map(|v| v.value.as_ref())
701                    .cloned()
702                    .collect();
703                assert_eq!(
704                    items,
705                    vec![
706                        ProtoValue::StringValue("a".into()),
707                        ProtoValue::StringValue("b".into())
708                    ]
709                );
710            }
711            other => panic!("expected an array attribute, got {other:?}"),
712        }
713
714        let status = span.status.as_ref().expect("error status is encoded");
715        assert_eq!(status.code, ProtoStatusCode::Error as i32);
716        assert_eq!(status.message, "upstream 502");
717
718        assert_eq!(span.events.len(), 1);
719        assert_eq!(span.events[0].name, "auth denied");
720        assert_eq!(span.events[0].time_unix_nano, 1_700_000_000_000_000_000);
721    }
722
723    #[test]
724    fn root_span_omits_parent_and_unset_status_and_clears_remote_bit() {
725        let decoded = decode(&encode_traces_request("plecto", &[record("root")]));
726        let span = &decoded.resource_spans[0].scope_spans[0].spans[0];
727        assert!(span.parent_span_id.is_empty(), "no parent field for a root");
728        assert!(span.status.is_none(), "Unset status is omitted entirely");
729        assert_eq!(span.flags, 0x100 | 0x01, "local parent context, sampled");
730    }
731
732    #[test]
733    fn filter_span_converts_with_local_parent_and_derived_end_time() {
734        let trace = RequestTrace::root();
735        let logs = vec![LogLine {
736            level: LogLevel::Info,
737            message: "hi".to_string(),
738        }];
739        let start = SystemTime::now();
740        let span = build_filter_span(
741            &trace,
742            "auth",
743            Isolation::Trusted,
744            Hook::OnRequest,
745            SpanOutcome::Continue,
746            start,
747            Duration::from_micros(250),
748            &logs,
749        );
750        let rec = SpanRecord::from(&span);
751        assert_eq!(rec.parent_span_id, Some(trace.request_span_id()));
752        assert!(!rec.remote_parent, "a filter span's parent is always local");
753        assert_eq!(rec.end_time, start + Duration::from_micros(250));
754        assert_eq!(rec.status, Status::Ok);
755        assert_eq!(rec.events.len(), 1);
756
757        let decoded = decode(&encode_traces_request("plecto", &[rec]));
758        let got = &decoded.resource_spans[0].scope_spans[0].spans[0];
759        assert_eq!(got.kind, 1, "INTERNAL");
760        assert_eq!(got.name, "auth");
761    }
762
763    #[test]
764    fn buffer_is_fifo_bounded_and_counts_drops() {
765        let buffer = OtlpBuffer::new(2);
766        buffer.push(record("a"));
767        buffer.push(record("b"));
768        buffer.push(record("c")); // over capacity → dropped, counted
769        assert_eq!(buffer.len(), 2);
770        assert_eq!(buffer.dropped_spans(), 1);
771
772        let first = buffer.drain(1);
773        assert_eq!(first.len(), 1);
774        assert_eq!(first[0].name, "a", "FIFO");
775        assert_eq!(buffer.drain(10).len(), 1);
776        assert!(buffer.is_empty());
777
778        buffer.record_dropped(3);
779        assert_eq!(buffer.dropped_spans(), 4, "export losses share the counter");
780    }
781
782    #[test]
783    fn sink_export_skips_unsampled_spans() {
784        let buffer = OtlpBuffer::default();
785        let trace = RequestTrace::from_traceparent(
786            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00",
787        )
788        .expect("valid unsampled traceparent");
789        let span = build_filter_span(
790            &trace,
791            "f",
792            Isolation::Untrusted,
793            Hook::OnRequest,
794            SpanOutcome::Continue,
795            SystemTime::now(),
796            Duration::from_nanos(1),
797            &[],
798        );
799        buffer.export(&span);
800        assert!(buffer.is_empty(), "unsampled spans are not exported");
801
802        let sampled = build_filter_span(
803            &RequestTrace::root(),
804            "f",
805            Isolation::Untrusted,
806            Hook::OnRequest,
807            SpanOutcome::Continue,
808            SystemTime::now(),
809            Duration::from_nanos(1),
810            &[],
811        );
812        buffer.export(&sampled);
813        assert_eq!(buffer.len(), 1);
814    }
815
816    #[test]
817    fn decodes_partial_success_and_ignores_clean_responses() {
818        let with_rejection = ExportTraceServiceResponse {
819            partial_success: Some(ExportTracePartialSuccess {
820                rejected_spans: 7,
821                error_message: "bad spans".to_string(),
822            }),
823        }
824        .encode_to_vec();
825        assert_eq!(
826            decode_export_partial_success(&with_rejection),
827            Some(PartialSuccess {
828                rejected_spans: 7,
829                error_message: "bad spans".to_string(),
830            })
831        );
832
833        let clean = ExportTraceServiceResponse {
834            partial_success: None,
835        }
836        .encode_to_vec();
837        assert_eq!(decode_export_partial_success(&clean), None);
838        assert_eq!(decode_export_partial_success(b""), None);
839        assert_eq!(
840            decode_export_partial_success(&[0xff, 0xff, 0xff]),
841            None,
842            "malformed bytes are fail-soft"
843        );
844    }
845}