Skip to content

Commit c4efea9

Browse files
committed
feat(logging): add customizable log formats for main and child logs
1 parent 4bf1e57 commit c4efea9

File tree

9 files changed

+270
-19
lines changed

9 files changed

+270
-19
lines changed

docs/configuration.rst

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,35 @@ follows.
301301

302302
*Introduced*: 3.0
303303

304+
``logfile_format``
305+
306+
The format string used for entries in the main supervisord activity log.
307+
This uses Python's `logging format strings <https://docs.python.org/3/library/logging.html#logrecord-attributes>`_.
308+
Available fields include ``%(asctime)s`` (timestamp), ``%(levelname)s``
309+
(log level), ``%(message)s`` (log message), ``%(process)d`` (process ID),
310+
``%(name)s`` (logger name), and other standard Python logging attributes.
311+
312+
*Default*: ``%(asctime)s %(levelname)s %(message)s``
313+
314+
*Required*: No.
315+
316+
*Introduced*: 4.3.0
317+
318+
``childlog_format``
319+
320+
The format string used for entries in child process log files (stdout/stderr).
321+
This uses Python's `logging format strings <https://docs.python.org/3/library/logging.html#logrecord-attributes>`_.
322+
Available fields include ``%(asctime)s`` (timestamp), ``%(message)s``
323+
(the actual output from the child process), ``%(name)s`` (logger name),
324+
and other standard Python logging attributes. Note that ``%(levelname)s``
325+
and ``%(process)d`` refer to the supervisord process, not the child process.
326+
327+
*Default*: ``%(message)s``
328+
329+
*Required*: No.
330+
331+
*Introduced*: 4.3.0
332+
304333
``pidfile``
305334

306335
The location in which supervisord keeps its pid file. This option
@@ -485,6 +514,8 @@ follows.
485514
logfile_maxbytes = 50MB
486515
logfile_backups=10
487516
loglevel = info
517+
logfile_format = %(asctime)s %(levelname)s %(message)s
518+
childlog_format = %(message)s
488519
pidfile = /tmp/supervisord.pid
489520
nodaemon = false
490521
minfds = 1024
@@ -930,6 +961,13 @@ where specified.
930961
that is not seekable, log rotation must be disabled by setting
931962
``stdout_logfile_maxbytes = 0``.
932963

964+
.. note::
965+
966+
The format of entries written to the stdout log file is controlled
967+
by the ``childlog_format`` option in the ``[supervisord]`` section.
968+
By default, only the raw output from the child process is logged,
969+
but you can customize it to include timestamps and other information.
970+
933971
*Default*: ``AUTO``
934972

935973
*Required*: No.
@@ -990,6 +1028,8 @@ where specified.
9901028
``stdout_syslog``
9911029

9921030
If true, stdout will be directed to syslog along with the process name.
1031+
The format of syslog entries is controlled by the ``childlog_format``
1032+
option in the ``[supervisord]`` section, prefixed with the process name.
9931033

9941034
*Default*: False
9951035

@@ -1015,6 +1055,13 @@ where specified.
10151055
that is not seekable, log rotation must be disabled by setting
10161056
``stderr_logfile_maxbytes = 0``.
10171057

1058+
.. note::
1059+
1060+
The format of entries written to the stderr log file is controlled
1061+
by the ``childlog_format`` option in the ``[supervisord]`` section.
1062+
By default, only the raw output from the child process is logged,
1063+
but you can customize it to include timestamps and other information.
1064+
10181065
*Default*: ``AUTO``
10191066

10201067
*Required*: No.
@@ -1073,6 +1120,8 @@ where specified.
10731120
``stderr_syslog``
10741121

10751122
If true, stderr will be directed to syslog along with the process name.
1123+
The format of syslog entries is controlled by the ``childlog_format``
1124+
option in the ``[supervisord]`` section, prefixed with the process name.
10761125

10771126
*Default*: False
10781127

supervisor/dispatchers.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,13 @@ def __init__(self, process, event_type, fd):
105105
self.endtoken_data = (endtoken, len(endtoken))
106106
self.mainlog_level = loggers.LevelsByName.DEBG
107107
config = self.process.config
108-
self.log_to_mainlog = config.options.loglevel <= self.mainlog_level
108+
# Handle case where options haven't been realized yet
109+
loglevel = getattr(config.options, 'loglevel', None)
110+
if loglevel is None:
111+
# Default loglevel when not set
112+
from supervisor.loggers import LevelsByName
113+
loglevel = LevelsByName.INFO
114+
self.log_to_mainlog = loglevel <= self.mainlog_level
109115
self.stdout_events_enabled = config.stdout_events_enabled
110116
self.stderr_events_enabled = config.stderr_events_enabled
111117

@@ -126,19 +132,26 @@ def _init_normallog(self):
126132
self.normallog = config.options.getLogger()
127133

128134
if logfile:
135+
fmt = getattr(config.options, 'childlog_format', None)
136+
if fmt is None:
137+
fmt = '%(message)s' # Default format
129138
loggers.handle_file(
130139
self.normallog,
131140
filename=logfile,
132-
fmt='%(message)s',
141+
fmt=fmt,
133142
rotating=not not maxbytes, # optimization
134143
maxbytes=maxbytes,
135144
backups=backups
136145
)
137146

138147
if to_syslog:
148+
childlog_format = getattr(config.options, 'childlog_format', None)
149+
if childlog_format is None:
150+
childlog_format = '%(message)s' # Default format
151+
fmt = config.name + ' ' + childlog_format
139152
loggers.handle_syslog(
140153
self.normallog,
141-
fmt=config.name + ' %(message)s'
154+
fmt=fmt
142155
)
143156

144157
def _init_capturelog(self):

supervisor/loggers.py

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,12 @@ class FileHandler(Handler):
153153
"""File handler which supports reopening of logs.
154154
"""
155155

156-
def __init__(self, filename, mode='ab'):
156+
def __init__(self, filename, mode='ab', fmt=None):
157157
Handler.__init__(self)
158158

159+
if fmt is not None:
160+
self.setFormat(fmt)
161+
159162
try:
160163
self.stream = open(filename, mode)
161164
except OSError as e:
@@ -187,7 +190,7 @@ def remove(self):
187190

188191
class RotatingFileHandler(FileHandler):
189192
def __init__(self, filename, mode='ab', maxBytes=512*1024*1024,
190-
backupCount=10):
193+
backupCount=10, fmt=None):
191194
"""
192195
Open the specified file and use it as the stream for logging.
193196
@@ -210,7 +213,7 @@ def __init__(self, filename, mode='ab', maxBytes=512*1024*1024,
210213
"""
211214
if maxBytes > 0:
212215
mode = 'ab' # doesn't make sense otherwise!
213-
FileHandler.__init__(self, filename, mode)
216+
FileHandler.__init__(self, filename, mode, fmt)
214217
self.maxBytes = maxBytes
215218
self.backupCount = backupCount
216219
self.counter = 0
@@ -292,8 +295,17 @@ def asdict(self):
292295
msg = as_string(self.msg)
293296
if self.kw:
294297
msg = msg % self.kw
295-
self.dictrepr = {'message':msg, 'levelname':levelname,
296-
'asctime':asctime}
298+
self.dictrepr = {
299+
'message': msg,
300+
'levelname': levelname,
301+
'asctime': asctime,
302+
'levelno': self.level,
303+
'process': os.getpid(),
304+
'processName': 'supervisord',
305+
'threadName': 'MainThread'
306+
}
307+
self.dictrepr.update(self.kw)
308+
297309
return self.dictrepr
298310

299311
class Logger:
@@ -379,8 +391,18 @@ def emit(self, record):
379391
except:
380392
self.handleError()
381393

382-
def getLogger(level=None):
383-
return Logger(level)
394+
def getLogger(level=None, fmt=None):
395+
logger = Logger(level)
396+
if fmt is not None:
397+
# Create a handler with the specified format
398+
handler = StreamHandler()
399+
handler.setFormat(fmt)
400+
if level is not None:
401+
handler.setLevel(level)
402+
else:
403+
handler.setLevel(logger.level)
404+
logger.addHandler(handler)
405+
return logger
384406

385407
_2MB = 1<<21
386408

@@ -400,6 +422,13 @@ def handle_stdout(logger, fmt):
400422
handler.setLevel(logger.level)
401423
logger.addHandler(handler)
402424

425+
def handle_stderr(logger, fmt):
426+
"""Attach a new StreamHandler with stderr handler to an existing Logger"""
427+
handler = StreamHandler(sys.stderr)
428+
handler.setFormat(fmt)
429+
handler.setLevel(logger.level)
430+
logger.addHandler(handler)
431+
403432
def handle_syslog(logger, fmt):
404433
"""Attach a new Syslog handler to an existing Logger"""
405434
handler = SyslogHandler()
@@ -413,10 +442,28 @@ def handle_file(logger, filename, fmt, rotating=False, maxbytes=0, backups=0):
413442
if filename == 'syslog': # TODO remove this
414443
handler = SyslogHandler()
415444
else:
416-
if rotating is False:
417-
handler = FileHandler(filename)
418-
else:
419-
handler = RotatingFileHandler(filename, 'a', maxbytes, backups)
420-
handler.setFormat(fmt)
421-
handler.setLevel(logger.level)
422-
logger.addHandler(handler)
445+
if filename == 'stdout':
446+
return handle_stdout(logger, fmt)
447+
if filename == 'stderr':
448+
return handle_stderr(logger, fmt)
449+
if not os.path.exists(filename):
450+
# touching the file
451+
try:
452+
open(filename, 'a').close()
453+
except (IOError, OSError):
454+
pass
455+
try:
456+
if rotating:
457+
handler = RotatingFileHandler(
458+
filename,
459+
maxBytes=maxbytes,
460+
backupCount=backups
461+
)
462+
else:
463+
handler = FileHandler(filename)
464+
handler.setFormat(fmt)
465+
handler.setLevel(logger.level)
466+
logger.addHandler(handler)
467+
return handler
468+
except (IOError, OSError):
469+
logger.error('Cannot open file %s for writing' % filename)

supervisor/options.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,12 @@ def __init__(self):
456456
self.add(None, None, "v", "version", self.version)
457457
self.add("nodaemon", "supervisord.nodaemon", "n", "nodaemon", flag=1,
458458
default=0)
459+
self.add("logfile_format", "supervisord.logfile_format",
460+
"", "logfile_format=", str,
461+
default="%(asctime)s %(levelname)s %(message)s")
462+
self.add("childlog_format", "supervisord.childlog_format",
463+
"", "childlog_format=", str,
464+
default="%(message)s")
459465
self.add("user", "supervisord.user", "u:", "user=")
460466
self.add("umask", "supervisord.umask", "m:", "umask=",
461467
octal_type, default='022')
@@ -528,6 +534,31 @@ def realize(self, *arg, **kw):
528534
self.uid = uid
529535
self.gid = gid_for_uid(uid)
530536

537+
loglevel = self.loglevel
538+
if loglevel is None:
539+
loglevel = section.loglevel
540+
541+
logfile_format = section.logfile_format
542+
childlog_format = section.childlog_format
543+
544+
# Configure the main logger
545+
self.logger = loggers.getLogger(loglevel)
546+
547+
if self.logfile:
548+
logfile = self.logfile
549+
else:
550+
logfile = section.logfile
551+
552+
if logfile:
553+
loggers.handle_file(
554+
self.logger,
555+
logfile,
556+
logfile_format,
557+
rotating=True,
558+
maxbytes=section.logfile_maxbytes,
559+
backups=section.logfile_backups
560+
)
561+
531562
if not self.loglevel:
532563
self.loglevel = section.loglevel
533564

@@ -650,14 +681,19 @@ def get(opt, default, **kwargs):
650681
section.pidfile = existing_dirpath(get('pidfile', 'supervisord.pid'))
651682
section.identifier = get('identifier', 'supervisor')
652683
section.nodaemon = boolean(get('nodaemon', 'false'))
684+
section.logfile_format = get('logfile_format',
685+
'%(asctime)s %(levelname)s %(message)s',
686+
do_expand=False)
687+
section.childlog_format = get('childlog_format', '%(message)s',
688+
do_expand=False)
653689
section.silent = boolean(get('silent', 'false'))
654690

655691
tempdir = tempfile.gettempdir()
656692
section.childlogdir = existing_directory(get('childlogdir', tempdir))
657693
section.nocleanup = boolean(get('nocleanup', 'false'))
658694
section.strip_ansi = boolean(get('strip_ansi', 'false'))
659695

660-
environ_str = get('environment', '')
696+
environ_str = get('environment', '', do_expand=False)
661697
environ_str = expand(environ_str, expansions, 'environment')
662698
section.environment = dict_of_key_value_pairs(environ_str)
663699

supervisor/skel/sample.conf

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ logfile=/tmp/supervisord.log ; main log file; default $CWD/supervisord.log
4646
logfile_maxbytes=50MB ; max main logfile bytes b4 rotation; default 50MB
4747
logfile_backups=10 ; # of main logfile backups; 0 means none, default 10
4848
loglevel=info ; log level; default info; others: debug,warn,trace
49+
50+
; Main supervisord log file format
51+
; Format string for main log file - uses standard Python logging format strings
52+
; Default: %(asctime)s %(levelname)s %(message)s
53+
;logfile_format=%(asctime)s %(levelname)s %(message)s
54+
55+
; Child process log file format
56+
; Format string for child process logs - uses standard Python logging format strings
57+
; Default: %(message)s
58+
;childlog_format=%(message)s
59+
4960
pidfile=/tmp/supervisord.pid ; supervisord pidfile; default supervisord.pid
5061
nodaemon=false ; start in foreground if true; default false
5162
silent=false ; no logs to stdout if true; default false

supervisor/tests/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def __init__(self):
8989
self.umaskset = None
9090
self.poller = DummyPoller(self)
9191
self.silent = False
92+
self.childlog_format = '%(message)s'
9293

9394
def getLogger(self, *args, **kw):
9495
logger = DummyLogger()

supervisor/tests/test_dispatchers.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,47 @@ def test_close(self):
569569
dispatcher.close() # make sure we don't error if we try to close twice
570570
self.assertEqual(dispatcher.closed, True)
571571

572+
def test_custom_format_for_process_logs(self):
573+
"""Test custom format is applied to process logs"""
574+
from supervisor.datatypes import boolean, logfile_name
575+
from supervisor.options import ServerOptions
576+
options = ServerOptions() # need real options to get a real logger
577+
options.childlog_format = '%(asctime)s [%(levelname)s] %(message)s'
578+
579+
config = DummyPConfig(options, 'process1', '/bin/process1',
580+
stdout_logfile=logfile_name('/tmp/foo'))
581+
process = DummyProcess(config)
582+
dispatcher = self._makeOne(process)
583+
584+
# Check that format is passed to logger
585+
self.assertEqual(dispatcher.childlog.handlers[0].fmt,
586+
'%(asctime)s [%(levelname)s] %(message)s')
587+
588+
def test_different_formats_for_main_and_child_logs(self):
589+
"""Test different formats for main and child logs"""
590+
from supervisor.datatypes import boolean, logfile_name
591+
from supervisor.options import ServerOptions
592+
options = ServerOptions()
593+
options.logfile_format = '%(asctime)s MAIN: %(message)s'
594+
options.childlog_format = '%(asctime)s CHILD: %(message)s'
595+
596+
# Configure child process logs
597+
config = DummyPConfig(options, 'process1', '/bin/process1',
598+
stdout_logfile=logfile_name('/tmp/foo'))
599+
process = DummyProcess(config)
600+
dispatcher = self._makeOne(process)
601+
602+
# Check that child format is used
603+
self.assertEqual(dispatcher.childlog.handlers[0].fmt,
604+
'%(asctime)s CHILD: %(message)s')
605+
606+
# Configure a main log
607+
from supervisor.loggers import getLogger
608+
logger = getLogger(fmt=options.logfile_format)
609+
610+
# Check that main format is used
611+
self.assertEqual(logger.handlers[0].fmt,
612+
'%(asctime)s MAIN: %(message)s')
572613

573614
class PInputDispatcherTests(unittest.TestCase):
574615
def _getTargetClass(self):

0 commit comments

Comments
 (0)