Add structured logfmt logger for queries, malformed/refused, startup

Jaime Fournier <jaimef@linbsd.org>

d4e305e259e8b6df2cfdc467a5b8a39a56cec5a8

diff --git a/Makefile b/Makefile
index 697ad6e..0a73e64 100644
--- a/Makefile
+++ b/Makefile
@@ -86,3 +86,5 @@ static-clean:
 clean:
 	find lib -name '*.so' -delete 2>/dev/null || true
 	find lib -name '*.wpo' -delete 2>/dev/null || true
+	find lib \( -name '*.tarm64osx' -o -name '*.ta6le' \
+	         -o -name '*.ta6fb'     -o -name '*.tarm64le' \) -delete 2>/dev/null || true
diff --git a/build.ss b/build.ss
index 19af422..67a1a72 100644
--- a/build.ss
+++ b/build.ss
@@ -10,5 +10,6 @@
   (jerboa-dns lookup)
   (jerboa-dns wasm-dns)
   (jerboa-dns wasm-cdb)
+  (jerboa-dns log)
   (jerboa-dns server)
   (jerboa-dns main))
diff --git a/lib/jerboa-dns/log.sls b/lib/jerboa-dns/log.sls
new file mode 100644
index 0000000..5ba5156
--- /dev/null
+++ b/lib/jerboa-dns/log.sls
@@ -0,0 +1,178 @@
+#!chezscheme
+;;; (jerboa-dns log) — structured logfmt logger for jdns.
+;;;
+;;; All events are written to stderr as a single line in logfmt:
+;;;
+;;;   ts=2026-05-17T03:25:09.624Z lvl=info evt=query src=192.0.2.1:53122 \
+;;;       qid=0x4f3a qname=example.com qtype=A qclass=IN rcode=NOERROR \
+;;;       resp_bytes=64 lat_us=312
+;;;
+;;; Values containing space, =, or " are double-quoted and escaped.
+;;;
+;;; Verbosity: per-query info events are gated by *log-queries* (default
+;;; #f, since a busy authoritative server emits thousands of these per
+;;; second). Warn-level events (malformed/refused/servfail) are always
+;;; emitted regardless.
+
+(library (jerboa-dns log)
+  (export
+    log-startup
+    log-query
+    log-malformed
+    log-refused
+    log-servfail
+    log-error
+    log-info
+    set-log-queries!
+    log-queries-on?
+    qtype-name
+    rcode-name
+    bytes-prefix-hex)
+
+  (import (chezscheme))
+
+  ;; ── Verbosity toggle ───────────────────────────────────────────────────
+  (define *log-queries* #f)
+  (define (log-queries-on?) *log-queries*)
+  (define (set-log-queries! flag) (set! *log-queries* (and flag #t)))
+
+  ;; ── Timestamp ──────────────────────────────────────────────────────────
+  ;; ISO-8601 UTC with millisecond precision. Uses Chez (current-date 0)
+  ;; which returns a date record with the requested UTC offset.
+  (define (format-ts!)
+    (let* ([d  (current-date 0)]
+           [ms (quotient (date-nanosecond d) 1000000)])
+      (fprintf (current-error-port)
+               "~4,'0d-~2,'0d-~2,'0dT~2,'0d:~2,'0d:~2,'0d.~3,'0dZ"
+               (date-year d) (date-month d) (date-day d)
+               (date-hour d) (date-minute d) (date-second d) ms)))
+
+  ;; ── logfmt value quoting ───────────────────────────────────────────────
+  (define (needs-quoting? s)
+    (let loop ([i 0])
+      (cond
+        [(= i (string-length s)) #f]
+        [(let ([c (string-ref s i)])
+           (or (char=? c #\space) (char=? c #\=) (char=? c #\")
+               (char<? c #\space)))
+         #t]
+        [else (loop (+ i 1))])))
+
+  (define (write-quoted! port s)
+    (write-char #\" port)
+    (let loop ([i 0])
+      (cond
+        [(= i (string-length s)) (write-char #\" port)]
+        [else
+         (let ([c (string-ref s i)])
+           (cond
+             [(char=? c #\") (write-char #\\ port) (write-char #\" port)]
+             [(char=? c #\\) (write-char #\\ port) (write-char #\\ port)]
+             [(char=? c #\newline) (write-char #\\ port) (write-char #\n port)]
+             [(char=? c #\tab)     (write-char #\\ port) (write-char #\t port)]
+             [else (write-char c port)])
+           (loop (+ i 1)))])))
+
+  (define (write-value! port v)
+    (let ([s (cond
+               [(string? v) v]
+               [(symbol? v) (symbol->string v)]
+               [(number? v) (number->string v)]
+               [(boolean? v) (if v "true" "false")]
+               [(not v) "-"]
+               [else (format "~a" v)])])
+      (cond
+        [(zero? (string-length s)) (display "\"\"" port)]
+        [(needs-quoting? s) (write-quoted! port s)]
+        [else (display s port)])))
+
+  ;; ── Core emitter ───────────────────────────────────────────────────────
+  ;; kvs is a flat list: (key val key val ...). Keys are symbols, values
+  ;; can be any displayable atom. Missing values (#f) print as `-`.
+  (define (emit! level event kvs)
+    (let ([p (current-error-port)])
+      (display "ts=" p) (format-ts!)
+      (display " lvl=" p) (display level p)
+      (display " evt=" p) (display event p)
+      (let loop ([rest kvs])
+        (cond
+          [(null? rest) (void)]
+          [(null? (cdr rest))
+           (error 'emit! "odd number of key/value args" event kvs)]
+          [else
+           (write-char #\space p)
+           (display (car rest) p)
+           (write-char #\= p)
+           (write-value! p (cadr rest))
+           (loop (cddr rest))]))
+      (newline p)
+      (flush-output-port p)))
+
+  ;; ── Public helpers ─────────────────────────────────────────────────────
+  ;; Each takes positional args (kept short for callers) and produces one
+  ;; event line. Use log-info / log-error for ad-hoc events without a
+  ;; dedicated helper.
+
+  (define (log-startup . kvs)         (emit! 'info 'startup  kvs))
+  (define (log-info evt . kvs)        (emit! 'info evt       kvs))
+  (define (log-error evt . kvs)       (emit! 'error evt      kvs))
+
+  (define (log-query src qid qname qtype qclass rcode resp-bytes lat-us)
+    (when *log-queries*
+      (emit! 'info 'query
+        (list 'src src 'qid qid 'qname qname 'qtype qtype 'qclass qclass
+              'rcode rcode 'resp_bytes resp-bytes 'lat_us lat-us))))
+
+  (define (log-malformed src bytes first16 reason)
+    (emit! 'warn 'malformed
+      (list 'src src 'bytes bytes 'first16 first16 'reason reason)))
+
+  (define (log-refused src qid qname qtype reason)
+    (emit! 'warn 'refused
+      (list 'src src 'qid qid 'qname qname 'qtype qtype 'reason reason)))
+
+  (define (log-servfail src qid qname qtype reason)
+    (emit! 'warn 'servfail
+      (list 'src src 'qid qid 'qname qname 'qtype qtype 'reason reason)))
+
+  ;; ── Shared lookup tables ──────────────────────────────────────────────
+  ;; Mapping qtype codes to symbolic names (covers everything jdns-data
+  ;; can emit plus the few client-side query types that may show up).
+  (define (qtype-name n)
+    (case n
+      [(1)   "A"]     [(2)   "NS"]    [(5)   "CNAME"]
+      [(6)   "SOA"]   [(12)  "PTR"]   [(15)  "MX"]
+      [(16)  "TXT"]   [(28)  "AAAA"]  [(33)  "SRV"]
+      [(35)  "NAPTR"] [(43)  "DS"]    [(46)  "RRSIG"]
+      [(47)  "NSEC"]  [(48)  "DNSKEY"][(50)  "NSEC3"]
+      [(52)  "TLSA"]  [(99)  "SPF"]   [(252) "AXFR"]
+      [(253) "MAILB"] [(255) "ANY"]
+      [else  (number->string n)]))
+
+  ;; DNS RCODEs (RFC 1035 §4.1.1) — low 4 bits of header byte 3.
+  (define (rcode-name code)
+    (case (bitwise-and code #x0f)
+      [(0)  "NOERROR"]   [(1) "FORMERR"]
+      [(2)  "SERVFAIL"]  [(3) "NXDOMAIN"]
+      [(4)  "NOTIMP"]    [(5) "REFUSED"]
+      [else (number->string code)]))
+
+  ;; First N bytes of a bytevector as a continuous hex string — useful for
+  ;; including in malformed-packet logs so an operator can decode by hand.
+  (define (bytes-prefix-hex bv n)
+    (let* ([m (min n (bytevector-length bv))]
+           [out (make-string (* m 2))])
+      (do ([i 0 (+ i 1)]) ((= i m))
+        (let* ([b (bytevector-u8-ref bv i)]
+               [hi (quotient b 16)]
+               [lo (remainder b 16)])
+          (string-set! out (* i 2)       (hex-digit hi))
+          (string-set! out (+ (* i 2) 1) (hex-digit lo))))
+      out))
+
+  (define (hex-digit n)
+    (cond
+      [(< n 10) (integer->char (+ (char->integer #\0) n))]
+      [else     (integer->char (+ (char->integer #\a) (- n 10)))]))
+
+  ) ;; end library
diff --git a/lib/jerboa-dns/main.sls b/lib/jerboa-dns/main.sls
index b204fdb..d25ba80 100644
--- a/lib/jerboa-dns/main.sls
+++ b/lib/jerboa-dns/main.sls
@@ -7,10 +7,17 @@
   (import
     (chezscheme)
     (jerboa-dns server)
+    (jerboa-dns log)
     (jerboa-dns zone-compiler))
 
   ;; ========== Server Entry Point ==========
 
+  (define (truthy-env? s)
+    (and s
+         (let ([s (string-downcase s)])
+           (not (or (string=? s "") (string=? s "0")
+                    (string=? s "false") (string=? s "no"))))))
+
   (define (run-jdns! args)
     ;; Configuration from environment variables (djbdns convention)
     ;; or command-line args: [ip] [port] [root-dir] [uid] [gid]
@@ -22,6 +29,9 @@
           [gid (and (getenv "GID") (string->number (getenv "GID")))]
           [data-file (or (getenv "DATA") "data.cdb")])
 
+      (when (truthy-env? (getenv "JDNS_LOG_QUERIES"))
+        (set-log-queries! #t))
+
       ;; Override from args if provided
       (let process-args ([rest args]
                          [ip ip] [port port] [root-dir root-dir]
@@ -45,10 +55,13 @@
              uid (string->number (cadr rest)) data-file)]
           [(string=? (car rest) "--data")
            (process-args (cddr rest) ip port root-dir uid gid (cadr rest))]
+          [(string=? (car rest) "--log-queries")
+           (set-log-queries! #t)
+           (process-args (cdr rest) ip port root-dir uid gid data-file)]
           [else
            (display (format "jdns: unknown option ~a\n" (car rest))
                     (current-error-port))
-           (display "Usage: jdns [--ip IP] [--port PORT] [--root DIR] [--uid UID] [--gid GID] [--data FILE]\n"
+           (display "Usage: jdns [--ip IP] [--port PORT] [--root DIR] [--uid UID] [--gid GID] [--data FILE] [--log-queries]\n"
                     (current-error-port))
            (exit 1)]))))
 
diff --git a/lib/jerboa-dns/server.sls b/lib/jerboa-dns/server.sls
index 60d8d63..fb59af7 100644
--- a/lib/jerboa-dns/server.sls
+++ b/lib/jerboa-dns/server.sls
@@ -36,6 +36,7 @@
     (jerboa-dns response)
     (jerboa-dns lookup)
     (jerboa-dns wasm-dns)
+    (jerboa-dns log)
     ;; The wasm-cdb wrapper exposes the same predicate/flag names as
     ;; wasm-dns; we only need its reader API here, so import that subset.
     (only (jerboa-dns wasm-cdb)
@@ -201,9 +202,7 @@
       (let ([rc (c-cap-rights-limit fd rights)])
         (foreign-free rights)
         (when (< rc 0)
-          (display (format "jdns: warning: cap_rights_limit failed for fd ~a\n" fd)
-                   (current-error-port))
-          (flush-output-port (current-error-port))))))
+          (log-info 'cap_rights_limit_failed 'fd fd)))))
 
   (define (apply-capsicum-fd-restrictions!)
     ;; Restrict stdio fds using Capsicum cap_rights_limit.
@@ -229,9 +228,7 @@
       (bitwise-ior CAP_WRITE CAP_FSTAT CAP_SEEK)
       CAP_EVENT)
 
-    (display "jdns: capsicum fd restrictions applied\n"
-             (current-error-port))
-    (flush-output-port (current-error-port)))
+    (log-info 'capsicum_applied))
 
   ;; ========== Query Processing ==========
   ;; One response-state reused across queries (like djbdns global).
@@ -261,64 +258,106 @@
         [(_) (values #f #f #f #f)]
         [(id qname qtype qclass) (values id qname qtype qclass)])))
 
+  (define (client-src-str client-addr)
+    (string-append
+      (sockaddr-in-addr-str client-addr)
+      ":"
+      (number->string (sockaddr-in-port client-addr))))
+
+  (define (now-monotonic-ns)
+    (let ([t (current-time 'time-monotonic)])
+      (+ (* (time-second t) 1000000000) (time-nanosecond t))))
+
+  (define (response-rcode rs)
+    (bitwise-and (bytevector-u8-ref (response-buffer rs) 3) #x0f))
+
+  (define (condition-reason e)
+    (cond
+      [(message-condition? e) (condition-message e)]
+      [else "unknown"]))
+
   (define (process-query! rs sock pkt-buf pkt-len client-addr cdb)
     ;; Parse query, look up in CDB, send response.
     ;; cdb is the shared reader opened once at startup (see run-server!).
-    (let ([pkt (make-bytevector pkt-len)])
+    (let ([pkt   (make-bytevector pkt-len)]
+          [t0    (now-monotonic-ns)]
+          [src   (client-src-str client-addr)])
       ;; Copy from foreign memory to bytevector
       (do ([i 0 (+ i 1)]) ((= i pkt-len))
         (bytevector-u8-set! pkt i (foreign-ref 'unsigned-8 pkt-buf i)))
 
       (guard (e [#t
-                 ;; On any error, send SERVFAIL and log
-                 (guard (e2 [#t (void)])
-                   (display (format "jdns: query error: ~a\n"
-                             (if (message-condition? e)
-                               (condition-message e)
-                               "unknown"))
-                            (current-error-port))
-                   (flush-output-port (current-error-port)))
-                 (let ([err-rs (new-response-state)])
-                   (when (>= pkt-len 12)
-                     (response-query! err-rs (make-bytevector 1 0) DNS-T-A DNS-C-IN)
-                     (response-id! err-rs (uint16-get pkt 0))
-                     (response-servfail! err-rs)
-                     (send-response! sock err-rs client-addr)))])
+                 ;; On any error, log SERVFAIL and try to send one over the wire
+                 (let ([qid (and (>= pkt-len 2) (uint16-get pkt 0))])
+                   (guard (e2 [#t (void)])
+                     (log-servfail src
+                       (or qid 0)
+                       "-" "-"
+                       (condition-reason e)))
+                   (guard (e3 [#t (void)])
+                     (when (>= pkt-len 12)
+                       (let ([err-rs (new-response-state)])
+                         (response-query! err-rs (make-bytevector 1 0) DNS-T-A DNS-C-IN)
+                         (response-id! err-rs qid)
+                         (response-servfail! err-rs)
+                         (send-response! sock err-rs client-addr)))))])
 
         ;; Parse query (via wasm sandbox if enabled and available)
         (let-values ([(id qname qtype qclass) (parse-query-dispatch pkt pkt-len)])
-          (when id
-            ;; Only handle class IN
-            (when (or (= qclass DNS-C-IN) (= qclass DNS-C-ANY))
-              ;; Reject AXFR
-              (unless (= qtype DNS-T-AXFR)
-                ;; Initialize response
-                (response-query! rs qname qtype qclass)
-                (response-id! rs id)
-
-                ;; Look up against the shared CDB reader. The reader is
-                ;; opened once in run-server! and reused across queries —
-                ;; the sandbox wrapper resets its bump arena and refills
-                ;; fuel on every cdb_query so the wasm instance stays
-                ;; clean between calls.
-                (let ([client-ip (make-bytevector 4 0)])
-                  ;; Extract from sockaddr (sin_addr at offset 4)
-                  (do ([i 0 (+ i 1)]) ((= i 4))
-                    (bytevector-u8-set! client-ip i
-                      (foreign-ref 'unsigned-8 (+ client-addr 4) i)))
-                  (dns-respond rs cdb qname qtype client-ip))
-
-                ;; Truncate if > 512 bytes
-                (when (> (response-length rs) MAX-PACKET)
-                  (response-tc! rs))
-
-                ;; Log query
-                (log-query qname qtype
-                  (sockaddr-in-addr-str client-addr)
-                  (response-length rs))
-
-                ;; Send response
-                (send-response! sock rs client-addr))))))))
+          (cond
+            [(not id)
+             ;; Malformed or unparseable — silent on the wire (djbdns
+             ;; anti-amplification convention), but loud in the logs.
+             (log-malformed src pkt-len
+               (bytes-prefix-hex pkt 16)
+               (if (< pkt-len 12) "header_too_short" "parse_failed"))]
+
+            [(not (or (= qclass DNS-C-IN) (= qclass DNS-C-ANY)))
+             ;; Non-IN class — silent drop, logged at warn level.
+             (log-refused src id
+               (dns-domain-to-dot qname 0)
+               (qtype-name qtype)
+               "non_in_class")]
+
+            [(= qtype DNS-T-AXFR)
+             ;; AXFR refused — silent drop, logged at warn level.
+             (log-refused src id
+               (dns-domain-to-dot qname 0)
+               (qtype-name qtype)
+               "axfr_disabled")]
+
+            [else
+             ;; Initialize response
+             (response-query! rs qname qtype qclass)
+             (response-id! rs id)
+
+             ;; Look up against the shared CDB reader. The reader is
+             ;; opened once in run-server! and reused across queries —
+             ;; the sandbox wrapper resets its bump arena and refills
+             ;; fuel on every cdb_query so the wasm instance stays
+             ;; clean between calls.
+             (let ([client-ip (make-bytevector 4 0)])
+               ;; Extract from sockaddr (sin_addr at offset 4)
+               (do ([i 0 (+ i 1)]) ((= i 4))
+                 (bytevector-u8-set! client-ip i
+                   (foreign-ref 'unsigned-8 (+ client-addr 4) i)))
+               (dns-respond rs cdb qname qtype client-ip))
+
+             ;; Truncate if > 512 bytes
+             (when (> (response-length rs) MAX-PACKET)
+               (response-tc! rs))
+
+             ;; Send response
+             (send-response! sock rs client-addr)
+
+             ;; Log query (gated by *log-queries*; see (jerboa-dns log)).
+             (log-query src id
+               (dns-domain-to-dot qname 0)
+               (qtype-name qtype)
+               (if (= qclass DNS-C-ANY) "ANY" "IN")
+               (rcode-name (response-rcode rs))
+               (response-length rs)
+               (quotient (- (now-monotonic-ns) t0) 1000))])))))
 
   (define (send-response! sock rs client-addr)
     (let* ([len (response-length rs)]
@@ -330,18 +369,6 @@
       (c-sendto sock foreign-buf len 0 client-addr SOCKADDR_IN_SIZE)
       (foreign-free foreign-buf)))
 
-  (define (log-query qname qtype client-addr-str response-len)
-    (let ([name (dns-domain-to-dot qname 0)]
-          [type-name (case qtype
-                       [(1)  "A"]     [(2)  "NS"]    [(5)  "CNAME"]
-                       [(6)  "SOA"]   [(12) "PTR"]   [(15) "MX"]
-                       [(16) "TXT"]   [(28) "AAAA"]  [(33) "SRV"]
-                       [(255) "ANY"]  [else (number->string qtype)])])
-      (display (format "query ~a ~a from ~a (~a bytes)\n"
-                 name type-name client-addr-str response-len)
-               (current-error-port))
-      (flush-output-port (current-error-port))))
-
   ;; ========== Main Server Loop ==========
 
   (define (run-server! config)
@@ -370,9 +397,7 @@
             (error 'run-server! "cannot bind" ip port))
           (foreign-free sa))
 
-        (display (format "jdns: listening on ~a:~a\n" ip port)
-                 (current-error-port))
-        (flush-output-port (current-error-port))
+        (log-startup 'ip ip 'port port)
 
         ;; 4. chroot + chdir to data directory
         ;;    chroot restricts filesystem access to this directory tree.
@@ -382,16 +407,12 @@
             (if chroot-ok
               (begin
                 (c-chdir "/")
-                (display (format "jdns: chroot to ~a\n" root-dir)
-                         (current-error-port))
-                (flush-output-port (current-error-port)))
+                (log-info 'chroot 'dir root-dir))
               (begin
                 ;; chroot failed (not root?) — fall back to chdir
                 (when (= (c-chdir root-dir) -1)
                   (error 'run-server! "cannot chdir" root-dir))
-                (display (format "jdns: chdir to ~a (chroot unavailable)\n" root-dir)
-                         (current-error-port))
-                (flush-output-port (current-error-port))))))
+                (log-info 'chdir 'dir root-dir 'reason "chroot_unavailable")))))
 
         ;; 5. Drop privileges (gid before uid, as setuid may remove
         ;;    the ability to call setgid)
@@ -403,10 +424,7 @@
             (error 'run-server! "cannot setuid" uid)))
 
         (when (or uid gid)
-          (display (format "jdns: dropped privileges (uid=~a gid=~a)\n"
-                    (or uid "unchanged") (or gid "unchanged"))
-                   (current-error-port))
-          (flush-output-port (current-error-port)))
+          (log-info 'privdrop 'uid (or uid "unchanged") 'gid (or gid "unchanged")))
 
         ;; 6. Apply OS-level sandbox (defense in depth)
         (cond
@@ -426,12 +444,10 @@
         ;; linear memory) and ~15% under no-sandbox load (__open syscall
         ;; on the CDB file) — see bench/results/summary.md.
         (let ([cdb (open-sandboxed-cdb data-file)])
-          (display (format "jdns: opened CDB ~a (~a path)\n"
-                    data-file
-                    (if (sandboxed-cdb-reader-using-sandbox? cdb)
+          (log-info 'cdb_opened
+            'file data-file
+            'parser (if (sandboxed-cdb-reader-using-sandbox? cdb)
                       "sandboxed" "in-process"))
-                   (current-error-port))
-          (flush-output-port (current-error-port))
 
           ;; 8. Main loop
           (let ([recv-buf (foreign-alloc 65536)]
@@ -448,12 +464,8 @@
                 (when (> n 0)
                   ;; Process query
                   (guard (e [#t
-                             (display (format "jdns: error processing query: ~a\n"
-                                       (if (message-condition? e)
-                                         (condition-message e)
-                                         "unknown"))
-                               (current-error-port))
-                             (flush-output-port (current-error-port))])
+                             (log-error 'recv_loop_error
+                               'reason (condition-reason e))])
                     (process-query! rs sock recv-buf n client-addr cdb))))
 
               (loop)))))))
@@ -465,9 +477,7 @@
     ;; Uses Landlock ABI if available (Linux 5.13+).
     ;; Graceful fallback if not available.
     (guard (e [#t
-               (display "jdns: landlock not available — running without filesystem sandbox\n"
-                        (current-error-port))
-               (flush-output-port (current-error-port))])
+               (log-info 'landlock_unavailable 'reason "no_filesystem_sandbox")])
       ;; Try to load the Jerboa Landlock module dynamically
       (let ([ll-available? (guard (e [#t #f])
                              (eval '(begin
@@ -481,8 +491,6 @@
                      (landlock-add-read-only! rs ".")
                      (landlock-install! rs)))
                 (interaction-environment))
-          (display "jdns: landlock filesystem sandbox active\n"
-                   (current-error-port))
-          (flush-output-port (current-error-port))))))
+          (log-info 'landlock_active)))))
 
   ) ;; end library
diff --git a/lib/jerboa-dns/wasm-cdb.sls b/lib/jerboa-dns/wasm-cdb.sls
index 16ec731..39311a4 100644
--- a/lib/jerboa-dns/wasm-cdb.sls
+++ b/lib/jerboa-dns/wasm-cdb.sls
@@ -47,7 +47,8 @@
   (import
     (chezscheme)
     (std wasm sandbox)
-    (jerboa-dns cdb))
+    (jerboa-dns cdb)
+    (jerboa-dns log))
 
   ;; Tag bytes — must match wasm/cdb/src/lib.rs.
   (define TAG-REC #x02)
@@ -127,12 +128,9 @@
   (define (warn-fallback-once! reason)
     (unless *warned-fallback?*
       (set! *warned-fallback?* #t)
-      (let ([p (current-error-port)])
-        (display "jdns: WARNING — wasm CDB parser unavailable (" p)
-        (display reason p)
-        (display "); falling back to in-process cdb-find-all. " p)
-        (display "Set JDNS_REQUIRE_WASM_CDB=1 to error instead.\n" p)
-        (flush-output-port p))))
+      (log-info 'wasm_cdb_fallback
+        'reason reason
+        'hint "set JDNS_REQUIRE_WASM_CDB=1 to error instead")))
 
   ;; ========== Reader record ==========
   ;; Carries both the wasm instance (when sandboxed) and the underlying
diff --git a/lib/jerboa-dns/wasm-dns.sls b/lib/jerboa-dns/wasm-dns.sls
index 19a5dbc..305cc7e 100644
--- a/lib/jerboa-dns/wasm-dns.sls
+++ b/lib/jerboa-dns/wasm-dns.sls
@@ -32,7 +32,8 @@
   (import
     (chezscheme)
     (std wasm sandbox)
-    (jerboa-dns protocol))
+    (jerboa-dns protocol)
+    (jerboa-dns log))
 
   ;; Tag bytes — must match wasm/src/lib.rs.
   (define TAG-QUERY #x01)
@@ -122,12 +123,9 @@
   (define (warn-fallback-once! reason)
     (unless *warned-fallback?*
       (set! *warned-fallback?* #t)
-      (let ([p (current-error-port)])
-        (display "jdns: WARNING — wasm DNS parser unavailable (" p)
-        (display reason p)
-        (display "); falling back to in-process parse-query. " p)
-        (display "Set JDNS_REQUIRE_WASM_PARSER=1 to error instead.\n" p)
-        (flush-output-port p))))
+      (log-info 'wasm_dns_fallback
+        'reason reason
+        'hint "set JDNS_REQUIRE_WASM_PARSER=1 to error instead")))
 
   ;; ---------- Output decoder ----------