Skip to main content

ytsaurus_client/
trace.rs

1//! The trace a request belongs to, carried to the cluster in a `traceparent`
2//! header.
3//!
4//! The cluster is already instrumented: the proxy opens a span for every
5//! request it serves, and each of those spans either starts a trace of its own
6//! or continues one the caller named. Naming it is this whole module — a
7//! request sent with a `traceparent` shows up under the caller's trace rather
8//! than as an orphan, so a launch that took four minutes can be looked at
9//! beside whatever asked for it.
10//!
11//! The header is the
12//! [W3C one](https://www.w3.org/TR/trace-context/#traceparent-header):
13//!
14//! ```text
15//! traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
16//!              ^^ ^^ 32 hex: the trace  ^^ 16 hex: the caller's span  ^^ flags
17//! ```
18//!
19//! All three official clients send exactly that: `FormatTraceParentHeader` in
20//! the C++ wrapper (`yt/cpp/mapreduce/http/helpers.cpp`), `injectTracing` in
21//! the Go SDK (`yt/go/yt/internal/httpclient/client.go`), and
22//! `generate_traceparent` in the Python wrapper (`yt/python/yt/wrapper/`).
23//! What the proxy accepts is `TryParseTraceParent` in
24//! `yt/yt/core/http/helpers.cpp`, and it is slightly wider than the standard:
25//! the version may be left off entirely — which is what the Go SDK does — and
26//! the flags are read as a byte with **bit 0 sampled, bit 1 debug**.
27//!
28//! # Finding the trace afterwards
29//!
30//! The cluster spells a trace id as one of its own GUIDs —
31//! `8e9bcc43-5c2be9b4-56f18c4e-117ea314` — and the header spells the same 128
32//! bits as 32 hex digits. They are the same four 32-bit groups in the same
33//! order, so the only difference is the dashes and the leading zeros the
34//! cluster drops (`WriteGuidToBuffer` in `library/cpp/yt/misc/guid.cpp`, and
35//! `FormatTraceParentHeader`, which pads them back). [`TraceContext::yt_trace_id`]
36//! does that conversion, so the id can be pasted into the cluster's own log
37//! search rather than translated by hand.
38//!
39//! # Watched rather than assumed
40//!
41//! A proxy puts the trace id it decided on into the `X-YT-Trace-Id` of the
42//! response, which makes every question above answerable with one request. On a
43//! local cluster, sending
44//! `traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01` to
45//! `/api/v4/exists` comes back with
46//! `X-YT-Trace-Id: 4bf92f35-77b34da6-a3ce929d-e0e4736` — the same id, the
47//! cluster's spelling, a leading zero dropped. The version-less form and
48//! uppercase hex are adopted the same way; a header that does not parse is
49//! answered 200 with an id the proxy invented, which is the whole reason
50//! [`TraceContext::parse`] refuses one rather than passing it on.
51
52use crate::error::{ClientError, Result};
53use crate::unique::word;
54
55/// Bit 0 of the flags: this trace is being recorded.
56const SAMPLED: u8 = 0x01;
57
58/// The trace a request belongs to.
59///
60/// Two ways in. [`TraceContext::parse`] continues a trace that already exists,
61/// which is the usual one: a service that received a `traceparent` of its own
62/// passes it on, and the cluster's work appears under the same trace as the
63/// request that caused it.
64///
65/// ```
66/// use ytsaurus_client::{Client, TraceContext};
67///
68/// # fn main() -> Result<(), ytsaurus_client::ClientError> {
69/// let incoming = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
70/// let client = Client::new("http://localhost:8000")
71///     .with_trace_context(&TraceContext::parse(incoming)?);
72/// # Ok(())
73/// # }
74/// ```
75///
76/// [`TraceContext::new`] starts one, for a program that is nobody's callee.
77/// Print the id it made and the cluster's copy of the trace can be found by
78/// it:
79///
80/// ```
81/// use ytsaurus_client::{Client, TraceContext};
82///
83/// let trace = TraceContext::new();
84/// eprintln!("trace {}", trace.yt_trace_id());
85///
86/// let client = Client::new("http://localhost:8000").with_trace_context(&trace);
87/// ```
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct TraceContext {
90    /// 32 lowercase hex digits.
91    trace_id: String,
92    /// 16 lowercase hex digits: the span this client's requests are children
93    /// of.
94    span_id: String,
95    flags: u8,
96    /// The `tracestate` that arrived beside the `traceparent`, if any, carried
97    /// unmodified. See [`TraceContext::with_tracestate`].
98    tracestate: Option<String>,
99}
100
101impl TraceContext {
102    /// Starts a trace, sampled.
103    ///
104    /// Sampled because a caller who asked for a trace wants it kept: the C++
105    /// wrapper's `EnableClientTracing` and the Python wrapper's
106    /// `generate_traceparent` both do the same. An unsampled context is one
107    /// that arrived that way — see [`TraceContext::parse`].
108    #[must_use]
109    pub fn new() -> Self {
110        Self {
111            trace_id: format!("{:016x}{:016x}", word(0), word(1)),
112            span_id: format!("{:016x}", word(2)),
113            flags: SAMPLED,
114            tracestate: None,
115        }
116    }
117
118    /// Continues the trace a `traceparent` header names.
119    ///
120    /// Both spellings the proxy accepts are accepted here: the standard
121    /// `00-<trace>-<span>-<flags>` and the version-less three-part form the Go
122    /// SDK sends. Hex digits may be upper or lower case on the way in; what
123    /// this client sends is always lowercase, as the standard requires.
124    ///
125    /// The span id is carried through **as it arrived**, so the cluster's spans
126    /// hang under the span the *caller* named rather than under one belonging
127    /// to this process. That is what a client with nothing of its own to point
128    /// at can honestly do: the W3C wording asks a forwarder to substitute the
129    /// id of its own current span, and this crate emits no spans the collector
130    /// would know about — an invented id would name a parent that does not
131    /// exist. The work still lands in the right trace, one level up from where
132    /// a fully instrumented service would put it.
133    ///
134    /// A `tracestate` that arrived beside the header is not in it, and is
135    /// passed on separately — see [`TraceContext::with_tracestate`].
136    ///
137    /// # Errors
138    ///
139    /// Returns [`ClientError::Config`] if the header is not a traceparent.
140    /// Refusing is the point: a malformed header is dropped by the proxy
141    /// without complaint, and the trace would then be silently missing the
142    /// half that mattered.
143    pub fn parse(header: &str) -> Result<Self> {
144        let header = header.trim();
145        let parts: Vec<&str> = header.split('-').collect();
146
147        let [version, trace_id, span_id, flags] = match parts[..] {
148            // The three-part form has no version, and the proxy reads it as
149            // zero. Recognised by the trace id rather than by the count, so
150            // that a four-part header with its flags cut off is not silently
151            // read as this one — see the arm below.
152            [trace_id, span_id, flags] if is_hex(trace_id, 32) => ["00", trace_id, span_id, flags],
153            // Version, trace, span, and nothing where the flags should be.
154            // Destructured as the version-less form this would report the
155            // *version* as a bad trace id, sending whoever is debugging a
156            // truncated header to a field that is perfectly well formed.
157            [version, trace_id, _] if is_hex(version, 2) && is_hex(trace_id, 32) => {
158                return Err(malformed(header, "the flags are missing"));
159            }
160            [version, trace_id, span_id, flags] => [version, trace_id, span_id, flags],
161            // A version this client does not know may define fields after the
162            // flags, and the standard's versioning rule is to read the four
163            // that version 00 defines and ignore the rest — that rule is the
164            // only reason a version-00 parser keeps working against a
165            // version-01 sender. Version 00 itself defines exactly four, so a
166            // fifth group there is malformed rather than from the future.
167            [version, trace_id, span_id, flags, ..] if !version.eq_ignore_ascii_case("00") => {
168                [version, trace_id, span_id, flags]
169            }
170            _ => {
171                return Err(malformed(
172                    header,
173                    "expected version-traceid-spanid-flags, in four hyphenated groups",
174                ));
175            }
176        };
177
178        if !is_hex(version, 2) {
179            return Err(malformed(header, "the version is not two hex digits"));
180        }
181        // `ff` is reserved by the standard as "no version will ever be this",
182        // so a header carrying it is malformed rather than from the future.
183        if version.eq_ignore_ascii_case("ff") {
184            return Err(malformed(header, "ff is not a valid version"));
185        }
186        if !is_hex(trace_id, 32) {
187            return Err(malformed(header, "the trace id is not 32 hex digits"));
188        }
189        if is_zero(trace_id) {
190            return Err(malformed(header, "the trace id is all zeros"));
191        }
192        if !is_hex(span_id, 16) {
193            return Err(malformed(header, "the span id is not 16 hex digits"));
194        }
195        if is_zero(span_id) {
196            return Err(malformed(header, "the span id is all zeros"));
197        }
198        if !is_hex(flags, 2) {
199            return Err(malformed(header, "the flags are not two hex digits"));
200        }
201
202        Ok(Self {
203            trace_id: trace_id.to_ascii_lowercase(),
204            span_id: span_id.to_ascii_lowercase(),
205            flags: u8::from_str_radix(flags, 16).unwrap_or_default(),
206            tracestate: None,
207        })
208    }
209
210    /// Carries a `tracestate` header alongside the `traceparent`.
211    ///
212    /// The standard pairs the two, and asks a participant that forwards one to
213    /// forward the other unmodified: `tracestate` is where a vendor puts the
214    /// sampling decision or the correlation key that its own backend reads, and
215    /// dropping it on this hop loses that for everything downstream. The proxy
216    /// itself has no opinion about it — this is for the caller's backend, not
217    /// the cluster's.
218    ///
219    /// Not modified on the way through, deliberately: rewriting the list means
220    /// claiming a vendor entry of one's own, and this client has none.
221    ///
222    /// ```
223    /// use ytsaurus_client::{Client, TraceContext};
224    ///
225    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
226    /// let incoming = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
227    /// let context = TraceContext::parse(incoming)?.with_tracestate("vendora=t61,vendorb=x9");
228    ///
229    /// let client = Client::new("http://localhost:8000").with_trace_context(&context);
230    /// # Ok(())
231    /// # }
232    /// ```
233    #[must_use]
234    pub fn with_tracestate(mut self, state: impl Into<String>) -> Self {
235        self.tracestate = Some(state.into());
236        self
237    }
238
239    /// The `tracestate` this context carries, if it was given one.
240    #[must_use]
241    pub fn tracestate(&self) -> Option<&str> {
242        self.tracestate.as_deref()
243    }
244
245    /// The trace, as the header spells it: 32 lowercase hex digits.
246    #[must_use]
247    pub fn trace_id(&self) -> &str {
248        &self.trace_id
249    }
250
251    /// The trace, as the **cluster** spells it: four hyphenated hex groups,
252    /// leading zeros dropped.
253    ///
254    /// This is the form that appears in the proxy log, in the `X-YT-Trace-Id`
255    /// header of a response, and in the cluster's UI — the same 128 bits as
256    /// [`TraceContext::trace_id`], punctuated the way every other YTsaurus id
257    /// is.
258    ///
259    /// ```
260    /// use ytsaurus_client::TraceContext;
261    ///
262    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
263    /// let trace = TraceContext::parse("00-08e9bcc435c2be9b456f18c4e117ea31-00f067aa0ba902b7-01")?;
264    ///
265    /// assert_eq!(trace.trace_id(), "08e9bcc435c2be9b456f18c4e117ea31");
266    /// assert_eq!(trace.yt_trace_id(), "8e9bcc4-35c2be9b-456f18c4-e117ea31");
267    /// # Ok(())
268    /// # }
269    /// ```
270    #[must_use]
271    pub fn yt_trace_id(&self) -> String {
272        // Sliced from the string rather than reassembled from its bytes: the
273        // trace id is 32 ASCII hex digits by construction — `parse` checks it
274        // and `new` formats it — so there is no decoding to fail. Going
275        // through `from_utf8` needed a fallback for a case that cannot happen,
276        // and the only cheap fallback was a wrong id, which is worse than an
277        // error: an id that is off by one group matches nothing in the proxy
278        // log and says nothing about why.
279        let groups: Vec<&str> = (0..4)
280            .map(|group| {
281                let group = &self.trace_id[group * 8..group * 8 + 8];
282                // The cluster prints one to eight digits per group, so a group
283                // that is all zeros keeps a single one.
284                let trimmed = group.trim_start_matches('0');
285                if trimmed.is_empty() { "0" } else { trimmed }
286            })
287            .collect();
288
289        groups.join("-")
290    }
291
292    /// The span this client's requests hang under: 16 lowercase hex digits.
293    #[must_use]
294    pub fn span_id(&self) -> &str {
295        &self.span_id
296    }
297
298    /// Whether the trace is being recorded.
299    ///
300    /// A context that arrived unsampled is passed on unsampled: the decision
301    /// belongs to whoever started the trace, and overriding it here would
302    /// record half a trace.
303    #[must_use]
304    pub fn is_sampled(&self) -> bool {
305        self.flags & SAMPLED != 0
306    }
307
308    /// The `traceparent` header value, as it goes on the wire.
309    #[must_use]
310    pub fn header(&self) -> String {
311        format!("00-{}-{}-{:02x}", self.trace_id, self.span_id, self.flags)
312    }
313}
314
315impl Default for TraceContext {
316    fn default() -> Self {
317        Self::new()
318    }
319}
320
321impl std::fmt::Display for TraceContext {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        f.write_str(&self.header())
324    }
325}
326
327fn malformed(header: &str, reason: &str) -> ClientError {
328    ClientError::Config(format!("{header:?} is not a traceparent: {reason}"))
329}
330
331fn is_hex(text: &str, digits: usize) -> bool {
332    text.len() == digits && text.bytes().all(|b| b.is_ascii_hexdigit())
333}
334
335fn is_zero(text: &str) -> bool {
336    text.bytes().all(|b| b == b'0')
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn a_header_is_carried_through_unchanged() {
345        // The example from the W3C specification, which is also the shape the
346        // proxy's own parser expects.
347        let header = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
348        let context = TraceContext::parse(header).expect("parses");
349
350        assert_eq!(context.trace_id(), "4bf92f3577b34da6a3ce929d0e0e4736");
351        assert_eq!(context.span_id(), "00f067aa0ba902b7");
352        assert!(context.is_sampled());
353        assert_eq!(context.header(), header);
354    }
355
356    #[test]
357    fn the_version_less_form_the_go_sdk_sends_is_accepted() {
358        // `injectTracing` formats `%s-%016x-%02x` — no version — and the
359        // proxy's parser has a note saying it supports exactly that. A client
360        // that refused it would refuse what an official client sends.
361        let context = TraceContext::parse("4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
362            .expect("parses");
363
364        assert_eq!(
365            context.header(),
366            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
367            "what is sent on is the four-part form"
368        );
369    }
370
371    #[test]
372    fn what_is_sent_is_lowercase_whatever_arrived() {
373        // The standard requires lowercase on the wire; the proxy's hex parser
374        // does not care. Liberal in, strict out.
375        let context =
376            TraceContext::parse("00-4BF92F3577B34DA6A3CE929D0E0E4736-00F067AA0BA902B7-01")
377                .expect("parses");
378
379        assert_eq!(
380            context.header(),
381            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
382        );
383    }
384
385    #[test]
386    fn an_unsampled_trace_stays_unsampled() {
387        // The sampling decision belongs to whoever started the trace. Turning
388        // it on here would record this client's half of a trace whose other
389        // half was dropped.
390        let context =
391            TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00")
392                .expect("parses");
393
394        assert!(!context.is_sampled());
395        assert!(context.header().ends_with("-00"));
396    }
397
398    #[test]
399    fn the_debug_flag_survives_the_round_trip() {
400        // Bit 1 is `debug` to the proxy — `spanContext.Debug = options & 2u` —
401        // and this client has no opinion about it beyond passing it on.
402        let context =
403            TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-03")
404                .expect("parses");
405
406        assert!(context.is_sampled());
407        assert!(context.header().ends_with("-03"));
408    }
409
410    #[test]
411    fn a_version_from_the_future_is_read_as_far_as_it_is_understood() {
412        // The standard's versioning rule, and the only thing that keeps a
413        // version-00 parser working against a later sender: read the four
414        // fields version 00 defines, ignore whatever follows. Refusing the
415        // whole header instead would turn "this client is older than the
416        // caller" into a failed request, because the documented usage
417        // `?`-propagates the refusal.
418        let context =
419            TraceContext::parse("01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-af00")
420                .expect("a later version is read as far as it is understood");
421
422        assert_eq!(context.trace_id(), "4bf92f3577b34da6a3ce929d0e0e4736");
423        assert_eq!(context.span_id(), "00f067aa0ba902b7");
424        assert!(context.is_sampled());
425        // Sent on as the version this client actually speaks, not the one it
426        // was handed: claiming 01 would promise fields it did not carry.
427        assert_eq!(
428            context.header(),
429            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
430        );
431    }
432
433    #[test]
434    fn version_zero_has_exactly_four_fields() {
435        // The other half of the rule: 00 defines four groups and no more, so a
436        // fifth is a malformed header rather than a newer one.
437        assert!(
438            TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-af00")
439                .is_err()
440        );
441    }
442
443    #[test]
444    fn a_truncated_header_says_which_field_is_missing() {
445        // Three groups, and the first is a version rather than a trace id —
446        // this is the four-part form cut short, not the version-less form the
447        // Go SDK sends. Read as the latter it would report a 32-digit trace id
448        // as "not 32 hex digits", which is the wrong field and a genuinely
449        // confusing thing to be told.
450        let refusal = TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7")
451            .expect_err("the flags are not optional");
452
453        let reason = refusal.to_string();
454        assert!(reason.contains("flags"), "{reason}");
455        assert!(
456            !reason.contains("trace id"),
457            "the trace id in this header is perfectly well formed: {reason}"
458        );
459    }
460
461    #[test]
462    fn a_tracestate_is_carried_beside_the_traceparent_untouched() {
463        // The standard pairs the two and asks a forwarder to pass the second
464        // on unmodified: it is where a vendor keeps its sampling decision or
465        // its correlation key, and this hop losing it costs the caller's own
466        // backend, not the cluster's.
467        let context =
468            TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
469                .expect("parses")
470                .with_tracestate("vendora=t61rcWkgMzE,vendorb=x9");
471
472        assert_eq!(
473            context.tracestate(),
474            Some("vendora=t61rcWkgMzE,vendorb=x9"),
475            "not rewritten: this client has no vendor entry of its own to add"
476        );
477        // And it is not smuggled into the traceparent, which has no room for it.
478        assert_eq!(
479            context.header(),
480            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
481        );
482    }
483
484    #[test]
485    fn a_context_without_a_tracestate_has_none() {
486        assert_eq!(TraceContext::new().tracestate(), None);
487        assert_eq!(
488            TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
489                .expect("parses")
490                .tracestate(),
491            None
492        );
493    }
494
495    #[test]
496    fn a_malformed_header_is_refused_rather_than_sent() {
497        // Watched on a local cluster: `traceparent: not-a-traceparent` is
498        // answered 200, with a trace id the proxy generated for itself. So a
499        // header this client failed to notice was wrong would leave the trace
500        // quietly lacking the part that mattered. Each of these says which
501        // part is wrong.
502        let refused = [
503            "",
504            "nonsense",
505            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7",
506            // One digit short, one digit long.
507            "00-4bf92f3577b34da6a3ce929d0e0e473-00f067aa0ba902b7-01",
508            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b77-01",
509            // Not hex.
510            "00-4bf92f3577b34da6a3ce929d0e0e473g-00f067aa0ba902b7-01",
511            "zz-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
512            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-0",
513            // All-zero ids are invalid by the standard, and useless anyway.
514            "00-00000000000000000000000000000000-00f067aa0ba902b7-01",
515            "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01",
516            // `ff` is reserved as never-a-version.
517            "ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
518        ];
519
520        for header in refused {
521            assert!(
522                TraceContext::parse(header).is_err(),
523                "{header:?} was accepted"
524            );
525        }
526    }
527
528    #[test]
529    fn the_cluster_spelling_is_the_same_bits_punctuated() {
530        // `FormatTraceParentHeader` writes the GUID's four 32-bit groups in
531        // the order the cluster prints them, zero-padded to eight digits each;
532        // `WriteGuidToBuffer` drops those zeros again. So the two spellings
533        // differ by punctuation and padding and nothing else.
534        let context =
535            TraceContext::parse("00-8e9bcc435c2be9b456f18c4e117ea314-00f067aa0ba902b7-01")
536                .expect("parses");
537
538        assert_eq!(context.yt_trace_id(), "8e9bcc43-5c2be9b4-56f18c4e-117ea314");
539    }
540
541    #[test]
542    fn a_group_the_cluster_would_shorten_is_shortened_here_too() {
543        // Not derived from the format string — captured. Each of these was sent
544        // to a local cluster as a `traceparent` and read back out of the
545        // `X-YT-Trace-Id` of the answer, which is the proxy saying which trace
546        // it decided the request belonged to.
547        let observed = [
548            (
549                "4bf92f3577b34da6a3ce929d0e0e4736",
550                "4bf92f35-77b34da6-a3ce929d-e0e4736",
551            ),
552            ("00000001000000020000000300000004", "1-2-3-4"),
553            // A group of nothing but zeros keeps one digit, never none.
554            ("00000000000000010000000000000002", "0-1-0-2"),
555        ];
556
557        for (sent, echoed) in observed {
558            let header = format!("00-{sent}-00f067aa0ba902b7-01");
559            let context = TraceContext::parse(&header).expect("parses");
560            assert_eq!(context.yt_trace_id(), echoed, "{sent}");
561        }
562    }
563
564    #[test]
565    fn a_fresh_context_is_well_formed_and_new_every_time() {
566        let mine = TraceContext::new();
567
568        assert!(mine.is_sampled());
569        assert_eq!(
570            TraceContext::parse(&mine.header()).expect("its own header parses"),
571            mine
572        );
573
574        let ids: std::collections::HashSet<String> =
575            (0..10_000).map(|_| TraceContext::new().trace_id).collect();
576        assert_eq!(
577            ids.len(),
578            10_000,
579            "two traces sharing an id would be one trace"
580        );
581    }
582}