Skip to content

Commit a4b92a6

Browse files
authored
Add node id to log records (#800)
1 parent fa4002e commit a4b92a6

File tree

4 files changed

+108
-16
lines changed

4 files changed

+108
-16
lines changed
+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Logger related helpers."""
2+
3+
import logging
4+
from typing import cast
5+
6+
import coloredlogs
7+
from coloredlogs import ColoredFormatter
8+
9+
10+
class MatterFormatter(ColoredFormatter): # type: ignore[misc]
11+
"""Custom formatter for Matter project."""
12+
13+
def __init__(
14+
self,
15+
fmt: str,
16+
node_fmt: str,
17+
datefmt: str,
18+
style: str = coloredlogs.DEFAULT_FORMAT_STYLE,
19+
level_styles: dict | None = None,
20+
field_styles: dict | None = None,
21+
):
22+
"""Initialize the Matter specific log formatter."""
23+
super().__init__(fmt, datefmt, style, level_styles, field_styles)
24+
self._node_style = logging.PercentStyle(self.colorize_format(node_fmt, style))
25+
26+
def format(self, record: logging.LogRecord) -> str:
27+
"""Format the log record."""
28+
original_style = self._style # type: ignore[has-type]
29+
if hasattr(record, "node"):
30+
self._style = self._node_style
31+
result = super().format(record)
32+
self._style = original_style
33+
return cast(str, result)
34+
35+
36+
class MatterNodeFilter(logging.Filter):
37+
"""Filter for Matter project to filter by node."""
38+
39+
def __init__(self, node: set[int], name: str = ""):
40+
"""Initialize the filter."""
41+
super().__init__(name)
42+
self.node = node
43+
44+
def filter(self, record: logging.LogRecord) -> bool:
45+
"""Filter the log record."""
46+
if not hasattr(record, "node"):
47+
return True
48+
49+
# Always display warnings and above
50+
if record.levelno >= logging.WARNING:
51+
return True
52+
return record.node in self.node

matter_server/server/__main__.py

+26-4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import coloredlogs
1616

1717
from matter_server.common.const import VERBOSE_LOG_LEVEL
18+
from matter_server.common.helpers.logger import MatterFormatter, MatterNodeFilter
1819
from matter_server.server import stack
1920

2021
from .server import MatterServer
@@ -109,6 +110,12 @@
109110
required=False,
110111
help="Optional bluetooth adapter (id) to enable direct commisisoning support.",
111112
)
113+
parser.add_argument(
114+
"--log-node-ids",
115+
type=int,
116+
nargs="+",
117+
help="List of node IDs to show logs from (applies only to server logs).",
118+
)
112119

113120
args = parser.parse_args()
114121

@@ -117,28 +124,43 @@ def _setup_logging() -> None:
117124
log_fmt = (
118125
"%(asctime)s.%(msecs)03d (%(threadName)s) %(levelname)s [%(name)s] %(message)s"
119126
)
127+
node_log_fmt = "%(asctime)s.%(msecs)03d (%(threadName)s) %(levelname)s [%(name)s] <Node:%(node)s> %(message)s"
120128
custom_level_style = {
121129
**coloredlogs.DEFAULT_LEVEL_STYLES,
122130
"chip_automation": {"color": "green", "faint": True},
123131
"chip_detail": {"color": "green", "faint": True},
124132
"chip_progress": {},
125133
"chip_error": {"color": "red"},
126134
}
135+
custom_field_styles = {
136+
**coloredlogs.DEFAULT_FIELD_STYLES,
137+
"node": {"color": "magenta"},
138+
}
127139
# Let coloredlogs handle all levels, we filter levels in the logging module
128-
coloredlogs.install(
129-
level=logging.NOTSET, level_styles=custom_level_style, fmt=log_fmt
140+
handler = coloredlogs.StandardErrorHandler(level=logging.NOTSET)
141+
handler.setFormatter(
142+
MatterFormatter(
143+
fmt=log_fmt,
144+
node_fmt=node_log_fmt,
145+
datefmt=FORMAT_DATETIME,
146+
level_styles=custom_level_style,
147+
field_styles=custom_field_styles,
148+
)
130149
)
131150

151+
if args.log_node_ids:
152+
handler.addFilter(MatterNodeFilter(set(args.log_node_ids)))
153+
132154
# Capture warnings.warn(...) and friends messages in logs.
133155
# The standard destination for them is stderr, which may end up unnoticed.
134156
# This way they're where other messages are, and can be filtered as usual.
135157
logging.captureWarnings(True)
136158

137-
logging.basicConfig(level=args.log_level.upper())
138-
logger = logging.getLogger()
139159
logging.addLevelName(VERBOSE_LOG_LEVEL, "VERBOSE")
160+
logging.basicConfig(level=args.log_level.upper(), handlers=[handler])
140161

141162
# setup file handler
163+
logger = logging.getLogger()
142164
if args.log_file:
143165
log_filename = os.path.join(args.log_file)
144166
file_handler = RotatingFileHandler(

matter_server/server/device_controller.py

+19-10
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import asyncio
1212
from collections import deque
1313
from datetime import datetime
14-
from functools import cached_property
14+
from functools import cached_property, lru_cache
1515
import logging
1616
import secrets
1717
import time
@@ -230,6 +230,13 @@ def _loop(self) -> asyncio.AbstractEventLoop:
230230
assert self.server.loop
231231
return self.server.loop
232232

233+
@lru_cache(maxsize=1024) # noqa: B019
234+
def get_node_logger(
235+
self, logger: logging.Logger, node_id: int
236+
) -> logging.LoggerAdapter:
237+
"""Return a logger for a specific node."""
238+
return logging.LoggerAdapter(logger, {"node": node_id})
239+
233240
@api_command(APICommand.GET_NODES)
234241
def get_nodes(self, only_available: bool = False) -> list[MatterNodeData]:
235242
"""Return all Nodes known to the server."""
@@ -757,7 +764,7 @@ async def ping_node(self, node_id: int, attempts: int = 1) -> NodePingResult:
757764
raise NodeNotExists(
758765
f"Node {node_id} does not exist or is not yet interviewed"
759766
)
760-
node_logger = LOGGER.getChild(f"node_{node_id}")
767+
node_logger = self.get_node_logger(LOGGER, node_id)
761768

762769
battery_powered = (
763770
node.attributes.get(ROUTING_ROLE_ATTRIBUTE_PATH, 0)
@@ -813,7 +820,7 @@ async def _get_node_ip_addresses(
813820
raise NodeNotExists(
814821
f"Node {node_id} does not exist or is not yet interviewed"
815822
)
816-
node_logger = LOGGER.getChild(f"node_{node_id}")
823+
node_logger = self.get_node_logger(LOGGER, node_id)
817824
# query mdns for all IP's
818825
# ensure both fabric id and node id have 16 characters (prefix with zero's)
819826
mdns_name = f"{self.compressed_fabric_id:0{16}X}-{node_id:0{16}X}.{MDNS_TYPE_OPERATIONAL_NODE}"
@@ -882,7 +889,7 @@ async def _subscribe_node(self, node_id: int) -> None:
882889
f"Node {node_id} does not exist or has not been interviewed."
883890
)
884891

885-
node_logger = LOGGER.getChild(f"node_{node_id}")
892+
node_logger = self.get_node_logger(LOGGER, node_id)
886893

887894
# Shutdown existing subscriptions for this node first
888895
await self._chip_device_controller.shutdown_subscription(node_id)
@@ -1104,7 +1111,8 @@ async def _setup_node(self, node_id: int) -> None:
11041111
# prevent duplicate setup actions
11051112
return
11061113
self._nodes_in_setup.add(node_id)
1107-
node_logger = LOGGER.getChild(f"node_{node_id}")
1114+
1115+
node_logger = self.get_node_logger(LOGGER, node_id)
11081116
node_data = self._nodes[node_id]
11091117
log_timers: dict[int, asyncio.TimerHandle] = {}
11101118
is_thread_node = (
@@ -1297,6 +1305,7 @@ def _on_mdns_operational_node_state(
12971305
# the mdns name is constructed as [fabricid]-[nodeid]._matter._tcp.local.
12981306
# extract the node id from the name
12991307
node_id = int(name.split("-")[1].split(".")[0], 16)
1308+
node_logger = self.get_node_logger(logger, node_id)
13001309

13011310
if not (node := self._nodes.get(node_id)):
13021311
return # this should not happen, but guard just in case
@@ -1315,13 +1324,13 @@ def _on_mdns_operational_node_state(
13151324
return
13161325

13171326
if not self._chip_device_controller.node_has_subscription(node_id):
1318-
logger.info("Node %s discovered on MDNS", node_id)
1327+
node_logger.info("Discovered on mDNS")
13191328
elif (now - last_seen) > NODE_MDNS_BACKOFF:
13201329
# node came back online after being offline for a while or restarted
1321-
logger.info("Node %s re-discovered on MDNS", node_id)
1330+
node_logger.info("Re-discovered on mDNS")
13221331
elif state_change == ServiceStateChange.Added:
13231332
# Trigger node re-subscriptions when mDNS entry got added
1324-
logger.info("Node %s activity on MDNS, trigger resubscribe", node_id)
1333+
node_logger.info("Activity on mDNS, trigger resubscribe")
13251334
asyncio.create_task(
13261335
self._chip_device_controller.trigger_resubscribe_if_scheduled(
13271336
node_id, "mDNS state change detected"
@@ -1383,7 +1392,7 @@ def _node_unavailable(
13831392
return
13841393
node.available = False
13851394
self.server.signal_event(EventType.NODE_UPDATED, node)
1386-
node_logger = LOGGER.getChild(f"node_{node_id}")
1395+
node_logger = self.get_node_logger(LOGGER, node_id)
13871396
node_logger.info("Marked node as unavailable")
13881397
if force_resubscription:
13891398
# Make sure the subscriptions are expiring very soon to trigger subscription
@@ -1419,7 +1428,7 @@ async def _node_offline(self, node_id: int) -> None:
14191428
return # nothing to do to
14201429
node.available = False
14211430
self.server.signal_event(EventType.NODE_UPDATED, node)
1422-
node_logger = LOGGER.getChild(f"node_{node_id}")
1431+
node_logger = self.get_node_logger(LOGGER, node_id)
14231432
node_logger.info("Marked node as offline")
14241433

14251434
async def _fallback_node_scanner(self) -> None:

matter_server/server/sdk.py

+11-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from __future__ import annotations
99

1010
import asyncio
11-
from functools import partial
11+
from functools import lru_cache, partial
1212
import logging
1313
import time
1414
from typing import TYPE_CHECKING, Any, TypeVar, cast
@@ -41,6 +41,8 @@
4141

4242
LOGGER = logging.getLogger(__name__)
4343

44+
# pylint: disable=too-many-public-methods
45+
4446

4547
class ChipDeviceControllerWrapper:
4648
"""Class exposing CHIP/Matter devices controller features.
@@ -99,6 +101,13 @@ async def _call_sdk(
99101
) -> _T:
100102
return await self._call_sdk_executor(None, target, *args, **kwargs)
101103

104+
@lru_cache(maxsize=1024) # noqa: B019
105+
def get_node_logger(
106+
self, logger: logging.Logger, node_id: int
107+
) -> logging.LoggerAdapter:
108+
"""Return a logger for a specific node."""
109+
return logging.LoggerAdapter(logger, {"node": node_id})
110+
102111
async def get_compressed_fabric_id(self) -> int:
103112
"""Get the compressed fabric id."""
104113
return await self._call_sdk(self._chip_controller.GetCompressedFabricId)
@@ -365,7 +374,7 @@ async def find_or_establish_case_session(
365374
if self._chip_controller is None:
366375
raise RuntimeError("Device Controller not initialized.")
367376

368-
node_logger = LOGGER.getChild(f"node_{node_id}")
377+
node_logger = self.get_node_logger(LOGGER, node_id)
369378
attempt = 1
370379

371380
while attempt <= retries:

0 commit comments

Comments
 (0)