Skip to content

Commit 9ec8a20

Browse files
Merge pull request #260 from labthings/get_logs
Allow an action to get a copy of the logs raised during the action.
2 parents 51d2349 + 4c91c61 commit 9ec8a20

4 files changed

Lines changed: 95 additions & 16 deletions

File tree

src/labthings_fastapi/actions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
from .base_descriptor import BaseDescriptor
4545
from .logs import add_thing_log_destination
4646
from .utilities import model_to_dict, wrap_plain_types_in_rootmodel
47-
from .invocations import InvocationModel, InvocationStatus, LogRecordModel
47+
from .invocations import InvocationModel, InvocationStatus
4848
from .dependencies.invocation import NonWarningInvocationID
4949
from .exceptions import (
5050
InvocationCancelledError,
@@ -154,7 +154,7 @@ def output(self) -> Any:
154154
return self._return_value
155155

156156
@property
157-
def log(self) -> list[LogRecordModel]:
157+
def log(self) -> list[logging.LogRecord]:
158158
"""A list of log items generated by the Action."""
159159
with self._status_lock:
160160
return list(self._log)

src/labthings_fastapi/invocations.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,26 @@ class LogRecordModel(BaseModel):
5454
def generate_message(cls, data: Any) -> Any:
5555
"""Ensure LogRecord objects have constructed their message.
5656
57-
:param data: The LogRecord to process.
57+
:param data: The LogRecord or serialised log record data to process.
5858
5959
:return: The LogRecord, with a message constructed.
6060
"""
61+
if not isinstance(data, logging.LogRecord):
62+
return data
63+
6164
if not hasattr(data, "message"):
62-
if isinstance(data, logging.LogRecord):
63-
try:
64-
data.message = data.getMessage()
65-
except (ValueError, TypeError) as e:
66-
# too many args causes an error - but errors
67-
# in validation can be a problem for us:
68-
# it will cause 500 errors when retrieving
69-
# the invocation.
70-
# This way, you can find and fix the source.
71-
data.message = f"Error constructing message ({e}) from {data!r}."
72-
73-
if data.exc_info:
65+
try:
66+
data.message = data.getMessage()
67+
except (ValueError, TypeError) as e:
68+
# too many args causes an error - but errors
69+
# in validation can be a problem for us:
70+
# it will cause 500 errors when retrieving
71+
# the invocation.
72+
# This way, you can find and fix the source.
73+
data.message = f"Error constructing message ({e}) from {data!r}."
74+
75+
# Also check data.exc_info[0] as sys.exc_info() can return (None, None, None).
76+
if data.exc_info and data.exc_info[0] is not None:
7477
data.exception_type = data.exc_info[0].__name__
7578
data.traceback = "\n".join(traceback.format_exception(*data.exc_info))
7679

src/labthings_fastapi/thing.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from .websockets import websocket_endpoint
3131
from .exceptions import PropertyNotObservableError
3232
from .thing_server_interface import ThingServerInterface
33-
33+
from .invocation_contexts import get_invocation_id
3434

3535
if TYPE_CHECKING:
3636
from .server import ThingServer
@@ -383,3 +383,26 @@ def observe_action(self, action_name: str, stream: ObjectSendStream) -> None:
383383
raise KeyError(f"{action_name} is not an LabThings Action")
384384
observers = action._observers_set(self)
385385
observers.add(stream)
386+
387+
def get_current_invocation_logs(self) -> list[logging.LogRecord]:
388+
"""Get the log records for an on going action.
389+
390+
This is useful if an action wishes to save its logs alongside any data.
391+
392+
Note that only the last 1000 logs are returned so for long running tasks that
393+
log frequently this may want to be read periodically.
394+
395+
This will error if it is called outside an action invocation.
396+
397+
:return: a list of all logs from this action.
398+
399+
:raises RuntimeError: If the server cannot be retrieved. This should never
400+
happen.
401+
"""
402+
inv_id = get_invocation_id()
403+
server = self._thing_server_interface._server()
404+
if server is None:
405+
raise RuntimeError("Could not get server from thing_server_interface")
406+
action_manager = server.action_manager
407+
this_invocation = action_manager.get_invocation(inv_id)
408+
return this_invocation.log

tests/test_logs.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from types import EllipsisType
1111
import pytest
1212
from uuid import UUID, uuid4
13+
from fastapi.testclient import TestClient
1314
from labthings_fastapi import logs
15+
from labthings_fastapi.invocations import LogRecordModel
1416
from labthings_fastapi.invocation_contexts import (
1517
fake_invocation_context,
1618
set_invocation_id,
@@ -19,13 +21,29 @@
1921
from labthings_fastapi.exceptions import LogConfigurationError
2022
from labthings_fastapi.testing import create_thing_without_server
2123

24+
from .temp_client import poll_task
25+
2226

2327
class ThingThatLogs(lt.Thing):
2428
@lt.action
2529
def log_a_message(self, msg: str):
2630
"""Log a message to the thing's logger."""
2731
self.logger.info(msg)
2832

33+
@lt.action
34+
def log_and_capture(self, msg: str) -> str:
35+
"""Log a message to the thing's logger and retrieve it as a string."""
36+
self.logger.info(msg)
37+
self.logger.warning(msg)
38+
self.logger.error(msg)
39+
logs = self.get_current_invocation_logs()
40+
logging_str = ""
41+
for record in logs:
42+
level = record.levelname
43+
msg = record.getMessage()
44+
logging_str += f"[{level}] {msg}\n"
45+
return logging_str
46+
2947

3048
def reset_thing_logger():
3149
"""Remove all handlers from the THING_LOGGER to reset it."""
@@ -176,3 +194,38 @@ def test_add_thing_log_destination():
176194
thing.log_a_message("Test Message.")
177195
assert len(dest) == 1
178196
assert dest[0].getMessage() == "Test Message."
197+
198+
199+
def _call_action_can_get_logs():
200+
"""Run `log_and_capture` as an action, Return the final HTTP response."""
201+
server = lt.ThingServer({"logging_thing": ThingThatLogs})
202+
with TestClient(server.app) as client:
203+
response = client.post("/logging_thing/log_and_capture", json={"msg": "foobar"})
204+
response.raise_for_status()
205+
return poll_task(client, response.json())
206+
207+
208+
def test_action_can_get_logs():
209+
"""Check that an action can get a copy of its own logs."""
210+
invocation = _call_action_can_get_logs()
211+
assert invocation["status"] == "completed"
212+
# Check the logs are returned by the action itself.
213+
expected_message = "[INFO] foobar\n[WARNING] foobar\n[ERROR] foobar\n"
214+
assert invocation["output"] == expected_message
215+
216+
217+
def test_action_logs_over_http():
218+
"""Check that the action logs are sent over HTTP in JSON."""
219+
invocation = _call_action_can_get_logs()
220+
logs = invocation["log"]
221+
assert isinstance(logs, list)
222+
assert len(logs) == 3
223+
assert logs[0]["levelname"] == "INFO"
224+
assert logs[0]["levelno"] == 20
225+
assert logs[1]["levelname"] == "WARNING"
226+
assert logs[1]["levelno"] == 30
227+
assert logs[2]["levelname"] == "ERROR"
228+
assert logs[2]["levelno"] == 40
229+
for log in logs:
230+
log_as_model = LogRecordModel(**log)
231+
assert log_as_model.message == "foobar"

0 commit comments

Comments
 (0)