Skip to content

diagnostic_bridge: keep the numbers behind a fault, and stop confirming every planned STALE - #691

Open
ahmedsleem109 wants to merge 4 commits into
selfpatch:mainfrom
ahmedsleem109:feat/diagnostic-evidence-and-stale-severity
Open

ahmedsleem109 wants to merge 4 commits into
selfpatch:mainfrom
ahmedsleem109:feat/diagnostic-evidence-and-stale-severity

Conversation

@ahmedsleem109

@ahmedsleem109 ahmedsleem109 commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

What this is

Both halves of #668, which are the same complaint about the same message: a
DiagnosticStatus carries more than the bridge was willing to use.

The numbers behind the fault (#668 part 1)

A sensor fusion node publishes rejected_fixes=37, nis=0.03. The bridge read those
key-values only to pick a fault code and dropped the rest, so the fault record said a node
complained but not what it saw.

A FAILED report now carries them. ReportFault.srv gains
diagnostic_msgs/KeyValue[] evidence - the type the bridge already receives, and
ros2_medkit_msgs already depends on diagnostic_msgs, so this costs no new dependency and
no conversion. The fault manager keeps them in the fault's freeze frame under x-reported
and GET /api/v1/apps/{app}/faults/{code} serves them:

$ curl -s .../faults/FUSION_DIVERGED | jq '.environment_data.snapshots[0].data | fromjson'
{"x-reported": {"rejected_fixes": "37", "nis": "0.03"}}

A field rather than a separate attach call. The issue left this open. The evidence is
part of the event: a second call can fail on its own and leave a fault with no numbers, or
numbers with no fault. It also keeps the door open to putting evidence on near-miss entries
later without another API change - not done here, and out of scope.

Four decisions inside that are worth looking at:

  • Written on every FAILED report, not only at confirmation. A code that keeps approaching
    confirmation without reaching it is exactly the case the near-miss series exists for, and
    evidence written only at confirmation would miss it.
  • x-reported, not a flat merge. The frame's other keys are topic names, always fully
    qualified and so always starting with /. A reserved key that cannot collide is what lets
    a reader tell a value the reporter asserted from one the fault manager sampled.
  • The capture path had to change. snapshot_capture.cpp rebuilds the frame from the
    topics it sampled and writes it whole, so evidence reported before a confirm was silently
    erased by it. Which of the two writes lands last is a matter of when the fault confirmed,
    not of intent, so the capture path now carries the key across. There is a test for exactly
    this ordering.
  • The read path had to change. The frame was served only when no snapshots remained (or
    retention was on), because a frame of sampled topic values duplicates the snapshots beside
    it. Evidence is not in them at all, so a frame carrying evidence is now always served.

Bounded per fault code: 32 entries, 512 characters per value. Over-long values are dropped
whole rather than truncated - half a number read back later is worse than a logged absence -
and drops are reported with a throttled warning. A key already stored can still be updated,
so a steady reporter at the bound can refresh its own numbers instead of freezing at whatever
the first report said.

STALE is a decision, not a fact (#668 part 2)

map_to_severity sent STALE to CRITICAL unconditionally, and CRITICAL bypasses debounce
(fault_storage.cpp:148), so a GPS that goes STALE in every tunnel confirmed a CRITICAL
fault on its first sample - the debounce settings an operator chose never got to run.

Two parameters:

stale_severity: "WARN"                      # default CRITICAL - unconfigured behaviour is unchanged
"stale_severity_overrides.gps": "WARN"      # name or prefix, longest match wins
"stale_severity_overrides.safety_chain": "CRITICAL"

Only STALE is configurable. WARN and ERROR are facts about the status; STALE is the only
level a node reaches by design, and it is the only one that needed an operator's judgement.

Prefix matching because diagnostic names are conventionally <component>: <check>, so a
component prefix covers every check it publishes without listing them. Longest match wins,
the same rule the fault manager's entity thresholds already use - one matching rule for an
operator to learn, not two.

A typo is ignored, not defaulted. An unparseable override is reported and skipped,
leaving stale_severity in force. Applying CRITICAL to a misspelling would hand back exactly
the immediate-confirm behaviour the operator was configuring their way out of, and it would
look like the configuration had been accepted.

One pre-existing doc bug fixed

docs/config/diagnostic-bridge.rst documented STALE as SEVERITY_ERROR (2). The bridge has
always sent CRITICAL. The table now says what the code does, with a warning explaining why
the difference matters.

Tests

  • test_reported_evidence.cpp - 14 cases: the merge, provenance separation, later reports
    updating keys, both bounds and their exact boundary, an unreadable stored frame,
    "everything dropped writes no key", the capture-path preservation in both directions, and
    what the read path gates on.
  • test_diagnostic_bridge.cpp - 10 new cases: the default, the global parameter, override
    beating default, prefix and longest-prefix matching, a non-CRITICAL default combined with a
    CRITICAL override, both unparseable paths, case-insensitivity, and the parser itself.
  • test_reported_evidence.test.py - the node end to end: the issue's own example read back
    through GetFault, evidence stored while the fault is still PREFAILED, a later report
    updating, a report without evidence not erasing it, and a code that never carried any
    still getting no frame at all.
  • REQ_INTEROP_109 and REQ_INTEROP_110 added, both traced by @verifies.

Existing bridge tests were updated for the signature change: map_to_severity is a method
now that STALE resolves through configuration.

Verified locally

ROS 2 Jazzy / Ubuntu 24.04. colcon test over ros2_medkit_diagnostic_bridge,
ros2_medkit_fault_manager and ros2_medkit_fault_reporter: 54 tests, 0 failures,
every linter included. Whole workspace built (--packages-skip ros2_medkit_opcua) because
ros2_medkit_msgs changed.

Numbering starts at REQ_INTEROP_109 to leave 107/108 to #690, which is open against the
same tree.

Closes #668

@ahmedsleem109
ahmedsleem109 force-pushed the feat/diagnostic-evidence-and-stale-severity branch from 5f282dc to 2745747 Compare September 15, 2026 22:41
@bburda
bburda self-requested a review October 6, 2026 11:59

@bburda bburda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this one too :) The description explains every decision, which made the review much easier, and the STALE severity code looks good to me.

There are many comments, so a short guide. The UTF-8 crash and the missing PostgreSQL catch can take the fault manager down, so I would start with those two. The race with the capture thread, the moving timestamp and the duplicated topic values all go away if the evidence moves to its own table. I am happy to discuss that design with you before you start on it.

A few things outside this diff also need an update:

  • src/ros2_medkit_fault_manager/README.md (freeze-frame section and the retain_on_clear paragraph) and the capture() comment in snapshot_capture.hpp still say that a frame exists only with a capture config and is served once the snapshots are gone.
  • docs/api/rest.rst, Snapshot Types, describes freeze_frame as data captured at confirmation and does not mention x-reported.
  • src/ros2_medkit_diagnostic_bridge/design/index.rst still shows map_to_severity(level), the fixed STALE -> CRITICAL mapping and the "STALE as CRITICAL" section.
  • src/ros2_medkit_fault_reporter/README.md and its design doc do not list the report() overload with evidence.
  • src/ros2_medkit_msgs/README.md has no evidence row for ReportFault, and no note that reporters and the fault manager now have to be updated together.

Also, src/ros2_medkit_diagnostic_bridge/test/test_integration.test.py runs the real bridge with the real fault manager, but it still only checks STALE -> CRITICAL. Could you add a STALE status with an override there, against confirmation_threshold: -3, and an ERROR status whose values you read back with GetFault? A check through the gateway REST fault detail would also catch the broken docs example.

Comment thread src/ros2_medkit_fault_manager/src/fault_storage.cpp Outdated
Comment thread src/ros2_medkit_fault_manager/src/fault_manager_node.cpp Outdated
Comment thread src/ros2_medkit_fault_manager/src/fault_manager_node.cpp Outdated
Comment thread src/ros2_medkit_fault_manager/src/fault_storage.cpp Outdated
Comment thread src/ros2_medkit_fault_manager/src/fault_manager_node.cpp Outdated
Comment thread src/ros2_medkit_msgs/srv/ReportFault.srv Outdated
Comment thread src/ros2_medkit_fault_manager/src/fault_manager_node.cpp Outdated
Comment thread src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/fault_storage.hpp Outdated
Comment thread src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/fault_storage.hpp Outdated
Comment thread docs/requirements/specs/faults.rst Outdated
@ahmedsleem109
ahmedsleem109 force-pushed the feat/diagnostic-evidence-and-stale-severity branch from 2745747 to 237d92a Compare October 8, 2026 14:29
@ahmedsleem109

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review, and for reproducing the UTF-8 crash.

I rebased on main and pushed 237d92a with the fixes that don't depend on the storage design:

  • the UTF-8 crash
  • the missing PostgreSQL catches
  • the rename to x-medkit-reported and the key length limit
  • the doc and comment updates, including the files outside the diff you listed
  • removing REQ_INTEROP_109
  • a new bridge launch test, test_stale_and_evidence.test.py

The new launch test runs the real bridge with the real fault manager at confirmation_threshold: -3, in its own file so the old tests keep their timing. It checks:

  • STALE with an override is first PREFAILED at WARN, then confirms
  • STALE without an override is CRITICAL and confirms at once
  • ERROR values come back from GetFault
  • a value that is not valid UTF-8

I left out the gateway REST check for now, because the REST shape changes with the new table. I'll add it with that change.

For the evidence table I'd like to agree on the design with you first. My proposal is in the thread on fault_manager_node.cpp (the race comment). The related comments will be fixed together once we agree.

Comment thread docs/requirements/specs/faults.rst Outdated
Comment thread docs/config/diagnostic-bridge.rst Outdated
Comment thread docs/config/diagnostic-bridge.rst Outdated
Comment thread src/ros2_medkit_diagnostic_bridge/README.md Outdated
@ahmedsleem109

Copy link
Copy Markdown
Contributor Author

Thanks for the answers :) Pushed 1545c13, which moves the evidence to its own table as agreed and fixes the remaining comments. Each thread has the details.

I also added the gateway REST check I left out last time: ros2_medkit_integration_tests/test/features/test_reported_evidence_rest.test.py reports a fault with evidence and reads it back from GET /apps/{app}/faults/{code}, in the shape the docs show. The docs example now uses .data["x-medkit"].reported.

One small new parameter: snapshots.max_evidence_occurrences (default 10) bounds the kept occurrences when retain_on_clear is on.

I ran the fault_manager unit and launch tests, the diagnostic_bridge launch tests, the new gateway test and the linters on Jazzy, and the PostgreSQL storage tests against a real PostgreSQL server.

@ahmedsleem109

Copy link
Copy Markdown
Contributor Author

@bburda ready for another look when you have time :)

bburda
bburda previously approved these changes Oct 19, 2026
…nfirming every planned STALE

A DiagnosticStatus carries more than the bridge was willing to use.

Key-values were read only to pick a fault code and then dropped, so a node that
publishes outlier counts and gate statistics - the numbers it already computed to
decide it was unhappy - left a fault record saying it complained but not what it
saw. A FAILED report now carries them: ReportFault.srv gains an evidence field of
diagnostic_msgs/KeyValue, the type the bridge already receives, and the fault
manager keeps them in the fault's freeze frame under a reserved key that cannot
collide with a topic name.

Written on every FAILED report rather than at confirmation, because a code that
keeps approaching confirmation without reaching it is the case the near-miss
series exists for. Two existing paths had to learn about the key: a confirmation
capture rebuilds the frame from the topics it sampled and would have erased it,
and the read path served the frame only when no snapshots remained, which would
have hidden the one thing the snapshots do not contain. Bounded per fault code in
entries and value length; an over-long value is dropped whole and reported, never
truncated into half a number.

STALE was mapped to CRITICAL unconditionally, and CRITICAL bypasses debounce, so
a GPS that goes STALE in every tunnel confirmed a CRITICAL fault on its first
sample and the operator's debounce settings never ran. stale_severity and
stale_severity_overrides make that a decision: longest matching prefix on the
status name, the same rule the fault manager's entity thresholds use. Only STALE
is configurable - the other levels are facts about the status, not deployment
choices. An override that does not name a severity is reported and ignored rather
than defaulted, because applying CRITICAL to a typo hands back exactly the
behaviour the operator was configuring their way out of.

docs/config/diagnostic-bridge.rst documented STALE as SEVERITY_ERROR. The bridge
has always sent CRITICAL; the table now says what the code does.

Closes selfpatch#668
…erity

- Replace invalid UTF-8 in evidence keys and values instead of throwing.
  A Latin-1 byte in a diagnostic value used to crash the fault manager.
- Catch IgnorableConnectionException around the new storage calls in
  handle_report_fault and handle_get_fault.
- Rename the evidence key to x-medkit-reported.
- Add a 128-byte key limit and count the value limit in bytes.
- Remove REQ_INTEROP_109 and its @verifies tags.
- Add a bridge launch test at confirmation_threshold -3: STALE with and
  without an override, ERROR values read back with GetFault, and a
  non-UTF-8 value.
- Update the docs and comments that still described the old behavior.
Evidence was merged into the freeze-frame row, which the capture thread
also writes. Move it to a reported_evidence table keyed by
(fault_code, occurrence, source_id, key) in the memory, SQLite and
PostgreSQL backends. Only the report handler writes it, so the two
paths never write the same row, and preserve_reported_evidence is gone.

- The occurrence is occurrence_count after the report. A new occurrence
  starts with no evidence. With snapshots.retain_on_clear on, the last
  snapshots.max_evidence_occurrences occurrences are kept (default 10).
- GetFault serves the current occurrence in the freeze frame as
  "x-medkit": {"reported": {"<source_id>": {"<key>":
  {"value", "reported_at"}}}}. With per-topic snapshots served, the
  frame holds only the evidence. captured_at_ns stays the capture time.
- reported_at is kept per key.
- At most 32 keys per occurrence over all sources. An oversized update
  removes the stored key. Nothing is written when every entry is dropped.
- Drop REQ_INTEROP_110 and its @verifies tags.
- Docs: the STALE warning starts with the current behaviour, and the
  lines about the behaviour before this PR are removed.
- Tests: storage tests for all three backends, a launch test with a
  capture config, a clear and a new occurrence, and a gateway test that
  reads the evidence through the REST fault detail.
A wrapped PlantUML note line and a wrapped list item broke the docs
build. Put each on one line.
@ahmedsleem109
ahmedsleem109 force-pushed the feat/diagnostic-evidence-and-stale-severity branch from a0f8ccc to 757cb53 Compare October 19, 2026 19:09
@ahmedsleem109

Copy link
Copy Markdown
Contributor Author

Rebased on main now that #690 is merged, and pushed 757cb53. The conflicts were only where both PRs added code next to each other (fault_manager_node.cpp and faults.rst), so nothing changed in this PR's logic. The commits are the same as before plus one small docs fix: two wrapped lines in src/ros2_medkit_diagnostic_bridge/design/index.rst broke the docs build.

Ran the fault_manager, diagnostic_bridge and PostgreSQL tests, the linters and the docs build again on the rebased branch.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

diagnostic_bridge: keep DiagnosticStatus key-values as evidence, STALE severity configurable per name

2 participants