Skip to content

Commit 16d686d

Browse files
committed
feat(gateway,#380): expose entity context on /faults/stream SSE events
Each `GET /api/v1/faults/stream` event payload now carries an optional `x-medkit` SOVD payload-extension object with `entity_type` and `entity_id` fields when the gateway can resolve the fault's first reporting source back to a SOVD entity. Consumers can hit `/{entity_type}/{entity_id}/bulk-data/rosbags/{fault_code}` directly instead of enumerating apps + components and HEAD-probing each one. Nested under `x-medkit` per the SOVD payload-extension convention (matches `x-medkit.aggregation_level`, `x-medkit.phase`, etc.). Flat `x-medkit-*` names are reserved for endpoint paths (`/x-medkit-graph`) and error codes, not payload fields. Resolution mirrors the existing `TriggerFaultSubscriber` / `LogManager` pattern: - Manifest / hybrid mode: cache's node-to-app linking index (with and without leading slash). - Runtime-only fallback: ROS FQN's last segment, only emitted when an App with that id exists in the cache - we never point consumers at a 404. - No match: `x-medkit` object omitted entirely, backward-compatible for existing SOVD consumers. Entity resolution is snapshotted in `on_fault_event` and stored next to the buffered event (new `QueuedEvent` struct), so a discovery refresh between enqueue and stream-out cannot retroactively flip the entity reported to consumers, and the format path stays lock-free wrt the entity cache.
1 parent 09c73ac commit 16d686d

6 files changed

Lines changed: 212 additions & 20 deletions

File tree

docs/api/rest.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2329,7 +2329,12 @@ Other extensions beyond SOVD:
23292329

23302330
- Vendor extension fields using ``x-medkit`` prefix (per SOVD extension mechanism)
23312331
- ``DELETE /faults`` - Clear all faults globally
2332-
- ``GET /faults/stream`` - SSE real-time fault notifications
2332+
- ``GET /faults/stream`` - SSE real-time fault notifications. Each event payload carries an
2333+
optional ``x-medkit`` SOVD payload-extension object with ``entity_type`` and ``entity_id``
2334+
fields when the gateway can resolve the fault's first reporting source back to an entity,
2335+
so consumers can hit ``/{entity_type}/{entity_id}/bulk-data/rosbags/{fault_code}`` directly
2336+
without enumerating entities. Resolution is snapshotted at event arrival; the entire
2337+
``x-medkit`` object is omitted when no entity can be resolved.
23332338
- ``/health`` - Health check with discovery pipeline diagnostics
23342339
- ``/version-info`` - Gateway version information
23352340
- ``/docs`` - OpenAPI capability description

src/ros2_medkit_gateway/CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Unreleased
1111

1212
**Features:**
1313

14+
* ``GET /api/v1/faults/stream`` event payloads now carry an optional ``x-medkit`` SOVD payload-extension object with ``entity_type`` and ``entity_id`` fields. When the gateway can resolve the fault's first reporting source back to a SOVD entity (via the manifest-mode linking index, or a runtime-mode last-segment match against an existing App), consumers can hit ``/{entity_type}/{entity_id}/bulk-data/rosbags/{fault_code}`` directly instead of HEAD-probing every entity. Resolution is snapshotted at event arrival, so a discovery refresh between enqueue and stream-out cannot retroactively change the entity reported to consumers. The ``x-medkit`` object is omitted entirely when no entity can be resolved, so existing SSE consumers ignore the addition (`#380 <https://github.com/selfpatch/ros2_medkit/issues/380>`_)
1415
* Plugin API version bumped to v7. Adds ``PluginContext::notify_entities_changed(EntityChangeScope)`` lifecycle hook for plugins that mutate the entity surface at runtime; default no-op keeps v6 source code compiling unchanged against v7 headers. Binary compatibility is not provided: the plugin loader uses a strict equality check on ``plugin_api_version()``, so out-of-tree plugins must be recompiled (`#376 <https://github.com/selfpatch/ros2_medkit/issues/376>`_)
1516
* New ``discovery.manifest.fragments_dir`` parameter: gateway scans the directory for ``*.yaml`` / ``*.yml`` fragment files on every manifest load / reload and merges apps, components, and functions on top of the base manifest. Fragments are forbidden from declaring top-level ``areas``, ``metadata``, ``discovery``, ``scripts``, ``capabilities``, or ``lock_overrides`` - those stay in the base manifest. Presence of any forbidden key (including empty-valued ones like ``areas: []``) is reported as a ``FRAGMENT_FORBIDDEN_FIELD`` validation error that fails the load / reload. Unknown top-level keys (typos such as ``app:`` vs ``apps:``) are ignored with a warning log. Files merged in alphabetical order for deterministic duplicate-id errors (`#376 <https://github.com/selfpatch/ros2_medkit/issues/376>`_)
1617
* Fragment files are size-capped at 1 MiB (``ManifestParser::kMaxFragmentBytes``) before being read into memory, and any symlink resolving outside the canonical ``fragments_dir`` is skipped with a warning, so misconfigurations or symlink-based escapes cannot hand arbitrary bytes to the YAML parser (`#376 <https://github.com/selfpatch/ros2_medkit/issues/376>`_)

src/ros2_medkit_gateway/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,7 @@ Real-time fault event stream using Server-Sent Events (SSE). Clients receive ins
764764
- **Automatic reconnection**: Supports `Last-Event-ID` header for seamless reconnection
765765
- **Keepalive**: Sends `:keepalive` comment every 30 seconds to prevent timeouts
766766
- **Event buffer**: Buffers up to 100 recent events for reconnecting clients
767+
- **Entity context (SOVD payload extension)**: When the gateway can resolve the fault's first reporting source back to an entity, the payload carries an `x-medkit` object with `entity_type` and `entity_id` fields so consumers can hit `/{entity_type}/{entity_id}/bulk-data/rosbags/{fault_code}` directly without enumerating entities
767768

768769
**Event Types:**
769770
- `fault_confirmed` - Fault transitioned to CONFIRMED status
@@ -781,13 +782,15 @@ curl -N http://localhost:8080/api/v1/faults/stream
781782
782783
id: 1
783784
event: fault_confirmed
784-
data: {"event_type":"fault_confirmed","fault":{"fault_code":"MOTOR_OVERHEAT",...},"timestamp":1735830000.123}
785+
data: {"event_type":"fault_confirmed","fault":{"fault_code":"MOTOR_OVERHEAT",...},"timestamp":1735830000.123,"x-medkit":{"entity_type":"apps","entity_id":"motor_controller"}}
785786
786787
id: 2
787788
event: fault_cleared
788-
data: {"event_type":"fault_cleared","fault":{"fault_code":"MOTOR_OVERHEAT",...},"timestamp":1735830060.456}
789+
data: {"event_type":"fault_cleared","fault":{"fault_code":"MOTOR_OVERHEAT",...},"timestamp":1735830060.456,"x-medkit":{"entity_type":"apps","entity_id":"motor_controller"}}
789790
```
790791

792+
**SOVD payload extension `x-medkit.entity_*`** (non-standard, SOVD-compatible): Resolution is best-effort and snapshotted at event arrival. Manifest and hybrid discovery use the linking result; runtime-only discovery falls back to the FQN's last segment and only emits the fields when an App with that ID exists in the cache. When no entity can be resolved (no reporting sources, orphan source, etc.), the entire `x-medkit` object is omitted and the consumer must fall back to discovery.
793+
791794
**Response (503 Service Unavailable - Client Limit Reached):**
792795
```json
793796
{

src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/sse_fault_handler.hpp

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
#include <mutex>
2323
#include <string>
2424

25+
#include <nlohmann/json.hpp>
26+
2527
#include "rclcpp/rclcpp.hpp"
2628
#include "ros2_medkit_gateway/core/http/sse_client_tracker.hpp"
2729
#include "ros2_medkit_gateway/http/handlers/handler_context.hpp"
@@ -111,8 +113,31 @@ class SSEFaultHandler {
111113
/// Callback for fault events from ROS 2 topic
112114
void on_fault_event(const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg);
113115

116+
/// Buffered queue entry. ``entity_id`` / ``entity_type`` populate the
117+
/// ``x-medkit`` SOVD payload-extension object (issue #380) and are
118+
/// resolved at enqueue time, snapshotting the cache so a discovery
119+
/// refresh between enqueue and stream-out cannot flip the entity context.
120+
struct QueuedEvent {
121+
uint64_t id;
122+
ros2_medkit_msgs::msg::FaultEvent event;
123+
std::string entity_type; ///< empty if no entity could be resolved
124+
std::string entity_id; ///< empty if no entity could be resolved
125+
};
126+
114127
/// Format a fault event as SSE message
115-
static std::string format_sse_event(const ros2_medkit_msgs::msg::FaultEvent & event, uint64_t event_id);
128+
static std::string format_sse_event(const QueuedEvent & queued);
129+
130+
/// Resolve the owning entity for a fault, snapshotting the cache. Manifest
131+
/// / hybrid mode uses the cache's node-to-app index; runtime mode falls
132+
/// back to the FQN's last segment and only emits the extension when an App
133+
/// with that ID actually exists. Returns ``{type, id}`` or empty strings
134+
/// when no entity can be resolved (consumer must then fall back to its
135+
/// previous discovery path).
136+
struct EntityContext {
137+
std::string type;
138+
std::string id;
139+
};
140+
EntityContext resolve_entity_context(const ros2_medkit_msgs::msg::Fault & fault) const;
116141

117142
HandlerContext & ctx_;
118143
std::shared_ptr<SSEClientTracker> client_tracker_;
@@ -123,7 +148,7 @@ class SSEFaultHandler {
123148
/// Event queue for broadcasting to clients
124149
mutable std::mutex queue_mutex_;
125150
std::condition_variable queue_cv_;
126-
std::deque<std::pair<uint64_t, ros2_medkit_msgs::msg::FaultEvent>> event_queue_;
151+
std::deque<QueuedEvent> event_queue_;
127152

128153
/// Monotonically increasing event ID for Last-Event-ID support
129154
std::atomic<uint64_t> next_event_id_{1};

src/ros2_medkit_gateway/src/http/handlers/sse_fault_handler.cpp

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,19 @@ void SSEFaultHandler::request_shutdown() {
8686
void SSEFaultHandler::on_fault_event(const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg) {
8787
uint64_t event_id = next_event_id_.fetch_add(1);
8888

89+
// Snapshot entity context at enqueue time (#380). Resolving here pins the
90+
// ``x-medkit.entity_{type,id}`` payload fields to the cache state that was
91+
// current when the fault arrived; a discovery refresh between enqueue and
92+
// stream-out cannot retroactively flip the entity reported to consumers,
93+
// and the formatting path stays lock-free with respect to the cache.
94+
auto entity_ctx = resolve_entity_context(msg->fault);
95+
8996
std::size_t dropped_this_call = 0;
9097
{
9198
std::lock_guard<std::mutex> lock(queue_mutex_);
9299

93-
// Add event to queue
94-
event_queue_.emplace_back(event_id, *msg);
100+
// Add event to queue with resolved entity context
101+
event_queue_.push_back(QueuedEvent{event_id, *msg, std::move(entity_ctx.type), std::move(entity_ctx.id)});
95102

96103
// Trim old events if buffer is full
97104
while (event_queue_.size() > kMaxBufferedEvents) {
@@ -156,13 +163,13 @@ void SSEFaultHandler::handle_stream(const httplib::Request & req, httplib::Respo
156163
// First, send any buffered events the client missed (for reconnection)
157164
{
158165
std::lock_guard<std::mutex> lock(queue_mutex_);
159-
for (const auto & [id, event] : event_queue_) {
160-
if (id > last_event_id) {
161-
std::string sse_msg = format_sse_event(event, id);
166+
for (const auto & queued : event_queue_) {
167+
if (queued.id > last_event_id) {
168+
std::string sse_msg = format_sse_event(queued);
162169
if (!sink.write(sse_msg.data(), sse_msg.size())) {
163170
return false; // Client disconnected
164171
}
165-
last_event_id = id;
172+
last_event_id = queued.id;
166173
}
167174
}
168175
}
@@ -198,15 +205,15 @@ void SSEFaultHandler::handle_stream(const httplib::Request & req, httplib::Respo
198205

199206
// Check for new events
200207
bool found_new = false;
201-
for (const auto & [id, event] : event_queue_) {
202-
if (id > last_event_id) {
203-
std::string sse_msg = format_sse_event(event, id);
208+
for (const auto & queued : event_queue_) {
209+
if (queued.id > last_event_id) {
210+
std::string sse_msg = format_sse_event(queued);
204211
lock.unlock();
205212
if (!sink.write(sse_msg.data(), sse_msg.size())) {
206213
return false; // Client disconnected
207214
}
208215
lock.lock();
209-
last_event_id = id;
216+
last_event_id = queued.id;
210217
found_new = true;
211218
}
212219
}
@@ -230,24 +237,73 @@ size_t SSEFaultHandler::connected_clients() const {
230237
return client_tracker_->connected_clients();
231238
}
232239

233-
std::string SSEFaultHandler::format_sse_event(const ros2_medkit_msgs::msg::FaultEvent & event, uint64_t event_id) {
234-
const auto sanitized_event_type = sanitize_sse_event_type(event.event_type);
240+
std::string SSEFaultHandler::format_sse_event(const QueuedEvent & queued) {
241+
const auto sanitized_event_type = sanitize_sse_event_type(queued.event.event_type);
235242

236243
nlohmann::json json_event;
237244
json_event["event_type"] = sanitized_event_type;
238-
json_event["fault"] = ros2::conversions::fault_to_json(event.fault);
245+
json_event["fault"] = ros2::conversions::fault_to_json(queued.event.fault);
239246

240247
// Convert timestamp to seconds with nanosecond precision
241-
double timestamp_sec = static_cast<double>(event.timestamp.sec) + static_cast<double>(event.timestamp.nanosec) * 1e-9;
248+
double timestamp_sec =
249+
static_cast<double>(queued.event.timestamp.sec) + static_cast<double>(queued.event.timestamp.nanosec) * 1e-9;
242250
json_event["timestamp"] = timestamp_sec;
243251

252+
// SOVD payload extension (#380): nest ``entity_type`` / ``entity_id`` under
253+
// the ``x-medkit`` response-extension object so global-stream consumers can
254+
// hit ``/{entity_type}/{entity_id}/bulk-data/rosbags/{fault_code}`` directly
255+
// instead of HEAD-probing every entity. Flat ``x-medkit-*`` names are
256+
// reserved for endpoint paths (``/x-medkit-graph``) and error codes, not
257+
// payload fields.
258+
if (!queued.entity_id.empty() && !queued.entity_type.empty()) {
259+
json_event["x-medkit"] = {{"entity_type", queued.entity_type}, {"entity_id", queued.entity_id}};
260+
}
261+
244262
std::ostringstream sse;
245-
sse << "id: " << event_id << "\n";
263+
sse << "id: " << queued.id << "\n";
246264
sse << "event: " << sanitized_event_type << "\n";
247265
sse << "data: " << json_event.dump() << "\n\n";
248266

249267
return sse.str();
250268
}
251269

270+
SSEFaultHandler::EntityContext
271+
SSEFaultHandler::resolve_entity_context(const ros2_medkit_msgs::msg::Fault & fault) const {
272+
if (fault.reporting_sources.empty()) {
273+
return {};
274+
}
275+
const auto & raw_fqn = fault.reporting_sources.front();
276+
if (raw_fqn.empty()) {
277+
return {};
278+
}
279+
280+
const auto & cache = ctx_.node()->get_thread_safe_cache();
281+
282+
// Manifest / hybrid mode: the linking step populated node_to_app with the
283+
// ROS FQN -> manifest app id mapping. Try both FQN forms (with and without
284+
// the leading '/'), mirroring gateway_node's node_resolver lambda.
285+
std::string entity_id = cache.resolve_node_to_app(raw_fqn);
286+
if (entity_id.empty() && raw_fqn.front() == '/') {
287+
entity_id = cache.resolve_node_to_app(raw_fqn.substr(1));
288+
}
289+
290+
// Runtime-only fallback: synthetic apps are created with id = ROS node name
291+
// (the FQN's last segment). Only emit the extension if such an app actually
292+
// exists in the cache - otherwise we'd be pointing consumers at a 404.
293+
if (entity_id.empty()) {
294+
auto last_slash = raw_fqn.rfind('/');
295+
auto candidate = (last_slash != std::string::npos) ? raw_fqn.substr(last_slash + 1) : raw_fqn;
296+
if (!candidate.empty() && cache.has_app(candidate)) {
297+
entity_id = std::move(candidate);
298+
}
299+
}
300+
301+
if (entity_id.empty()) {
302+
return {};
303+
}
304+
305+
return EntityContext{"apps", std::move(entity_id)};
306+
}
307+
252308
} // namespace handlers
253309
} // namespace ros2_medkit_gateway

src/ros2_medkit_gateway/test/test_sse_fault_handler.cpp

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
#include <unistd.h>
2929

3030
#include "ros2_medkit_gateway/core/config.hpp"
31+
#include "ros2_medkit_gateway/core/discovery/models/app.hpp"
3132
#include "ros2_medkit_gateway/core/http/sse_client_tracker.hpp"
33+
#include "ros2_medkit_gateway/core/models/thread_safe_entity_cache.hpp"
3234
#include "ros2_medkit_gateway/fault_manager_paths.hpp"
3335
#include "ros2_medkit_gateway/gateway_node.hpp"
3436
#include "ros2_medkit_gateway/http/handlers/handler_context.hpp"
@@ -37,10 +39,12 @@
3739

3840
using json = nlohmann::json;
3941
using namespace std::chrono_literals;
42+
using ros2_medkit_gateway::App;
4043
using ros2_medkit_gateway::AuthConfig;
4144
using ros2_medkit_gateway::CorsConfig;
4245
using ros2_medkit_gateway::GatewayNode;
4346
using ros2_medkit_gateway::SSEClientTracker;
47+
using ros2_medkit_gateway::ThreadSafeEntityCache;
4448
using ros2_medkit_gateway::TlsConfig;
4549
using ros2_medkit_gateway::handlers::HandlerContext;
4650
using ros2_medkit_gateway::handlers::SSEFaultHandler;
@@ -415,6 +419,104 @@ TEST_F(SSEFaultHandlerTest, NonPositiveKeepaliveOverrideLogsWarning) {
415419
EXPECT_NE(logs.find("Non-positive SSE keepalive override"), std::string::npos);
416420
}
417421

422+
TEST_F(SSEFaultHandlerTest, StreamOmitsXMedkitWhenNoMatchingApp) {
423+
// Empty cache: reporting source ("/apps/temp_sensor") has no manifest mapping
424+
// and no App with id "temp_sensor" exists in runtime cache, so the
425+
// ``x-medkit`` payload extension is not emitted (consumer falls back to
426+
// discovery).
427+
enqueue_event(make_fault_event(FaultEvent::EVENT_CONFIRMED, "NO_OWNER", 10));
428+
429+
auto req = make_stream_request("127.0.0.1");
430+
httplib::Response res;
431+
handler_->handle_stream(req, res);
432+
433+
auto output = read_stream_once(res, 1);
434+
auto payload = parse_sse_payload(output);
435+
436+
EXPECT_FALSE(payload.contains("x-medkit"));
437+
438+
release_stream(res);
439+
}
440+
441+
TEST_F(SSEFaultHandlerTest, StreamEmitsXMedkitForRuntimeApp) {
442+
// Runtime_only fallback: synthetic App with id matching the reporting
443+
// source's last segment exists in the cache, so the ``x-medkit`` payload
444+
// extension points consumers straight at
445+
// /apps/temp_sensor/bulk-data/rosbags/<fault_code>.
446+
App app;
447+
app.id = "temp_sensor";
448+
app.name = "temp_sensor";
449+
app.source = "heuristic";
450+
app.bound_fqn = "/apps/temp_sensor";
451+
auto & cache = const_cast<ThreadSafeEntityCache &>(node_->get_thread_safe_cache());
452+
cache.update_apps({app});
453+
454+
enqueue_event(make_fault_event(FaultEvent::EVENT_CONFIRMED, "WITH_OWNER", 20));
455+
456+
auto req = make_stream_request("127.0.0.1");
457+
httplib::Response res;
458+
handler_->handle_stream(req, res);
459+
460+
auto output = read_stream_once(res, 1);
461+
auto payload = parse_sse_payload(output);
462+
463+
ASSERT_TRUE(payload.contains("x-medkit")) << payload.dump();
464+
EXPECT_EQ(payload["x-medkit"]["entity_type"], "apps");
465+
EXPECT_EQ(payload["x-medkit"]["entity_id"], "temp_sensor");
466+
467+
release_stream(res);
468+
}
469+
470+
TEST_F(SSEFaultHandlerTest, StreamEmitsXMedkitForManifestApp) {
471+
// Manifest/hybrid mode: linking populates node_to_app with the ROS FQN ->
472+
// manifest app id mapping. The fault's reporting source uses the ROS FQN
473+
// form (with leading slash), but the manifest app id can be arbitrary (here
474+
// "diagnostic-bridge" with a hyphen, to model the FQN/id naming mismatch
475+
// called out in #380).
476+
App app;
477+
app.id = "diagnostic-bridge";
478+
app.name = "diagnostic_bridge";
479+
app.source = "manifest";
480+
app.bound_fqn = "/bridge/diagnostic_bridge";
481+
std::unordered_map<std::string, std::string> node_to_app{{"/bridge/diagnostic_bridge", "diagnostic-bridge"}};
482+
auto & cache = const_cast<ThreadSafeEntityCache &>(node_->get_thread_safe_cache());
483+
cache.update_all({}, {}, {app}, {}, node_to_app);
484+
485+
auto event = make_fault_event(FaultEvent::EVENT_CONFIRMED, "BRIDGE_FAULT", 30);
486+
event.fault.reporting_sources = {"/bridge/diagnostic_bridge"};
487+
enqueue_event(event);
488+
489+
auto req = make_stream_request("127.0.0.1");
490+
httplib::Response res;
491+
handler_->handle_stream(req, res);
492+
493+
auto output = read_stream_once(res, 1);
494+
auto payload = parse_sse_payload(output);
495+
496+
ASSERT_TRUE(payload.contains("x-medkit")) << payload.dump();
497+
EXPECT_EQ(payload["x-medkit"]["entity_type"], "apps");
498+
EXPECT_EQ(payload["x-medkit"]["entity_id"], "diagnostic-bridge");
499+
500+
release_stream(res);
501+
}
502+
503+
TEST_F(SSEFaultHandlerTest, StreamOmitsXMedkitWhenReportingSourcesEmpty) {
504+
auto event = make_fault_event(FaultEvent::EVENT_CONFIRMED, "ORPHAN", 40);
505+
event.fault.reporting_sources.clear();
506+
enqueue_event(event);
507+
508+
auto req = make_stream_request("127.0.0.1");
509+
httplib::Response res;
510+
handler_->handle_stream(req, res);
511+
512+
auto output = read_stream_once(res, 1);
513+
auto payload = parse_sse_payload(output);
514+
515+
EXPECT_FALSE(payload.contains("x-medkit"));
516+
517+
release_stream(res);
518+
}
519+
418520
TEST_F(SSEFaultHandlerTest, DisconnectReleasesTrackedClientSlot) {
419521
auto req = make_stream_request("127.0.0.1");
420522
httplib::Response res;

0 commit comments

Comments
 (0)