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}