openrvdas.server.logger_runner

Low-level class to run a logger config in its own process and write its stderr to a file.

Can be run from the command line as follows:

   server/logger_runner.py      --config test/NBP1406/NBP1406_cruise.yaml:gyr1->net      --stderr_file /var/log/openrvdas/gyr1.stderr

But its main intended use is to be invoked by another module to start a logger in its own, non-blocking process:

    runner = LoggerRunner(config=config, name=logger,
                          stderr_file=stderr_file,
                          logger_log_level=self.logger_log_level)
    self.logger_runner_map[logger] = runner
    self.logger_runner_map[logger].start()

Simulated Serial Ports:

The NBP1406_cruise.yaml file above specifies configs that read from simulated serial ports and write to UDP port 6224. To get the configs to actually run, you'll need to run

  logger/utils/simulate_data.py --config test/NBP1406/simulate_NBP1406.yaml

in a separate terminal window to create the virtual serial ports the sample config references and feed simulated data through them.)

To verify that the scripts are actually working as intended, you can create a network listener on port 6224 in yet another window:

  logger/listener/listen.py --network :6224
  1#!/usr/bin/env python3
  2"""Low-level class to run a logger config in its own process and write
  3its stderr to a file.
  4
  5Can be run from the command line as follows:
  6```
  7   server/logger_runner.py \
  8     --config test/NBP1406/NBP1406_cruise.yaml:gyr1->net \
  9     --stderr_file /var/log/openrvdas/gyr1.stderr
 10```
 11
 12But its main intended use is to be invoked by another module to start
 13a logger in its own, non-blocking process:
 14```
 15    runner = LoggerRunner(config=config, name=logger,
 16                          stderr_file=stderr_file,
 17                          logger_log_level=self.logger_log_level)
 18    self.logger_runner_map[logger] = runner
 19    self.logger_runner_map[logger].start()
 20```
 21Simulated Serial Ports:
 22
 23The NBP1406_cruise.yaml file above specifies configs that read from
 24simulated serial ports and write to UDP port 6224. To get the configs
 25to actually run, you'll need to run
 26
 27```
 28  logger/utils/simulate_data.py --config test/NBP1406/simulate_NBP1406.yaml
 29```
 30in a separate terminal window to create the virtual serial ports the
 31sample config references and feed simulated data through them.)
 32
 33To verify that the scripts are actually working as intended, you can
 34create a network listener on port 6224 in yet another window:
 35```
 36  logger/listener/listen.py --network :6224
 37```
 38"""
 39import logging
 40import multiprocessing
 41import os
 42import pprint
 43import signal
 44import time
 45
 46from importlib import reload
 47from logging.handlers import RotatingFileHandler
 48from setproctitle import setproctitle
 49
 50# Add the openrvdas/ directory to module search path
 51from logger.utils.read_config import read_config  # noqa: E402
 52from logger.utils.stderr_logging import DEFAULT_LOGGING_FORMAT  # noqa: E402
 53from logger.listener.listen import ListenerFromLoggerConfig  # noqa: E402
 54
 55# For writing to cached data server
 56from logger.transforms.to_das_record_transform import ToDASRecordTransform  # noqa: E402
 57from logger.writers.cached_data_writer import CachedDataWriter  # noqa: E402
 58from logger.writers.composed_writer import ComposedWriter  # noqa: E402
 59from logger.utils.stderr_logging import StdErrLoggingHandler  # noqa: E402
 60
 61# Rotate stderr logs out so that their sizes remain manageable. Plan to keep all
 62# stderr logs, but don't swamp if something goes awry. Note: these values
 63# should probably be extracted to a settings.py file somewhere.
 64STDERR_MAX_BYTES = 1000000  # 10M
 65STDERR_BACKUP_COUNT = 100  # 100 backups should be plenty
 66
 67
 68################################################################################
 69def kill_handler(self, signum):
 70    """Translate an external signal (such as we'd get from os.kill) into a
 71    KeyboardInterrupt, which will signal the start() loop to exit nicely."""
 72    logging.info('Received external kill')
 73    raise KeyboardInterrupt('Received external kill signal')
 74
 75
 76################################################################################
 77def config_from_filename(filename):
 78    """Load a logger configuration from a filename. If there's a ':' in
 79    the config file name, then we expect what is before the colon to be
 80    a cruise definition, and what is after to be the name of a
 81    configuration inside that definition.
 82    """
 83    config_name = None
 84    if filename.find(':') > 0:
 85        (filename, config_name) = filename.split(':', maxsplit=1)
 86    config = read_config(filename)
 87
 88    if config_name:
 89        config_dict = config.get('configs')
 90        if not config_dict:
 91            raise ValueError('Configuration name "%s" specified, but no '
 92                             '"configs" section found in file "%s"'
 93                             % (config_name, filename))
 94        config = config_dict.get(config_name)
 95        if not config:
 96            raise ValueError('Configuration name "%s" not found in file "%s"'
 97                             % (config_name, filename))
 98    logging.info('Loaded config file: %s', pprint.pformat(config))
 99    return config
100
101
102################################################################################
103def config_is_runnable(config):
104    """Is this logger configuration runnable? (Or, e.g. does it just have
105    a name and no readers/transforms/writers?)
106    """
107    if not config:
108        return False
109    return 'readers' in config or 'writers' in config
110
111
112################################################################################
113def run_logger(logger, config, stderr_filename=None, stderr_data_server=None,
114               log_level=logging.INFO):
115    """Run a logger, sending its stderr to a cached data server if so indicated
116
117    logger -    Name of logger
118
119    config -    Config dict
120
121    stderr_filename  - If not None, send stderr to this file.
122
123    stderr_data_server  - If not None, host:port of cached data server to
124                send stderr messages to.
125
126    log_level - Level at which logger should be logging (e.g logging.WARNING,
127                logging.INFO, etc.
128    """
129    # Reset logging to its freshly-imported state
130    reload(logging)
131
132    if stderr_filename:
133        stderr_handlers = [RotatingFileHandler(stderr_filename,
134                                               maxBytes=STDERR_MAX_BYTES,
135                                               backupCount=STDERR_BACKUP_COUNT)]
136    else:
137        stderr_handlers = []
138    logging.basicConfig(
139        handlers=stderr_handlers,
140        level=log_level,
141        format=DEFAULT_LOGGING_FORMAT)
142
143    if stderr_data_server:
144        field_name = 'stderr:logger:' + logger
145        cds_writer = ComposedWriter(
146            transforms=ToDASRecordTransform(data_id='stderr', field_name=field_name),
147            writers=CachedDataWriter(data_server=stderr_data_server))
148        logging.getLogger().addHandler(StdErrLoggingHandler(cds_writer))
149
150    # Set the name of the process for ps
151    config_name = config.get('name', 'no_name')
152    setproctitle('openrvdas/server/logger_runner.py:' + config_name)
153    logging.info(f'Starting logger {logger} config {config_name}')
154
155    try:
156        if config_is_runnable(config):
157            listener = ListenerFromLoggerConfig(config=config)
158            try:
159                listener.run()
160            except KeyboardInterrupt:
161                logging.warning(f'Received quit for {config_name}')
162    except Exception as e:
163        logging.fatal(e)
164
165    # Allow a moment for stderr_writers to finish up
166    time.sleep(0.25)
167
168
169################################################################################
170class LoggerRunner:
171    ############################
172    def __init__(self, config, name=None, stderr_filename=None,
173                 stderr_data_server=None, logger_log_level=logging.WARNING):
174        """Create a LoggerRunner.
175        ```
176        config   - Python dict containing the logger configuration to be run
177
178        name     - Optional name to give to logger process.
179
180        stderr_filename - Optional name of file to write stderr to.
181
182        stderr_data_server - Optional host:port of a cached data server to
183                   send encoded stderr messages to.
184
185        logger_log_level - At what logging level our logger should operate.
186        ```
187        """
188        self.config = config
189        self.name = name or config.get('name', 'Unnamed logger')
190        self.stderr_filename = stderr_filename
191        self.stderr_data_server = stderr_data_server
192        self.logger_log_level = logger_log_level
193
194        self.process = None     # this is hold the logger process
195        self.failed = False     # flag - has logger failed?
196        self.quit_flag = False  # flag - has quit been signaled?
197
198        # Set the signal handler so that an external break will get
199        # translated into a KeyboardInterrupt. But signal only works if
200        # we're in the main thread - catch if we're not, and just assume
201        # everything's gonna be okay and we'll get shut down with a proper
202        # "quit()" call otherwise.
203        try:
204            signal.signal(signal.SIGTERM, kill_handler)
205        except ValueError:
206            logging.debug('LoggerRunner not running in main thread; '
207                          'shutting down with Ctl-C may not work.')
208
209    ############################
210    def start(self):
211        """Start a listener subprocess."""
212        self.quit_flag = False
213        self.failed = False
214
215        # We're going to go ahead and create the process, even if the
216        # config is not runnable, just so we can get log messages that the
217        # config has been started.
218
219        # If config is not runnable, just say so and be done with it.
220        # if not self.is_runnable():
221        #  logging.info('Process %s is complete. Not running.', self.name)
222        #  return
223
224        run_logger_kwargs = {
225            'logger': self.name,
226            'config': self.config,
227            'stderr_filename': self.stderr_filename,
228            'stderr_data_server': self.stderr_data_server,
229            'log_level': self.logger_log_level
230        }
231        self.process = multiprocessing.Process(target=run_logger,
232                                               kwargs=run_logger_kwargs,
233                                               daemon=True)
234        self.process.start()
235
236    ############################
237    def is_runnable(self):
238        """Is this logger configuration runnable? (Or, e.g. does it just have
239        a name and no readers/transforms/writers?)
240        """
241        return config_is_runnable(self.config)
242
243    ############################
244    def is_alive(self):
245        """Is the logger in question alive?"""
246        return self.process and self.process.is_alive()
247
248    ############################
249    def is_failed(self):
250        """Return whether the logger has failed."""
251        return self.failed
252
253    ############################
254    def quit(self):
255        """Signal loop exit and try to cleanly terminate the process."""
256        self.quit_flag = True
257        if self.process:
258            # First attempt: terminate gracefully
259            self.process.terminate()
260            self.process.join(timeout=5)
261
262            if self.process.is_alive():
263                # Escalation: send SIGKILL (Unix only)
264                try:
265                    os.kill(self.process.pid, signal.SIGKILL)
266                except OSError:
267                    pass  # process may have already exited
268                self.process.join(timeout=5)
269
270                if self.process.is_alive():
271                    # If it's *still* alive, warn, and just live with the undead process
272                    logging.error(f'Process {self.process.pid} could not be killed')
273
274        self.process = None
275        self.failed = False
276
277
278################################################################################
279if __name__ == '__main__':
280    import argparse
281    parser = argparse.ArgumentParser()
282    parser.add_argument('--config', dest='config', action='store', required=True,
283                        help='Logger configuration to run. May either be the '
284                        'name of a file containing a single logger configuration '
285                        'or filename:config_name, for a file containing a cruise '
286                        'definition followed by the name of the specific '
287                        'configuration inside that definition.')
288
289    parser.add_argument('--name', dest='name', action='store', default=None,
290                        help='Name to give to logger process.')
291
292    parser.add_argument('--stderr_filename', dest='stderr_filename', default=None,
293                        help='Optional filename to which stderr should be '
294                        'written. Will attempt to create path if it does not '
295                        'exist.')
296
297    parser.add_argument('--stderr_data_server', dest='stderr_data_server', default=None,
298                        help='Optional host:port of a cached data server to which '
299                        ' stderr messages should be written.')
300
301    parser.add_argument('-v', '--verbosity', dest='verbosity',
302                        default=0, action='count',
303                        help='Increase output verbosity')
304
305    parser.add_argument('-V', '--logger_verbosity', dest='logger_verbosity',
306                        default=0, action='count',
307                        help='Increase output verbosity of component loggers')
308
309    args = parser.parse_args()
310
311    # Set up logging first of all
312
313    LOG_LEVELS = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}
314    log_level = LOG_LEVELS[min(args.verbosity, max(LOG_LEVELS))]
315    logging.basicConfig(format=DEFAULT_LOGGING_FORMAT)
316    logging.getLogger().setLevel(log_level)
317
318    # What level do we want our component loggers to write?
319    logger_log_level = LOG_LEVELS[min(args.logger_verbosity, max(LOG_LEVELS))]
320
321    config = config_from_filename(args.config)
322
323    # Finally, create our runner and run it
324    runner = LoggerRunner(config=config,
325                          name=args.name,
326                          stderr_filename=args.stderr_filename,
327                          stderr_data_server=args.stderr_data_server,
328                          logger_log_level=logger_log_level)
329    runner.start()
330
331    # Wait for it to complete
332    runner.process.join()
STDERR_MAX_BYTES = 1000000
STDERR_BACKUP_COUNT = 100
def kill_handler(self, signum):
70def kill_handler(self, signum):
71    """Translate an external signal (such as we'd get from os.kill) into a
72    KeyboardInterrupt, which will signal the start() loop to exit nicely."""
73    logging.info('Received external kill')
74    raise KeyboardInterrupt('Received external kill signal')

Translate an external signal (such as we'd get from os.kill) into a KeyboardInterrupt, which will signal the start() loop to exit nicely.

def config_from_filename(filename):
 78def config_from_filename(filename):
 79    """Load a logger configuration from a filename. If there's a ':' in
 80    the config file name, then we expect what is before the colon to be
 81    a cruise definition, and what is after to be the name of a
 82    configuration inside that definition.
 83    """
 84    config_name = None
 85    if filename.find(':') > 0:
 86        (filename, config_name) = filename.split(':', maxsplit=1)
 87    config = read_config(filename)
 88
 89    if config_name:
 90        config_dict = config.get('configs')
 91        if not config_dict:
 92            raise ValueError('Configuration name "%s" specified, but no '
 93                             '"configs" section found in file "%s"'
 94                             % (config_name, filename))
 95        config = config_dict.get(config_name)
 96        if not config:
 97            raise ValueError('Configuration name "%s" not found in file "%s"'
 98                             % (config_name, filename))
 99    logging.info('Loaded config file: %s', pprint.pformat(config))
100    return config

Load a logger configuration from a filename. If there's a ':' in the config file name, then we expect what is before the colon to be a cruise definition, and what is after to be the name of a configuration inside that definition.

def config_is_runnable(config):
104def config_is_runnable(config):
105    """Is this logger configuration runnable? (Or, e.g. does it just have
106    a name and no readers/transforms/writers?)
107    """
108    if not config:
109        return False
110    return 'readers' in config or 'writers' in config

Is this logger configuration runnable? (Or, e.g. does it just have a name and no readers/transforms/writers?)

def run_logger( logger, config, stderr_filename=None, stderr_data_server=None, log_level=20):
114def run_logger(logger, config, stderr_filename=None, stderr_data_server=None,
115               log_level=logging.INFO):
116    """Run a logger, sending its stderr to a cached data server if so indicated
117
118    logger -    Name of logger
119
120    config -    Config dict
121
122    stderr_filename  - If not None, send stderr to this file.
123
124    stderr_data_server  - If not None, host:port of cached data server to
125                send stderr messages to.
126
127    log_level - Level at which logger should be logging (e.g logging.WARNING,
128                logging.INFO, etc.
129    """
130    # Reset logging to its freshly-imported state
131    reload(logging)
132
133    if stderr_filename:
134        stderr_handlers = [RotatingFileHandler(stderr_filename,
135                                               maxBytes=STDERR_MAX_BYTES,
136                                               backupCount=STDERR_BACKUP_COUNT)]
137    else:
138        stderr_handlers = []
139    logging.basicConfig(
140        handlers=stderr_handlers,
141        level=log_level,
142        format=DEFAULT_LOGGING_FORMAT)
143
144    if stderr_data_server:
145        field_name = 'stderr:logger:' + logger
146        cds_writer = ComposedWriter(
147            transforms=ToDASRecordTransform(data_id='stderr', field_name=field_name),
148            writers=CachedDataWriter(data_server=stderr_data_server))
149        logging.getLogger().addHandler(StdErrLoggingHandler(cds_writer))
150
151    # Set the name of the process for ps
152    config_name = config.get('name', 'no_name')
153    setproctitle('openrvdas/server/logger_runner.py:' + config_name)
154    logging.info(f'Starting logger {logger} config {config_name}')
155
156    try:
157        if config_is_runnable(config):
158            listener = ListenerFromLoggerConfig(config=config)
159            try:
160                listener.run()
161            except KeyboardInterrupt:
162                logging.warning(f'Received quit for {config_name}')
163    except Exception as e:
164        logging.fatal(e)
165
166    # Allow a moment for stderr_writers to finish up
167    time.sleep(0.25)

Run a logger, sending its stderr to a cached data server if so indicated

logger - Name of logger

config - Config dict

stderr_filename - If not None, send stderr to this file.

stderr_data_server - If not None, host:port of cached data server to send stderr messages to.

log_level - Level at which logger should be logging (e.g logging.WARNING, logging.INFO, etc.

class LoggerRunner:
171class LoggerRunner:
172    ############################
173    def __init__(self, config, name=None, stderr_filename=None,
174                 stderr_data_server=None, logger_log_level=logging.WARNING):
175        """Create a LoggerRunner.
176        ```
177        config   - Python dict containing the logger configuration to be run
178
179        name     - Optional name to give to logger process.
180
181        stderr_filename - Optional name of file to write stderr to.
182
183        stderr_data_server - Optional host:port of a cached data server to
184                   send encoded stderr messages to.
185
186        logger_log_level - At what logging level our logger should operate.
187        ```
188        """
189        self.config = config
190        self.name = name or config.get('name', 'Unnamed logger')
191        self.stderr_filename = stderr_filename
192        self.stderr_data_server = stderr_data_server
193        self.logger_log_level = logger_log_level
194
195        self.process = None     # this is hold the logger process
196        self.failed = False     # flag - has logger failed?
197        self.quit_flag = False  # flag - has quit been signaled?
198
199        # Set the signal handler so that an external break will get
200        # translated into a KeyboardInterrupt. But signal only works if
201        # we're in the main thread - catch if we're not, and just assume
202        # everything's gonna be okay and we'll get shut down with a proper
203        # "quit()" call otherwise.
204        try:
205            signal.signal(signal.SIGTERM, kill_handler)
206        except ValueError:
207            logging.debug('LoggerRunner not running in main thread; '
208                          'shutting down with Ctl-C may not work.')
209
210    ############################
211    def start(self):
212        """Start a listener subprocess."""
213        self.quit_flag = False
214        self.failed = False
215
216        # We're going to go ahead and create the process, even if the
217        # config is not runnable, just so we can get log messages that the
218        # config has been started.
219
220        # If config is not runnable, just say so and be done with it.
221        # if not self.is_runnable():
222        #  logging.info('Process %s is complete. Not running.', self.name)
223        #  return
224
225        run_logger_kwargs = {
226            'logger': self.name,
227            'config': self.config,
228            'stderr_filename': self.stderr_filename,
229            'stderr_data_server': self.stderr_data_server,
230            'log_level': self.logger_log_level
231        }
232        self.process = multiprocessing.Process(target=run_logger,
233                                               kwargs=run_logger_kwargs,
234                                               daemon=True)
235        self.process.start()
236
237    ############################
238    def is_runnable(self):
239        """Is this logger configuration runnable? (Or, e.g. does it just have
240        a name and no readers/transforms/writers?)
241        """
242        return config_is_runnable(self.config)
243
244    ############################
245    def is_alive(self):
246        """Is the logger in question alive?"""
247        return self.process and self.process.is_alive()
248
249    ############################
250    def is_failed(self):
251        """Return whether the logger has failed."""
252        return self.failed
253
254    ############################
255    def quit(self):
256        """Signal loop exit and try to cleanly terminate the process."""
257        self.quit_flag = True
258        if self.process:
259            # First attempt: terminate gracefully
260            self.process.terminate()
261            self.process.join(timeout=5)
262
263            if self.process.is_alive():
264                # Escalation: send SIGKILL (Unix only)
265                try:
266                    os.kill(self.process.pid, signal.SIGKILL)
267                except OSError:
268                    pass  # process may have already exited
269                self.process.join(timeout=5)
270
271                if self.process.is_alive():
272                    # If it's *still* alive, warn, and just live with the undead process
273                    logging.error(f'Process {self.process.pid} could not be killed')
274
275        self.process = None
276        self.failed = False
LoggerRunner( config, name=None, stderr_filename=None, stderr_data_server=None, logger_log_level=30)
173    def __init__(self, config, name=None, stderr_filename=None,
174                 stderr_data_server=None, logger_log_level=logging.WARNING):
175        """Create a LoggerRunner.
176        ```
177        config   - Python dict containing the logger configuration to be run
178
179        name     - Optional name to give to logger process.
180
181        stderr_filename - Optional name of file to write stderr to.
182
183        stderr_data_server - Optional host:port of a cached data server to
184                   send encoded stderr messages to.
185
186        logger_log_level - At what logging level our logger should operate.
187        ```
188        """
189        self.config = config
190        self.name = name or config.get('name', 'Unnamed logger')
191        self.stderr_filename = stderr_filename
192        self.stderr_data_server = stderr_data_server
193        self.logger_log_level = logger_log_level
194
195        self.process = None     # this is hold the logger process
196        self.failed = False     # flag - has logger failed?
197        self.quit_flag = False  # flag - has quit been signaled?
198
199        # Set the signal handler so that an external break will get
200        # translated into a KeyboardInterrupt. But signal only works if
201        # we're in the main thread - catch if we're not, and just assume
202        # everything's gonna be okay and we'll get shut down with a proper
203        # "quit()" call otherwise.
204        try:
205            signal.signal(signal.SIGTERM, kill_handler)
206        except ValueError:
207            logging.debug('LoggerRunner not running in main thread; '
208                          'shutting down with Ctl-C may not work.')

Create a LoggerRunner.

config   - Python dict containing the logger configuration to be run

name     - Optional name to give to logger process.

stderr_filename - Optional name of file to write stderr to.

stderr_data_server - Optional host:port of a cached data server to
           send encoded stderr messages to.

logger_log_level - At what logging level our logger should operate.
config
name
stderr_filename
stderr_data_server
logger_log_level
process
failed
quit_flag
def start(self):
211    def start(self):
212        """Start a listener subprocess."""
213        self.quit_flag = False
214        self.failed = False
215
216        # We're going to go ahead and create the process, even if the
217        # config is not runnable, just so we can get log messages that the
218        # config has been started.
219
220        # If config is not runnable, just say so and be done with it.
221        # if not self.is_runnable():
222        #  logging.info('Process %s is complete. Not running.', self.name)
223        #  return
224
225        run_logger_kwargs = {
226            'logger': self.name,
227            'config': self.config,
228            'stderr_filename': self.stderr_filename,
229            'stderr_data_server': self.stderr_data_server,
230            'log_level': self.logger_log_level
231        }
232        self.process = multiprocessing.Process(target=run_logger,
233                                               kwargs=run_logger_kwargs,
234                                               daemon=True)
235        self.process.start()

Start a listener subprocess.

def is_runnable(self):
238    def is_runnable(self):
239        """Is this logger configuration runnable? (Or, e.g. does it just have
240        a name and no readers/transforms/writers?)
241        """
242        return config_is_runnable(self.config)

Is this logger configuration runnable? (Or, e.g. does it just have a name and no readers/transforms/writers?)

def is_alive(self):
245    def is_alive(self):
246        """Is the logger in question alive?"""
247        return self.process and self.process.is_alive()

Is the logger in question alive?

def is_failed(self):
250    def is_failed(self):
251        """Return whether the logger has failed."""
252        return self.failed

Return whether the logger has failed.

def quit(self):
255    def quit(self):
256        """Signal loop exit and try to cleanly terminate the process."""
257        self.quit_flag = True
258        if self.process:
259            # First attempt: terminate gracefully
260            self.process.terminate()
261            self.process.join(timeout=5)
262
263            if self.process.is_alive():
264                # Escalation: send SIGKILL (Unix only)
265                try:
266                    os.kill(self.process.pid, signal.SIGKILL)
267                except OSError:
268                    pass  # process may have already exited
269                self.process.join(timeout=5)
270
271                if self.process.is_alive():
272                    # If it's *still* alive, warn, and just live with the undead process
273                    logging.error(f'Process {self.process.pid} could not be killed')
274
275        self.process = None
276        self.failed = False

Signal loop exit and try to cleanly terminate the process.