Skip to content

Commit 02e6933

Browse files
committed
feat(logging): add log rotation and transition default server posture to stdout/stderr
Introduce thread-safe log rotation via RotatingFileSink and transition direct lemond executions to console-first logging with optional rotating file logging. - Implement RotatingFileSink with size-triggered rotation, max_files backup retention, excess backup pruning, backup slot fallback, and in-place truncation for max_files=0. - Add configuration settings log_max_file_size_mb (1..2048) and log_max_files (0..100) with 64-bit validation across CLI, RuntimeConfig, and config.json. - Support --log-file, --log-max-size-mb, and --log-max-files CLI overrides as in-memory runtime parameters without mutating persistent config. - Add comprehensive C++ unit test suite (test_log_rotation) and Python integration suite (test/server_log_rotation.py).
1 parent dc058ad commit 02e6933

20 files changed

Lines changed: 1251 additions & 82 deletions

.github/actions/capture-server-logs/action.yml

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,18 @@ runs:
1313
run: |
1414
New-Item -ItemType Directory -Path server-logs -Force | Out-Null
1515
16-
# Check common log locations
17-
$candidates = @(
18-
"$env:TEMP\lemonade-server.log",
19-
"$env:TEMP\lemond.log",
20-
"$env:LOCALAPPDATA\lemonade_server\lemonade-server.log"
21-
)
22-
16+
# Check common log locations (including rotated log files .1, .2, etc.)
17+
$logFiles = Get-ChildItem -Path "$env:TEMP", "$env:LOCALAPPDATA\lemonade_server" -Filter "lemonade-server.log*" -ErrorAction SilentlyContinue
2318
$found = $false
24-
foreach ($logFile in $candidates) {
25-
if (Test-Path $logFile) {
26-
Write-Host "=== Last 200 lines of $logFile ==="
27-
Get-Content $logFile -Tail 200
28-
Copy-Item $logFile server-logs/ -ErrorAction SilentlyContinue
29-
$found = $true
30-
}
19+
foreach ($file in $logFiles) {
20+
Write-Host "=== Last 200 lines of $($file.FullName) ==="
21+
Get-Content $file.FullName -Tail 200
22+
Copy-Item $file.FullName server-logs/ -ErrorAction SilentlyContinue
23+
$found = $true
24+
}
25+
if (Test-Path "$env:TEMP\lemond.log") {
26+
Copy-Item "$env:TEMP\lemond.log" server-logs/ -ErrorAction SilentlyContinue
27+
$found = $true
3128
}
3229
3330
if (-not $found) {
@@ -53,20 +50,22 @@ runs:
5350
done
5451
fi
5552
56-
# --- Linux: check file-based logs ---
53+
# --- Linux: check file-based logs (including rotated backups) ---
5754
if [ "$RUNNER_OS" = "Linux" ]; then
5855
# XDG runtime dir (e.g. /run/user/1001/lemonade/)
5956
LEMON_RUNTIME_DIR="${XDG_RUNTIME_DIR:+${XDG_RUNTIME_DIR}/lemonade}"
60-
for candidate in \
61-
"${LEMON_RUNTIME_DIR}/lemonade-server.log" \
62-
"$RUNNER_TEMP/lemonade-server.log" \
63-
"$RUNNER_TEMP/lemond.log"; do
64-
if [ -f "$candidate" ]; then
65-
echo "=== Last 200 lines of $candidate ==="
66-
tail -200 "$candidate"
67-
cp "$candidate" server-logs/ 2>/dev/null || true
68-
found=true
69-
fi
57+
for log_pattern in \
58+
"${LEMON_RUNTIME_DIR}"/lemonade-server.log* \
59+
"$RUNNER_TEMP"/lemonade-server.log* \
60+
"$RUNNER_TEMP"/lemond.log*; do
61+
for candidate in $log_pattern; do
62+
if [ -f "$candidate" ]; then
63+
echo "=== Last 200 lines of $candidate ==="
64+
tail -200 "$candidate"
65+
cp "$candidate" server-logs/ 2>/dev/null || true
66+
found=true
67+
fi
68+
done
7069
done
7170
7271
# Systemd journal (works when server runs as lemond.service)

.github/workflows/cpp_server_build_test_release.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1952,6 +1952,13 @@ jobs:
19521952
.venv/bin/python test/server_websocket_auth.py
19531953
echo "WebSocket auth tests PASSED!"
19541954
1955+
- name: Test log rotation
1956+
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
1957+
shell: bash
1958+
run: |
1959+
.venv/bin/python -m test.utils.reset_server_state --best-effort --label "ubuntu log-rotation"
1960+
.venv/bin/python test/server_log_rotation.py
1961+
19551962
- name: Test router
19561963
if: ${{ !cancelled() && steps.setup.outcome == 'success' }}
19571964
shell: bash

.github/workflows/linux_distro_builds.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,21 @@ jobs:
243243
244244
echo "Endpoint tests PASSED!"
245245
246+
- name: Run log rotation tests
247+
env:
248+
LEMONADE_CI_MODE: "True"
249+
PYTHONIOENCODING: utf-8
250+
run: |
251+
set -e
252+
253+
. .venv/bin/activate
254+
CLI_BINARY="$(pwd)/build/lemonade"
255+
256+
echo "Running log rotation tests..."
257+
python test/server_log_rotation.py
258+
259+
echo "Log rotation tests PASSED!"
260+
246261
- name: Capture and upload server logs
247262
if: always()
248263
uses: ./.github/actions/capture-server-logs

CMakeLists.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3016,6 +3016,13 @@ if(BUILD_TESTING AND EXISTS "${_CLI_RUNTIME_OVERRIDE_TEST_SRC}")
30163016
add_cpp_ci_test(CliRuntimeOverrideTest CI ON COMMAND test_cli_runtime_override)
30173017
endif()
30183018

3019+
set(_LOG_ROTATION_TEST_SRC "${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/test_log_rotation.cpp")
3020+
if(BUILD_TESTING AND EXISTS "${_LOG_ROTATION_TEST_SRC}")
3021+
add_executable(test_log_rotation test/cpp/test_log_rotation.cpp)
3022+
target_link_libraries(test_log_rotation PRIVATE lemonade-server-core)
3023+
add_cpp_ci_test(LogRotationTest CI ON COMMAND test_log_rotation)
3024+
endif()
3025+
30193026
# ROCm root resolution (ROCM_PATH / rocm-sdk / /opt/rocm priority): covers the
30203027
# external-ROCm detection that lets Lemonade skip the bundled TheRock download.
30213028
set(_ROCM_ROOT_TEST_SRC "${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/test_rocm_root_resolution.cpp")

docs/dev/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -826,7 +826,7 @@ The tray application provides a system tray icon for desktop users:
826826
### Logging and Console Output
827827
828828
When running `LemonadeServer.exe` or `lemond`:
829-
- **Log File:** Direct runs write logs to a persistent log file (default: `%TEMP%\lemonade-server.log` on Windows). When `lemond` runs as the systemd service, logs go to the journal instead.
829+
- **Log File:** Direct CLI server runs (`lemond`) default to standard console output (`stdout`/`stderr`) with file logging disabled (`"auto"` mode), allowing systemd, launchd, container runtimes, and CI runners to capture and rotate logs natively. When running via `LemonadeServer.exe` (embedded tray app) or when file logging is explicitly enabled (`--log-file enabled` or a custom file path), logs are written to `lemonade-server.log` (under `%TEMP%` on Windows, `$XDG_RUNTIME_DIR/lemonade/` on Linux) with automatic log rotation (10 MB file cap, 5 rotated backups `.1` through `.5`, bounding steady-state log disk usage to ~60 MB under normal record sizes). Pre-existing legacy logs at startup are rotated into `.1` to preserve diagnostic history and pruned across subsequent rotation cycles.
830830
- **Logs UI:** Click "Show Logs" in the tray or use `lemonade logs` to open the desktop app's logs view
831831
- Connects to the server's WebSocket log stream
832832
- Shows retained recent log history plus live entries

docs/guide/configuration/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@ When `lemond` starts, effective configuration is resolved by deep-merging settin
7676
"vulkan_args": "",
7777
"vulkan_bin": "builtin"
7878
},
79+
"log_file": "auto",
7980
"log_level": "info",
81+
"log_max_file_size_mb": 10,
82+
"log_max_files": 5,
8083
"max_loaded_models": 1,
8184
"models_dir": "auto",
8285
"moonshine": {
@@ -188,6 +191,9 @@ When `lemond` starts, effective configuration is resolved by deep-merging settin
188191
| `port` | int | 13305 | Port number for the HTTP server |
189192
| `host` | string | "localhost" | Address to bind for connections |
190193
| `log_level` | string | "info" | Logging level (trace, debug, info, warning, error, fatal, none) |
194+
| `log_file` | string | "auto" | File logging mode: "auto" (console-only for direct server runs, lemonade-server.log for embedded tray app), "disabled", "enabled", or custom target file path |
195+
| `log_max_file_size_mb` | int | 10 | Max active log file size in MB before triggering rotation (steady-state footprint bounded to ~`log_max_file_size_mb * (log_max_files + 1)`) |
196+
| `log_max_files` | int | 5 | Max number of rotated log backup files to retain (.1 through .N); legacy oversized files are rotated into .1 and pruned over cycles |
191197
| `global_timeout` | int | 600 | Timeout in seconds for HTTP, inference, and readiness checks |
192198
| `max_loaded_models` | int | 1 | Max models per type slot. Use -1 for unlimited |
193199
| `broadcast` | bool | true | Enable or disable UDP broadcasting for server discovery |

src/cpp/include/lemon/cli_parser.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ struct ServerConfig {
1212
int port = -1; // -1 = not specified on CLI, use config.json value
1313
std::string host; // Empty = not specified on CLI, use config.json value
1414
std::optional<bool> broadcast; // std::nullopt = not specified on CLI, use config.json value
15+
std::string log_file; // Empty = not specified on CLI, use config.json value
16+
int log_max_file_size_mb = -1; // -1 = not specified on CLI, use config.json value
17+
int log_max_files = -1; // -1 = not specified on CLI, use config.json value
1518
};
1619

1720
class CLIParser {
Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
#pragma once
22

3+
#include "lemon/utils/aixlog.hpp"
4+
5+
#include <fstream>
6+
#include <mutex>
37
#include <optional>
48
#include <string>
59

@@ -10,15 +14,47 @@ enum class LoggingMode {
1014
embedded_tray_server,
1115
};
1216

17+
struct LogRotationConfig {
18+
std::string file_mode = "auto";
19+
size_t max_file_size_mb = 10;
20+
size_t max_files = 5;
21+
};
22+
1323
struct LoggingTargets {
1424
bool console = false;
1525
bool stream_hub = true;
1626
bool file = false;
1727
std::optional<std::string> file_path;
28+
LogRotationConfig rotation;
29+
};
30+
31+
class RotatingFileSink : public AixLog::SinkFormat {
32+
public:
33+
RotatingFileSink(const AixLog::Filter& filter,
34+
const std::string& filename,
35+
const std::string& format,
36+
size_t max_file_size_mb,
37+
size_t max_files);
38+
~RotatingFileSink() override;
39+
40+
void log(const AixLog::Metadata& metadata, const std::string& message) override;
41+
42+
size_t current_size() const;
43+
44+
private:
45+
void rotate_if_needed_nolock();
46+
void prune_excess_backups_nolock();
47+
48+
std::string filename_;
49+
size_t max_file_size_bytes_;
50+
size_t max_files_;
51+
size_t current_size_{0};
52+
std::ofstream file_;
53+
mutable std::mutex mutex_;
1854
};
1955

20-
LoggingTargets resolve_logging_targets(LoggingMode mode);
21-
void configure_application_logging(const std::string& log_level, LoggingMode mode);
22-
void reconfigure_application_logging(const std::string& log_level);
56+
LoggingTargets resolve_logging_targets(LoggingMode mode, const LogRotationConfig& rotation = {});
57+
void configure_application_logging(const std::string& log_level, LoggingMode mode, const LogRotationConfig& rotation = {});
58+
void reconfigure_application_logging(const std::string& log_level, const LogRotationConfig& rotation = {});
2359

2460
} // namespace lemon

src/cpp/include/lemon/runtime_config.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ class RuntimeConfig {
3131
void set_host_override(std::optional<std::string> override_val);
3232
int websocket_port() const;
3333
std::string log_level() const;
34+
std::string log_file() const;
35+
void set_log_file_override(std::optional<std::string> override_val);
36+
int log_max_file_size_mb() const;
37+
void set_log_max_file_size_mb_override(std::optional<int> override_val);
38+
int log_max_files() const;
39+
void set_log_max_files_override(std::optional<int> override_val);
3440
std::string extra_models_dir() const;
3541
bool broadcast() const;
3642
void set_broadcast_override(std::optional<bool> override_val);
@@ -158,6 +164,9 @@ class RuntimeConfig {
158164
std::optional<int> port_override_;
159165
std::optional<std::string> host_override_;
160166
std::optional<bool> broadcast_override_;
167+
std::optional<std::string> log_file_override_;
168+
std::optional<int> log_max_file_size_mb_override_;
169+
std::optional<int> log_max_files_override_;
161170

162171
// Valid log levels
163172
static const std::vector<std::string> valid_log_levels_;

src/cpp/include/lemon/utils/aixlog.hpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@
5151
#endif
5252

5353
#ifdef _WIN32
54+
#ifndef WIN32_LEAN_AND_MEAN
55+
#define WIN32_LEAN_AND_MEAN
56+
#endif
57+
#ifndef NOMINMAX
58+
#define NOMINMAX
59+
#endif
5460
#include <Windows.h>
5561
// ERROR macro is defined in Windows header
5662
// To avoid conflict between these macro and declaration of ERROR / DEBUG in SEVERITY enum

0 commit comments

Comments
 (0)