Skip to content

Commit 675f511

Browse files
Add /rosout log query API with level, node, and grep filters (#6)
* Initial plan * Add /rosout log query API with level, node, and grep filters Co-authored-by: marc-hanheide <1153084+marc-hanheide@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marc-hanheide <1153084+marc-hanheide@users.noreply.github.com>
1 parent e50045f commit 675f511

2 files changed

Lines changed: 613 additions & 3 deletions

File tree

src/rosbag_blackbox/rosbag_blackbox/snapshot_server.py

Lines changed: 232 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
Full bag metadata: topics, message counts, duration, time range.
3434
* ``GET /snapshots/{name}/messages``
3535
Extract and deserialise messages with optional filters.
36+
* ``GET /snapshots/{name}/rosout``
37+
Query ``/rosout`` log messages with optional level, node, and text filters.
3638
* ``GET /snapshots/{name}/image/{topic}``
3739
Return the nearest ``sensor_msgs/Image`` or ``CompressedImage`` as a PNG.
3840
* ``GET /snapshots/{name}/laserscan/{topic}``
@@ -92,6 +94,46 @@
9294
_IMAGING = False
9395

9496

97+
# ---------------------------------------------------------------------------
98+
# /rosout log-level constants (rcl_interfaces/msg/Log)
99+
# ---------------------------------------------------------------------------
100+
101+
ROSOUT_TOPIC = '/rosout'
102+
103+
#: Numeric level values used by ``rcl_interfaces/msg/Log``.
104+
LOG_LEVEL_MAP: Dict[str, int] = {
105+
'DEBUG': 10,
106+
'INFO': 20,
107+
'WARN': 30,
108+
'ERROR': 40,
109+
'FATAL': 50,
110+
}
111+
112+
#: Reverse map: integer → canonical level name.
113+
_LOG_LEVEL_NAMES: Dict[int, str] = {v: k for k, v in LOG_LEVEL_MAP.items()}
114+
115+
116+
def _level_int(level: str) -> int:
117+
"""Convert a level string or integer string to its numeric value.
118+
119+
Accepts canonical names (``'DEBUG'``, ``'INFO'``, ``'WARN'``,
120+
``'ERROR'``, ``'FATAL'``) case-insensitively, or a plain integer
121+
string such as ``'30'``.
122+
123+
:raises ValueError: When *level* is not recognised.
124+
"""
125+
upper = level.upper()
126+
if upper in LOG_LEVEL_MAP:
127+
return LOG_LEVEL_MAP[upper]
128+
try:
129+
return int(level)
130+
except ValueError:
131+
raise ValueError(
132+
f'Unknown log level {level!r}. '
133+
f'Use one of {list(LOG_LEVEL_MAP)} or a numeric value.'
134+
)
135+
136+
95137
# ---------------------------------------------------------------------------
96138
# Snapshot discovery helpers
97139
# ---------------------------------------------------------------------------
@@ -648,7 +690,118 @@ def get_topic_stats_from_bag(
648690

649691

650692
# ---------------------------------------------------------------------------
651-
# Application factory
693+
# /rosout helper
694+
# ---------------------------------------------------------------------------
695+
696+
def get_rosout_logs_from_bag(
697+
bag_path: str,
698+
level: Optional[str] = None,
699+
node: Optional[str] = None,
700+
grep: Optional[str] = None,
701+
start_time_sec: Optional[float] = None,
702+
end_time_sec: Optional[float] = None,
703+
max_messages: int = 200,
704+
) -> List[Dict[str, Any]]:
705+
"""Extract ``rcl_interfaces/msg/Log`` messages from the ``/rosout`` topic.
706+
707+
All filters are applied with **AND** semantics: a message must satisfy
708+
every supplied filter to be included in the result.
709+
710+
:param bag_path: Path to the bag directory.
711+
:param level: Minimum log level to include. Accepts a level
712+
name (``'DEBUG'``, ``'INFO'``, ``'WARN'``,
713+
``'ERROR'``, ``'FATAL'``) or a numeric string
714+
(e.g. ``'30'`` for WARN). When omitted all
715+
levels are returned.
716+
:param node: Case-insensitive substring filter on the
717+
``name`` field (publishing node name).
718+
Omit to include logs from all nodes.
719+
:param grep: Case-insensitive substring filter on the
720+
``msg`` field (log message text). Omit to
721+
return all messages.
722+
:param start_time_sec: Unix-time lower bound (seconds). Messages
723+
timestamped before this value are skipped.
724+
:param end_time_sec: Unix-time upper bound (seconds). Reading
725+
stops once the message timestamp exceeds this
726+
value.
727+
:param max_messages: Maximum number of matching messages to return
728+
(default 200).
729+
:returns: List of dicts, each with keys ``timestamp_ns``,
730+
``timestamp_sec``, ``level``, ``level_int``, ``node``,
731+
``message``, ``file``, ``function``, ``line``.
732+
:raises LookupError: When the bag contains no ``/rosout`` topic.
733+
"""
734+
reader = _open_reader(bag_path)
735+
topics_and_types = reader.get_all_topics_and_types()
736+
type_map: Dict[str, str] = {t.name: t.type for t in topics_and_types}
737+
738+
rosout_type = type_map.get(ROSOUT_TOPIC)
739+
if not rosout_type:
740+
del reader
741+
raise LookupError(
742+
f'Topic {ROSOUT_TOPIC!r} not found in bag. '
743+
'Ensure the bag was recorded with /rosout included.'
744+
)
745+
746+
msg_class = get_message(rosout_type)
747+
748+
filt = rosbag2_py.StorageFilter()
749+
filt.topics = [ROSOUT_TOPIC]
750+
reader.set_filter(filt)
751+
752+
min_level_int: Optional[int] = _level_int(level) if level is not None else None
753+
node_lower = node.lower() if node else None
754+
grep_lower = grep.lower() if grep else None
755+
start_ns = int(start_time_sec * 1e9) if start_time_sec is not None else None
756+
end_ns = int(end_time_sec * 1e9) if end_time_sec is not None else None
757+
758+
results: List[Dict[str, Any]] = []
759+
760+
while reader.has_next() and len(results) < max_messages:
761+
t_name, data, ts = reader.read_next()
762+
if t_name != ROSOUT_TOPIC:
763+
continue
764+
if start_ns is not None and ts < start_ns:
765+
continue
766+
if end_ns is not None and ts > end_ns:
767+
break
768+
769+
try:
770+
msg = deserialize_message(data, msg_class)
771+
except Exception: # noqa: BLE001
772+
continue
773+
774+
msg_level_int: int = int(msg.level)
775+
776+
if min_level_int is not None and msg_level_int < min_level_int:
777+
continue
778+
779+
msg_node: str = str(msg.name)
780+
if node_lower is not None and node_lower not in msg_node.lower():
781+
continue
782+
783+
msg_text: str = str(msg.msg)
784+
if grep_lower is not None and grep_lower not in msg_text.lower():
785+
continue
786+
787+
level_name = _LOG_LEVEL_NAMES.get(msg_level_int, str(msg_level_int))
788+
789+
results.append({
790+
'timestamp_ns': ts,
791+
'timestamp_sec': ts / 1e9,
792+
'level': level_name,
793+
'level_int': msg_level_int,
794+
'node': msg_node,
795+
'message': msg_text,
796+
'file': str(msg.file),
797+
'function': str(msg.function),
798+
'line': int(msg.line),
799+
})
800+
801+
del reader
802+
return results
803+
804+
652805
# ---------------------------------------------------------------------------
653806

654807
def create_app(snapshot_dir: str = '.') -> 'FastAPI':
@@ -1122,6 +1275,79 @@ def get_topic_stats_rest(
11221275
except Exception as exc: # noqa: BLE001
11231276
raise HTTPException(500, str(exc)) from exc
11241277

1278+
@app.get(
1279+
'/snapshots/{bag_name}/rosout',
1280+
tags=['analytics'],
1281+
summary='Query /rosout log messages with optional filters',
1282+
response_description=(
1283+
'Object with ``logs`` (list of log-entry dicts, each with '
1284+
'``timestamp_ns``, ``timestamp_sec``, ``level``, ``level_int``, '
1285+
'``node``, ``message``, ``file``, ``function``, ``line``) '
1286+
'and ``count``.'
1287+
),
1288+
)
1289+
def get_rosout_rest( # noqa: PLR0913
1290+
bag_name: str,
1291+
level: Optional[str] = None,
1292+
node: Optional[str] = None,
1293+
grep: Optional[str] = None,
1294+
start: Optional[float] = None,
1295+
end: Optional[float] = None,
1296+
limit: int = 200,
1297+
) -> Dict[str, Any]:
1298+
"""Return ``/rosout`` log messages from the named snapshot with
1299+
optional filtering.
1300+
1301+
All filters are combined with **AND** semantics – a log entry must
1302+
satisfy every supplied filter to appear in the result.
1303+
1304+
Query parameters
1305+
----------------
1306+
level : str, optional
1307+
Minimum log level to include. Accepts a level name
1308+
(``DEBUG``, ``INFO``, ``WARN``, ``ERROR``, ``FATAL``)
1309+
case-insensitively, or a numeric string (e.g. ``30`` for
1310+
WARN). When omitted all levels are returned.
1311+
Level values: DEBUG=10, INFO=20, WARN=30, ERROR=40, FATAL=50.
1312+
node : str, optional
1313+
Case-insensitive substring filter on the publishing node name
1314+
(the ``name`` field of the Log message). For example
1315+
``node=/my_robot/navigation`` returns only entries whose node
1316+
name contains that substring. Omit to include all nodes.
1317+
grep : str, optional
1318+
Case-insensitive substring filter on the log message text
1319+
(the ``msg`` field). Only entries whose message contains this
1320+
string are returned. Omit to return all messages.
1321+
start : float, optional
1322+
Unix-time lower bound in seconds. Log entries with a timestamp
1323+
before this value are skipped.
1324+
end : float, optional
1325+
Unix-time upper bound in seconds. Reading stops once the
1326+
message timestamp exceeds this value.
1327+
limit : int
1328+
Maximum number of matching log entries to return (default 200).
1329+
"""
1330+
bag_path = os.path.join(snapshot_dir, bag_name)
1331+
if not os.path.exists(bag_path):
1332+
raise HTTPException(404, f'Snapshot not found: {bag_name}')
1333+
try:
1334+
logs = get_rosout_logs_from_bag(
1335+
bag_path,
1336+
level=level,
1337+
node=node,
1338+
grep=grep,
1339+
start_time_sec=start,
1340+
end_time_sec=end,
1341+
max_messages=limit,
1342+
)
1343+
return {'logs': logs, 'count': len(logs)}
1344+
except LookupError as exc:
1345+
raise HTTPException(404, str(exc)) from exc
1346+
except ValueError as exc:
1347+
raise HTTPException(400, str(exc)) from exc
1348+
except Exception as exc: # noqa: BLE001
1349+
raise HTTPException(500, str(exc)) from exc
1350+
11251351
@app.get(
11261352
'/snapshots/{bag_name}/node_params',
11271353
tags=['node_params'],
@@ -1241,8 +1467,11 @@ def get_node_params_rest(
12411467
'topics were recorded and how many messages each has.\n'
12421468
'3. Use ``get_messages_rest`` to read raw message data, '
12431469
'``get_laserscan_rest`` / ``get_trajectory_rest`` / '
1244-
'``get_map_rest`` to visualise sensor data, or '
1245-
'``get_topic_stats_rest`` for frequency diagnostics.'
1470+
'``get_map_rest`` to visualise sensor data, '
1471+
'``get_topic_stats_rest`` for frequency diagnostics, or '
1472+
'``get_rosout_rest`` to query /rosout log messages filtered '
1473+
'by level (DEBUG/INFO/WARN/ERROR/FATAL), node name, or '
1474+
'message text.'
12461475
),
12471476
)
12481477
_mcp_http = _mcp.http_app(

0 commit comments

Comments
 (0)