Skip to content

Commit 66aae5a

Browse files
feat: add orcid-fetcher-logger crate for flexible logging and log file output (#10)
* feat: add orcid-fetcher-logger crate * creates the `orcid-fetcher-logger` crate based on `flexi_logger` * replace `tracing` with `orcid-fetcher-logger` * enhance logging functionality: flexible logging + logfile output * docs: update README.md for orcid-fetcher-logger * fix: remove multiple clap parsing * fix: correct error messages in io.rs
1 parent 5194481 commit 66aae5a

File tree

9 files changed

+502
-301
lines changed

9 files changed

+502
-301
lines changed

Cargo.lock

Lines changed: 277 additions & 180 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
[workspace]
22
members = [
3+
"crates/orcid-fetcher-logger",
34
"crates/orcid-works-model",
4-
"crates/orcid-works-cli"
5+
"crates/orcid-works-cli",
56
]
67
resolver = "2"

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ orcid-works-cli --id $ORCID_ID [Options]
5656
| `--rate-limit` \<u32\> | Requests-per-second cap (1–40). See also [Guidelines](#guidelines) section. | `12` |
5757
| `--user-agent-note` \<String\> | Text appended to the built-in User-Agent string | *(none)* |
5858
| `--force-fetch` | Ignore diff and refetch every work-detail entry | `false` |
59+
| `-v, -vv`, `--verbose` | Increase console verbosity ||
60+
| `-q, -qq, -qqq`, `--quiet` | Decrease console verbosity ||
61+
| `-l`, `--log` \<PathBuf\> | Output trace log file path (parent dirs auto-created) | *(none)* |
5962
| `-h`, `--help` | Print help ||
6063
| `-V`, `--version` | Print version ||
6164

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[package]
2+
name = "orcid-fetcher-logger"
3+
version = "0.0.0"
4+
edition = "2024"
5+
license = "Apache-2.0"
6+
7+
[dependencies]
8+
anyhow = "1"
9+
flexi_logger = "0.31"
10+
log = "0.4"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
use anyhow::{Context, Result};
2+
use flexi_logger::{Duplicate, FileSpec, LevelFilter, Logger};
3+
use std::path::PathBuf;
4+
5+
pub fn console_level(v: u8, q: u8) -> LevelFilter {
6+
let def_idx = 3i8; // def: Info
7+
let idx = def_idx + v as i8 - q as i8;
8+
match idx {
9+
i8::MIN..=0 => LevelFilter::Off, // -qqq
10+
1 => LevelFilter::Error, // -qq
11+
2 => LevelFilter::Warn, // -q
12+
3 => LevelFilter::Info, // default
13+
4 => LevelFilter::Debug, // -v
14+
_ => LevelFilter::Trace, // -vv
15+
}
16+
}
17+
18+
pub fn init_logger(console_level: LevelFilter, log_file: Option<PathBuf>) -> Result<()> {
19+
let logger = if let Some(p) = &log_file {
20+
Logger::with(LevelFilter::Trace)
21+
.log_to_file(FileSpec::try_from(p)?)
22+
.duplicate_to_stderr(Duplicate::from(console_level))
23+
} else {
24+
Logger::with(console_level)
25+
};
26+
27+
if let Err(e) = logger.start() {
28+
return Err(e).context("failed to start logger");
29+
}
30+
31+
Ok(())
32+
}

crates/orcid-works-cli/Cargo.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@ build = "build.rs"
66
license = "Apache-2.0"
77

88
[dependencies]
9+
orcid-fetcher-logger = { path = "../orcid-fetcher-logger" , version = "0.0.0" }
910
orcid-works-model = { path = "../orcid-works-model" , version = "0.2.1" }
11+
1012
anyhow = "1"
1113
clap = { version = "4", features = ["derive"] }
14+
flexi_logger = "0.31"
1215
futures = "0.3"
1316
governor = "0.10"
17+
log = "0.4"
1418
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
1519
serde = { version = "1", features = ["derive"] }
1620
serde_json = "1"
1721
serde_path_to_error = "0.1"
1822
tempfile = "3"
1923
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
20-
tracing = "0.1"
21-
tracing-subscriber = "0.3"
24+
uuid = { version = "1", features = ["v4"] }

crates/orcid-works-cli/src/api.rs

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,85 @@
11
use anyhow::{Context, Result, bail};
2+
use log::{debug, error};
23
use reqwest::{
34
Client,
45
header::{ACCEPT, HeaderValue},
56
};
67
use serde::de::DeserializeOwned;
7-
use tracing::{Instrument, error, instrument};
8+
use uuid::Uuid;
89

910
use orcid_works_model::{OrcidWorkDetail, OrcidWorks};
1011

1112
const BASE: &str = "https://pub.orcid.org/v3.0";
12-
const JSON_ACCEPT: &str = "application/json";
1313

1414
// Build HTTP Client
1515
pub(crate) fn build_client(ua: &str) -> Result<Client> {
16-
let client = Client::builder().user_agent(ua).build()?;
16+
let client = match Client::builder().user_agent(ua).build() {
17+
Ok(cli) => cli,
18+
Err(e) => {
19+
let eid = Uuid::new_v4();
20+
error!("[{eid}] failed to build HTTP client");
21+
debug!("[{eid}] ua={ua}");
22+
return Err(e).with_context(|| format!("[{eid}] failed to build HTTP client"));
23+
}
24+
};
1725
Ok(client)
1826
}
1927

2028
// Get JSON from URL
21-
#[instrument(name = "get_json", skip_all)]
2229
async fn get_json<T>(client: &Client, url: &str) -> Result<T>
2330
where
2431
T: DeserializeOwned,
2532
{
26-
let res = client
33+
let res = match client
2734
.get(url)
28-
.header(ACCEPT, HeaderValue::from_static(JSON_ACCEPT))
35+
.header(ACCEPT, HeaderValue::from_static("application/json"))
2936
.send()
3037
.await
31-
.with_context(|| format!("GET {url}"))?;
38+
{
39+
Ok(r) => r,
40+
Err(e) => {
41+
let eid = Uuid::new_v4();
42+
error!("E[{eid}] HTTP connection failure");
43+
debug!("E[{eid}] url={url}");
44+
return Err(e).with_context(|| format!("[{eid}] HTTP connection failure"));
45+
}
46+
};
3247

3348
if res.error_for_status_ref().is_err() {
3449
let status = res.status();
3550
let body = res.text().await.unwrap_or_default();
3651

37-
error!(%status, %url, response_body = %body, "HTTP error");
38-
bail!("HTTP {status} while GET {url}: {body}");
52+
let eid = Uuid::new_v4();
53+
error!("E[{eid}] HTTP {status}");
54+
debug!("E[{eid}] url={url}");
55+
debug!("E[{eid}] response body={body}");
56+
bail!("E[{eid}] HTTP {status}");
3957
}
4058

4159
match res.json::<T>().await {
4260
Ok(parsed) => Ok(parsed),
43-
4461
Err(e) => {
62+
let eid = Uuid::new_v4();
4563
if e.is_decode() {
46-
error!(%url, err = %e, "JSON parse failure");
64+
error!("E[{eid}] JSON parse failure");
4765
} else {
48-
error!(%url, err = %e, "response body read failure");
66+
error!("E[{eid}] response body read failure");
4967
}
50-
Err(e).with_context(|| format!("parse JSON from {url}"))
68+
debug!("E[{eid}] url={url}");
69+
Err(e).with_context(|| format!("E[{eid}] failed to parse JSON"))
5170
}
5271
}
5372
}
5473

5574
// GET /{id}/works
56-
#[instrument(name = "fetch_works", skip_all)]
5775
pub async fn fetch_works(client: &reqwest::Client, id: &str) -> Result<OrcidWorks> {
5876
let url = format!("{BASE}/{id}/works");
5977
get_json::<OrcidWorks>(client, &url)
60-
.in_current_span()
6178
.await
62-
.with_context(|| format!("fetch work summaries for ORCID iD {id}"))
79+
.with_context(|| format!("failed to fetch work summaries of ORCID iD {id}"))
6380
}
6481

6582
// GET /{id}/work/{putcode}
66-
#[instrument(name = "fetch_work_detail", skip_all)]
6783
pub async fn fetch_work_detail(
6884
client: &reqwest::Client,
6985
id: &str,
@@ -72,7 +88,6 @@ pub async fn fetch_work_detail(
7288
let url = format!("{BASE}/{id}/work/{putcode}");
7389

7490
get_json::<OrcidWorkDetail>(client, &url)
75-
.in_current_span()
7691
.await
77-
.with_context(|| format!("fetch work detail of putcode {putcode}"))
92+
.with_context(|| format!("failed to fetch work detail of putcode {putcode}"))
7893
}

crates/orcid-works-cli/src/io.rs

Lines changed: 73 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,126 @@
1-
use anyhow::{Context, Result};
1+
use anyhow::{Context, Result, bail};
2+
use log::{debug, error, info};
23
use serde_path_to_error::deserialize;
34
use std::{
45
fs::File,
5-
io::{BufReader, BufWriter, ErrorKind, Write},
6+
io::{BufReader, ErrorKind, Write},
67
path::Path,
78
};
8-
99
use tempfile::NamedTempFile;
10-
use tracing::{error, info, instrument, warn};
10+
use uuid::Uuid;
1111

1212
use orcid_works_model::OrcidWorkDetailFile;
1313

1414
// Read the existing JSON file; use the empty list if absent.
15-
#[instrument(name = "read_work_details_json", skip_all)]
1615
pub(crate) fn read_work_details_json<P: AsRef<Path>>(path: P) -> Result<OrcidWorkDetailFile> {
1716
let path = path.as_ref();
1817

1918
match File::open(path) {
2019
Ok(file) => {
20+
// Treat zero-byte files as an empty JSON list.
21+
if file.metadata()?.len() == 0 {
22+
return Ok(OrcidWorkDetailFile { records: vec![] });
23+
}
24+
2125
let reader = BufReader::new(file);
2226
let mut de = serde_json::Deserializer::from_reader(reader);
2327

24-
let data: OrcidWorkDetailFile = deserialize(&mut de).map_err(|e| {
25-
error!(
26-
path = path.display().to_string(),
27-
err = %e,
28-
"JSON parse failure"
29-
);
30-
e
31-
})?;
28+
let eid = Uuid::new_v4();
29+
let data: OrcidWorkDetailFile = deserialize(&mut de)
30+
.inspect_err(|e| {
31+
error!("E[{eid}] JSON parse failure: path={}", path.display());
32+
debug!("E[{eid}] error: {e}");
33+
})
34+
.with_context(|| {
35+
format!("E[{eid}] failed to parse JSON file {}", path.display())
36+
})?;
3237

3338
Ok(data)
3439
}
3540

3641
Err(e) if e.kind() == ErrorKind::NotFound => {
37-
info!(
38-
path = path.display().to_string(),
39-
"file not found; use empty JSON"
40-
);
42+
info!("file not found; use empty JSON: path={}", path.display());
4143
Ok(OrcidWorkDetailFile { records: vec![] })
4244
}
4345

4446
Err(e) => {
45-
error!(path= path.display().to_string(), err = %e, "failed to open work-detail file");
46-
Err(e).with_context(|| format!("open {}", path.display()))
47+
let eid = Uuid::new_v4();
48+
error!(
49+
"E[{eid}] failed to open work-detail file: path={}",
50+
path.display()
51+
);
52+
debug!("E[{eid}] error: {e}");
53+
Err(e).with_context(|| format!("E[{eid}] failed to open {}", path.display()))
4754
}
4855
}
4956
}
5057

5158
// Write JSON file
52-
#[instrument(name = "write_pretty_json", skip_all)]
5359
pub(crate) fn write_pretty_json<P: AsRef<Path>>(
5460
path: P,
5561
value: &OrcidWorkDetailFile,
5662
) -> Result<()> {
5763
let path = path.as_ref();
64+
65+
// Fail fast if the target path is a directory.
66+
if path.is_dir() {
67+
bail!("output path is a directory: {}", path.display());
68+
}
69+
5870
let parent = path.parent().unwrap_or(Path::new("."));
5971

60-
let mut tmp = match NamedTempFile::new_in(parent)
61-
.with_context(|| format!("create temp file for {}", path.display()))
62-
{
72+
let mut temp = match NamedTempFile::new_in(parent) {
6373
Ok(f) => f,
6474
Err(e) => {
65-
error!(path = path.display().to_string(), err = %e, "failed to create temp file");
66-
return Err(e);
75+
let eid = Uuid::new_v4();
76+
debug!("E[{eid}] error: {e}");
77+
error!(
78+
"E[{eid}] failed to create temp file: path={}",
79+
path.display()
80+
);
81+
return Err(e).with_context(|| {
82+
format!("E[{eid}] failed to create temp file for {}", path.display())
83+
});
6784
}
6885
};
6986

70-
if let Err(e) = serde_json::to_writer_pretty(BufWriter::new(&mut tmp), value)
71-
.with_context(|| format!("serialize JSON into {}", path.display()))
72-
{
73-
error!(path = path.display().to_string(), err = %e, "JSON serialization failure");
74-
return Err(e);
87+
if let Err(e) = serde_json::to_writer_pretty(&mut temp, value) {
88+
let eid = Uuid::new_v4();
89+
debug!("E[{eid}] error: {e}");
90+
error!(
91+
"E[{eid}] JSON serialisation failure: path={}",
92+
path.display(),
93+
);
94+
return Err(e)
95+
.with_context(|| format!("E[{eid}] failed to serialise JSON into {}", path.display()));
7596
}
7697

77-
if let Err(e) = tmp.as_file_mut().flush() {
78-
error!(path = path.display().to_string(), err = %e, "flush failure");
79-
return Err(e).context("flush tmp file");
80-
}
81-
if let Err(e) = tmp.as_file_mut().sync_all() {
82-
error!(path = path.display().to_string(), err = %e, "fsync failure");
83-
return Err(e).context("fsync tmp file");
98+
if let Err(e) = temp.as_file_mut().flush() {
99+
let eid = Uuid::new_v4();
100+
debug!("E[{eid}] error: {e}");
101+
error!("E[{eid}] flush failure: path={}", path.display());
102+
return Err(e)
103+
.with_context(|| format!("E[{eid}] failed to flush temp file {}", path.display()));
84104
}
85105

86-
if let Err(e) = tmp
87-
.persist(path)
88-
.map_err(|e| e.error)
89-
.with_context(|| format!("rename temp file into {}", path.display()))
90-
{
91-
error!(path = path.display().to_string(), err = %e, "atomic rename failure");
92-
return Err(e);
106+
if let Err(e) = temp.as_file_mut().sync_all() {
107+
let eid = Uuid::new_v4();
108+
debug!("E[{eid}] error: {e}");
109+
error!("E[{eid}] fsync failure: path={}", path.display());
110+
return Err(e)
111+
.with_context(|| format!("E[{eid}] failed to fsync temp file {}", path.display()));
93112
}
94113

95-
if let Ok(dir) = parent.to_owned().canonicalize() {
96-
if let Ok(dir_fd) = File::open(&dir) {
97-
let _ = dir_fd.sync_all();
98-
}
114+
if let Err(e) = temp.persist(path).map_err(|e| e.error) {
115+
let eid = Uuid::new_v4();
116+
debug!("E[{eid}] error: {e}");
117+
error!("E[{eid}] atomic rename failure path={}", path.display());
118+
return Err(e).with_context(|| {
119+
format!(
120+
"E[{eid}] failed to rename temp file into {}",
121+
path.display()
122+
)
123+
});
99124
}
100125

101126
Ok(())

0 commit comments

Comments
 (0)