From c1d731f85d06244480d893996eca7714db2c5e36 Mon Sep 17 00:00:00 2001 From: Ignace Date: Sun, 30 Aug 2026 12:01:46 +0200 Subject: [PATCH] added logging --- .env.example | 6 +++- README.md | 7 +++++ netatmo_service/app.py | 57 +++++++++++++++++++++++++++++++++++++- netatmo_service/config.py | 6 ++++ netatmo_service/netatmo.py | 17 +++++++++++- requirements.txt | 3 +- 6 files changed, 92 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 6e71c5d..adcb033 100644 --- a/.env.example +++ b/.env.example @@ -8,12 +8,16 @@ NETATMO_REFRESH_TOKEN= # Rotated OAuth tokens are persisted here with mode 0600. NETATMO_TOKEN_FILE=/var/lib/rrd/netatmo_tokens.json # Must exactly match a redirect URI configured for the Netatmo application. -NETATMO_REDIRECT_URI=http://www.suy.nl +NETATMO_REDIRECT_URI=http://www.suy.nl/w # Alternatively, useful for a short-lived test (refresh credentials are preferred): # NETATMO_ACCESS_TOKEN= # NETATMO_DEVICE_ID= RRD_FOLDER=/var/lib/rrd +LOG_FILE=/var/lib/rrd/netatmo_service.log +LOG_LEVEL=INFO +LOG_MAX_BYTES=5242880 +LOG_BACKUP_COUNT=5 POLL_INTERVAL=600 START_COLLECTOR=true diff --git a/README.md b/README.md index 1358dbd..a0803fa 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,13 @@ Set `URL_PREFIX=/w` to mount the dashboard, static assets, and every API endpoin below `/w`; leave it empty to serve from the site root. When a prefix is set, open instead. +`LOG_FILE` selects a rotating application log (by default +`RRD_FOLDER/netatmo_service.log`). Every Netatmo HTTP attempt records success or +failure, HTTP status, endpoint, and duration without credentials. Collector, +Flask, uncaught main-thread, and uncaught worker-thread exceptions are written to +the same log. `LOG_MAX_BYTES` defaults to 5 MiB and `LOG_BACKUP_COUNT` to five. +The service account must have write permission on the log directory. + ## HTTP API ```text diff --git a/netatmo_service/app.py b/netatmo_service/app.py index 220d4ed..fb7f61c 100644 --- a/netatmo_service/app.py +++ b/netatmo_service/app.py @@ -1,9 +1,13 @@ from __future__ import annotations import logging +from logging.handlers import RotatingFileHandler import os +import sys +import threading from flask import Flask, Response, abort, jsonify, render_template, url_for +from werkzeug.exceptions import HTTPException from .collector import Collector from .config import Config @@ -28,6 +32,49 @@ class PrefixMiddleware: return [b"Not Found\n"] +def configure_logging(app: Flask) -> None: + log_path = app.config["LOG_FILE"] + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler( + log_path, + maxBytes=app.config["LOG_MAX_BYTES"], + backupCount=app.config["LOG_BACKUP_COUNT"], + encoding="utf-8", + ) + handler.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)s %(name)s [%(threadName)s] %(message)s" + )) + root = logging.getLogger() + root.setLevel(app.config["LOG_LEVEL"]) + # Avoid duplicate handlers when an app factory is called repeatedly in tests. + target = str(log_path) + if not any( + isinstance(item, RotatingFileHandler) + and getattr(item, "baseFilename", None) == target + for item in root.handlers + ): + root.addHandler(handler) + else: + handler.close() + + def uncaught_exception(exception_type, exception, traceback): + if issubclass(exception_type, KeyboardInterrupt): + return sys.__excepthook__(exception_type, exception, traceback) + logging.getLogger("netatmo_service.uncaught").critical( + "Uncaught Python exception", exc_info=(exception_type, exception, traceback) + ) + + def uncaught_thread_exception(arguments): + logging.getLogger("netatmo_service.uncaught").critical( + "Uncaught exception in thread %s", + arguments.thread.name if arguments.thread else "unknown", + exc_info=(arguments.exc_type, arguments.exc_value, arguments.exc_traceback), + ) + + sys.excepthook = uncaught_exception + threading.excepthook = uncaught_thread_exception + + def create_app(test_config: dict | None = None) -> Flask: app = Flask(__name__) app.config.from_object(Config) @@ -39,7 +86,8 @@ def create_app(test_config: dict | None = None) -> Flask: if prefix: app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix) - logging.basicConfig(level=app.config.get("LOG_LEVEL", "INFO")) + configure_logging(app) + app.logger.info("Starting GetNetatmoData v2 (URL prefix=%s)", prefix or "/") store = app.config.get("RRD_STORE") or RRDStore( app.config["RRD_FOLDER"], app.config["GRAPH_WIDTH"], app.config["GRAPH_HEIGHT"] ) @@ -70,6 +118,13 @@ def create_app(test_config: dict | None = None) -> Flask: def health(): return jsonify(status="ok", collector_configured=client.configured) + @app.errorhandler(Exception) + def unexpected_error(error): + if isinstance(error, HTTPException): + return error + app.logger.exception("Unhandled Flask request error") + return jsonify(error="Internal server error"), 500 + @app.get("/last//") def last(rrd_name: str, data_point: str): try: diff --git a/netatmo_service/config.py b/netatmo_service/config.py index bf0f2d4..b51dfde 100644 --- a/netatmo_service/config.py +++ b/netatmo_service/config.py @@ -19,6 +19,12 @@ def _prefix(value: str) -> str: class Config: URL_PREFIX = _prefix(os.getenv("URL_PREFIX", "")) RRD_FOLDER = Path(os.getenv("RRD_FOLDER", "./rrd")).expanduser().resolve() + LOG_FILE = Path( + os.getenv("LOG_FILE", str(RRD_FOLDER / "netatmo_service.log")) + ).expanduser().resolve() + LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() + LOG_MAX_BYTES = int(os.getenv("LOG_MAX_BYTES", str(5 * 1024 * 1024))) + LOG_BACKUP_COUNT = int(os.getenv("LOG_BACKUP_COUNT", "5")) NETATMO_TOKEN_FILE = Path( os.getenv("NETATMO_TOKEN_FILE", str(RRD_FOLDER / "netatmo_tokens.json")) ).expanduser().resolve() diff --git a/netatmo_service/netatmo.py b/netatmo_service/netatmo.py index 917babb..d5ec77e 100644 --- a/netatmo_service/netatmo.py +++ b/netatmo_service/netatmo.py @@ -76,10 +76,25 @@ class NetatmoClient: return bool(self.access_token or (self.client_id and self.client_secret and self.refresh_token)) def _request(self, request: urllib.request.Request) -> dict: + endpoint = urllib.parse.urlsplit(request.full_url).path + method = request.get_method() + started = time.monotonic() + LOG.info("Netatmo request attempt method=%s endpoint=%s", method, endpoint) try: with urllib.request.urlopen(request, timeout=30) as response: - return json.load(response) + result = json.load(response) + LOG.info( + "Netatmo request success method=%s endpoint=%s status=%s duration_ms=%d", + method, endpoint, response.status, + round((time.monotonic() - started) * 1000), + ) + return result except (urllib.error.URLError, urllib.error.HTTPError, ValueError) as exc: + LOG.exception( + "Netatmo request failure method=%s endpoint=%s status=%s duration_ms=%d", + method, endpoint, getattr(exc, "code", "unavailable"), + round((time.monotonic() - started) * 1000), + ) detail = getattr(exc, "read", lambda: b"")().decode(errors="replace") raise NetatmoError(f"Netatmo request failed: {exc}; {detail}") from exc diff --git a/requirements.txt b/requirements.txt index 0c0465d..f16dce0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ Flask>=3.1,<4 -rrdtool-bindings +rrdtool-bindings # if python 3.14 +rrdtool # if python 3.12