Skip to content

Commit 93b22f9

Browse files
authored
Merge pull request #14 from noxied/release/v2.1.0
Release 2.1.0
2 parents 655f6cd + c7b65a0 commit 93b22f9

15 files changed

Lines changed: 322 additions & 30 deletions

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@ All notable changes to this project are documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.1.0] - 2026-06-13
9+
10+
Resilience and observability improvements. No breaking changes; all new
11+
behaviour is either automatic or opt-in, and existing configuration keeps
12+
working.
13+
14+
### Added
15+
16+
- Optional structured JSON logging (`LOG_FORMAT=json`). Each line is a single
17+
JSON object with a UTC ISO 8601 timestamp, level, logger name, and message,
18+
ready for aggregators such as Loki, Datadog, or Splunk. The default stays
19+
human-readable text. No new dependencies; the formatter is built on the
20+
standard library.
21+
22+
### Changed
23+
24+
- After a failed check the monitor now uses a short adaptive backoff (starting
25+
at 30 seconds, doubling, capped at `CHECK_INTERVAL`) instead of always
26+
waiting the full interval, so connectivity recovery is detected sooner. The
27+
steady-state interval is unchanged when checks succeed.
28+
- Retry and backoff delays now include random jitter, so independent instances
29+
do not all retry on the same second (avoids synchronised request spikes
30+
against the detection and notification services).
31+
832
## [2.0.0] - 2026-06-11
933

1034
The application was restructured into a Python package (`wanwatcher/`). The old

Dockerfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ FROM python:3.14-slim
22

33
LABEL maintainer="noxied"
44
LABEL description="WAN IP monitoring with notifications, DDNS updates and Home Assistant integration"
5-
LABEL version="2.0.0"
5+
LABEL version="2.1.0"
66
LABEL org.opencontainers.image.title="WANwatcher"
77
LABEL org.opencontainers.image.description="Monitor WAN IPv4/IPv6 addresses with notifications, DDNS and MQTT"
8-
LABEL org.opencontainers.image.version="2.0.0"
8+
LABEL org.opencontainers.image.version="2.1.0"
99
LABEL org.opencontainers.image.authors="noxied"
1010
LABEL org.opencontainers.image.url="https://github.com/noxied/wanwatcher"
1111
LABEL org.opencontainers.image.source="https://github.com/noxied/wanwatcher"
@@ -83,6 +83,7 @@ ENV DISCORD_ENABLED="false" \
8383
SERVER_NAME="WANwatcher Docker" \
8484
IP_DB_FILE="/data/ipinfo.db" \
8585
LOG_FILE="/logs/wanwatcher.log" \
86+
LOG_FORMAT="text" \
8687
BOT_NAME="WANwatcher" \
8788
CHECK_INTERVAL="900" \
8889
HTTP_TIMEOUT="10" \

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,15 @@ docker run -d \
4343
-e SERVER_NAME="My Server" \
4444
-v ./data:/data \
4545
-v ./logs:/logs \
46-
noxied/wanwatcher:2.0.0
46+
noxied/wanwatcher:2.1.0
4747
```
4848

4949
Or with compose:
5050

5151
```yaml
5252
services:
5353
wanwatcher:
54-
image: noxied/wanwatcher:2.0.0
54+
image: noxied/wanwatcher:2.1.0
5555
container_name: wanwatcher
5656
restart: unless-stopped
5757
environment:
@@ -91,6 +91,7 @@ Everything is configured through environment variables. Booleans are the string
9191
| `HTTP_TIMEOUT` | `10` | Timeout in seconds for outbound HTTP requests |
9292
| `IP_DB_FILE` | `/data/ipinfo.db` | State file path |
9393
| `LOG_FILE` | `/logs/wanwatcher.log` | Log file path |
94+
| `LOG_FORMAT` | `text` | `text` for human-readable logs, or `json` for structured logs (one JSON object per line, UTC timestamps) for log aggregators like Loki or Datadog |
9495

9596
### Discord
9697

SECURITY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Environment variables:
3737

3838
```bash
3939
export DISCORD_WEBHOOK_URL="your_webhook"
40-
docker run -e DISCORD_WEBHOOK_URL ... noxied/wanwatcher:2.0.0
40+
docker run -e DISCORD_WEBHOOK_URL ... noxied/wanwatcher:2.1.0
4141
```
4242

4343
A `.env` file (add it to `.gitignore`):
@@ -86,7 +86,7 @@ Reasonable extras for the paranoid:
8686
```yaml
8787
services:
8888
wanwatcher:
89-
image: noxied/wanwatcher:2.0.0 # pin a version, avoid :latest
89+
image: noxied/wanwatcher:2.1.0 # pin a version, avoid :latest
9090
security_opt:
9191
- no-new-privileges:true
9292
deploy:

UPGRADING.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

33
Version-specific upgrade notes. The newest upgrade path is at the top.
44

5+
## 2.0.x to 2.1.0
6+
7+
No breaking changes and no required configuration. Pull the new image and
8+
restart:
9+
10+
```bash
11+
docker compose pull
12+
docker compose up -d
13+
```
14+
15+
What changed:
16+
17+
- Optional structured JSON logging. Set `LOG_FORMAT=json` to emit one JSON
18+
object per line (UTC timestamps) for log aggregators; the default stays
19+
human-readable text, so doing nothing keeps the old behaviour.
20+
- After a failed check the monitor retries sooner (short adaptive backoff
21+
capped at `CHECK_INTERVAL`) and adds jitter to retry delays. This is
22+
automatic; there is nothing to configure.
23+
524
## 1.x to 2.0.0
625

726
v2.0.0 restructures the application into a Python package and adds a number

docker-compose.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
services:
22
wanwatcher:
3-
image: noxied/wanwatcher:2.0.0
3+
image: noxied/wanwatcher:2.1.0
44
container_name: wanwatcher
55
restart: unless-stopped
66

@@ -20,6 +20,8 @@ services:
2020
IP_CHANGE_CONFIRMATION: "true"
2121
# Optional ipinfo.io token for geographic data
2222
IPINFO_TOKEN: ""
23+
# Log format: "text" (default) or "json" for log aggregators
24+
LOG_FORMAT: "text"
2325

2426
# ========================================================================
2527
# Discord

docs/TROUBLESHOOTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ creating datasets for `/data` and `/logs`.
293293
Synology: create the shared folders in File Station first, give uid 1000
294294
write access, and use absolute paths in Container Manager.
295295

296-
Raspberry Pi: the published image is multi-arch; `noxied/wanwatcher:2.0.0`
296+
Raspberry Pi: the published image is multi-arch; `noxied/wanwatcher:2.1.0`
297297
pulls the ARM64 variant automatically on a 64-bit OS. 32-bit ARM is not
298298
published; build locally if you need it.
299299

tests/test_app.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,35 @@ def test_run_exits_promptly_when_shutdown_set(app, monkeypatch):
113113
assert rc == 0
114114

115115

116+
def test_next_wait_steady_state(app):
117+
# No failures: wait the full configured interval.
118+
app.consecutive_failures = 0
119+
assert app._next_wait() == float(app.config.check_interval)
120+
121+
122+
def test_next_wait_backoff_grows(app, monkeypatch):
123+
monkeypatch.setattr(app, "_jitter", lambda: 0.0)
124+
app.consecutive_failures = 1
125+
assert app._next_wait() == 30
126+
app.consecutive_failures = 2
127+
assert app._next_wait() == 60
128+
app.consecutive_failures = 3
129+
assert app._next_wait() == 120
130+
131+
132+
def test_next_wait_capped_at_interval(app, monkeypatch):
133+
monkeypatch.setattr(app, "_jitter", lambda: 0.0)
134+
# Many failures must never wait longer than the normal interval.
135+
app.consecutive_failures = 20
136+
assert app._next_wait() == float(app.config.check_interval)
137+
138+
139+
def test_next_wait_includes_jitter(app, monkeypatch):
140+
monkeypatch.setattr(app, "_jitter", lambda: 2.5)
141+
app.consecutive_failures = 1
142+
assert app._next_wait() == 32.5
143+
144+
116145
def test_heartbeat_emitted_when_due(config):
117146
config.events = EventsConfig(
118147
heartbeat_enabled=True, heartbeat_interval=0, notify_on_startup=False

tests/test_logconfig.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests for logging configuration and the JSON formatter."""
2+
3+
import json
4+
import logging
5+
6+
from wanwatcher.logconfig import JSONFormatter, build_formatter, configure_logging
7+
8+
9+
def _record(level=logging.INFO, msg="hello", **kwargs):
10+
return logging.LogRecord(
11+
name="wanwatcher.test",
12+
level=level,
13+
pathname=__file__,
14+
lineno=1,
15+
msg=msg,
16+
args=(),
17+
exc_info=None,
18+
**kwargs,
19+
)
20+
21+
22+
def test_json_formatter_basic_fields():
23+
out = JSONFormatter().format(_record(msg="ip changed"))
24+
data = json.loads(out)
25+
assert data["level"] == "INFO"
26+
assert data["logger"] == "wanwatcher.test"
27+
assert data["message"] == "ip changed"
28+
assert "timestamp" in data
29+
30+
31+
def test_json_formatter_timestamp_is_utc_iso8601():
32+
data = json.loads(JSONFormatter().format(_record()))
33+
# ISO 8601 UTC ends with +00:00 offset
34+
assert data["timestamp"].endswith("+00:00")
35+
36+
37+
def test_json_formatter_includes_extra_fields():
38+
rec = _record(msg="changed")
39+
rec.extra_fields = {"old_ip": "1.1.1.1", "new_ip": "2.2.2.2"}
40+
data = json.loads(JSONFormatter().format(rec))
41+
assert data["old_ip"] == "1.1.1.1"
42+
assert data["new_ip"] == "2.2.2.2"
43+
44+
45+
def test_json_formatter_includes_exception():
46+
try:
47+
raise ValueError("boom")
48+
except ValueError:
49+
import sys
50+
51+
rec = _record(level=logging.ERROR, msg="failed")
52+
rec.exc_info = sys.exc_info()
53+
data = json.loads(JSONFormatter().format(rec))
54+
assert "exception" in data
55+
assert "ValueError" in data["exception"]
56+
57+
58+
def test_json_formatter_message_with_args():
59+
rec = logging.LogRecord("x", logging.INFO, __file__, 1, "value=%s", ("42",), None)
60+
data = json.loads(JSONFormatter().format(rec))
61+
assert data["message"] == "value=42"
62+
63+
64+
def test_build_formatter_selects_type():
65+
assert isinstance(build_formatter("json"), JSONFormatter)
66+
assert isinstance(build_formatter("JSON"), JSONFormatter)
67+
assert not isinstance(build_formatter("text"), JSONFormatter)
68+
assert not isinstance(build_formatter("anything-else"), JSONFormatter)
69+
70+
71+
def test_configure_logging_text(tmp_path):
72+
log_file = tmp_path / "ww.log"
73+
configure_logging(str(log_file), "text")
74+
logging.getLogger("wanwatcher.test").info("plain line")
75+
for h in logging.getLogger().handlers:
76+
h.flush()
77+
content = log_file.read_text(encoding="utf-8")
78+
assert "plain line" in content
79+
# text format is not JSON
80+
assert not content.strip().startswith("{")
81+
82+
83+
def test_configure_logging_json(tmp_path):
84+
log_file = tmp_path / "ww.log"
85+
configure_logging(str(log_file), "json")
86+
logging.getLogger("wanwatcher.test").info("json line")
87+
for h in logging.getLogger().handlers:
88+
h.flush()
89+
first = log_file.read_text(encoding="utf-8").strip().splitlines()[0]
90+
data = json.loads(first)
91+
assert data["message"] == "json line"
92+
93+
94+
def test_configure_logging_replaces_handlers(tmp_path):
95+
log_file = tmp_path / "ww.log"
96+
configure_logging(str(log_file), "text")
97+
count1 = len(logging.getLogger().handlers)
98+
configure_logging(str(log_file), "text")
99+
count2 = len(logging.getLogger().handlers)
100+
# handlers replaced, not stacked
101+
assert count1 == count2

wanwatcher/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""WANwatcher - WAN IP address monitor with notifications, DDNS and integrations."""
22

3-
VERSION = "2.0.0"
3+
VERSION = "2.1.0"
44

55
__version__ = VERSION

0 commit comments

Comments
 (0)