Skip to content

Commit 6f56260

Browse files
authored
feat: capture Fuseki error bodies into events + server-side filtering for the admin event viewer (#1050)
* feat(triple-store): capture Fuseki error body into TripleStoreError events HTTP-backed adapters discarded the server response body on failure, so TripleStoreError events only carried a generic "500 Server Error for url ..." string — useless for telling whether a Fuseki 500 is OOM, disk-full or a lock abort. - Add Exceptions.RequestError (TripleStorePorts) carrying operation, status_code, response_body (truncated to 4KB), endpoint, attempts. Plain-data domain exception so the service layer needs no HTTP-library import. - ApacheJenaTDB2: read response.text on failure and raise RequestError; the distributed-lock acquire timeout now raises RequestError(operation=acquire_write_lock) instead of a bare RuntimeError. - TripleStoreService: enrich TripleStoreError.message via exc.detail(); query()/query_view() now emit TripleStoreError on read failures (previously silent). * feat(nexus): server-side filtering, search and paging for the admin event viewer The admin event viewer fetched the last 100 events of ALL types then filtered/searched client-side, so rare events (e.g. TripleStoreError) were invisible and the type dropdown only listed whatever was in the loaded sample. - EventService/EventSQLiteAdapter + ports: add newest_first (ORDER BY seq DESC -> last N matching, via the existing (event_type, seq) index) and search (case-insensitive LIKE over the payload, wildcards escaped). Additive and backward-compatible — cursor readers keep ASC ordering. - /api/admin/events/recent: add event_class (type URI), q (whole-log search) and before_seq (cursor); limit is applied AFTER the filters so you get the last N of that kind. New /api/admin/events/types lists the full LogProcess registry for the dropdown. - Web viewer: server-driven type dropdown, debounced server-side search over the whole log, 'Load older' cursor paging; the live poll respects active filters.
1 parent 927de67 commit 6f56260

11 files changed

Lines changed: 658 additions & 101 deletions

File tree

libs/naas-abi-core/naas_abi_core/services/event/EventPort.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,18 @@ def query(
5252
until_timestamp: str | None = None,
5353
json_filter: dict | None = None,
5454
limit: int | None = None,
55+
newest_first: bool = False,
56+
search: str | None = None,
5557
) -> list[StoredEvent]:
56-
"""Read events matching the filters, ordered by `seq` ascending.
58+
"""Read events matching the filters.
59+
60+
Ordered by `seq` ascending by default; pass ``newest_first=True`` to order
61+
descending so ``limit`` returns the most recent N matching rows.
5762
5863
``json_filter`` is an EventBridge-style dict translated by the adapter
5964
into backend-native pushdown (SQLite JSON1 for the SQLite adapter).
65+
``search`` is a case-insensitive substring matched against the raw payload
66+
text (keys and values).
6067
"""
6168

6269
@abstractmethod
@@ -118,8 +125,15 @@ def query(
118125
until_timestamp: str | None = None,
119126
filter: dict | None = None,
120127
limit: int | None = None,
128+
newest_first: bool = False,
129+
search: str | None = None,
121130
) -> list[Any]:
122-
"""Return reconstructed event instances matching the filters."""
131+
"""Return reconstructed event instances matching the filters.
132+
133+
``newest_first=True`` orders by ``seq`` descending so ``limit`` yields the
134+
most recent N matches. ``search`` is a case-insensitive substring match
135+
over the raw payload text.
136+
"""
123137

124138
@abstractmethod
125139
def iter_query(

libs/naas-abi-core/naas_abi_core/services/event/EventService.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,19 @@ def query(
131131
until_timestamp: str | None = None,
132132
filter: dict | None = None,
133133
limit: int | None = None,
134+
newest_first: bool = False,
135+
search: str | None = None,
134136
) -> list[Any]:
135137
"""Read events.
136138
137139
``filter`` is an EventBridge-style dict; see :mod:`EventFilter` for
138140
the supported syntax. Translated to SQLite JSON1 SQL — pushdown
139141
happens at the adapter, not in Python.
142+
143+
``newest_first=True`` orders results by ``seq`` descending so ``limit``
144+
returns the most recent N matches (e.g. "last 100 of this event type").
145+
``search`` is a case-insensitive substring matched against the raw event
146+
payload (keys and values).
140147
"""
141148
event_type = str(event_class._class_uri) if event_class is not None else None
142149
rows = self._adapter.query(
@@ -147,6 +154,8 @@ def query(
147154
until_timestamp=until_timestamp,
148155
json_filter=filter,
149156
limit=limit,
157+
newest_first=newest_first,
158+
search=search,
150159
)
151160
return [self._reconstruct(row, event_class) for row in rows]
152161

libs/naas-abi-core/naas_abi_core/services/event/adapters/secondary/EventSQLiteAdapter.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,22 @@ def query(
9696
until_timestamp: str | None = None,
9797
json_filter: dict | None = None,
9898
limit: int | None = None,
99+
newest_first: bool = False,
100+
search: str | None = None,
99101
) -> list[StoredEvent]:
102+
"""Read events matching the given filters.
103+
104+
``newest_first`` controls ordering: by default rows come back oldest-first
105+
(``seq ASC``) — the order cursor-based readers rely on. Pass
106+
``newest_first=True`` to order ``seq DESC`` so ``limit`` yields the *most
107+
recent* N matching rows (e.g. "last 100 of this type") rather than the
108+
oldest N. The composite ``(event_type, seq)`` index serves both directions.
109+
110+
``search`` is a case-insensitive substring matched against the raw JSON
111+
payload text (keys and values) — a server-side equivalent of the admin
112+
viewer's previous client-side ``JSON.stringify(...).includes()``. LIKE
113+
wildcards in the term are escaped so they match literally.
114+
"""
100115
clauses: list[str] = []
101116
params: list[object] = []
102117
if event_type is not None:
@@ -119,11 +134,18 @@ def query(
119134
if where_sql:
120135
clauses.append(where_sql)
121136
params.extend(where_params)
137+
if search:
138+
# Escape LIKE metacharacters so the term matches literally.
139+
like = (
140+
search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
141+
)
142+
clauses.append("CAST(payload AS TEXT) LIKE ? ESCAPE '\\'")
143+
params.append(f"%{like}%")
122144

123145
sql = "SELECT id, event_type, seq, timestamp, payload FROM events"
124146
if clauses:
125147
sql += " WHERE " + " AND ".join(clauses)
126-
sql += " ORDER BY seq ASC"
148+
sql += " ORDER BY seq DESC" if newest_first else " ORDER BY seq ASC"
127149
if limit is not None:
128150
sql += " LIMIT ?"
129151
params.append(limit)

libs/naas-abi-core/naas_abi_core/services/event/adapters/secondary/EventSQLiteAdapter_test.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,79 @@ def test_max_seq(adapter):
121121
assert adapter.max_seq(event_type="urn:Type:DoesNotExist") == 0
122122

123123

124+
# ---------------------------------------------------------------------------
125+
# newest_first ordering ("last N of a type")
126+
# ---------------------------------------------------------------------------
127+
128+
129+
def test_query_newest_first_orders_seq_desc(adapter):
130+
for i in range(5):
131+
adapter.append(f"urn:e{i}", "urn:Type:A", _ts(i), b"p")
132+
rows = adapter.query(newest_first=True)
133+
assert [r.seq for r in rows] == [5, 4, 3, 2, 1]
134+
135+
136+
def test_query_newest_first_with_limit_returns_most_recent(adapter):
137+
for i in range(5):
138+
adapter.append(f"urn:e{i}", "urn:Type:A", _ts(i), b"p")
139+
rows = adapter.query(newest_first=True, limit=2)
140+
# The two MOST RECENT, not the two oldest.
141+
assert [r.seq for r in rows] == [5, 4]
142+
143+
144+
def test_query_newest_first_with_type_returns_last_n_of_that_type(adapter):
145+
# Interleave types; the rare type B is sparse and old relative to seq.
146+
adapter.append("urn:b1", "urn:Type:B", _ts(0), b"p")
147+
for i in range(10):
148+
adapter.append(f"urn:a{i}", "urn:Type:A", _ts(i + 1), b"p")
149+
adapter.append("urn:b2", "urn:Type:B", _ts(20), b"p")
150+
151+
rows = adapter.query(event_type="urn:Type:B", newest_first=True, limit=100)
152+
# Both B events are returned even though A dominates the recent seq window.
153+
assert [r.id for r in rows] == ["urn:b2", "urn:b1"]
154+
155+
156+
def test_query_default_ordering_is_unchanged(adapter):
157+
for i in range(3):
158+
adapter.append(f"urn:e{i}", "urn:Type:A", _ts(i), b"p")
159+
# Default (no newest_first) still oldest-first — cursor readers depend on it.
160+
rows = adapter.query()
161+
assert [r.seq for r in rows] == [1, 2, 3]
162+
163+
164+
# ---------------------------------------------------------------------------
165+
# search (substring over payload)
166+
# ---------------------------------------------------------------------------
167+
168+
169+
def test_query_search_matches_payload_substring(adapter):
170+
adapter.append("urn:e1", "urn:Type:A", _ts(0), b'{"message":"disk full"}')
171+
adapter.append("urn:e2", "urn:Type:A", _ts(1), b'{"message":"all good"}')
172+
adapter.append("urn:e3", "urn:Type:A", _ts(2), b'{"message":"DISK pressure"}')
173+
174+
rows = adapter.query(search="disk")
175+
# Case-insensitive over the raw JSON text.
176+
assert {r.id for r in rows} == {"urn:e1", "urn:e3"}
177+
178+
179+
def test_query_search_combines_with_type_and_newest_first(adapter):
180+
adapter.append("urn:e1", "urn:Type:A", _ts(0), b'{"status":500}')
181+
adapter.append("urn:e2", "urn:Type:B", _ts(1), b'{"status":500}')
182+
adapter.append("urn:e3", "urn:Type:A", _ts(2), b'{"status":500}')
183+
184+
rows = adapter.query(event_type="urn:Type:A", search="500", newest_first=True)
185+
assert [r.id for r in rows] == ["urn:e3", "urn:e1"]
186+
187+
188+
def test_query_search_escapes_like_wildcards(adapter):
189+
adapter.append("urn:e1", "urn:Type:A", _ts(0), b'{"path":"a/b"}')
190+
adapter.append("urn:e2", "urn:Type:A", _ts(1), b'{"path":"axb"}')
191+
192+
# "a%b" must match literally, not as the LIKE wildcard "a<anything>b".
193+
rows = adapter.query(search="a%b")
194+
assert rows == []
195+
196+
124197
# ---------------------------------------------------------------------------
125198
# cursor / query_for_consumer
126199
# ---------------------------------------------------------------------------

libs/naas-abi-core/naas_abi_core/services/triple_store/TripleStorePorts.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,57 @@ class GraphNotFoundError(Exception):
2222
class GraphAlreadyExistsError(Exception):
2323
pass
2424

25+
class RequestError(Exception):
26+
"""Raised by HTTP-backed adapters when the triple-store server rejects a request.
27+
28+
Carries the server's own diagnostics (status code + response body) so the
29+
domain layer can record a meaningful ``TripleStoreError`` event instead of a
30+
generic transport-error string. This is a plain-data domain exception: the
31+
``TripleStoreService`` catches it without importing any HTTP library, keeping
32+
business logic technology-agnostic.
33+
34+
Attributes:
35+
operation: The triple-store operation that failed (e.g. ``"update"``,
36+
``"query"``, ``"acquire_write_lock"``).
37+
status_code: HTTP status returned by the server, when applicable.
38+
response_body: The server's response body (already truncated by the
39+
adapter), which holds the real cause — OOM, disk-full, lock abort,
40+
a TDB2 stack trace, etc.
41+
endpoint: The endpoint the request targeted.
42+
attempts: How many attempts were made before giving up.
43+
"""
44+
45+
def __init__(
46+
self,
47+
*,
48+
operation: str,
49+
message: str,
50+
status_code: int | None = None,
51+
response_body: str | None = None,
52+
endpoint: str | None = None,
53+
attempts: int | None = None,
54+
) -> None:
55+
self.operation = operation
56+
self.status_code = status_code
57+
self.response_body = response_body
58+
self.endpoint = endpoint
59+
self.attempts = attempts
60+
super().__init__(message)
61+
62+
def detail(self) -> str:
63+
"""Single multi-line summary suitable for logs and event messages."""
64+
fields = [f"operation={self.operation}"]
65+
if self.status_code is not None:
66+
fields.append(f"status={self.status_code}")
67+
if self.endpoint:
68+
fields.append(f"endpoint={self.endpoint}")
69+
if self.attempts is not None:
70+
fields.append(f"attempts={self.attempts}")
71+
summary = f"{' '.join(fields)}: {super().__str__()}"
72+
if self.response_body:
73+
summary += f"\nServer response:\n{self.response_body}"
74+
return summary
75+
2576

2677
class OntologyEvent(Enum):
2778
INSERT = "INSERT"

libs/naas-abi-core/naas_abi_core/services/triple_store/TripleStoreService.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from naas_abi_core import logger
1212
from naas_abi_core.services.ServiceBase import ServiceBase
1313
from naas_abi_core.services.triple_store.TripleStorePorts import (
14+
Exceptions,
1415
ITripleStorePort,
1516
ITripleStoreService,
1617
OntologyEvent,
@@ -117,6 +118,21 @@ def __publish_event(self, event: object) -> None:
117118
# Triple store mutations are the source of truth; event logging must not break them.
118119
logger.warning(f"TripleStoreService: failed to publish event: {exc}")
119120

121+
@staticmethod
122+
def _error_message(exc: Exception) -> str:
123+
"""Build a rich, loggable message from an adapter exception.
124+
125+
HTTP-backed adapters (e.g. Apache Jena Fuseki) raise
126+
:class:`Exceptions.RequestError`, which carries the server's own status
127+
code and response body — the actual cause of a 5xx (OOM, disk-full, lock
128+
abort, TDB2 stack trace). Surface that detail so the persisted
129+
``TripleStoreError`` event is genuinely diagnosable instead of a generic
130+
"500 Server Error" string. Any other exception falls back to ``str``.
131+
"""
132+
if isinstance(exc, Exceptions.RequestError):
133+
return exc.detail()
134+
return str(exc)
135+
120136
def _hash_value(self, value: object) -> str:
121137
return hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:32]
122138

@@ -140,7 +156,7 @@ def insert(
140156
TripleStoreError(
141157
operation="insert",
142158
graph_name=str(graph_name),
143-
message=str(exc),
159+
message=self._error_message(exc),
144160
)
145161
)
146162
raise
@@ -185,7 +201,7 @@ def remove(
185201
TripleStoreError(
186202
operation="remove",
187203
graph_name=str(graph_name),
188-
message=str(exc),
204+
message=self._error_message(exc),
189205
)
190206
)
191207
raise
@@ -219,10 +235,28 @@ def get(self) -> Graph:
219235
return self.__triple_store_adapter.get()
220236

221237
def query(self, query: str) -> rdflib.query.Result:
222-
return self.__triple_store_adapter.query(query)
238+
try:
239+
return self.__triple_store_adapter.query(query)
240+
except Exception as exc:
241+
self.__publish_event(
242+
TripleStoreError(
243+
operation="query",
244+
message=self._error_message(exc),
245+
)
246+
)
247+
raise
223248

224249
def query_view(self, view: str, query: str) -> rdflib.query.Result:
225-
return self.__triple_store_adapter.query_view(view, query)
250+
try:
251+
return self.__triple_store_adapter.query_view(view, query)
252+
except Exception as exc:
253+
self.__publish_event(
254+
TripleStoreError(
255+
operation="query_view",
256+
message=self._error_message(exc),
257+
)
258+
)
259+
raise
226260

227261
def create_graph(self, graph_name: URIRef) -> None:
228262
assert graph_name is not None
@@ -235,7 +269,7 @@ def create_graph(self, graph_name: URIRef) -> None:
235269
TripleStoreError(
236270
operation="create_graph",
237271
graph_name=str(graph_name),
238-
message=str(exc),
272+
message=self._error_message(exc),
239273
)
240274
)
241275
raise
@@ -253,7 +287,7 @@ def clear_graph(self, graph_name: URIRef) -> None:
253287
TripleStoreError(
254288
operation="clear_graph",
255289
graph_name=str(graph_name),
256-
message=str(exc),
290+
message=self._error_message(exc),
257291
)
258292
)
259293
raise
@@ -268,7 +302,7 @@ def drop_graph(self, graph_name: URIRef) -> None:
268302
TripleStoreError(
269303
operation="drop_graph",
270304
graph_name=str(graph_name),
271-
message=str(exc),
305+
message=self._error_message(exc),
272306
)
273307
)
274308
raise
@@ -435,7 +469,7 @@ def _apply_schema_for_file_with_event(
435469
TripleStoreError(
436470
operation="load_schema",
437471
filepath=filepath,
438-
message=str(exc),
472+
message=self._error_message(exc),
439473
)
440474
)
441475
raise
@@ -579,7 +613,7 @@ def remove_schema(self, filepath: str):
579613
TripleStoreError(
580614
operation="remove_schema",
581615
filepath=filepath,
582-
message=str(exc),
616+
message=self._error_message(exc),
583617
)
584618
)
585619
raise

0 commit comments

Comments
 (0)