healthchecks.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import logging
  2. import requests
  3. from borgmatic.hooks import monitor
  4. logger = logging.getLogger(__name__)
  5. MONITOR_STATE_TO_HEALTHCHECKS = {
  6. monitor.State.START: 'start',
  7. monitor.State.FINISH: None, # Healthchecks doesn't append to the URL for the finished state.
  8. monitor.State.FAIL: 'fail',
  9. monitor.State.LOG: 'log',
  10. }
  11. PAYLOAD_TRUNCATION_INDICATOR = '...\n'
  12. DEFAULT_PING_BODY_LIMIT_BYTES = 100000
  13. class Forgetful_buffering_handler(logging.Handler):
  14. '''
  15. A buffering log handler that stores log messages in memory, and throws away messages (oldest
  16. first) once a particular capacity in bytes is reached. But if the given byte capacity is zero,
  17. don't throw away any messages.
  18. '''
  19. def __init__(self, byte_capacity, log_level):
  20. super().__init__()
  21. self.byte_capacity = byte_capacity
  22. self.byte_count = 0
  23. self.buffer = []
  24. self.forgot = False
  25. self.setLevel(log_level)
  26. def emit(self, record):
  27. message = record.getMessage() + '\n'
  28. self.byte_count += len(message)
  29. self.buffer.append(message)
  30. if not self.byte_capacity:
  31. return
  32. while self.byte_count > self.byte_capacity and self.buffer:
  33. self.byte_count -= len(self.buffer[0])
  34. self.buffer.pop(0)
  35. self.forgot = True
  36. def format_buffered_logs_for_payload():
  37. '''
  38. Get the handler previously added to the root logger, and slurp buffered logs out of it to
  39. send to Healthchecks.
  40. '''
  41. try:
  42. buffering_handler = next(
  43. handler
  44. for handler in logging.getLogger().handlers
  45. if isinstance(handler, Forgetful_buffering_handler)
  46. )
  47. except StopIteration:
  48. # No handler means no payload.
  49. return ''
  50. payload = ''.join(message for message in buffering_handler.buffer)
  51. if buffering_handler.forgot:
  52. return PAYLOAD_TRUNCATION_INDICATOR + payload
  53. return payload
  54. def initialize_monitor(hook_config, config_filename, monitoring_log_level, dry_run):
  55. '''
  56. Add a handler to the root logger that stores in memory the most recent logs emitted. That way,
  57. we can send them all to Healthchecks upon a finish or failure state. But skip this if the
  58. "send_logs" option is false.
  59. '''
  60. if hook_config.get('send_logs') is False:
  61. return
  62. ping_body_limit = max(
  63. hook_config.get('ping_body_limit', DEFAULT_PING_BODY_LIMIT_BYTES)
  64. - len(PAYLOAD_TRUNCATION_INDICATOR),
  65. 0,
  66. )
  67. logging.getLogger().addHandler(
  68. Forgetful_buffering_handler(ping_body_limit, monitoring_log_level)
  69. )
  70. def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
  71. '''
  72. Ping the configured Healthchecks URL or UUID, modified with the monitor.State. Use the given
  73. configuration filename in any log entries, and log to Healthchecks with the giving log level.
  74. If this is a dry run, then don't actually ping anything.
  75. '''
  76. ping_url = (
  77. hook_config['ping_url']
  78. if hook_config['ping_url'].startswith('http')
  79. else f"https://hc-ping.com/{hook_config['ping_url']}"
  80. )
  81. dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
  82. if 'states' in hook_config and state.name.lower() not in hook_config['states']:
  83. logger.info(
  84. f'{config_filename}: Skipping Healthchecks {state.name.lower()} ping due to configured states'
  85. )
  86. return
  87. healthchecks_state = MONITOR_STATE_TO_HEALTHCHECKS.get(state)
  88. if healthchecks_state:
  89. ping_url = f'{ping_url}/{healthchecks_state}'
  90. logger.info(f'{config_filename}: Pinging Healthchecks {state.name.lower()}{dry_run_label}')
  91. logger.debug(f'{config_filename}: Using Healthchecks ping URL {ping_url}')
  92. if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG):
  93. payload = format_buffered_logs_for_payload()
  94. else:
  95. payload = ''
  96. if not dry_run:
  97. logging.getLogger('urllib3').setLevel(logging.ERROR)
  98. try:
  99. response = requests.post(
  100. ping_url, data=payload.encode('utf-8'), verify=hook_config.get('verify_tls', True)
  101. )
  102. if not response.ok:
  103. response.raise_for_status()
  104. except requests.exceptions.RequestException as error:
  105. logger.warning(f'{config_filename}: Healthchecks error: {error}')
  106. def destroy_monitor(hook_config, config_filename, monitoring_log_level, dry_run):
  107. '''
  108. Remove the monitor handler that was added to the root logger. This prevents the handler from
  109. getting reused by other instances of this monitor.
  110. '''
  111. logger = logging.getLogger()
  112. for handler in tuple(logger.handlers):
  113. if isinstance(handler, Forgetful_buffering_handler):
  114. logger.removeHandler(handler)