From 4c4980a539ceefb4e67bed69a0fe7c5b769f8943 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 7 Apr 2026 10:39:28 +0200 Subject: [PATCH] feat(logging): add size-limited log rotation via lumberjack Add lumberjack-based log rotation to prevent unbounded growth of the application log file on long-running devices. The app now writes to /userdata/jetkvm/app.log (managed, 50MB max, 1 backup) instead of relying solely on the shell redirect to last.log. This works safely regardless of whether the rv1106-system shell redirect is updated: lumberjack writes to a different file path, so the old redirect to last.log is harmless. Also log app and system version at startup using NoLevel so it always appears regardless of configured log level. --- DEVELOPMENT.md | 10 +++--- go.mod | 3 +- go.sum | 2 ++ internal/logging/logger.go | 23 +++++++++++++- internal/supervisor/consts.go | 2 +- main.go | 2 +- ui/e2e/global-teardown.ts | 2 +- ui/e2e/helpers.ts | 2 +- ui/e2e/log-rotation.spec.ts | 59 +++++++++++++++++++++++++++++++++++ 9 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 ui/e2e/log-rotation.spec.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5b5024f5b..0533d4b97 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -89,7 +89,7 @@ Now edit files in `ui/src/` and see changes live in your browser! ```bash ssh root@192.168.1.100 -tail -f /userdata/jetkvm/last.log +tail -f /userdata/jetkvm/app.log ``` --- @@ -200,11 +200,11 @@ ssh root@192.168.1.100 ps aux | grep jetkvm ### View live logs -The file `/userdata/jetkvm/last.log` contains the JetKVM logs. You can view live logs with: +The file `/userdata/jetkvm/app.log` contains the JetKVM logs. You can view live logs with: ```bash ssh root@192.168.1.100 -tail -f /userdata/jetkvm/last.log +tail -f /userdata/jetkvm/app.log ``` ### Reset everything (if stuck) @@ -230,7 +230,7 @@ The code and GDB server will be deployed automatically. 1. Deploy your changes: `./dev_deploy.sh -r ` 2. Open browser: `http://` 3. Test your feature -4. Check logs: `ssh root@ tail -f /userdata/jetkvm/last.log` +4. Check logs: `ssh root@ tail -f /userdata/jetkvm/app.log` ### Automated Testing @@ -425,7 +425,7 @@ export JETKVM_PROXY_URL="ws://" ## Need Help? -1. **Check logs first:** `ssh root@ tail -f /userdata/jetkvm/last.log` +1. **Check logs first:** `ssh root@ tail -f /userdata/jetkvm/app.log` 2. **Search issues:** [GitHub Issues](https://github.com/jetkvm/kvm/issues) 3. **Ask on Discord:** [JetKVM Discord](https://jetkvm.com/discord) 4. **Read docs:** [JetKVM Documentation](https://jetkvm.com/docs) diff --git a/go.mod b/go.mod index f4388a4db..889414517 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/coder/websocket v1.8.14 github.com/coreos/go-oidc/v3 v3.16.0 github.com/creack/pty v1.1.24 + github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/erikdubbelboer/gspt v0.0.0-20210805194459-ce36a5128377 github.com/fsnotify/fsnotify v1.9.0 github.com/gin-contrib/logger v1.2.6 @@ -41,6 +42,7 @@ require ( golang.org/x/sys v0.37.0 google.golang.org/grpc v1.76.0 google.golang.org/protobuf v1.36.10 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b @@ -54,7 +56,6 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/creack/goselect v0.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect diff --git a/go.sum b/go.sum index 4223acb59..93731cbc0 100644 --- a/go.sum +++ b/go.sum @@ -265,6 +265,8 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 4a75300b0..da0bac370 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -4,11 +4,13 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "sync" "time" "github.com/rs/zerolog" + "gopkg.in/natefinch/lumberjack.v2" ) type Logger struct { @@ -24,6 +26,9 @@ type Logger struct { const ( defaultLogLevel = zerolog.ErrorLevel + AppLogPath = "/userdata/jetkvm/app.log" + appLogMaxSizeMB = 50 + appLogBackups = 1 ) type logOutput struct { @@ -44,6 +49,22 @@ func (w *logOutput) Write(p []byte) (n int, err error) { return len(p), nil } +func newDefaultLogOutput() io.Writer { + writers := []io.Writer{consoleLogOutput, fileLogOutput} + + dir := filepath.Dir(AppLogPath) + if _, err := os.Stat(dir); err == nil { + writers = append(writers, &lumberjack.Logger{ + Filename: AppLogPath, + MaxSize: appLogMaxSizeMB, + MaxBackups: appLogBackups, + Compress: false, + }) + } + + return zerolog.MultiLevelWriter(writers...) +} + var ( consoleLogOutput io.Writer = zerolog.ConsoleWriter{ Out: os.Stdout, @@ -61,7 +82,7 @@ var ( }, } fileLogOutput io.Writer = &logOutput{mu: &sync.Mutex{}} - defaultLogOutput = zerolog.MultiLevelWriter(consoleLogOutput, fileLogOutput) + defaultLogOutput = newDefaultLogOutput() zerologLevels = map[string]zerolog.Level{ "DISABLE": zerolog.Disabled, diff --git a/internal/supervisor/consts.go b/internal/supervisor/consts.go index 2a55b2484..24dcb3bdf 100644 --- a/internal/supervisor/consts.go +++ b/internal/supervisor/consts.go @@ -6,7 +6,7 @@ const ( ErrorDumpDir = "/userdata/jetkvm/crashdump" // The error dump directory is the directory where the error dumps are stored ErrorDumpLastFile = "last-crash.log" // The error dump last file is the last error dump file ErrorDumpTemplate = "jetkvm-%s.log" // The error dump template is the template for the error dump file - AppLogPath = "/userdata/jetkvm/last.log" // The application stdout/stderr log file + AppLogPath = "/userdata/jetkvm/app.log" // The application log file (managed by lumberjack) FailsafeReasonVideoMaxRestartAttemptsReached = "failsafe::video.max_restart_attempts_reached" ) diff --git a/main.go b/main.go index ac9ec1bb4..2ca4a8108 100644 --- a/main.go +++ b/main.go @@ -56,7 +56,7 @@ func Main() { logger.Warn().Err(err).Msg("failed to get local version") } - logger.Info(). + logger.Log(). Interface("system_version", systemVersionLocal). Interface("app_version", appVersionLocal). Msg("starting JetKVM") diff --git a/ui/e2e/global-teardown.ts b/ui/e2e/global-teardown.ts index 479dbfca9..e4c5730ef 100644 --- a/ui/e2e/global-teardown.ts +++ b/ui/e2e/global-teardown.ts @@ -20,7 +20,7 @@ export default async function globalTeardown() { fs.mkdirSync(logDir, { recursive: true }); const logs: Record = { - "device-last.log": "cat /userdata/jetkvm/last.log", + "device-app.log": "cat /userdata/jetkvm/app.log", "device-config.json": "cat /userdata/kvm_config.json", "device-dmesg.txt": "dmesg | tail -200", }; diff --git a/ui/e2e/helpers.ts b/ui/e2e/helpers.ts index 86fa9e1fb..c18f9486b 100644 --- a/ui/e2e/helpers.ts +++ b/ui/e2e/helpers.ts @@ -732,7 +732,7 @@ export async function restartAppViaSSH(): Promise { await sshExec("killall jetkvm_app", true); await new Promise(r => setTimeout(r, 500)); await sshExec( - "setsid env LD_LIBRARY_PATH=/oem/usr/lib:/oem/lib /userdata/jetkvm/bin/jetkvm_app > /userdata/jetkvm/last.log 2>&1 &", + "setsid env LD_LIBRARY_PATH=/oem/usr/lib:/oem/lib /userdata/jetkvm/bin/jetkvm_app > /dev/null 2>&1 &", true, ); await new Promise(r => setTimeout(r, 1000)); diff --git a/ui/e2e/log-rotation.spec.ts b/ui/e2e/log-rotation.spec.ts new file mode 100644 index 000000000..007fbfe15 --- /dev/null +++ b/ui/e2e/log-rotation.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "@playwright/test"; + +import { sshExec, getDeviceHost, waitForDeviceReady } from "./helpers"; + +const APP_LOG_PATH = "/userdata/jetkvm/app.log"; +const MAX_LOG_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB + +test.describe("Log rotation and diagnostics", () => { + test("app.log exists and contains version banner", async () => { + const ls = await sshExec(`ls -la ${APP_LOG_PATH}`); + expect(ls).toContain("app.log"); + + const appVersion = await sshExec(`grep "app_version" ${APP_LOG_PATH}`); + expect(appVersion.trim().length).toBeGreaterThan(0); + + const systemVersion = await sshExec(`grep "system_version" ${APP_LOG_PATH}`); + expect(systemVersion.trim().length).toBeGreaterThan(0); + }); + + test("app.log is within the size limit", async () => { + const sizeStr = await sshExec(`stat -c%s ${APP_LOG_PATH}`); + const size = parseInt(sizeStr.trim(), 10); + expect(size).toBeGreaterThan(0); + expect(size).toBeLessThan(MAX_LOG_SIZE_BYTES); + }); + + test("diagnostics zip contains app.log", async ({ page }) => { + const host = getDeviceHost(); + const resp = await page.request.get(`http://${host}/diagnostics`); + expect(resp.status()).toBe(200); + + const body = await resp.body(); + expect(body.length).toBeGreaterThan(0); + + const zipContent = body.toString("binary"); + expect(zipContent).toContain("app.log"); + }); + + test("app.log continues growing after restart", async () => { + test.setTimeout(120_000); + + const host = getDeviceHost(); + + const beforeLines = await sshExec(`wc -l < ${APP_LOG_PATH}`); + const lineCountBefore = parseInt(beforeLines.trim(), 10); + + await sshExec("killall jetkvm_app", true); + await new Promise(r => setTimeout(r, 2000)); + + await waitForDeviceReady(host, 60000); + + const afterLines = await sshExec(`wc -l < ${APP_LOG_PATH}`); + const lineCountAfter = parseInt(afterLines.trim(), 10); + expect(lineCountAfter).toBeGreaterThan(lineCountBefore); + + const versionAfterRestart = await sshExec(`grep "app_version" ${APP_LOG_PATH}`); + expect(versionAfterRestart.trim().length).toBeGreaterThan(0); + }); +});