Skip to content

Commit bbea9af

Browse files
authored
Add enable_logger_service option for runtime log level control (#1522)
Port rclpy's `LoggingService` to rclnodejs so a node can optionally expose `<node>/get_logger_levels` and `<node>/set_logger_levels` services for runtime log-level inspection and updates, and stabilize CI by wrapping flaky test steps with `nick-fields/retry@v4`. ## Feature - New `lib/logging_service.js` (`LoggingService`) with idempotent `start()`, UNSET fallback on `getLogger` failure, and per-entry `{ successful, reason }` for set requests. - New `enableLoggerService` option on `NodeOptions` (default `false`, matches rclpy) with backing field, getter/setter, and TypeScript declaration. - Wired into `Node` so the service starts after `parameterService` when the option is enabled. ## Tests - Sinon-based unit tests for `LoggingService` (no ROS runtime needed). - Extended `NodeOptions` and type tests for the new flag. ## CI - Wrap test invocations with `nick-fields/retry@v4` (`max_attempts: 2`, `retry_wait_seconds: 10`, `timeout_minutes: 60`) in linux-x64, linux-arm64, windows, and asan workflows. Build-only, docs, publish, and orchestration workflows are intentionally left untouched. Fix: #1521
1 parent b527ca3 commit bbea9af

11 files changed

Lines changed: 349 additions & 27 deletions

.github/workflows/linux-arm64-build-and-test.yml

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,18 @@ jobs:
9595
# Fix ownership of the workspace to allow write access
9696
sudo chown -R $(whoami):$(whoami) $GITHUB_WORKSPACE
9797
98+
# Wrap the test step in nick-fields/retry@v4 to absorb transient flakes
99+
# (mocha races, native rebuilds, Electron postinstall, network blips).
100+
# Keep max_attempts low so real regressions still surface quickly.
98101
- name: Build and test rclnodejs
99-
run: |
100-
uname -a
101-
source /opt/ros/${{ matrix.ros_distribution }}/setup.bash
102-
npm i
103-
npm test
102+
uses: nick-fields/retry@v4
103+
with:
104+
shell: bash
105+
max_attempts: 2
106+
retry_wait_seconds: 10
107+
timeout_minutes: 60
108+
command: |
109+
uname -a
110+
source /opt/ros/${{ matrix.ros_distribution }}/setup.bash
111+
npm i
112+
npm test

.github/workflows/linux-x64-asan-test.yml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,16 @@ jobs:
4141
source /opt/ros/jazzy/setup.bash
4242
npm i
4343
44+
# Wrap the asan test step in nick-fields/retry@v4 to absorb transient
45+
# flakes. ASan can be sensitive to timing-related test races; keep
46+
# max_attempts low so real regressions still surface quickly.
4447
- name: Build and test with AddressSanitizer
45-
run: |
46-
source /opt/ros/jazzy/setup.bash
47-
npm run test:asan
48+
uses: nick-fields/retry@v4
49+
with:
50+
shell: bash
51+
max_attempts: 2
52+
retry_wait_seconds: 10
53+
timeout_minutes: 60
54+
command: |
55+
source /opt/ros/jazzy/setup.bash
56+
npm run test:asan

.github/workflows/linux-x64-build-and-test.yml

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -123,22 +123,39 @@ jobs:
123123
124124
- uses: actions/checkout@v6
125125

126+
# The mocha suite and the Electron postinstall (extract-zip 2.0.1) are
127+
# known to be intermittently flaky on this runner. Wrap both test
128+
# invocations in nick-fields/retry@v4 so a single transient failure does
129+
# not fail the whole matrix leg. Keep max_attempts conservative so real
130+
# regressions still surface quickly.
126131
- name: Build and test rclnodejs
127-
run: |
128-
uname -a
129-
source /opt/ros/${{ matrix.ros_distribution }}/setup.bash
130-
npm i
131-
npm run lint
132-
npm test
133-
npm run clean
132+
uses: nick-fields/retry@v4
133+
with:
134+
shell: bash
135+
max_attempts: 2
136+
retry_wait_seconds: 10
137+
timeout_minutes: 60
138+
command: |
139+
uname -a
140+
source /opt/ros/${{ matrix.ros_distribution }}/setup.bash
141+
npm i
142+
npm run lint
143+
npm test
144+
npm run clean
134145
135146
- name: Test with IDL ROS messages (rolling / lyrical)
136147
if: ${{ matrix.ros_distribution == 'rolling' || matrix.ros_distribution == 'lyrical' }}
137-
run: |
138-
source /opt/ros/${{ matrix.ros_distribution }}/setup.bash
139-
npm i
140-
npm run test-idl
141-
npm run clean
148+
uses: nick-fields/retry@v4
149+
with:
150+
shell: bash
151+
max_attempts: 2
152+
retry_wait_seconds: 10
153+
timeout_minutes: 60
154+
command: |
155+
source /opt/ros/${{ matrix.ros_distribution }}/setup.bash
156+
npm i
157+
npm run test-idl
158+
npm run clean
142159
143160
- name: Coveralls
144161
if: ${{ matrix.ros_distribution == 'rolling' && matrix['node-version'] == '24.X' }}

.github/workflows/windows-build-and-test.yml

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,19 @@ jobs:
8888
call "C:\pixi_ws\ros2-windows\setup.bat"
8989
npm i
9090
91+
# Wrap the test step in nick-fields/retry@v4 to absorb transient flakes
92+
# (mocha races, native rebuilds, network blips). Keep max_attempts low so
93+
# real regressions still surface quickly.
9194
- name: Test rclnodejs
9295
if: ${{ matrix.run_tests }}
93-
shell: cmd
94-
run: |
95-
set PATH=C:\pixi_ws\.pixi\envs\default\Library\bin;C:\pixi_ws\.pixi\envs\default\Scripts;C:\pixi_ws\.pixi\envs\default\bin;%PATH%
96-
set RMW_IMPLEMENTATION=rmw_fastrtps_cpp
97-
call "C:\pixi_ws\ros2-windows\setup.bat"
98-
npm test
96+
uses: nick-fields/retry@v4
97+
with:
98+
shell: cmd
99+
max_attempts: 2
100+
retry_wait_seconds: 10
101+
timeout_minutes: 60
102+
command: |
103+
set PATH=C:\pixi_ws\.pixi\envs\default\Library\bin;C:\pixi_ws\.pixi\envs\default\Scripts;C:\pixi_ws\.pixi\envs\default\bin;%PATH%
104+
set RMW_IMPLEMENTATION=rmw_fastrtps_cpp
105+
call "C:\pixi_ws\ros2-windows\setup.bat"
106+
npm test

lib/logging_service.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026, The Robot Web Tools Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
'use strict';
16+
17+
const Logging = require('./logging.js');
18+
19+
const LOGGING_SEVERITY_UNSET = 0;
20+
21+
/**
22+
* Implements the ROS 2 logging service interfaces for a node.
23+
*
24+
* The interfaces implemented are:
25+
* rcl_interfaces/srv/GetLoggerLevels
26+
* rcl_interfaces/srv/SetLoggerLevels
27+
*
28+
* @class
29+
*/
30+
class LoggingService {
31+
/**
32+
* Create a new instance.
33+
* @param {Node} node - The node these services support.
34+
*/
35+
constructor(node) {
36+
this._node = node;
37+
this._isRunning = false;
38+
}
39+
40+
/**
41+
* Get the node this service supports.
42+
* @return {Node} - The supported node.
43+
*/
44+
get node() {
45+
return this._node;
46+
}
47+
48+
/**
49+
* Check if logging services are configured and accepting requests.
50+
* @return {boolean} - True if services are active; false otherwise.
51+
*/
52+
isStarted() {
53+
return this._isRunning;
54+
}
55+
56+
/**
57+
* Configure logging services and begin processing client requests.
58+
* @return {undefined}
59+
*/
60+
start() {
61+
if (this._isRunning) return;
62+
63+
this._isRunning = true;
64+
const nodeName = this.node.name();
65+
66+
this.node.createService(
67+
'rcl_interfaces/srv/GetLoggerLevels',
68+
nodeName + '/get_logger_levels',
69+
(request, response) => this._handleGetLoggerLevels(request, response)
70+
);
71+
72+
this.node.createService(
73+
'rcl_interfaces/srv/SetLoggerLevels',
74+
nodeName + '/set_logger_levels',
75+
(request, response) => this._handleSetLoggerLevels(request, response)
76+
);
77+
}
78+
79+
_handleGetLoggerLevels(request, response) {
80+
const msg = response.template;
81+
82+
for (const name of request.names) {
83+
try {
84+
msg.levels.push({
85+
name,
86+
level: Logging.getLogger(name).loggerEffectiveLevel,
87+
});
88+
} catch {
89+
msg.levels.push({
90+
name,
91+
level: LOGGING_SEVERITY_UNSET,
92+
});
93+
}
94+
}
95+
96+
response.send(msg);
97+
}
98+
99+
_handleSetLoggerLevels(request, response) {
100+
const msg = response.template;
101+
102+
for (const loggerLevel of request.levels) {
103+
const result = {
104+
successful: false,
105+
reason: '',
106+
};
107+
108+
try {
109+
Logging.getLogger(loggerLevel.name).setLoggerLevel(loggerLevel.level);
110+
result.successful = true;
111+
} catch (error) {
112+
result.reason = error.message;
113+
}
114+
115+
msg.results.push(result);
116+
}
117+
118+
response.send(msg);
119+
}
120+
}
121+
122+
module.exports = LoggingService;

lib/node.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const DistroUtils = require('./distro.js');
2525
const GuardCondition = require('./guard_condition.js');
2626
const loader = require('./interface_loader.js');
2727
const Logging = require('./logging.js');
28+
const LoggingService = require('./logging_service.js');
2829
const NodeOptions = require('./node_options.js');
2930
const {
3031
ParameterType,
@@ -121,6 +122,8 @@ class Node extends rclnodejs.ShadowNode {
121122
defaults.startTypeDescriptionService,
122123
enableRosout: options.enableRosout ?? defaults.enableRosout,
123124
rosoutQos: options.rosoutQos ?? defaults.rosoutQos,
125+
enableLoggerService:
126+
options.enableLoggerService ?? defaults.enableLoggerService,
124127
};
125128
}
126129

@@ -159,6 +162,7 @@ class Node extends rclnodejs.ShadowNode {
159162
this._parameterDescriptors = new Map();
160163
this._parameters = new Map();
161164
this._parameterService = null;
165+
this._loggerService = null;
162166
this._typeDescriptionService = null;
163167
this._parameterEventPublisher = null;
164168
this._preSetParametersCallbacks = [];
@@ -219,6 +223,11 @@ class Node extends rclnodejs.ShadowNode {
219223
this._parameterService.start();
220224
}
221225

226+
if (options.enableLoggerService) {
227+
this._loggerService = new LoggingService(this);
228+
this._loggerService.start();
229+
}
230+
222231
if (
223232
DistroUtils.getDistroId() >= DistroUtils.getDistroId('jazzy') &&
224233
options.startTypeDescriptionService

lib/node_options.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,16 @@ class NodeOptions {
2929
* @param {boolean} [startTypeDescriptionService=true]
3030
* @param {boolean} [enableRosout=true]
3131
* @param {QoS} [rosoutQos=QoS.profileDefault]
32+
* @param {boolean} [enableLoggerService=false]
3233
*/
3334
constructor(
3435
startParameterServices = true,
3536
parameterOverrides = [],
3637
automaticallyDeclareParametersFromOverrides = false,
3738
startTypeDescriptionService = true,
3839
enableRosout = true,
39-
rosoutQos = null
40+
rosoutQos = null,
41+
enableLoggerService = false
4042
) {
4143
this._startParameterServices = startParameterServices;
4244
this._parameterOverrides = parameterOverrides;
@@ -45,6 +47,7 @@ class NodeOptions {
4547
this._startTypeDescriptionService = startTypeDescriptionService;
4648
this._enableRosout = enableRosout;
4749
this._rosoutQos = rosoutQos;
50+
this._enableLoggerService = enableLoggerService;
4851
}
4952

5053
/**
@@ -164,6 +167,23 @@ class NodeOptions {
164167
this._rosoutQos = rosoutQos;
165168
}
166169

170+
/**
171+
* Get the enableLoggerService option.
172+
* Default value = false;
173+
* @returns {boolean} - true if logger services are enabled.
174+
*/
175+
get enableLoggerService() {
176+
return this._enableLoggerService;
177+
}
178+
179+
/**
180+
* Set enableLoggerService.
181+
* @param {boolean} enableLoggerService
182+
*/
183+
set enableLoggerService(enableLoggerService) {
184+
this._enableLoggerService = enableLoggerService;
185+
}
186+
167187
/**
168188
* Return an instance configured with default options.
169189
* @returns {NodeOptions} - An instance with default values.

0 commit comments

Comments
 (0)