• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

spesmilo / electrum / 5766584628674560

20 Aug 2025 05:57PM UTC coverage: 61.507% (-0.03%) from 61.535%
5766584628674560

Pull #10159

CirrusCI

SomberNight
logging: add config.LOGS_MAX_TOTAL_SIZE_BYTES: to limit size on disk
Pull Request #10159: logging: add config.LOGS_MAX_TOTAL_SIZE_BYTES: to limit size on disk

7 of 31 new or added lines in 2 files covered. (22.58%)

125 existing lines in 47 files now uncovered.

22810 of 37085 relevant lines covered (61.51%)

3.07 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

43.78
/electrum/logging.py
1
# Copyright (C) 2019 The Electrum developers
2
# Distributed under the MIT software license, see the accompanying
3
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
4

5
import logging
5✔
6
import logging.handlers
5✔
7
import datetime
5✔
8
import sys
5✔
9
import pathlib
5✔
10
import os
5✔
11
import platform
5✔
12
from typing import Optional, TYPE_CHECKING, Set
5✔
13
import copy
5✔
14
import subprocess
5✔
15
import hashlib
5✔
16

17
if TYPE_CHECKING:
2✔
UNCOV
18
    from .simple_config import SimpleConfig
19

20

21
class LogFormatterForFiles(logging.Formatter):
5✔
22

23
    def formatTime(self, record, datefmt=None):
5✔
24
        # timestamps follow ISO 8601 UTC
25
        date = datetime.datetime.fromtimestamp(record.created).astimezone(datetime.timezone.utc)
×
26
        if not datefmt:
×
27
            datefmt = "%Y%m%dT%H%M%S.%fZ"
×
28
        return date.strftime(datefmt)
×
29

30
    def format(self, record):
5✔
31
        record = _shorten_name_of_logrecord(record)
×
32
        return super().format(record)
×
33

34

35
file_formatter = LogFormatterForFiles(fmt="%(asctime)22s | %(levelname)8s | %(name)s | %(message)s")
5✔
36

37

38
class LogFormatterForConsole(logging.Formatter):
5✔
39

40
    def formatTime(self, record, datefmt=None):
5✔
41
        t = record.relativeCreated / 1000
5✔
42
        return f"{t:6.2f}"
5✔
43

44
    def format(self, record):
5✔
45
        record = copy.copy(record)  # avoid mutating arg
5✔
46
        record = _shorten_name_of_logrecord(record)
5✔
47
        text = super().format(record)
5✔
48
        return text
5✔
49

50

51
# try to make console log lines short... no timestamp, short levelname, no "electrum."
52
console_formatter = LogFormatterForConsole(fmt="%(asctime)s | %(levelname).1s | %(name)s | %(message)s")
5✔
53

54

55
def _shorten_name_of_logrecord(record: logging.LogRecord) -> logging.LogRecord:
5✔
56
    record = copy.copy(record)  # avoid mutating arg
5✔
57
    # strip the main module name from the logger name
58
    if record.name.startswith("electrum."):
5✔
59
        record.name = record.name[9:]
5✔
60
    # manual map to shorten common module names
61
    record.name = record.name.replace("interface.Interface", "interface", 1)
5✔
62
    record.name = record.name.replace("network.Network", "network", 1)
5✔
63
    record.name = record.name.replace("synchronizer.Synchronizer", "synchronizer", 1)
5✔
64
    record.name = record.name.replace("verifier.SPV", "verifier", 1)
5✔
65
    record.name = record.name.replace("gui.qt.main_window.ElectrumWindow", "gui.qt.main_window", 1)
5✔
66
    return record
5✔
67

68

69
class TruncatingMemoryHandler(logging.handlers.MemoryHandler):
5✔
70
    """An in-memory log handler that only keeps the first N log messages
71
    and discards the rest.
72
    """
73
    target: Optional['logging.Handler']
5✔
74

75
    def __init__(self):
5✔
76
        logging.handlers.MemoryHandler.__init__(
×
77
            self,
78
            capacity=1,  # note: this is the flushing frequency, ~unused by us
79
            flushLevel=logging.DEBUG,
80
        )
81
        self.max_size = 100  # max num of messages we keep
×
82
        self.num_messages_seen = 0
×
83
        self.__never_dumped = True
×
84

85
    # note: this flush implementation *keeps* the buffer as-is, instead of clearing it
86
    def flush(self):
5✔
87
        self.acquire()
×
88
        try:
×
89
            if self.target:
×
90
                for record in self.buffer:
×
91
                    if record.levelno >= self.target.level:
×
92
                        self.target.handle(record)
×
93
        finally:
94
            self.release()
×
95

96
    def dump_to_target(self, target: 'logging.Handler'):
5✔
97
        self.acquire()
×
98
        try:
×
99
            self.setTarget(target)
×
100
            self.flush()
×
101
            self.setTarget(None)
×
102
        finally:
103
            self.__never_dumped = False
×
104
            self.release()
×
105

106
    def emit(self, record):
5✔
107
        self.num_messages_seen += 1
×
108
        if len(self.buffer) < self.max_size:
×
109
            super().emit(record)
×
110

111
    def close(self) -> None:
5✔
112
        # Check if captured log lines were never to dumped to e.g. stderr,
113
        # and if so, try to do it now. This is useful e.g. in case of sys.exit().
114
        if self.__never_dumped:
×
115
            _configure_stderr_logging()
×
116
        super().close()
×
117

118

119
def _delete_old_logs(path, *, num_files_keep: int, max_total_size: int):
5✔
120
    """Delete old logfiles, only keeping the latest few."""
NEW
121
    def sortkey_oldest_first(p: pathlib.PurePath):
×
NEW
122
        fname = p.name
×
NEW
123
        basename, ext, counter = str(fname).partition(".log")
×
124
        # - each time electrum is launched, there will be a new basename, ordered by date
125
        # - for any given basename, there might be multiple log files, differing by counter
126
        #   - empty counter is newest, then .1 is older, .2 is even older, etc
127
        try:
×
NEW
128
            counter = int(counter[1:]) if counter else 0  # convert ".2" -> 2
×
NEW
129
        except ValueError:
×
NEW
130
            _logger.warning(f"failed to parse log file name: {fname}")
×
NEW
131
            counter = 0
×
NEW
132
        return basename, -counter
×
NEW
133
    files = sorted(
×
134
        list(pathlib.Path(path).glob("electrum_log_*.log*")),
135
        key=sortkey_oldest_first,
136
    )
NEW
137
    total_size = sum(os.stat(f).st_size for f in files)  # in bytes
×
NEW
138
    num_files_remaining = len(files)
×
NEW
139
    for f in files:
×
NEW
140
        fsize = os.stat(f).st_size
×
NEW
141
        if total_size < max_total_size and num_files_remaining <= num_files_keep:
×
NEW
142
            break
×
NEW
143
        total_size -= fsize
×
NEW
144
        num_files_remaining -= 1
×
NEW
145
        try:
×
NEW
146
            os.remove(f)
×
147
        except OSError as e:
×
148
            _logger.warning(f"cannot delete old logfile: {e}")
×
149

150

151
_logfile_path = None
5✔
152
def _configure_file_logging(
5✔
153
    log_directory: pathlib.Path,
154
    *,
155
    num_files_keep: int,
156
    max_total_size: int,
157
):
UNCOV
158
    from .util import os_chmod
×
159

160
    global _logfile_path
161
    assert _logfile_path is None, 'file logging already initialized'
×
162
    log_directory.mkdir(exist_ok=True, mode=0o700)
×
163

NEW
164
    _delete_old_logs(log_directory, num_files_keep=num_files_keep, max_total_size=max_total_size)
×
165

166
    timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
×
167
    PID = os.getpid()
×
168
    _logfile_path = log_directory / f"electrum_log_{timestamp}_{PID}.log"
×
169
    # we create the file with restrictive perms, instead of letting FileHandler create it
170
    with open(_logfile_path, "w+") as f:
×
171
        os_chmod(_logfile_path, 0o600)
×
172

NEW
173
    logfile_backupcount = 4
×
NEW
174
    file_handler = logging.handlers.RotatingFileHandler(
×
175
        _logfile_path,
176
        maxBytes=max_total_size // (logfile_backupcount+1),
177
        backupCount=logfile_backupcount,
178
        encoding='utf-8')
179
    file_handler.setFormatter(file_formatter)
×
180
    file_handler.setLevel(logging.DEBUG)
×
181
    root_logger.addHandler(file_handler)
×
182
    if _inmemory_startup_logs:
×
183
        _inmemory_startup_logs.dump_to_target(file_handler)
×
184

185

186
console_stderr_handler = None
5✔
187
def _configure_stderr_logging(*, verbosity=None):
5✔
188
    # log to stderr; by default only WARNING and higher
189
    global console_stderr_handler
190
    if console_stderr_handler is not None:
5✔
191
        _logger.warning("stderr handler already exists")
×
192
        return
×
193
    console_stderr_handler = logging.StreamHandler(sys.stderr)
5✔
194
    console_stderr_handler.setFormatter(console_formatter)
5✔
195
    if not verbosity:
5✔
196
        console_stderr_handler.setLevel(logging.WARNING)
×
197
        root_logger.addHandler(console_stderr_handler)
×
198
    else:
199
        console_stderr_handler.setLevel(logging.DEBUG)
5✔
200
        root_logger.addHandler(console_stderr_handler)
5✔
201
        _process_verbosity_log_levels(verbosity)
5✔
202
    if _inmemory_startup_logs:
5✔
203
        _inmemory_startup_logs.dump_to_target(console_stderr_handler)
×
204

205

206
def _process_verbosity_log_levels(verbosity):
5✔
207
    if verbosity == '*' or not isinstance(verbosity, str):
5✔
208
        return
5✔
209
    # example verbosity:
210
    #   debug,network=error,interface=error      // effectively blacklists network and interface
211
    #   warning,network=debug,interface=debug    // effectively whitelists network and interface
212
    filters = verbosity.split(',')
×
213
    for filt in filters:
×
214
        if not filt: continue
×
215
        items = filt.split('=')
×
216
        if len(items) == 1:
×
217
            level = items[0]
×
218
            electrum_logger.setLevel(level.upper())
×
219
        elif len(items) == 2:
×
220
            logger_name, level = items
×
221
            logger = get_logger(logger_name)
×
222
            logger.setLevel(level.upper())
×
223
        else:
224
            raise Exception(f"invalid log filter: {filt}")
×
225

226

227
class _CustomLogger(logging.getLoggerClass()):
5✔
228
    def __init__(self, name, *args, **kwargs):
5✔
229
        super().__init__(name, *args, **kwargs)
5✔
230
        self.msg_hashes_seen = set()  # type: Set[bytes]
5✔
231
        # ^ note: size grows without bounds, but only for log lines using "only_once".
232

233
    def _log(self, level, msg: str, *args, only_once: bool = False, **kwargs) -> None:
5✔
234
        """Overridden to add 'only_once' arg to logger.debug()/logger.info()/logger.warning()/etc."""
235
        if only_once:  # if set, this logger will only log this msg a single time during its lifecycle
5✔
236
            msg_hash = hashlib.sha256(msg.encode("utf-8")).digest()
5✔
237
            if msg_hash in self.msg_hashes_seen:
5✔
238
                return
×
239
            self.msg_hashes_seen.add(msg_hash)
5✔
240
        super()._log(level, msg, *args, **kwargs)
5✔
241

242
logging.setLoggerClass(_CustomLogger)
5✔
243

244

245
# enable logs universally (including for other libraries)
246
root_logger = logging.getLogger()
5✔
247
root_logger.setLevel(logging.WARNING)
5✔
248

249
# Start collecting log messages now, into an in-memory buffer. This buffer is only
250
# used until the proper log handlers are fully configured, including their verbosity,
251
# at which point we will dump its contents into those, and remove this log handler.
252
# Note: this is set up at import-time instead of e.g. as part of a function that is
253
#       called from run_electrum (the main script). This is to have this run as early
254
#       as possible.
255
# Note: some users might use Electrum as a python library and not use run_electrum,
256
#       in which case these logs might never get redirected or cleaned up.
257
#       Also, the python docs recommend libraries not to set a handler, to
258
#       avoid interfering with the user's logging.
259
_inmemory_startup_logs = None
5✔
260
if getattr(sys, "_ELECTRUM_RUNNING_VIA_RUNELECTRUM", False):
5✔
261
    _inmemory_startup_logs = TruncatingMemoryHandler()
×
262
    root_logger.addHandler(_inmemory_startup_logs)
×
263

264
# creates a logger specifically for electrum library
265
electrum_logger = logging.getLogger("electrum")
5✔
266
electrum_logger.setLevel(logging.DEBUG)
5✔
267

268

269
# --- External API
270

271
def get_logger(name: str) -> _CustomLogger:
5✔
272
    prefix = "electrum."
5✔
273
    if name.startswith(prefix):
5✔
274
        name = name[len(prefix):]
5✔
275
    return electrum_logger.getChild(name)
5✔
276

277

278
_logger = get_logger(__name__)
5✔
279
_logger.setLevel(logging.INFO)
5✔
280

281

282
class Logger:
5✔
283

284
    def __init__(self):
5✔
285
        self.logger = self.__get_logger_for_obj()
5✔
286

287
    def __get_logger_for_obj(self) -> logging.Logger:
5✔
288
        cls = self.__class__
5✔
289
        if cls.__module__:
5✔
290
            name = f"{cls.__module__}.{cls.__name__}"
5✔
291
        else:
292
            name = cls.__name__
×
293
        try:
5✔
294
            diag_name = self.diagnostic_name()
5✔
295
        except Exception as e:
×
296
            raise Exception("diagnostic name not yet available?") from e
×
297
        if diag_name:
5✔
298
            name += f".[{diag_name}]"
5✔
299
        logger = get_logger(name)
5✔
300
        return logger
5✔
301

302
    def diagnostic_name(self):
5✔
303
        return ''
5✔
304

305

306
def configure_logging(config: 'SimpleConfig', *, log_to_file: Optional[bool] = None) -> None:
5✔
307
    from .util import is_android_debug_apk
×
308

309
    verbosity = config.get('verbosity')
×
310
    if not verbosity and config.GUI_ENABLE_DEBUG_LOGS:
×
311
        verbosity = '*'
×
312
    _configure_stderr_logging(verbosity=verbosity)
×
313

314
    if log_to_file is None:
×
315
        log_to_file = config.WRITE_LOGS_TO_DISK
×
316
        log_to_file |= is_android_debug_apk()
×
317
    if log_to_file:
×
318
        log_directory = pathlib.Path(config.path) / "logs"
×
319
        num_files_keep = config.LOGS_NUM_FILES_KEEP
×
NEW
320
        max_total_size = config.LOGS_MAX_TOTAL_SIZE_BYTES
×
NEW
321
        _configure_file_logging(log_directory, num_files_keep=num_files_keep, max_total_size=max_total_size)
×
322

323
    # clean up and delete in-memory logs
324
    global _inmemory_startup_logs
325
    if _inmemory_startup_logs:
×
326
        num_discarded = _inmemory_startup_logs.num_messages_seen - _inmemory_startup_logs.max_size
×
327
        if num_discarded > 0:
×
328
            _logger.warning(f"Too many log messages! Some have been discarded. "
×
329
                            f"(discarded {num_discarded} messages)")
330
        _inmemory_startup_logs.close()
×
331
        root_logger.removeHandler(_inmemory_startup_logs)
×
332
        _inmemory_startup_logs = None
×
333

334
    from . import ELECTRUM_VERSION
×
335
    from .constants import GIT_REPO_URL
×
336
    _logger.info(f"Electrum version: {ELECTRUM_VERSION} - https://electrum.org - {GIT_REPO_URL}")
×
337
    _logger.info(f"Python version: {sys.version}. On platform: {describe_os_version()}")
×
338
    _logger.info(f"Logging to file: {str(_logfile_path)}")
×
339
    _logger.info(f"Log filters: verbosity {repr(verbosity)}")
×
340

341

342
def get_logfile_path() -> Optional[pathlib.Path]:
5✔
343
    return _logfile_path
×
344

345

346
def describe_os_version() -> str:
5✔
347
    if 'ANDROID_DATA' in os.environ:
×
348
        import jnius
×
349
        bv = jnius.autoclass('android.os.Build$VERSION')
×
350
        b = jnius.autoclass('android.os.Build')
×
351
        return "Android {} on {} {} ({})".format(bv.RELEASE, b.BRAND, b.DEVICE, b.DISPLAY)
×
352
    else:
353
        return platform.platform()
×
354

355

356
def get_git_version() -> Optional[str]:
5✔
357
    dir = os.path.dirname(os.path.realpath(__file__))
×
358
    try:
×
359
        version = subprocess.check_output(
×
360
            ['git', 'describe', '--always', '--dirty'], cwd=dir)
361
        version = str(version, "utf8").strip()
×
362
    except Exception:
×
363
        version = None
×
364
    return version
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc