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}