Skip to content

Commit 59d9bb1

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 59d9bb1

File tree

3 files changed

+167
-7
lines changed

3 files changed

+167
-7
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: 160 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ use std::io::Write;
1414
use std::path::Path;
1515
use std::sync::Arc;
1616

17+
use bitcoin::secp256k1::PublicKey;
1718
use chrono::Utc;
19+
use lightning::ln::types::ChannelId;
20+
use lightning::types::payment::PaymentHash;
1821
pub use lightning::util::logger::Level as LogLevel;
1922
pub(crate) use lightning::util::logger::{Logger as LdkLogger, Record as LdkRecord};
2023
pub(crate) use lightning::{log_bytes, log_debug, log_error, log_info, log_trace};
@@ -32,6 +35,37 @@ pub struct LogRecord<'a> {
3235
pub module_path: &'a str,
3336
/// The line containing the message.
3437
pub line: u32,
38+
/// The node id of the peer pertaining to the logged record.
39+
pub peer_id: Option<PublicKey>,
40+
/// The channel id of the channel pertaining to the logged record.
41+
pub channel_id: Option<ChannelId>,
42+
/// The payment hash pertaining to the logged record.
43+
pub payment_hash: Option<PaymentHash>,
44+
}
45+
46+
/// Formats the structured context fields (channel_id, peer_id, payment_hash) into a string
47+
/// suitable for appending to log messages.
48+
///
49+
/// Note: LDK's `Record` Display implementation uses fixed-width padded columns and different
50+
/// formatting for test vs production builds. We intentionally use a simpler format here:
51+
/// fields are only included when present (no padding), and the format is consistent across
52+
/// all build configurations. This keeps the implementation straightforward and avoids
53+
/// the complexity of conditional compilation for log formatting.
54+
pub fn format_log_context(
55+
channel_id: Option<ChannelId>, peer_id: Option<PublicKey>, payment_hash: Option<PaymentHash>,
56+
) -> String {
57+
fn truncate_hex(s: &str, len: usize) -> &str {
58+
&s[..s.len().min(len)]
59+
}
60+
61+
let channel_id_str =
62+
channel_id.map(|c| format!(" ch:{}", truncate_hex(&c.to_string(), 6))).unwrap_or_default();
63+
let peer_id_str =
64+
peer_id.map(|p| format!(" p:{}", truncate_hex(&p.to_string(), 6))).unwrap_or_default();
65+
let payment_hash_str = payment_hash
66+
.map(|h| format!(" h:{}", truncate_hex(&format!("{:?}", h), 6)))
67+
.unwrap_or_default();
68+
format!("{}{}{}", channel_id_str, peer_id_str, payment_hash_str)
3569
}
3670

3771
/// A unit of logging output with metadata to enable filtering `module_path`,
@@ -50,6 +84,12 @@ pub struct LogRecord {
5084
pub module_path: String,
5185
/// The line containing the message.
5286
pub line: u32,
87+
/// The node id of the peer pertaining to the logged record.
88+
pub peer_id: Option<PublicKey>,
89+
/// The channel id of the channel pertaining to the logged record.
90+
pub channel_id: Option<ChannelId>,
91+
/// The payment hash pertaining to the logged record.
92+
pub payment_hash: Option<PaymentHash>,
5393
}
5494

5595
#[cfg(feature = "uniffi")]
@@ -60,6 +100,9 @@ impl<'a> From<LdkRecord<'a>> for LogRecord {
60100
args: record.args.to_string(),
61101
module_path: record.module_path.to_string(),
62102
line: record.line,
103+
peer_id: record.peer_id,
104+
channel_id: record.channel_id,
105+
payment_hash: record.payment_hash,
63106
}
64107
}
65108
}
@@ -72,6 +115,9 @@ impl<'a> From<LdkRecord<'a>> for LogRecord<'a> {
72115
args: record.args,
73116
module_path: record.module_path,
74117
line: record.line,
118+
peer_id: record.peer_id,
119+
channel_id: record.channel_id,
120+
payment_hash: record.payment_hash,
75121
}
76122
}
77123
}
@@ -113,19 +159,22 @@ pub(crate) enum Writer {
113159

114160
impl LogWriter for Writer {
115161
fn log(&self, record: LogRecord) {
162+
let context = format_log_context(record.channel_id, record.peer_id, record.payment_hash);
163+
116164
match self {
117165
Writer::FileWriter { file_path, max_log_level } => {
118166
if record.level < *max_log_level {
119167
return;
120168
}
121169

122170
let log = format!(
123-
"{} {:<5} [{}:{}] {}\n",
171+
"{} {:<5} [{}:{}] {}{}\n",
124172
Utc::now().format("%Y-%m-%d %H:%M:%S%.3f"),
125173
record.level.to_string(),
126174
record.module_path,
127175
record.line,
128-
record.args
176+
record.args,
177+
context,
129178
);
130179

131180
fs::OpenOptions::new()
@@ -153,7 +202,7 @@ impl LogWriter for Writer {
153202
.target(record.module_path)
154203
.module_path(Some(record.module_path))
155204
.line(Some(record.line))
156-
.args(format_args!("{}", record.args))
205+
.args(format_args!("{}{}", record.args, context))
157206
.build(),
158207
);
159208
#[cfg(feature = "uniffi")]
@@ -162,7 +211,7 @@ impl LogWriter for Writer {
162211
.target(&record.module_path)
163212
.module_path(Some(&record.module_path))
164213
.line(Some(record.line))
165-
.args(format_args!("{}", record.args))
214+
.args(format_args!("{}{}", record.args, context))
166215
.build(),
167216
);
168217
},
@@ -222,3 +271,110 @@ impl LdkLogger for Logger {
222271
}
223272
}
224273
}
274+
275+
#[cfg(test)]
276+
mod tests {
277+
use super::*;
278+
use std::sync::Mutex;
279+
280+
/// Tests that format_log_context correctly formats all three structured fields
281+
/// (channel_id, peer_id, payment_hash) with space prefixes and 6-char truncation.
282+
#[test]
283+
fn test_format_log_context_all_fields() {
284+
let channel_id = ChannelId::from_bytes([
285+
0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
286+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
287+
0x00, 0x00, 0x00, 0x00,
288+
]);
289+
let peer_id = PublicKey::from_slice(&[
290+
0x02, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1, 0x23, 0x45,
291+
0x67, 0x89, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1, 0x23,
292+
0x45, 0x67, 0x89, 0xab, 0xcd,
293+
])
294+
.unwrap();
295+
let payment_hash = PaymentHash([
296+
0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
297+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
298+
0x00, 0x00, 0x00, 0x00,
299+
]);
300+
301+
let result = format_log_context(Some(channel_id), Some(peer_id), Some(payment_hash));
302+
303+
assert_eq!(result, " ch:abcdef p:02abcd h:fedcba");
304+
}
305+
306+
/// Tests that format_log_context returns an empty string when no fields are provided.
307+
#[test]
308+
fn test_format_log_context_no_fields() {
309+
let result = format_log_context(None, None, None);
310+
assert_eq!(result, "");
311+
}
312+
313+
/// Tests that format_log_context only includes present fields.
314+
#[test]
315+
fn test_format_log_context_partial_fields() {
316+
let channel_id = ChannelId::from_bytes([
317+
0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
318+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
319+
0x00, 0x00, 0x00, 0x00,
320+
]);
321+
322+
let result = format_log_context(Some(channel_id), None, None);
323+
assert_eq!(result, " ch:123456");
324+
}
325+
326+
/// A minimal log facade logger that captures log output for testing.
327+
struct TestLogger {
328+
log: Arc<Mutex<String>>,
329+
}
330+
331+
impl log::Log for TestLogger {
332+
fn enabled(&self, _metadata: &log::Metadata) -> bool {
333+
true
334+
}
335+
336+
fn log(&self, record: &log::Record) {
337+
*self.log.lock().unwrap() = record.args().to_string();
338+
}
339+
340+
fn flush(&self) {}
341+
}
342+
343+
/// Tests that LogFacadeWriter appends structured context fields to the log message.
344+
#[test]
345+
fn test_log_facade_writer_includes_structured_context() {
346+
let log = Arc::new(Mutex::new(String::new()));
347+
let test_logger = TestLogger { log: log.clone() };
348+
349+
let _ = log::set_boxed_logger(Box::new(test_logger));
350+
log::set_max_level(log::LevelFilter::Trace);
351+
352+
let writer = Writer::LogFacadeWriter;
353+
354+
let channel_id = ChannelId::from_bytes([
355+
0xab, 0xcd, 0xef, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
356+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
357+
0x00, 0x00, 0x00, 0x00,
358+
]);
359+
let peer_id = PublicKey::from_slice(&[
360+
0x02, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1, 0x23, 0x45,
361+
0x67, 0x89, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1, 0x23,
362+
0x45, 0x67, 0x89, 0xab, 0xcd,
363+
])
364+
.unwrap();
365+
366+
let record = LogRecord {
367+
level: LogLevel::Info,
368+
args: format_args!("Test message"),
369+
module_path: "test_module",
370+
line: 42,
371+
peer_id: Some(peer_id),
372+
channel_id: Some(channel_id),
373+
payment_hash: None,
374+
};
375+
376+
writer.log(record);
377+
378+
assert_eq!(*log.lock().unwrap(), "Test message ch:abcdef p:02abcd");
379+
}
380+
}

tests/common/logging.rs

Lines changed: 4 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::{format_log_context, 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,14 @@ 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+
format_log_context(record.channel_id, record.peer_id, record.payment_hash),
166167
);
167168

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

0 commit comments

Comments
 (0)