Skip to content

fix(log): redact serials, add connection context, demote non-fault errors - #616

Merged
yozik04 merged 2 commits into
devfrom
fix/logging-hygiene
Aug 14, 2026
Merged

fix(log): redact serials, add connection context, demote non-fault errors#616
yozik04 merged 2 commits into
devfrom
fix/logging-hygiene

Conversation

@yozik04

@yozik04 yozik04 commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Why

Reviewing a real 0.5 MB production log surfaced three problems that make PAI logs hard to triage:

  1. Serials leak. The IP module authentication line printed the module serial verbatim (serial: 7106152c). Logs get pasted into issues routinely.
  2. No context at the failure. Using IP Connection is logged once at startup — often thousands of lines before the error being reported, so issue reports rarely show which transport was involved.
  3. Level inflation. Benign, recoverable and bad-input conditions were logged at ERROR. In a LOGGING_LEVEL_FILE = ERROR log (the default), the noise floor made real faults invisible, and "has the connection been stable?" could only be answered by inferring it from the absence of messages.

What changed

Redaction

  • New mask_secret() / mask_email() in paradox/lib/utils.py.
  • Applied to the IP module serial (connections/ip/commands.py) and the STUN site listing's panel serial (connections/ip/stun_session.py).
  • Authentication Success. IP(IP150) Module version 20, firmware: 1.32, serial: ****152c

Connection context

New describe_connection() renders the configured connection as a short, redacted descriptor. It reads config only — never a live connection object — so it is safe to call before connecting and after a drop.

CONNECTION_TYPE Rendering
Serial Serial(/dev/ttyS1@9600)
IP local IP(192.168.1.10:10000)
IP site/SWAN SITE(MySite / j****@e****.com, serial ****152c)
PRT3 PRT3(/dev/ttyUSB0@57600)

Used in a startup banner and injected into the connect-failure and connection-lost messages:

INFO  - ========================================================
INFO  -  PAI 3.4.2-dev
INFO  -  Python 3.8.20 on macOS-26.6-arm64-arm-64bit
INFO  -  Connection: Serial(/dev/ttyS1@9600)
INFO  -  Interfaces: BasicMQTTInterface, HomeAssistantMQTTInterface
INFO  - ========================================================

Local IP host/port are shown unmasked — they are almost always RFC1918 and are essential for diagnosing wrong-host problems. The site name is shown (user-chosen, not a credential); the account email and panel serial are masked.

Log level audit

ERROR → WARNING (31 sites): unhandled panel broadcasts (No handler for message), unsupported/not-implemented commands, No <zones/partitions/outputs/doors> selected, invalid MQTT topics and utility keys, PRT3 PRT3_USER_CODE config errors, recoverable IP framing noise, MQTT broker drops (the client auto-reconnects), and unimplemented RAM status parsers.

ERROR → DEBUG (7 sites): every control_* canceled / send_panic canceled. These fire on CancelledError during shutdown or supersession and are never actionable.

Deliberately unchanged: control_* timeout, Installer login failed, Authentication Failed, Could not read <mem_type>, Could not fully load labels, Unable to parse RAM Status Block, Connection to panel was lost, Serial Port Timeout, port-not-readable, everything in lib/help.py, and every logger.exception(...).

Message text is byte-identical for all demoted sites — only the level moves — so anyone grepping for the exact strings still matches.

Connection uptime

The retry loop in main.py now tracks connect/disconnect timestamps using time.monotonic() (not wall clock, so NTP steps and DST cannot produce negative durations):

WARNING - Panel connection ended after 3d 15h up
ERROR   - Unable to connect to alarm via Serial(/dev/ttyS1@9600)
INFO    - Connection recovered after 42s down

First connect after process start logs no recovery line.

Testing

  • 1691 tests pass.
  • New unit tests for mask_secret, mask_email, format_duration and describe_connection, including an assertion that the email and panel serial never appear verbatim in the descriptor.
  • New tests/lib/test_handlers_level.py asserts No handler for message is emitted at WARNING.
  • The banner and the full connect → drop → fail → recover cycle were exercised end-to-end against a fake alarm.
  • Verified on Python 3.8.20; no PEP 585/604 syntax introduced.

Notes for the reviewer

  • No config keys added or changed. Users who currently grep for ERROR will simply see fewer lines — that is the intent.
  • Two unrelated one-liners in interfaces/mqtt/core.py (unused MQTT_ERR_SUCCESS import, redundant getattr) are included because the flake8 pre-commit hook blocks any commit touching that file until they are fixed. They pre-date this branch.
  • pyupgrade/black reformatted some pre-existing long lines in interfaces/mqtt/core.py and hardware/evo/panel.py as a side effect of the hooks running on touched files.
  • .gitignore gains /docs/superpowers/ for local design notes.

…rors

Three problems made production logs hard to use for triage:

- The IP module and panel serial numbers were printed verbatim, and logs
  are routinely pasted into GitHub issues.
- The connection method appeared once at startup, often thousands of
  lines before the failure being reported.
- Benign, recoverable and bad-input conditions were logged at ERROR,
  making an ERROR-only log unusable and training users to ignore it.

Redaction: add mask_secret() and mask_email() and apply them to the IP
module serial and the STUN site listing's panel serial.

Context: add describe_connection(), which renders the configured
connection as a short redacted descriptor from config alone, so it is
safe to call before connecting and after a drop. Log a startup banner
(version, Python, platform, connection, interfaces) and include the
descriptor in the connect-failure and connection-lost messages.

Levels: demote 31 sites to WARNING (unhandled broadcasts, unsupported
commands, bad MQTT input, PRT3 config errors, recoverable framing noise,
MQTT broker drops) and 7 `control_* canceled` sites to DEBUG, since
those fire on CancelledError during shutdown and are never actionable.
Message text is unchanged; only the level moves. Genuine faults
(timeouts, login failures, connection loss, every logger.exception)
stay at ERROR.

Uptime: track connect/disconnect timestamps in the retry loop with
time.monotonic() and log "Panel connection ended after X up" and
"Connection recovered after X down", so stability is greppable from an
ERROR/WARNING-level log.

Also drops an unused paho import and a redundant getattr in mqtt/core.py,
which the flake8 hook flags on any commit touching that file.
- Cold start no longer claims a false recovery. mark_disconnected() now
  returns early when no healthy session was ever established, so a run
  that fails its first N connect attempts does not log "Connection
  recovered after X down" on the first success. Covered by a test that
  fails against the previous logic.

- "Send panic: user or partition is not found" goes back to ERROR. A
  panic command silently failing is operationally serious and should
  reach anyone alerting on ERROR. Also adds the missing `return False`
  on that branch: control flow previously fell through to
  `partition["id"]` and raised TypeError on None.

- Redacts the SWAN site listing. The full site_info blob was dumped to
  DEBUG as raw JSON, leaking every module's panelSerial and the account
  email four lines before the point where panelSerial was masked.

- Records session uptime when the loop exits via PAICriticalException,
  KeyboardInterrupt or SystemExit, which previously lost the uptime of a
  session ending in a critical fault.

- Raises the two uptime messages and the LOGGING_LEVEL_FILE default to
  WARNING. The default was ERROR, so the uptime signal this feature adds
  would have been invisible in exactly the file logs users paste into
  issue reports. The demotions in the previous commit lower the noise
  floor enough that WARNING is now a quiet level.

Adds tests for the cold-start case, the connect/drop/recover cycle, the
site-info redaction, and the SITEID-without-EMAIL fallback.
@yozik04

yozik04 commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Review round 1 — findings addressed

Reviewed by three perspectives (Advocate / Critic / Architect). Pushed e242cb1 addressing everything actionable.

Fixed

Major — false recovery on cold start. mark_disconnected() set disconnected_at even when no session had ever been established, so a run that failed its first N connect attempts logged "Connection recovered after X down" on the first success — contradicting this PR's own documented behaviour. It now returns early when connected_since is None. tests/test_main_uptime.py::test_cold_start_failures_do_not_log_a_recovery fails against the previous logic and passes now.

Major — panic failure demoted. Send panic: user or partition is not found is back at ERROR; a silently failing panic command is operationally serious. While there, added the missing return False — control flow previously fell through to partition["id"] and raised TypeError on None. That was a pre-existing bug the demotion would have made harder to spot.

Minor — SWAN site listing leaked wholesale. stun_session.py dumped the entire site_info blob to DEBUG as raw JSON — every module's panelSerial plus the account email — four lines before the point where this PR masks panelSerial. Now recursively redacted via redact_site_info(), with tests asserting no raw serial or email survives the dump.

Minor — uptime lost on critical exit. PAICriticalException / KeyboardInterrupt / SystemExit skipped uptime accounting, so a session ending in a critical fault reported nothing.

Minor — test gaps. Added coverage for the SITEID-without-EMAIL fallback and the full connect → drop → fail → recover cycle.

Design change from the Architect review

LOGGING_LEVEL_FILE defaulted to ERROR — which is precisely why the production log that motivated this PR contained only ERROR records. The uptime signal added here was WARNING/INFO, so it would have been invisible in exactly the logs users paste into issues, undercutting the premise. Both uptime messages and the LOGGING_LEVEL_FILE default are now WARNING. The demotions in 34d59b3 lower the noise floor enough that WARNING is a quiet level.

This is the one behaviour change beyond logging levels, and it is deliberate. The startup banner stays INFO; connection context still reaches shared logs because the failure messages carry describe_connection() at ERROR.

Deferred to follow-up

Both from the Architect, both agreed but out of scope:

  • Extract mask_secret/mask_email/format_duration/describe_connection out of lib/utils.py into a dedicated module — utils.py is becoming a junk drawer.
  • Document the log-level rubric in CONTRIBUTING.md (ERROR = actionable fault; WARNING = recoverable/unsupported/bad input; DEBUG = shutdown churn; logger.exception for unexpected), or the audit will drift.

1700 tests pass.

@sonarqubecloud

Copy link
Copy Markdown

@yozik04
yozik04 merged commit be1e46e into dev Aug 14, 2026
9 checks passed
@yozik04
yozik04 deleted the fix/logging-hygiene branch August 14, 2026 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant