Before this commit: IoT logs are kept in an IoT log file. Any time the support would need IoT log information, we are forced to ask the customer to send it to us as it requires to be in the LAN. After this commit: Relevant* log lines will be automatically send out to the server using an HTTP route. This feature can be toggled within the Handlers list page * Relevant = - Any odoo logs (depending on the level set in the handlers list, see: https://github.com/odoo/odoo/pull/134174 ) - Any other logs (werkzeug, python libraries, etc.) except /hw_proxy/hello Note: IoT logs received by the server will ALWAYS be logged regardless of it level. So an Odoo server set in INFO which receive a DEBUG log from the IoT will log it in its log with the DEBUG level opw-3696519 closes odoo/odoo#156605 X-original-commit: 9f35fcd63d0a8a833b8b47e80084e5baccdcfb72 Related: odoo/enterprise#58111 Signed-off-by: Loan Sens (lse) <lse@odoo.com>
165 lines
7.0 KiB
Python
165 lines
7.0 KiB
Python
|
|
import logging
|
|
import queue
|
|
import requests
|
|
import threading
|
|
import time
|
|
import urllib3.exceptions
|
|
|
|
from odoo.addons.hw_drivers.tools import helpers
|
|
from odoo.netsvc import DBFormatter
|
|
from odoo.tools import config
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
IOT_LOG_TO_SERVER_CONFIG_NAME = 'iot_log_to_server' # config name in odoo.conf
|
|
|
|
|
|
class AsyncHTTPHandler(logging.Handler):
|
|
"""
|
|
Custom logging handler which send IoT logs using asynchronous requests.
|
|
To avoid spamming the server, we send logs by batch each X seconds
|
|
"""
|
|
_MAX_QUEUE_SIZE = 1000
|
|
"""Maximum queue size. If a log record is received but the queue if full it will be discarded"""
|
|
_MAX_BATCH_SIZE = 50
|
|
"""Maximum number of sent logs batched at once. Used to avoid too heavy request. Log records still in the queue will
|
|
be handle in future flushes"""
|
|
_FLUSH_INTERVAL = 0.5
|
|
"""How much seconds it will sleep before checking for new logs to send"""
|
|
_REQUEST_TIMEOUT = 0.5
|
|
"""Amount of seconds to wait per log to send before timeout"""
|
|
_DELAY_BEFORE_NO_SERVER_LOG = 5 * 60 # 5 minutes
|
|
"""Minimum delay in seconds before we log a server disconnection.
|
|
Used in order to avoid the IoT log file to have a log recorded each _FLUSH_INTERVAL (as this value is very small)"""
|
|
|
|
def __init__(self, odoo_server_url, active):
|
|
"""
|
|
:param odoo_server_url: Odoo Server URL
|
|
"""
|
|
super().__init__()
|
|
self._odoo_server_url = odoo_server_url
|
|
self._log_queue = queue.Queue(self._MAX_QUEUE_SIZE)
|
|
self._flush_thread = None
|
|
self._active = None
|
|
self._next_disconnection_time = None
|
|
self.toggle_active(active)
|
|
|
|
def toggle_active(self, is_active):
|
|
"""
|
|
Switch it on or off the handler (depending on the IoT setting) without the need to close/reset it
|
|
"""
|
|
self._active = is_active
|
|
if self._active and self._odoo_server_url:
|
|
# Start the thread to periodically flush logs
|
|
self._flush_thread = threading.Thread(target=self._periodic_flush, name="ThreadServerLogSender", daemon=True)
|
|
self._flush_thread.start()
|
|
else:
|
|
self._flush_thread and self._flush_thread.join() # let a last flush
|
|
|
|
def _periodic_flush(self):
|
|
odoo_session = requests.Session()
|
|
while self._odoo_server_url and self._active: # allow to exit the loop on thread.join
|
|
time.sleep(self._FLUSH_INTERVAL)
|
|
self._flush_logs(odoo_session)
|
|
|
|
def _flush_logs(self, odoo_session):
|
|
def convert_to_byte(s):
|
|
return bytes(s, encoding="ascii") + b'<log/>\n'
|
|
|
|
def convert_server_line(log_level, line_formatted):
|
|
return convert_to_byte(f"{log_level},{line_formatted}")
|
|
|
|
def empty_queue():
|
|
yield convert_to_byte(f"mac {helpers.get_mac_address()}")
|
|
for _ in range(self._MAX_BATCH_SIZE):
|
|
# Use a limit to avoid having too heavy requests & infinite loop of the queue receiving new entries
|
|
try:
|
|
log_record = self._log_queue.get_nowait()
|
|
yield convert_server_line(log_record.levelno, self.format(log_record))
|
|
except queue.Empty:
|
|
break
|
|
|
|
# Report to the server if the queue is close from saturation
|
|
if queue_size >= .8 * self._MAX_QUEUE_SIZE:
|
|
log_message = "The IoT {} queue is saturating: {}/{} ({:.2f}%)".format(
|
|
self.__class__.__name__, queue_size, self._MAX_QUEUE_SIZE,
|
|
100 * queue_size / self._MAX_QUEUE_SIZE)
|
|
_logger.warning(log_message) # As we don't log our own logs, this will be part of the IoT logs
|
|
# In order to report this to the server (on the current batch) we will append it manually
|
|
yield convert_server_line(logging.WARNING, log_message)
|
|
|
|
queue_size = self._log_queue.qsize() # This is an approximate value
|
|
|
|
if not self._odoo_server_url or queue_size == 0:
|
|
return
|
|
try:
|
|
odoo_session.post(
|
|
self._odoo_server_url + '/iot/log',
|
|
data=empty_queue(),
|
|
timeout=self._REQUEST_TIMEOUT
|
|
).raise_for_status()
|
|
self._next_disconnection_time = None
|
|
except (requests.exceptions.ConnectTimeout, requests.exceptions.ConnectionError, urllib3.exceptions.NewConnectionError) as request_errors:
|
|
now = time.time()
|
|
if not self._next_disconnection_time or now >= self._next_disconnection_time:
|
|
_logger.info("Connection with the server to send the logs failed. It is likely down: %s", request_errors)
|
|
self._next_disconnection_time = now + self._DELAY_BEFORE_NO_SERVER_LOG
|
|
except Exception as _:
|
|
_logger.exception('Unexpected error happened while sending logs to server')
|
|
|
|
def emit(self, record):
|
|
# This is important that this method is as fast as possible.
|
|
# The log calls will be waiting for this function to finish
|
|
if not self._active:
|
|
return
|
|
try:
|
|
self._log_queue.put_nowait(record)
|
|
except queue.Full:
|
|
pass
|
|
|
|
def close(self):
|
|
self.toggle_active(False)
|
|
super().close()
|
|
|
|
|
|
def close_server_log_sender_handler():
|
|
_server_log_sender_handler.close()
|
|
|
|
|
|
def get_odoo_config_log_to_server_option():
|
|
return config.get(IOT_LOG_TO_SERVER_CONFIG_NAME, True) # Enabled by default
|
|
|
|
|
|
def check_and_update_odoo_config_log_to_server_option(new_state):
|
|
"""
|
|
:return: wherever the config file need to be updated or not
|
|
"""
|
|
if get_odoo_config_log_to_server_option() != new_state:
|
|
config[IOT_LOG_TO_SERVER_CONFIG_NAME] = new_state
|
|
_server_log_sender_handler.toggle_active(new_state)
|
|
return True
|
|
return False
|
|
|
|
|
|
def _server_log_sender_handler_filter(log_record):
|
|
def _filter_my_logs():
|
|
"""Filter out our own logs (to avoid infinite loop)"""
|
|
return log_record.name == __name__
|
|
|
|
def _filter_frequent_irrelevant_calls():
|
|
"""Filter out this frequent irrelevant HTTP calls, to avoid spamming the server with useless logs"""
|
|
return log_record.name == 'werkzeug' and log_record.args and len(log_record.args) > 0 and log_record.args[0].startswith('GET /hw_proxy/hello ')
|
|
|
|
return not (_filter_my_logs() or _filter_frequent_irrelevant_calls())
|
|
|
|
|
|
# The server URL is set once at initlialisation as the IoT will always restart if the URL is changed
|
|
# The only other possible case is when the server URL value is "Cleared",
|
|
# in this case we force close the log handler (as it does not make sense anymore)
|
|
_server_log_sender_handler = AsyncHTTPHandler(helpers.get_odoo_server_url(), get_odoo_config_log_to_server_option())
|
|
_server_log_sender_handler.setFormatter(DBFormatter('%(asctime)s %(pid)s %(levelname)s %(dbname)s %(name)s: %(message)s %(perf_info)s'))
|
|
_server_log_sender_handler.addFilter(_server_log_sender_handler_filter)
|
|
# Set it in the 'root' logger, on which every logger (including odoo) is a child
|
|
logging.getLogger().addHandler(_server_log_sender_handler)
|