77//! suitable for offline analysis with `jq`, Grafana Loki, or similar tools.
88
99use std:: collections:: HashMap ;
10+ use std:: fs;
11+ use std:: io:: Write ;
1012use std:: path:: PathBuf ;
13+ use std:: time:: { Duration , SystemTime , UNIX_EPOCH } ;
1114
1215use serde:: { Deserialize , Serialize } ;
1316
@@ -51,43 +54,56 @@ pub struct MetricRecord {
5154// ---------------------------------------------------------------------------
5255
5356/// Writes telemetry data to local files. Never sends data over the network.
54- ///
55- /// Creates separate files for spans and metrics within the output directory:
56- /// - `spans-{date}.ndjson`
57- /// - `metrics-{date}.ndjson`
5857pub struct LocalExporter {
59- /// Directory where telemetry files are written.
6058 output_dir : PathBuf ,
6159}
6260
6361impl LocalExporter {
6462 /// Create a new exporter targeting the given directory.
65- ///
66- /// The directory is created if it does not exist.
6763 pub fn new ( output_dir : PathBuf ) -> Self {
6864 Self { output_dir }
6965 }
7066
7167 /// Export a batch of span records to the spans file.
72- ///
73- /// Appends to the current day's file. Each record is serialized as a
74- /// single JSON line.
75- ///
76- /// # Errors
77- ///
78- /// Returns an error if the output directory cannot be created or the
79- /// file cannot be written.
80- pub fn export_spans ( & self , _spans : & [ SpanRecord ] ) -> crab_common:: Result < ( ) > {
81- todo ! ( )
68+ pub fn export_spans ( & self , spans : & [ SpanRecord ] ) -> crab_common:: Result < ( ) > {
69+ if spans. is_empty ( ) {
70+ return Ok ( ( ) ) ;
71+ }
72+ fs:: create_dir_all ( & self . output_dir ) ?;
73+ let path = self
74+ . output_dir
75+ . join ( format ! ( "spans-{}.ndjson" , today_str( ) ) ) ;
76+ let mut file = fs:: OpenOptions :: new ( )
77+ . create ( true )
78+ . append ( true )
79+ . open ( path) ?;
80+ for span in spans {
81+ if let Ok ( json) = serde_json:: to_string ( span) {
82+ writeln ! ( file, "{json}" ) ?;
83+ }
84+ }
85+ Ok ( ( ) )
8286 }
8387
8488 /// Export a batch of metric records to the metrics file.
85- ///
86- /// # Errors
87- ///
88- /// Returns an error if the file cannot be written.
89- pub fn export_metrics ( & self , _metrics : & [ MetricRecord ] ) -> crab_common:: Result < ( ) > {
90- todo ! ( )
89+ pub fn export_metrics ( & self , metrics : & [ MetricRecord ] ) -> crab_common:: Result < ( ) > {
90+ if metrics. is_empty ( ) {
91+ return Ok ( ( ) ) ;
92+ }
93+ fs:: create_dir_all ( & self . output_dir ) ?;
94+ let path = self
95+ . output_dir
96+ . join ( format ! ( "metrics-{}.ndjson" , today_str( ) ) ) ;
97+ let mut file = fs:: OpenOptions :: new ( )
98+ . create ( true )
99+ . append ( true )
100+ . open ( path) ?;
101+ for metric in metrics {
102+ if let Ok ( json) = serde_json:: to_string ( metric) {
103+ writeln ! ( file, "{json}" ) ?;
104+ }
105+ }
106+ Ok ( ( ) )
91107 }
92108
93109 /// Return the output directory path.
@@ -97,15 +113,65 @@ impl LocalExporter {
97113
98114 /// List all telemetry files in the output directory.
99115 pub fn list_files ( & self ) -> crab_common:: Result < Vec < PathBuf > > {
100- todo ! ( )
116+ let mut files = Vec :: new ( ) ;
117+ let Ok ( entries) = fs:: read_dir ( & self . output_dir ) else {
118+ return Ok ( files) ;
119+ } ;
120+ for entry in entries. flatten ( ) {
121+ let path = entry. path ( ) ;
122+ if path. extension ( ) . and_then ( |e| e. to_str ( ) ) == Some ( "ndjson" ) {
123+ files. push ( path) ;
124+ }
125+ }
126+ files. sort ( ) ;
127+ Ok ( files)
101128 }
102129
103130 /// Delete telemetry files older than the given number of days.
104- pub fn cleanup_older_than ( & self , _days : u32 ) -> crab_common:: Result < u32 > {
105- todo ! ( )
131+ pub fn cleanup_older_than ( & self , days : u32 ) -> crab_common:: Result < u32 > {
132+ let cutoff = SystemTime :: now ( ) - Duration :: from_secs ( u64:: from ( days) * 86400 ) ;
133+ let mut removed = 0u32 ;
134+ let Ok ( entries) = fs:: read_dir ( & self . output_dir ) else {
135+ return Ok ( 0 ) ;
136+ } ;
137+ for entry in entries. flatten ( ) {
138+ let path = entry. path ( ) ;
139+ if path. extension ( ) . and_then ( |e| e. to_str ( ) ) != Some ( "ndjson" ) {
140+ continue ;
141+ }
142+ if let Ok ( meta) = path. metadata ( )
143+ && let Ok ( modified) = meta. modified ( )
144+ && modified < cutoff
145+ && fs:: remove_file ( & path) . is_ok ( )
146+ {
147+ removed += 1 ;
148+ }
149+ }
150+ Ok ( removed)
106151 }
107152}
108153
154+ /// Get today's date as YYYY-MM-DD for file naming.
155+ fn today_str ( ) -> String {
156+ let secs = SystemTime :: now ( )
157+ . duration_since ( UNIX_EPOCH )
158+ . unwrap_or_default ( )
159+ . as_secs ( ) ;
160+ let days = secs / 86400 ;
161+ // Reuse the Hinnant civil date algorithm
162+ let z = days. cast_signed ( ) + 719_468 ;
163+ let era = z. div_euclid ( 146_097 ) ;
164+ let doe = z. rem_euclid ( 146_097 ) as u32 ;
165+ let yoe = ( doe - doe / 1460 + doe / 36524 - doe / 146_096 ) / 365 ;
166+ let y = i64:: from ( yoe) + era * 400 ;
167+ let doy = doe - ( 365 * yoe + yoe / 4 - yoe / 100 ) ;
168+ let mp = ( 5 * doy + 2 ) / 153 ;
169+ let d = doy - ( 153 * mp + 2 ) / 5 + 1 ;
170+ let m = if mp < 10 { mp + 3 } else { mp - 9 } ;
171+ let y = if m <= 2 { y + 1 } else { y } ;
172+ format ! ( "{y:04}-{m:02}-{d:02}" )
173+ }
174+
109175impl std:: fmt:: Debug for LocalExporter {
110176 fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
111177 f. debug_struct ( "LocalExporter" )
@@ -153,4 +219,43 @@ mod tests {
153219 let exporter = LocalExporter :: new ( PathBuf :: from ( "/tmp/telemetry" ) ) ;
154220 assert_eq ! ( exporter. output_dir( ) , & PathBuf :: from( "/tmp/telemetry" ) ) ;
155221 }
222+
223+ #[ test]
224+ fn export_empty_is_noop ( ) {
225+ let tmp = std:: env:: temp_dir ( ) . join ( "crab_export_test_empty" ) ;
226+ let exporter = LocalExporter :: new ( tmp. clone ( ) ) ;
227+ exporter. export_spans ( & [ ] ) . unwrap ( ) ;
228+ exporter. export_metrics ( & [ ] ) . unwrap ( ) ;
229+ let _ = fs:: remove_dir_all ( & tmp) ;
230+ }
231+
232+ #[ test]
233+ fn export_and_list_spans ( ) {
234+ let tmp = std:: env:: temp_dir ( ) . join ( "crab_export_test_spans" ) ;
235+ let _ = fs:: remove_dir_all ( & tmp) ;
236+ let exporter = LocalExporter :: new ( tmp. clone ( ) ) ;
237+
238+ let span = SpanRecord {
239+ name : "test" . into ( ) ,
240+ duration_ms : 10 ,
241+ start_time_ms : 0 ,
242+ attributes : HashMap :: new ( ) ,
243+ parent_id : None ,
244+ span_id : "s1" . into ( ) ,
245+ } ;
246+ exporter. export_spans ( & [ span] ) . unwrap ( ) ;
247+
248+ let files = exporter. list_files ( ) . unwrap ( ) ;
249+ assert ! ( !files. is_empty( ) ) ;
250+
251+ let _ = fs:: remove_dir_all ( & tmp) ;
252+ }
253+
254+ #[ test]
255+ fn today_str_format ( ) {
256+ let s = today_str ( ) ;
257+ assert_eq ! ( s. len( ) , 10 ) ; // YYYY-MM-DD
258+ assert_eq ! ( s. as_bytes( ) [ 4 ] , b'-' ) ;
259+ assert_eq ! ( s. as_bytes( ) [ 7 ] , b'-' ) ;
260+ }
156261}
0 commit comments