Skip to content

Commit 479183b

Browse files
committed
better export format
1 parent b1df933 commit 479183b

File tree

3 files changed

+132
-42
lines changed

3 files changed

+132
-42
lines changed

Cargo.lock

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/psql/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ redb = "2.1.3"
3030
serde = { version = "1.0", features = ["derive"] }
3131
serde_json = "1.0"
3232
terminal_size = "0.4.0"
33+
jiff = "0.1"
3334
tracing = "0.1"
3435
lloggs = { version = "1.1.0", optional = true }
3536
rand = "0.8"

crates/psql/src/bin/psql-history.rs

Lines changed: 115 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use bestool_psql::history::History;
22
use clap::{Parser, Subcommand};
3+
use jiff::Timestamp;
34
use lloggs::{LoggingArgs, PreArgs, WorkerGuard};
45
use miette::{miette, IntoDiagnostic, Result};
6+
use serde::Serialize;
57
use std::path::PathBuf;
68
use tracing::debug;
79

@@ -122,7 +124,7 @@ fn main() -> Result<()> {
122124
}
123125

124126
for (timestamp, entry) in entries {
125-
let datetime = timestamp_to_datetime(timestamp);
127+
let datetime = timestamp_to_rfc3339(timestamp);
126128
let mode = if entry.writemode { "WRITE" } else { "READ" };
127129
println!(
128130
"[{}] {} - db={} sys={}",
@@ -149,7 +151,7 @@ fn main() -> Result<()> {
149151
}
150152

151153
for (timestamp, entry) in entries {
152-
let datetime = timestamp_to_datetime(timestamp);
154+
let datetime = timestamp_to_rfc3339(timestamp);
153155
let mode = if entry.writemode { "WRITE" } else { "READ" };
154156
println!(
155157
"[{}] {} - db:{} sys:{}",
@@ -211,8 +213,8 @@ fn main() -> Result<()> {
211213
}
212214
}
213215

214-
let oldest = timestamp_to_datetime(entries.first().unwrap().0);
215-
let newest = timestamp_to_datetime(entries.last().unwrap().0);
216+
let oldest = timestamp_to_rfc3339(entries.first().unwrap().0);
217+
let newest = timestamp_to_rfc3339(entries.last().unwrap().0);
216218

217219
println!("History Statistics");
218220
println!("==================");
@@ -247,53 +249,125 @@ fn main() -> Result<()> {
247249
Commands::Export { output } => {
248250
let entries = history.list()?;
249251

250-
let json = serde_json::to_string_pretty(&entries).into_diagnostic()?;
252+
// Convert entries to export format with RFC3339 timestamps
253+
let export_entries: Vec<ExportEntry> = entries
254+
.into_iter()
255+
.map(|(timestamp, entry)| ExportEntry {
256+
ts: timestamp_to_rfc3339(timestamp),
257+
query: entry.query,
258+
db_user: entry.db_user,
259+
sys_user: entry.sys_user,
260+
writemode: entry.writemode,
261+
tailscale: entry.tailscale,
262+
ots: entry.ots,
263+
})
264+
.collect();
251265

252266
if let Some(path) = output {
253-
std::fs::write(path, json).into_diagnostic()?;
267+
use std::io::Write;
268+
let mut file = std::fs::File::create(path).into_diagnostic()?;
269+
for entry in export_entries {
270+
let json = serde_json::to_string(&entry).into_diagnostic()?;
271+
writeln!(file, "{}", json).into_diagnostic()?;
272+
}
254273
println!("History exported.");
255274
} else {
256-
println!("{}", json);
275+
for entry in export_entries {
276+
let json = serde_json::to_string(&entry).into_diagnostic()?;
277+
println!("{}", json);
278+
}
257279
}
258280
}
259281
}
260282

261283
Ok(())
262284
}
263285

264-
fn timestamp_to_datetime(micros: u64) -> String {
265-
use std::time::{Duration, UNIX_EPOCH};
266-
267-
let duration = Duration::from_micros(micros);
268-
let system_time = UNIX_EPOCH + duration;
269-
270-
if let Ok(duration_since_epoch) = system_time.duration_since(UNIX_EPOCH) {
271-
let secs = duration_since_epoch.as_secs();
272-
let nanos = duration_since_epoch.subsec_nanos();
273-
274-
let days_since_epoch = secs / 86400;
275-
let seconds_today = secs % 86400;
276-
let hours = seconds_today / 3600;
277-
let minutes = (seconds_today % 3600) / 60;
278-
let seconds = seconds_today % 60;
279-
280-
let year = 1970 + (days_since_epoch / 365) as i32;
281-
let day_of_year = (days_since_epoch % 365) as u32;
282-
283-
let month = 1 + (day_of_year / 30).min(11);
284-
let day = 1 + (day_of_year % 30).min(30);
285-
286-
format!(
287-
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:06}",
288-
year,
289-
month,
290-
day,
291-
hours,
292-
minutes,
293-
seconds,
294-
nanos / 1000
295-
)
296-
} else {
297-
format!("{}", micros)
286+
/// Export entry format with RFC3339 timestamp
287+
#[derive(Debug, Serialize)]
288+
struct ExportEntry {
289+
ts: String,
290+
query: String,
291+
db_user: String,
292+
sys_user: String,
293+
writemode: bool,
294+
#[serde(skip_serializing_if = "Vec::is_empty")]
295+
tailscale: Vec<bestool_psql::history::TailscalePeer>,
296+
#[serde(skip_serializing_if = "Option::is_none")]
297+
ots: Option<String>,
298+
}
299+
300+
fn timestamp_to_rfc3339(micros: u64) -> String {
301+
// Convert microseconds to seconds and nanoseconds
302+
let secs = (micros / 1_000_000) as i64;
303+
let nanos = ((micros % 1_000_000) * 1_000) as i32;
304+
305+
// Create timestamp and format as RFC3339
306+
Timestamp::new(secs, nanos)
307+
.map(|ts| ts.to_string())
308+
.unwrap_or_else(|_| format!("invalid-timestamp-{}", micros))
309+
}
310+
311+
#[cfg(test)]
312+
mod tests {
313+
use super::*;
314+
315+
#[test]
316+
fn test_timestamp_to_rfc3339() {
317+
// Test a known timestamp: 2024-01-15 13:10:45.123456 UTC
318+
// = 1705324245123456 microseconds since epoch
319+
let micros = 1705324245123456;
320+
let rfc3339 = timestamp_to_rfc3339(micros);
321+
322+
// Should be in RFC3339 format with T separator and Z timezone
323+
assert_eq!(rfc3339, "2024-01-15T13:10:45.123456Z");
324+
}
325+
326+
#[test]
327+
fn test_export_entry_serialization() {
328+
let entry = ExportEntry {
329+
ts: "2024-01-15T12:30:45.123456Z".to_string(),
330+
query: "SELECT * FROM users;".to_string(),
331+
db_user: "postgres".to_string(),
332+
sys_user: "alice".to_string(),
333+
writemode: false,
334+
tailscale: vec![],
335+
ots: None,
336+
};
337+
338+
let json = serde_json::to_string(&entry).unwrap();
339+
340+
// Should be compact (single line)
341+
assert!(!json.contains('\n'));
342+
343+
// Should contain expected fields
344+
assert!(json.contains("\"ts\""));
345+
assert!(json.contains("\"query\""));
346+
assert!(json.contains("\"db_user\""));
347+
assert!(json.contains("\"sys_user\""));
348+
assert!(json.contains("\"writemode\""));
349+
350+
// Should NOT contain empty tailscale or null ots (due to skip_serializing_if)
351+
assert!(!json.contains("\"tailscale\""));
352+
assert!(!json.contains("\"ots\""));
353+
}
354+
355+
#[test]
356+
fn test_export_entry_with_ots() {
357+
let entry = ExportEntry {
358+
ts: "2024-01-15T12:30:45.123456Z".to_string(),
359+
query: "INSERT INTO logs VALUES (1);".to_string(),
360+
db_user: "postgres".to_string(),
361+
sys_user: "alice".to_string(),
362+
writemode: true,
363+
tailscale: vec![],
364+
ots: Some("bob-watching".to_string()),
365+
};
366+
367+
let json = serde_json::to_string(&entry).unwrap();
368+
369+
// Should contain ots when present
370+
assert!(json.contains("\"ots\""));
371+
assert!(json.contains("bob-watching"));
298372
}
299373
}

0 commit comments

Comments
 (0)