Rotating log file with retention

This commit is contained in:
2026-08-30 09:28:58 +02:00
parent f88ee4951e
commit 8810edf801
5 changed files with 202 additions and 3 deletions
+21 -2
View File
@@ -2,8 +2,8 @@
`simple-log` is a small convenience layer on top of Racket's logging system. `simple-log` is a small convenience layer on top of Racket's logging system.
It provides generated procedures for the standard log levels and supports It provides generated procedures for the standard log levels and supports
logging to the display, a file, custom callbacks, and a filterable in-memory logging to the display, regular or daily rotating files, custom callbacks, and
store. a filterable in-memory store.
```racket ```racket
#lang racket #lang racket
@@ -31,3 +31,22 @@ Topic filters can select multiple topics or invert a match:
The Scribble manual documents logger definitions, destinations, log levels, The Scribble manual documents logger definitions, destinations, log levels,
synchronization, and all in-memory store operations. synchronization, and all in-memory store operations.
## Rotating log files
Daily rotation keeps the current log as plain text and gzip-compresses older
days. The retention value counts the current day as well.
```racket
(sl-log-to-rotating-file "application.log") ; 7 days
(sl-log-to-rotating-file "application.log" 14) ; 14 days
```
With a retention of 7 days the files look like this:
```text
application.log
application.log.2026-08-28.gz
application.log.2026-08-27.gz
...
```
+1 -1
View File
@@ -1,7 +1,7 @@
#lang info #lang info
(define pkg-authors '(hnmdijkema)) (define pkg-authors '(hnmdijkema))
(define version "0.2.2") (define version "0.2.3")
(define license 'MIT) (define license 'MIT)
(define collection "simple-log") (define collection "simple-log")
(define pkg-desc "simple-log - A simple wrapper around the racket logging system") (define pkg-desc "simple-log - A simple wrapper around the racket logging system")
+20
View File
@@ -7,11 +7,13 @@
data/queue data/queue
"private/loghash.rkt" "private/loghash.rkt"
"private/store.rkt" "private/store.rkt"
"private/rotating-file.rkt"
) )
(provide sl-def-log (provide sl-def-log
sl-log-to sl-log-to
sl-log-to-file sl-log-to-file
sl-log-to-rotating-file
sl-log-to-display sl-log-to-display
sl-log-to-store sl-log-to-store
sl-log-to-file&display sl-log-to-file&display
@@ -211,6 +213,24 @@
(dbg-simple-log "log-to-file enabled with file ~a" filename) (dbg-simple-log "log-to-file enabled with file ~a" filename)
) )
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Enable daily rotating file logging.
; pre : filename is a path string and retention-days is a positive integer.
; post : The current day is logged as plain text. Older retained days are
; stored as <filename>.YYYY-MM-DD.gz.
; result : void
; internals : retention-days includes the current, uncompressed day.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (sl-log-to-rotating-file filename [retention-days 7])
(unless (exact-positive-integer? retention-days)
(raise-argument-error 'sl-log-to-rotating-file
"exact-positive-integer?"
retention-days))
(sl-log-to file (make-rotating-log-callback filename retention-days))
(dbg-simple-log "rotating log-to-file enabled with file ~a and retention ~a days"
filename retention-days)
)
(define-syntax def-log2 (define-syntax def-log2
(syntax-rules () (syntax-rules ()
((_ id parent receiver log-callbacks dbgn infon warnn errn fataln syncn) ((_ id parent receiver log-callbacks dbgn infon warnn errn fataln syncn)
+132
View File
@@ -0,0 +1,132 @@
#lang racket
(require file/gzip
racket/date
racket-sprintf)
(provide make-rotating-log-callback)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Supporting functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (iso-date seconds [local-time? #t])
(let ((d (seconds->date seconds local-time?)))
(sprintf "%04d-%02d-%02d"
(date-year d)
(date-month d)
(date-day d))))
(define (rotate-log-file! filename log-date)
(when (file-exists? filename)
(if (> (file-size filename) 0)
(let* ((filename-path (if (path? filename) filename (string->path filename)))
(archive
(string->path
(string-append (path->string filename-path) "." log-date ".gz"))))
(gzip filename archive)
(delete-file filename))
(delete-file filename))))
(define (remove-expired-log-files! filename log-date retention-days)
(let* ((filename-path (if (path? filename) filename (string->path filename)))
(directory (or (path-only filename-path) (current-directory)))
(base-name (path->string (file-name-from-path filename-path)))
(archive-regexp
(regexp
(format "^~a\\.([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9])\\.gz$"
(regexp-quote base-name))))
(date-match
(regexp-match #rx"^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"
log-date))
(year (string->number (cadr date-match)))
(month (string->number (caddr date-match)))
(day (string->number (cadddr date-match)))
(date-seconds (find-seconds 0 0 12 day month year #f))
(cutoff-seconds (- date-seconds (* 86400 (sub1 retention-days))))
(cutoff-date (iso-date cutoff-seconds #f)))
(for-each
(λ (entry)
(let* ((entry-name (path->string entry))
(archive-match (regexp-match archive-regexp entry-name))
(archive-path (build-path directory entry)))
(when (and archive-match
(string<? (cadr archive-match) cutoff-date)
(file-exists? archive-path))
(delete-file archive-path))))
(directory-list directory))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Provided functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Create a callback that writes to a daily rotating log file.
; pre : filename is a path string and retention-days is a positive integer.
; post : The current day is kept as plain text. Older retained days are
; stored as <filename>.YYYY-MM-DD.gz.
; result : A simple-log callback procedure.
; internals : Rotation is checked for every log entry using the entry timestamp.
; retention-days includes the current, uncompressed day.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (make-rotating-log-callback filename retention-days)
(let ((active-date (iso-date (current-seconds)))
(out #f))
;; A file left by an earlier process belongs to the day on which it was
;; last written. Keep today's file plain, and archive an older one first.
(when (file-exists? filename)
(let ((file-date
(iso-date (file-or-directory-modify-seconds filename))))
(when (not (string=? file-date active-date))
(rotate-log-file! filename file-date))))
(remove-expired-log-files! filename active-date retention-days)
(set! out (open-output-file filename #:exists 'append))
(λ (topic level dt msg)
(let ((entry-date (substring dt 0 10)))
(when (not (string=? entry-date active-date))
(close-output-port out)
(rotate-log-file! filename active-date)
(set! active-date entry-date)
(remove-expired-log-files! filename active-date retention-days)
(set! out (open-output-file filename #:exists 'append)))
(displayln (format "~a:~a:~a:~a" topic level dt msg) out)
(flush-output out)))))
(module+ test
(require rackunit
racket/file
file/gunzip)
(let ((test-directory (make-temporary-file "simple-log-rotation~a" 'directory)))
(dynamic-wind
void
(λ ()
(let* ((logfile (build-path test-directory "application.log"))
(archive (build-path test-directory "application.log.2026-08-28.gz"))
(expired (build-path test-directory "application.log.2026-08-26.gz")))
(call-with-output-file logfile
(λ (out) (displayln "previous day" out))
#:exists 'replace)
(call-with-output-file expired
(λ (out) (displayln "expired" out))
#:exists 'replace)
(rotate-log-file! logfile "2026-08-28")
(check-false (file-exists? logfile))
(check-true (file-exists? archive))
(let ((out (open-output-bytes)))
(call-with-input-file archive
(λ (in) (gunzip-through-ports in out)))
(check-equal? (get-output-bytes out) #"previous day\n"))
(remove-expired-log-files! logfile "2026-08-29" 2)
(check-true (file-exists? archive))
(check-false (file-exists? expired))))
(λ ()
(delete-directory/files test-directory)))))
+28
View File
@@ -101,6 +101,34 @@ opened with @racket['replace], and the output is flushed after every line.
Calling the procedure again replaces the existing file destination. Calling the procedure again replaces the existing file destination.
} }
@defproc[(sl-log-to-rotating-file
[filename path-string?]
[retention-days exact-positive-integer? 7])
void?]{
Registers a daily rotating file destination. The current day's log is kept as
plain text in @racket[filename]. When the date changes, the previous file is
compressed with gzip and stored as
@tt{filename.YYYY-MM-DD.gz}.
@racket[retention-days] is the total number of calendar days retained, including
the current uncompressed day. With the default value of @racket[7], the current
day and at most the previous six calendar days are retained. Compressed files
older than that period are removed.
If @racket[filename] already exists when rotating logging is enabled, a file
modified today is appended to. An older file is first archived under its last
modification date. This makes restarting an application on the same day preserve
the current day's log.
For example:
@racketblock[
(sl-log-to-rotating-file "application.log")
(sl-log-to-rotating-file "application.log" 14)
]
}
@defproc[(sl-log-to-file&display [filename path-string?]) void?]{ @defproc[(sl-log-to-file&display [filename path-string?]) void?]{
Enables both the display and file destinations. Enables both the display and file destinations.