|
33 | 33 | Full bag metadata: topics, message counts, duration, time range. |
34 | 34 | * ``GET /snapshots/{name}/messages`` |
35 | 35 | Extract and deserialise messages with optional filters. |
| 36 | +* ``GET /snapshots/{name}/rosout`` |
| 37 | + Query ``/rosout`` log messages with optional level, node, and text filters. |
36 | 38 | * ``GET /snapshots/{name}/image/{topic}`` |
37 | 39 | Return the nearest ``sensor_msgs/Image`` or ``CompressedImage`` as a PNG. |
38 | 40 | * ``GET /snapshots/{name}/laserscan/{topic}`` |
|
92 | 94 | _IMAGING = False |
93 | 95 |
|
94 | 96 |
|
| 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 | + |
95 | 137 | # --------------------------------------------------------------------------- |
96 | 138 | # Snapshot discovery helpers |
97 | 139 | # --------------------------------------------------------------------------- |
@@ -648,7 +690,118 @@ def get_topic_stats_from_bag( |
648 | 690 |
|
649 | 691 |
|
650 | 692 | # --------------------------------------------------------------------------- |
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 | + |
652 | 805 | # --------------------------------------------------------------------------- |
653 | 806 |
|
654 | 807 | def create_app(snapshot_dir: str = '.') -> 'FastAPI': |
@@ -1122,6 +1275,79 @@ def get_topic_stats_rest( |
1122 | 1275 | except Exception as exc: # noqa: BLE001 |
1123 | 1276 | raise HTTPException(500, str(exc)) from exc |
1124 | 1277 |
|
| 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 | + |
1125 | 1351 | @app.get( |
1126 | 1352 | '/snapshots/{bag_name}/node_params', |
1127 | 1353 | tags=['node_params'], |
@@ -1241,8 +1467,11 @@ def get_node_params_rest( |
1241 | 1467 | 'topics were recorded and how many messages each has.\n' |
1242 | 1468 | '3. Use ``get_messages_rest`` to read raw message data, ' |
1243 | 1469 | '``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.' |
1246 | 1475 | ), |
1247 | 1476 | ) |
1248 | 1477 | _mcp_http = _mcp.http_app( |
|
0 commit comments