Merge branch 'send-json-logs' into 'v3'

Store logs as dict on server, and send as json, rather than regex from str

Closes #321

See merge request openflexure/openflexure-microscope-server!234
This commit is contained in:
Joe Knapper 2026-07-09 11:11:49 +00:00
commit 4093c4f7f7
8 changed files with 1089 additions and 5705 deletions

1
.gitignore vendored
View file

@ -56,7 +56,6 @@ pytest_report.xml
.coverage.picamera .coverage.picamera
.coverage.main .coverage.main
picamera_test_hashes.json picamera_test_hashes.json
webapp/src/tests/fixtures/*
# Translations # Translations
*.mo *.mo

View file

@ -17,7 +17,8 @@ from logging.handlers import RotatingFileHandler
from typing import Optional from typing import Optional
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.responses import PlainTextResponse from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse, PlainTextResponse
from labthings_fastapi.logs import inject_invocation_id from labthings_fastapi.logs import inject_invocation_id
@ -53,8 +54,9 @@ def configure_logging(log_folder: str, debug: bool = False) -> None:
# Add OFM_HANDLER first so it can capture the error log if the # Add OFM_HANDLER first so it can capture the error log if the
# log file can't be accessed. # log file can't be accessed.
ofm_format_str = "[%(asctime)s] [%(levelname)s] %(message)s" ofm_format_str = "[%(asctime)s] [%(levelname)s] %(message)s"
OFM_HANDLER.setFormatter(logging.Formatter(ofm_format_str)) OFM_HANDLER.setFormatter(OFMLogFileFormatter(ofm_format_str))
OFM_HANDLER.addFilter(UvicornAccessFilter()) OFM_HANDLER.addFilter(UvicornAccessFilter())
OFM_HANDLER.addFilter(inject_invocation_id)
OFM_HANDLER.level = root_logger.level OFM_HANDLER.level = root_logger.level
root_logger.addHandler(OFM_HANDLER) root_logger.addHandler(OFM_HANDLER)
@ -84,7 +86,7 @@ def configure_logging(log_folder: str, debug: bool = False) -> None:
) )
def retrieve_log() -> PlainTextResponse: def retrieve_log() -> JSONResponse:
"""Return logs since the server started, up to a maximum of 250 messages. """Return logs since the server started, up to a maximum of 250 messages.
This log is the one shown in the UI and on the logging page. This log is the one shown in the UI and on the logging page.
@ -95,7 +97,7 @@ def retrieve_log() -> PlainTextResponse:
All logs, including ``uvicorn.access`` are logged to the OFM_LOG_FILE (see above) All logs, including ``uvicorn.access`` are logged to the OFM_LOG_FILE (see above)
this is the best place to get logs about crashes. this is the best place to get logs about crashes.
""" """
return PlainTextResponse(OFM_HANDLER.log_history) return JSONResponse(content=jsonable_encoder(OFM_HANDLER.log_history))
def retrieve_log_from_file() -> PlainTextResponse: def retrieve_log_from_file() -> PlainTextResponse:
@ -155,23 +157,39 @@ class OFMHandler(logging.Handler):
how many can be returned over HTTP. how many can be returned over HTTP.
""" """
super().__init__(level=level) super().__init__(level=level)
self._log: list[str] = [] self._log: list[dict] = []
self._max_logs = max_logs self._max_logs = max_logs
# start at -1 so first record is 0
self._running_counter = -1
def _as_record_dict(self, record: logging.LogRecord) -> dict:
"""Format the record into the dictionary structure expected by the server."""
msg = record.message
self._running_counter += 1
return {
"timestamp": record.asctime,
"level": record.levelname,
"summary": msg.split("\n")[0],
"message": msg,
"invocation_id": getattr(record, "invocation_id", None),
"logger": record.name,
"sequence": self._running_counter,
}
def append_record(self, record: logging.LogRecord) -> None: def append_record(self, record: logging.LogRecord) -> None:
"""Format message and append it to a list of records. """Append the record as a dictionary to a log of records.
The built in formatter is used to format the record.
Any records in excess of the maximum number of logs are removed. Any records in excess of the maximum number of logs are removed.
""" """
self._log.append(self.format(record)) self._log.append(self._as_record_dict(record))
while len(self._log) > self._max_logs: while len(self._log) > self._max_logs:
self._log.pop(0) self._log.pop(0)
def emit(self, record: logging.LogRecord) -> None: def emit(self, record: logging.LogRecord) -> None:
"""Emit will save the logged record to the log.""" """Emit will save the logged record to the log."""
try: try:
# Always format before trying to read or the attributes are not set.
self.format(record)
if record.levelno >= self.level: if record.levelno >= self.level:
self.append_record(record) self.append_record(record)
except RecursionError as e: except RecursionError as e:
@ -182,9 +200,24 @@ class OFMHandler(logging.Handler):
self.handleError(record) self.handleError(record)
@property @property
def log_history(self) -> str: def log_history(self) -> list[dict]:
"""Return the log history up to the maximum number of logs.""" """Return the log history up to the maximum number of logs.
return "\n".join(self._log)
Order is reversed so most recent logs are at the top.
"""
return list(reversed(self._log))
@property
def get_formatted_log_history(self) -> str:
"""Return the log history as a formatted string.
This is to be used for the fallback server, the formatting does not contain invocation
IDs as no actions should have started.
"""
output = ""
for entry in self._log:
output += f"[{entry['timestamp']} [{entry['level']}] <{entry['logger']}> {entry['message']}\n"
return output
class UvicornAccessFilter(logging.Filter): class UvicornAccessFilter(logging.Filter):

View file

@ -150,7 +150,7 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None:
print(f"Error: {e}") # noqa: T201 print(f"Error: {e}") # noqa: T201
print("Starting fallback server.") # noqa: T201 print("Starting fallback server.") # noqa: T201
try: try:
log_history = OFM_HANDLER.log_history log_history = OFM_HANDLER.get_formatted_log_history
except BaseException: except BaseException:
# If log history fails for any reason carry on. # If log history fails for any reason carry on.
log_history = None log_history = None

View file

@ -1,5 +1,6 @@
"""Test the server's logging configuration and handling.""" """Test the server's logging configuration and handling."""
import json
import logging import logging
import os import os
import tempfile import tempfile
@ -8,7 +9,7 @@ from logging.handlers import RotatingFileHandler
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.responses import PlainTextResponse from fastapi.responses import JSONResponse, PlainTextResponse
from openflexure_microscope_server import logging as ofm_logging from openflexure_microscope_server import logging as ofm_logging
@ -90,18 +91,18 @@ def test_max_logs():
# But I would log five hundred times. # But I would log five hundred times.
for i in range(500): for i in range(500):
logging.info("log %s", i) logging.info("log %s", i)
logs = ofm_logging.OFM_HANDLER.log_history.split("\n") logs = ofm_logging.OFM_HANDLER.log_history
assert len(logs) == 250 assert len(logs) == 250
assert logs[0].endswith("log 250") assert logs[-1]["message"] == "log 250"
assert logs[-1].endswith("log 499") assert logs[0]["message"] == "log 499"
# And I would log five hundred more. # And I would log five hundred more.
for i in range(500): for i in range(500):
logging.info("log %s", 500 + i) logging.info("log %s", 500 + i)
# Just to be the test who logged a thousand times to check your hand-el-or! # Just to be the test who logged a thousand times to check your hand-el-or!
logs = ofm_logging.OFM_HANDLER.log_history.split("\n") logs = ofm_logging.OFM_HANDLER.log_history
assert len(logs) == 250 assert len(logs) == 250
assert logs[0].endswith("log 750") assert logs[-1]["message"] == "log 750"
assert logs[-1].endswith("log 999") assert logs[0]["message"] == "log 999"
def test_ofm_handler_ignores_uvicorn_access(): def test_ofm_handler_ignores_uvicorn_access():
@ -113,17 +114,17 @@ def test_ofm_handler_ignores_uvicorn_access():
ofm_logging.OFM_HANDLER = ofm_logging.OFMHandler() ofm_logging.OFM_HANDLER = ofm_logging.OFMHandler()
with tmp_logging_dir() as tmpdir: with tmp_logging_dir() as tmpdir:
ofm_logging.configure_logging(tmpdir) ofm_logging.configure_logging(tmpdir)
logs = ofm_logging.OFM_HANDLER.log_history.split("\n") logs = ofm_logging.OFM_HANDLER.log_history
starting_len = len(logs) starting_len = len(logs)
logging.info("Root 1") logging.info("Root 1")
uvicorn_logger = logging.getLogger("uvicorn.access") uvicorn_logger = logging.getLogger("uvicorn.access")
for i in range(500): for i in range(500):
uvicorn_logger.info("Uvicorn log %s", i) uvicorn_logger.info("Uvicorn log %s", i)
logging.info("Root 2") logging.info("Root 2")
logs = ofm_logging.OFM_HANDLER.log_history.split("\n") logs = ofm_logging.OFM_HANDLER.log_history
assert len(logs) == starting_len + 2 assert len(logs) == starting_len + 2
assert logs[starting_len].endswith("Root 1") assert logs[starting_len]["message"] == "Root 1"
assert logs[-1].endswith("Root 2") assert logs[0]["message"] == "Root 2"
def test_server_responses(): def test_server_responses():
@ -140,8 +141,10 @@ def test_server_responses():
for i in range(500): for i in range(500):
logging.info("log %s", i) logging.info("log %s", i)
log_response = ofm_logging.retrieve_log() log_response = ofm_logging.retrieve_log()
assert isinstance(log_response, PlainTextResponse) # Retrieve log should get a json that can be decoded into a list
logs = log_response.body.decode().split("\n") assert isinstance(log_response, JSONResponse)
logs = json.loads(log_response.body.decode())
assert isinstance(logs, list)
assert len(logs) == 250 assert len(logs) == 250
file_log_response = ofm_logging.retrieve_log_from_file() file_log_response = ofm_logging.retrieve_log_from_file()
assert isinstance(file_log_response, PlainTextResponse) assert isinstance(file_log_response, PlainTextResponse)

View file

@ -185,51 +185,8 @@ export default {
} }
}, },
async updateLogs() { async updateLogs() {
let response = await axios.get(this.logURI); let logs = await axios.get(this.logURI);
let lines = response.data.split("\n"); this.logs = Object.values(logs.data);
let logs = [];
let regexp = /^\[(.+?)\] \[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\] (.*)$/;
for (let line of lines) {
if (line.length > 0) {
let m = line.match(regexp);
if (m) {
logs.push({
timestamp: m[1],
level: m[2],
summary: m[3],
message: this.escapeText(m[3]),
sequence: logs.length,
expanded: false,
});
} else if (logs) {
// If a line does not look like a log entry, append it to the last
// log entry (i.e. allow multi-line messages)
let entry = logs[logs.length - 1];
m = line.match(/^( *)(\^+)/); // detect python stack trace "underlines"
if (m) {
let linestart = entry.message.lastIndexOf("\n") + 1;
let ustart = linestart + m[1].length;
let uend = ustart + m[2].length;
entry.message =
entry.message.substring(0, ustart) +
"<u>" +
entry.message.substring(ustart, uend) +
"</u>" +
entry.message.substring(uend);
} else {
entry.message += "\n" + this.escapeText(line);
if (entry.message.startsWith("Traceback")) {
entry.summary = line; // For tracebacks, the last line is the best summary
}
}
} else {
// if there's no existing log message to append to, discard lines
// until we find one.
continue;
}
}
}
this.logs = logs.reverse(); // Display in reverse chronological order
}, },
formatDateTime: function (isoDateTimeString) { formatDateTime: function (isoDateTimeString) {
isoDateTimeString = isoDateTimeString.replace(",", "."); isoDateTimeString = isoDateTimeString.replace(",", ".");

1018
webapp/src/tests/fixtures/realLog.json vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,8 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { createTestingPinia } from "@pinia/testing"; import { createTestingPinia } from "@pinia/testing";
import { setActivePinia } from "pinia"; import { setActivePinia } from "pinia";
import axios from "axios"; import axios from "axios";
import fs from "fs"; import realLogs from "../fixtures/realLog.json";
import path from "path";
import LoggingContent from "../../components/tabContentComponents/loggingContent.vue"; import LoggingContent from "../../components/tabContentComponents/loggingContent.vue";
// Mock Axios // Mock Axios
@ -33,12 +32,10 @@ describe("Test LoggingContent.vue", () => {
let wrapper; let wrapper;
// Define path to a real log file and read it // Define path to a real log file and read it
const logFilePath = path.resolve(__dirname, "../fixtures/realLog.txt"); const mockLogData = realLogs;
const mockLogData = fs.readFileSync(logFilePath, "utf-8");
// Things to do before each test // Things to do before each test
beforeEach(async () => { beforeEach(async () => {
vi.useFakeTimers();
// Reset mocks before each test // Reset mocks before each test
vi.resetAllMocks(); vi.resetAllMocks();
@ -69,8 +66,6 @@ describe("Test LoggingContent.vue", () => {
}); });
// flush so we avoid leaving uncompleted processes running on background // flush so we avoid leaving uncompleted processes running on background
await flushPromises(); await flushPromises();
vi.advanceTimersByTime(1000);
vi.useRealTimers();
}); });
// Tear down wrapper, unmount testing component // Tear down wrapper, unmount testing component
@ -107,14 +102,11 @@ describe("Test LoggingContent.vue", () => {
// Verify the logs have correct file length // Verify the logs have correct file length
// Helps validate formatting, etc // Helps validate formatting, etc
expect(wrapper.vm.logs.length).toBe(5491); expect(wrapper.vm.logs.length).toBe(113);
// Check the most recent log // Check the most recent log
expect(wrapper.vm.logs[0].level).toBe("INFO"); expect(wrapper.vm.logs[0].level).toBe("INFO");
expect(wrapper.vm.logs[0].logger).toContain("smart_scan");
// Check if the multi-line file has real first connection information
expect(wrapper.vm.logs[0].message).toContain("uvicorn");
expect(wrapper.vm.logs[0].summary).toContain("http://0.0.0.0:5000");
}); });
// Test 3: Filter logging file information // Test 3: Filter logging file information
@ -124,19 +116,19 @@ describe("Test LoggingContent.vue", () => {
// Set filter to WARNING wait and compare expected length // Set filter to WARNING wait and compare expected length
wrapper.vm.filterLevel = "WARNING"; wrapper.vm.filterLevel = "WARNING";
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.vm.filteredItems.length).toBe(1449); expect(wrapper.vm.filteredItems.length).toBe(5);
expect(wrapper.vm.filteredItems[0].level).toBe("WARNING"); expect(wrapper.vm.filteredItems[0].level).toBe("WARNING");
// Set filter to INFO wait and compare expected length // Set filter to INFO wait and compare expected length
wrapper.vm.filterLevel = "INFO"; wrapper.vm.filterLevel = "INFO";
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.vm.filteredItems.length).toBe(5491); expect(wrapper.vm.filteredItems.length).toBe(113);
expect(wrapper.vm.filteredItems[0].level).toBe("INFO"); expect(wrapper.vm.filteredItems[0].level).toBe("INFO");
// Set filter to ERROR wait and compare expected length // Set filter to ERROR wait and compare expected length
wrapper.vm.filterLevel = "ERROR"; wrapper.vm.filterLevel = "ERROR";
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.vm.filteredItems.length).toBe(7); expect(wrapper.vm.filteredItems.length).toBe(1);
expect(wrapper.vm.filteredItems[0].level).toBe("ERROR"); expect(wrapper.vm.filteredItems[0].level).toBe("ERROR");
}); });