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