added logging
This commit is contained in:
+5
-1
@@ -8,12 +8,16 @@ NETATMO_REFRESH_TOKEN=
|
|||||||
# Rotated OAuth tokens are persisted here with mode 0600.
|
# Rotated OAuth tokens are persisted here with mode 0600.
|
||||||
NETATMO_TOKEN_FILE=/var/lib/rrd/netatmo_tokens.json
|
NETATMO_TOKEN_FILE=/var/lib/rrd/netatmo_tokens.json
|
||||||
# Must exactly match a redirect URI configured for the Netatmo application.
|
# 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):
|
# Alternatively, useful for a short-lived test (refresh credentials are preferred):
|
||||||
# NETATMO_ACCESS_TOKEN=
|
# NETATMO_ACCESS_TOKEN=
|
||||||
# NETATMO_DEVICE_ID=
|
# NETATMO_DEVICE_ID=
|
||||||
|
|
||||||
RRD_FOLDER=/var/lib/rrd
|
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
|
POLL_INTERVAL=600
|
||||||
START_COLLECTOR=true
|
START_COLLECTOR=true
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
below `/w`; leave it empty to serve from the site root. When a prefix is set,
|
||||||
open <http://localhost:5000/w/> instead.
|
open <http://localhost:5000/w/> 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
|
## HTTP API
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+56
-1
@@ -1,9 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
from flask import Flask, Response, abort, jsonify, render_template, url_for
|
from flask import Flask, Response, abort, jsonify, render_template, url_for
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from .collector import Collector
|
from .collector import Collector
|
||||||
from .config import Config
|
from .config import Config
|
||||||
@@ -28,6 +32,49 @@ class PrefixMiddleware:
|
|||||||
return [b"Not Found\n"]
|
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:
|
def create_app(test_config: dict | None = None) -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
@@ -39,7 +86,8 @@ def create_app(test_config: dict | None = None) -> Flask:
|
|||||||
if prefix:
|
if prefix:
|
||||||
app.wsgi_app = PrefixMiddleware(app.wsgi_app, 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(
|
store = app.config.get("RRD_STORE") or RRDStore(
|
||||||
app.config["RRD_FOLDER"], app.config["GRAPH_WIDTH"], app.config["GRAPH_HEIGHT"]
|
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():
|
def health():
|
||||||
return jsonify(status="ok", collector_configured=client.configured)
|
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/<rrd_name>/<data_point>")
|
@app.get("/last/<rrd_name>/<data_point>")
|
||||||
def last(rrd_name: str, data_point: str):
|
def last(rrd_name: str, data_point: str):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ def _prefix(value: str) -> str:
|
|||||||
class Config:
|
class Config:
|
||||||
URL_PREFIX = _prefix(os.getenv("URL_PREFIX", ""))
|
URL_PREFIX = _prefix(os.getenv("URL_PREFIX", ""))
|
||||||
RRD_FOLDER = Path(os.getenv("RRD_FOLDER", "./rrd")).expanduser().resolve()
|
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(
|
NETATMO_TOKEN_FILE = Path(
|
||||||
os.getenv("NETATMO_TOKEN_FILE", str(RRD_FOLDER / "netatmo_tokens.json"))
|
os.getenv("NETATMO_TOKEN_FILE", str(RRD_FOLDER / "netatmo_tokens.json"))
|
||||||
).expanduser().resolve()
|
).expanduser().resolve()
|
||||||
|
|||||||
@@ -76,10 +76,25 @@ class NetatmoClient:
|
|||||||
return bool(self.access_token or (self.client_id and self.client_secret and self.refresh_token))
|
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:
|
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:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=30) as response:
|
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:
|
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")
|
detail = getattr(exc, "read", lambda: b"")().decode(errors="replace")
|
||||||
raise NetatmoError(f"Netatmo request failed: {exc}; {detail}") from exc
|
raise NetatmoError(f"Netatmo request failed: {exc}; {detail}") from exc
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
Flask>=3.1,<4
|
Flask>=3.1,<4
|
||||||
rrdtool-bindings
|
rrdtool-bindings # if python 3.14
|
||||||
|
rrdtool # if python 3.12
|
||||||
|
|||||||
Reference in New Issue
Block a user