sentinel_proxy/
trace_id.rs

1//! TinyFlake: Operator-friendly Trace ID Generation
2//!
3//! TinyFlake is Sentinel's default trace ID format, designed for operators who need to
4//! copy, paste, and correlate request IDs across logs, dashboards, and support tickets.
5//!
6//! # Format
7//!
8//! ```text
9//! k7BxR3nVp2Ym
10//! └──┘└───────┘
11//!  3ch   8ch
12//!  time  random
13//! ```
14//!
15//! - **11 characters total** (vs 36 for UUID)
16//! - **Base58 encoded** (excludes confusing chars: `0`, `O`, `I`, `l`)
17//! - **Time-prefixed** for chronological sorting in logs
18//! - **No dashes** for easy double-click selection in terminals
19//!
20//! # Comparison with Snowflake
21//!
22//! TinyFlake is inspired by Twitter's Snowflake but differs in key ways:
23//!
24//! | Feature | Snowflake | TinyFlake |
25//! |---------|-----------|-----------|
26//! | Length | 19 digits | 11 chars |
27//! | Encoding | Decimal | Base58 |
28//! | Coordination | Requires worker IDs | None (random) |
29//! | Time resolution | Milliseconds | Seconds |
30//! | Uniqueness | Guaranteed | Statistical |
31//!
32//! # Collision Probability
33//!
34//! The 8-character random component provides 58^8 ≈ 128 trillion combinations.
35//! Using the birthday paradox formula:
36//!
37//! - At **1,000 req/sec**: 50% collision chance after ~11 million requests (~3 hours)
38//! - At **10,000 req/sec**: 50% collision chance after ~11 million requests (~18 minutes)
39//! - At **100,000 req/sec**: 50% collision chance after ~11 million requests (~2 minutes)
40//!
41//! However, collisions only matter within the same second (due to time prefix).
42//! Within a single second at 100k req/sec, collision probability is ~0.004%.
43//!
44//! For guaranteed uniqueness, use UUID format instead.
45//!
46//! # Configuration
47//!
48//! In `sentinel.kdl`:
49//!
50//! ```kdl
51//! server {
52//!     trace-id-format "tinyflake"  // default, or "uuid"
53//! }
54//! ```
55//!
56//! # Examples
57//!
58//! ```
59//! use sentinel_proxy::trace_id::{generate_tinyflake, generate_uuid, generate_for_format, TraceIdFormat};
60//!
61//! // Generate TinyFlake (default)
62//! let id = generate_tinyflake();
63//! assert_eq!(id.len(), 11);
64//!
65//! // Generate UUID
66//! let uuid = generate_uuid();
67//! assert_eq!(uuid.len(), 36);
68//!
69//! // Generate based on format config
70//! let id = generate_for_format(TraceIdFormat::TinyFlake);
71//! ```
72//!
73//! # Header Propagation
74//!
75//! TinyFlake respects incoming trace headers in this order:
76//! 1. `X-Trace-Id`
77//! 2. `X-Correlation-Id`
78//! 3. `X-Request-Id`
79//!
80//! If an incoming request has any of these headers, that value is used instead of
81//! generating a new ID. This allows distributed tracing across services.
82
83use std::time::{SystemTime, UNIX_EPOCH};
84
85// Re-export TraceIdFormat from sentinel_common for convenience
86pub use sentinel_common::TraceIdFormat;
87
88/// Generate a trace ID using the specified format
89#[inline]
90pub fn generate_for_format(format: TraceIdFormat) -> String {
91    match format {
92        TraceIdFormat::TinyFlake => generate_tinyflake(),
93        TraceIdFormat::Uuid => generate_uuid(),
94    }
95}
96
97// ============================================================================
98// TinyFlake Generation
99// ============================================================================
100
101/// Base58 alphabet (Bitcoin-style)
102///
103/// Excludes visually ambiguous characters:
104/// - `0` (zero) and `O` (capital o)
105/// - `I` (capital i) and `l` (lowercase L)
106const BASE58_ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
107
108/// TinyFlake ID length
109pub const TINYFLAKE_LENGTH: usize = 11;
110
111/// Time component length (3 Base58 chars = 58^3 = 195,112 values ≈ 54 hours)
112const TIME_COMPONENT_LENGTH: usize = 3;
113
114/// Random component length (8 Base58 chars = 58^8 ≈ 128 trillion values)
115const RANDOM_COMPONENT_LENGTH: usize = 8;
116
117/// Time component modulo (58^3)
118const TIME_MODULO: u64 = 195_112;
119
120/// Generate a TinyFlake trace ID
121///
122/// Format: 11 characters, Base58 encoded
123/// - 3 chars: timestamp component (cycles every ~54 hours)
124/// - 8 chars: random component
125///
126/// # Example
127///
128/// ```
129/// use sentinel_proxy::trace_id::generate_tinyflake;
130///
131/// let id = generate_tinyflake();
132/// assert_eq!(id.len(), 11);
133/// println!("Generated TinyFlake: {}", id);
134/// ```
135pub fn generate_tinyflake() -> String {
136    let mut id = String::with_capacity(TINYFLAKE_LENGTH);
137
138    // Time component: seconds since epoch mod TIME_MODULO
139    let now = SystemTime::now()
140        .duration_since(UNIX_EPOCH)
141        .unwrap_or_default()
142        .as_secs();
143    let time_component = (now % TIME_MODULO) as usize;
144    encode_base58(time_component, TIME_COMPONENT_LENGTH, &mut id);
145
146    // Random component: 6 random bytes encoded as 8 Base58 chars
147    let random_bytes: [u8; 6] = rand::random();
148    let random_value = u64::from_le_bytes([
149        random_bytes[0],
150        random_bytes[1],
151        random_bytes[2],
152        random_bytes[3],
153        random_bytes[4],
154        random_bytes[5],
155        0,
156        0,
157    ]) as usize;
158    encode_base58(random_value, RANDOM_COMPONENT_LENGTH, &mut id);
159
160    id
161}
162
163/// Encode a number as Base58 with fixed width
164///
165/// The output is zero-padded (using '1', the first Base58 char) to ensure
166/// consistent length.
167fn encode_base58(mut value: usize, width: usize, output: &mut String) {
168    let mut chars = Vec::with_capacity(width);
169
170    for _ in 0..width {
171        chars.push(BASE58_ALPHABET[value % 58] as char);
172        value /= 58;
173    }
174
175    // Reverse to get most significant digit first
176    for c in chars.into_iter().rev() {
177        output.push(c);
178    }
179}
180
181// ============================================================================
182// UUID Generation
183// ============================================================================
184
185/// Generate a UUID v4 trace ID
186///
187/// Format: 36 characters with dashes (standard UUID format)
188///
189/// # Example
190///
191/// ```
192/// use sentinel_proxy::trace_id::generate_uuid;
193///
194/// let id = generate_uuid();
195/// assert_eq!(id.len(), 36);
196/// assert!(id.contains('-'));
197/// ```
198pub fn generate_uuid() -> String {
199    uuid::Uuid::new_v4().to_string()
200}
201
202// ============================================================================
203// Tests
204// ============================================================================
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::collections::HashSet;
210
211    #[test]
212    fn test_tinyflake_format() {
213        let id = generate_tinyflake();
214
215        // Should be exactly 11 characters
216        assert_eq!(
217            id.len(),
218            TINYFLAKE_LENGTH,
219            "TinyFlake should be {} chars, got: {} ({})",
220            TINYFLAKE_LENGTH,
221            id.len(),
222            id
223        );
224
225        // Should only contain Base58 characters
226        for c in id.chars() {
227            assert!(
228                BASE58_ALPHABET.contains(&(c as u8)),
229                "Invalid char '{}' in TinyFlake: {}",
230                c,
231                id
232            );
233        }
234
235        // Should not contain confusing characters
236        assert!(!id.contains('0'), "TinyFlake should not contain '0'");
237        assert!(!id.contains('O'), "TinyFlake should not contain 'O'");
238        assert!(!id.contains('I'), "TinyFlake should not contain 'I'");
239        assert!(!id.contains('l'), "TinyFlake should not contain 'l'");
240    }
241
242    #[test]
243    fn test_tinyflake_uniqueness() {
244        // Generate 10,000 IDs and verify no duplicates
245        let mut ids = HashSet::new();
246        for _ in 0..10_000 {
247            let id = generate_tinyflake();
248            assert!(
249                ids.insert(id.clone()),
250                "Duplicate TinyFlake generated: {}",
251                id
252            );
253        }
254    }
255
256    #[test]
257    fn test_tinyflake_time_ordering() {
258        // IDs generated in the same second should have same time prefix
259        let id1 = generate_tinyflake();
260        let id2 = generate_tinyflake();
261
262        assert_eq!(
263            &id1[..TIME_COMPONENT_LENGTH],
264            &id2[..TIME_COMPONENT_LENGTH],
265            "Time prefix should match within same second: {} vs {}",
266            id1,
267            id2
268        );
269    }
270
271    #[test]
272    fn test_uuid_format() {
273        let id = generate_uuid();
274
275        // Should be exactly 36 characters
276        assert_eq!(id.len(), 36, "UUID should be 36 chars, got: {}", id.len());
277
278        // Should contain 4 dashes
279        assert_eq!(
280            id.matches('-').count(),
281            4,
282            "UUID should have 4 dashes: {}",
283            id
284        );
285
286        // Should be parseable as UUID
287        assert!(
288            uuid::Uuid::parse_str(&id).is_ok(),
289            "Should be valid UUID: {}",
290            id
291        );
292    }
293
294    #[test]
295    fn test_trace_id_format_generate() {
296        let tinyflake = generate_for_format(TraceIdFormat::TinyFlake);
297        assert_eq!(tinyflake.len(), TINYFLAKE_LENGTH);
298
299        let uuid = generate_for_format(TraceIdFormat::Uuid);
300        assert_eq!(uuid.len(), 36);
301    }
302
303    #[test]
304    fn test_trace_id_format_from_str() {
305        assert_eq!(TraceIdFormat::from_str_loose("tinyflake"), TraceIdFormat::TinyFlake);
306        assert_eq!(TraceIdFormat::from_str_loose("TINYFLAKE"), TraceIdFormat::TinyFlake);
307        assert_eq!(TraceIdFormat::from_str_loose("uuid"), TraceIdFormat::Uuid);
308        assert_eq!(TraceIdFormat::from_str_loose("UUID"), TraceIdFormat::Uuid);
309        assert_eq!(TraceIdFormat::from_str_loose("uuid4"), TraceIdFormat::Uuid);
310        assert_eq!(TraceIdFormat::from_str_loose("uuidv4"), TraceIdFormat::Uuid);
311        assert_eq!(TraceIdFormat::from_str_loose("unknown"), TraceIdFormat::TinyFlake); // Default
312    }
313
314    #[test]
315    fn test_trace_id_format_display() {
316        assert_eq!(TraceIdFormat::TinyFlake.to_string(), "tinyflake");
317        assert_eq!(TraceIdFormat::Uuid.to_string(), "uuid");
318    }
319
320    #[test]
321    fn test_encode_base58() {
322        let mut output = String::new();
323
324        // 0 encodes to all '1's (first char in Base58 alphabet)
325        encode_base58(0, 3, &mut output);
326        assert_eq!(output, "111");
327
328        // 57 (last index) encodes to 'z' (last char in Base58 alphabet)
329        output.clear();
330        encode_base58(57, 3, &mut output);
331        assert_eq!(output, "11z");
332
333        // 58 wraps to next position
334        output.clear();
335        encode_base58(58, 3, &mut output);
336        assert_eq!(output, "121");
337    }
338
339    #[test]
340    fn test_base58_alphabet_is_correct() {
341        // Verify no confusing characters
342        let alphabet_str = std::str::from_utf8(BASE58_ALPHABET).unwrap();
343        assert!(!alphabet_str.contains('0'));
344        assert!(!alphabet_str.contains('O'));
345        assert!(!alphabet_str.contains('I'));
346        assert!(!alphabet_str.contains('l'));
347
348        // Verify length
349        assert_eq!(BASE58_ALPHABET.len(), 58);
350
351        // Verify all unique
352        let unique: HashSet<u8> = BASE58_ALPHABET.iter().copied().collect();
353        assert_eq!(unique.len(), 58);
354    }
355}