Skip to content

Commit db45dff

Browse files
joostjagerclaude
andcommitted
Add structured logging context fields to LogRecord
Extend LogRecord with peer_id, channel_id, and payment_hash fields from LDK's Record struct. These structured fields are now available to custom LogWriter implementations and are automatically appended to log messages by the built-in FileWriter and LogFacadeWriter. - Add peer_id, channel_id, payment_hash fields to LogRecord (both uniffi and non-uniffi versions) - Add format_log_context() helper to format fields with truncated hex - Update FileWriter and LogFacadeWriter to append context to messages - Update UDL bindings with new LogRecord fields - Add unit tests for format_log_context and LogFacadeWriter Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 5ea8f3c commit db45dff

File tree

3 files changed

+223
-8
lines changed

3 files changed

+223
-8
lines changed

bindings/ldk_node.udl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ dictionary LogRecord {
8383
string args;
8484
string module_path;
8585
u32 line;
86+
PublicKey? peer_id;
87+
ChannelId? channel_id;
88+
PaymentHash? payment_hash;
8689
};
8790

8891
[Trait, WithForeign]

src/logger.rs

Lines changed: 212 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77

88
//! Logging-related objects.
99
10-
#[cfg(not(feature = "uniffi"))]
1110
use core::fmt;
1211
use std::fs;
1312
use std::io::Write;
1413
use std::path::Path;
1514
use std::sync::Arc;
1615

16+
use bitcoin::secp256k1::PublicKey;
1717
use chrono::Utc;
18+
use lightning::ln::types::ChannelId;
19+
use lightning::types::payment::PaymentHash;
1820
pub use lightning::util::logger::Level as LogLevel;
1921
pub(crate) use lightning::util::logger::{Logger as LdkLogger, Record as LdkRecord};
2022
pub(crate) use lightning::{log_bytes, log_debug, log_error, log_info, log_trace};
@@ -32,6 +34,73 @@ pub struct LogRecord<'a> {
3234
pub module_path: &'a str,
3335
/// The line containing the message.
3436
pub line: u32,
37+
/// The node id of the peer pertaining to the logged record.
38+
pub peer_id: Option<PublicKey>,
39+
/// The channel id of the channel pertaining to the logged record.
40+
pub channel_id: Option<ChannelId>,
41+
/// The payment hash pertaining to the logged record.
42+
pub payment_hash: Option<PaymentHash>,
43+
}
44+
45+
/// Structured context fields for log messages.
46+
///
47+
/// Implements `Display` to format context fields (channel_id, peer_id, payment_hash) directly
48+
/// into a formatter, avoiding intermediate heap allocations when used with `format_args!` or
49+
/// `write!` macros.
50+
pub struct LogContext<'a> {
51+
/// The channel id of the channel pertaining to the logged record.
52+
pub channel_id: Option<&'a ChannelId>,
53+
/// The node id of the peer pertaining to the logged record.
54+
pub peer_id: Option<&'a PublicKey>,
55+
/// The payment hash pertaining to the logged record.
56+
pub payment_hash: Option<&'a PaymentHash>,
57+
}
58+
59+
impl<'a> LogContext<'a> {
60+
/// Creates a new `LogContext` from the given fields.
61+
pub fn new(
62+
channel_id: Option<&'a ChannelId>, peer_id: Option<&'a PublicKey>,
63+
payment_hash: Option<&'a PaymentHash>,
64+
) -> Self {
65+
Self { channel_id, peer_id, payment_hash }
66+
}
67+
}
68+
69+
/// Note: LDK's `Record` Display implementation uses fixed-width padded columns and different
70+
/// formatting for test vs production builds. We intentionally use a simpler format here:
71+
/// fields are only included when present (no padding), and the format is consistent across
72+
/// all build configurations.
73+
impl fmt::Display for LogContext<'_> {
74+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75+
fn truncate(s: &str) -> &str {
76+
&s[..s.len().min(6)]
77+
}
78+
79+
if self.channel_id.is_none() && self.peer_id.is_none() && self.payment_hash.is_none() {
80+
return Ok(());
81+
}
82+
83+
write!(f, " (")?;
84+
let mut need_space = false;
85+
if let Some(c) = self.channel_id {
86+
write!(f, "ch:{}", truncate(&c.to_string()))?;
87+
need_space = true;
88+
}
89+
if let Some(p) = self.peer_id {
90+
if need_space {
91+
write!(f, " ")?;
92+
}
93+
write!(f, "p:{}", truncate(&p.to_string()))?;
94+
need_space = true;
95+
}
96+
if let Some(h) = self.payment_hash {
97+
if need_space {
98+
write!(f, " ")?;
99+
}
100+
write!(f, "h:{}", truncate(&format!("{:?}", h)))?;
101+
}
102+
write!(f, ")")
103+
}
35104
}
36105

37106
/// A unit of logging output with metadata to enable filtering `module_path`,
@@ -50,6 +119,12 @@ pub struct LogRecord {
50119
pub module_path: String,
51120
/// The line containing the message.
52121
pub line: u32,
122+
/// The node id of the peer pertaining to the logged record.
123+
pub peer_id: Option<PublicKey>,
124+
/// The channel id of the channel pertaining to the logged record.
125+
pub channel_id: Option<ChannelId>,
126+
/// The payment hash pertaining to the logged record.
127+
pub payment_hash: Option<PaymentHash>,
53128
}
54129

55130
#[cfg(feature = "uniffi")]
@@ -60,6 +135,9 @@ impl<'a> From<LdkRecord<'a>> for LogRecord {
60135
args: record.args.to_string(),
61136
module_path: record.module_path.to_string(),
62137
line: record.line,
138+
peer_id: record.peer_id,
139+
channel_id: record.channel_id,
140+
payment_hash: record.payment_hash,
63141
}
64142
}
65143
}
@@ -72,6 +150,9 @@ impl<'a> From<LdkRecord<'a>> for LogRecord<'a> {
72150
args: record.args,
73151
module_path: record.module_path,
74152
line: record.line,
153+
peer_id: record.peer_id,
154+
channel_id: record.channel_id,
155+
payment_hash: record.payment_hash,
75156
}
76157
}
77158
}
@@ -113,19 +194,26 @@ pub(crate) enum Writer {
113194

114195
impl LogWriter for Writer {
115196
fn log(&self, record: LogRecord) {
197+
let context = LogContext::new(
198+
record.channel_id.as_ref(),
199+
record.peer_id.as_ref(),
200+
record.payment_hash.as_ref(),
201+
);
202+
116203
match self {
117204
Writer::FileWriter { file_path, max_log_level } => {
118205
if record.level < *max_log_level {
119206
return;
120207
}
121208

122209
let log = format!(
123-
"{} {:<5} [{}:{}] {}\n",
210+
"{} {:<5} [{}:{}] {}{}\n",
124211
Utc::now().format("%Y-%m-%d %H:%M:%S%.3f"),
125212
record.level.to_string(),
126213
record.module_path,
127214
record.line,
128-
record.args
215+
record.args,
216+
context,
129217
);
130218

131219
fs::OpenOptions::new()
@@ -153,7 +241,7 @@ impl LogWriter for Writer {
153241
.target(record.module_path)
154242
.module_path(Some(record.module_path))
155243
.line(Some(record.line))
156-
.args(format_args!("{}", record.args))
244+
.args(format_args!("{}{}", record.args, context))
157245
.build(),
158246
);
159247
#[cfg(feature = "uniffi")]
@@ -162,7 +250,7 @@ impl LogWriter for Writer {
162250
.target(&record.module_path)
163251
.module_path(Some(&record.module_path))
164252
.line(Some(record.line))
165-
.args(format_args!("{}", record.args))
253+
.args(format_args!("{}{}", record.args, context))
166254
.build(),
167255
);
168256
},
@@ -222,3 +310,122 @@ impl LdkLogger for Logger {
222310
}
223311
}
224312
}
313+
314+
#[cfg(test)]
315+
mod tests {
316+
use super::*;
317+
use std::sync::Mutex;
318+
319+
/// Tests that LogContext correctly formats all three structured fields
320+
/// (channel_id, peer_id, payment_hash) with space prefixes and 6-char truncation.
321+
#[test]
322+
fn test_log_context_all_fields() {
323+
let channel_id = ChannelId::from_bytes([
324+
0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
325+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
326+
0x00, 0x00, 0x00, 0x00,
327+
]);
328+
let peer_id = PublicKey::from_slice(&[
329+
0x02, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1, 0x23, 0x45,
330+
0x67, 0x89, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1, 0x23,
331+
0x45, 0x67, 0x89, 0xab, 0xcd,
332+
])
333+
.unwrap();
334+
let payment_hash = PaymentHash([
335+
0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
336+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
337+
0x00, 0x00, 0x00, 0x00,
338+
]);
339+
340+
let context = LogContext::new(Some(&channel_id), Some(&peer_id), Some(&payment_hash));
341+
342+
assert_eq!(context.to_string(), " (ch:abcdef p:02abcd h:fedcba)");
343+
}
344+
345+
/// Tests that LogContext returns an empty string when no fields are provided.
346+
#[test]
347+
fn test_log_context_no_fields() {
348+
let context = LogContext::new(None, None, None);
349+
assert_eq!(context.to_string(), "");
350+
}
351+
352+
/// Tests that LogContext only includes present fields.
353+
#[test]
354+
fn test_log_context_partial_fields() {
355+
let channel_id = ChannelId::from_bytes([
356+
0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
357+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
358+
0x00, 0x00, 0x00, 0x00,
359+
]);
360+
361+
let context = LogContext::new(Some(&channel_id), None, None);
362+
assert_eq!(context.to_string(), " (ch:123456)");
363+
}
364+
365+
/// A minimal log facade logger that captures log output for testing.
366+
struct TestLogger {
367+
log: Arc<Mutex<String>>,
368+
}
369+
370+
impl log::Log for TestLogger {
371+
fn enabled(&self, _metadata: &log::Metadata) -> bool {
372+
true
373+
}
374+
375+
fn log(&self, record: &log::Record) {
376+
*self.log.lock().unwrap() = record.args().to_string();
377+
}
378+
379+
fn flush(&self) {}
380+
}
381+
382+
/// Tests that LogFacadeWriter appends structured context fields to the log message.
383+
#[test]
384+
fn test_log_facade_writer_includes_structured_context() {
385+
let log = Arc::new(Mutex::new(String::new()));
386+
let test_logger = TestLogger { log: log.clone() };
387+
388+
let _ = log::set_boxed_logger(Box::new(test_logger));
389+
log::set_max_level(log::LevelFilter::Trace);
390+
391+
let writer = Writer::LogFacadeWriter;
392+
393+
let channel_id = ChannelId::from_bytes([
394+
0xab, 0xcd, 0xef, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
395+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
396+
0x00, 0x00, 0x00, 0x00,
397+
]);
398+
let peer_id = PublicKey::from_slice(&[
399+
0x02, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1, 0x23, 0x45,
400+
0x67, 0x89, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1, 0x23,
401+
0x45, 0x67, 0x89, 0xab, 0xcd,
402+
])
403+
.unwrap();
404+
405+
#[cfg(not(feature = "uniffi"))]
406+
let record = LogRecord {
407+
level: LogLevel::Info,
408+
args: format_args!("Test message"),
409+
module_path: "test_module",
410+
line: 42,
411+
peer_id: Some(peer_id),
412+
channel_id: Some(channel_id),
413+
payment_hash: None,
414+
};
415+
416+
#[cfg(feature = "uniffi")]
417+
let record = LogRecord {
418+
level: LogLevel::Info,
419+
args: "Test message".to_string(),
420+
module_path: "test_module".to_string(),
421+
line: 42,
422+
peer_id: Some(peer_id),
423+
channel_id: Some(channel_id),
424+
payment_hash: None,
425+
};
426+
427+
writer.log(record);
428+
429+
assert_eq!(*log.lock().unwrap(), "Test message (ch:abcdef p:02abcd)");
430+
}
431+
}

tests/common/logging.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::sync::{Arc, Mutex};
22

33
use chrono::Utc;
4-
use ldk_node::logger::{LogLevel, LogRecord, LogWriter};
4+
use ldk_node::logger::{LogContext, LogLevel, LogRecord, LogWriter};
55
#[cfg(not(feature = "uniffi"))]
66
use log::Record as LogFacadeRecord;
77
use log::{Level as LogFacadeLevel, LevelFilter as LogFacadeLevelFilter, Log as LogFacadeLog};
@@ -156,13 +156,18 @@ impl MultiNodeLogger {
156156
impl LogWriter for MultiNodeLogger {
157157
fn log(&self, record: LogRecord) {
158158
let log = format!(
159-
"[{}] {} {:<5} [{}:{}] {}\n",
159+
"[{}] {} {:<5} [{}:{}] {}{}\n",
160160
self.node_id,
161161
Utc::now().format("%Y-%m-%d %H:%M:%S%.3f"),
162162
record.level.to_string(),
163163
record.module_path,
164164
record.line,
165-
record.args
165+
record.args,
166+
LogContext::new(
167+
record.channel_id.as_ref(),
168+
record.peer_id.as_ref(),
169+
record.payment_hash.as_ref()
170+
),
166171
);
167172

168173
print!("{}", log);

0 commit comments

Comments
 (0)