Log debug dumps to a timestamped file
Debug mode now writes through stdlib Logger to log/debug.log (overridable with PRO_TACTS_DEBUG_LOG; "stderr" restores the old destination). Timestamps make it possible to see where a CardDAV client stalled between requests.

Assisted-by: GLM 5.2 via pi
change rzsrkzluqsyyorzpuorxnolyoxmrxkrt
commit 25e063e89e63bb15b91dff98af3e89e1a7cab1b5
author Alpha Chen <alpha@kejadlen.dev>
date
parent ttlsrouy
diff --git a/.gitignore b/.gitignore
index ff0886e..0e3001d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
+/log
 /servers
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index 09909bb..fb123dd 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -22,5 +22,11 @@ module ProTacts
       value = @env.fetch("PRO_TACTS_DEBUG", nil)
       !value.nil? && value.match?(TRUTHY)
     end
+
+    # Where the debug logger writes. A path, overridable with
+    # PRO_TACTS_DEBUG_LOG; "stderr" keeps it on the process's stderr.
+    def debug_log_path
+      @env.fetch("PRO_TACTS_DEBUG_LOG", "log/debug.log")
+    end
   end
 end
diff --git a/lib/pro_tacts/debug_logger.rb b/lib/pro_tacts/debug_logger.rb
index 881d43f..def352d 100644
--- a/lib/pro_tacts/debug_logger.rb
+++ b/lib/pro_tacts/debug_logger.rb
@@ -1,18 +1,41 @@
 # frozen_string_literal: true
 
+require "fileutils"
+require "logger"
 require "rack"
 
 module ProTacts
   # Rack middleware that dumps the full request and response exchange to a
-  # log stream: method, path, every header, and the full body on both sides.
+  # Logger: method, path, every header, and the full body on both sides.
+  # Every line is prefixed ">>" (request) or "<<" (response) so multi-line
+  # XML/vCard bodies stay readable.
   #
   # Off by default because it logs contact data. The only card right now is
   # fictional, so the debug path stays verbose while the normal one-line path
   # (Roda's common_logger) can be narrowed later without losing this.
   class DebugLogger
-    def initialize(app, io: $stderr)
+    # Builds the Logger the middleware writes to: appended and unbuffered
+    # (Logger syncs its own device), one timestamped line per dump. path
+    # "stderr" writes to the process's stderr.
+    def self.open_log(path)
+      target = if path == "stderr"
+                 $stderr
+               else
+                 FileUtils.mkdir_p(File.dirname(path))
+                 path
+               end
+
+      Logger.new(target).tap do |logger|
+        logger.level = :debug
+        logger.formatter = proc do |_severity, datetime, _progname, msg|
+          "#{datetime.strftime('%Y-%m-%dT%H:%M:%S.%3N')} #{msg}\n"
+        end
+      end
+    end
+
+    def initialize(app, logger:)
       @app = app
-      @io = io
+      @logger = logger
     end
 
     def call(env)
@@ -78,7 +101,7 @@ module ProTacts
     end
 
     def write(prefix, text)
-      text.to_s.each_line(chomp: true) { |line| @io.puts("#{prefix} #{line}") }
+      text.to_s.each_line(chomp: true) { |line| @logger.debug("#{prefix} #{line}") }
     end
   end
 end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 445a180..30fdeae 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -29,7 +29,10 @@ module ProTacts
     # and then rewind it so the application can still access it.
     use Rack::RewindableInput::Middleware
     use Sentry::Rack::CaptureExceptions
-    use ProTacts::DebugLogger if ProTacts.config.debug?
+    if ProTacts.config.debug?
+      logger = ProTacts::DebugLogger.open_log(ProTacts.config.debug_log_path)
+      use ProTacts::DebugLogger, logger: logger
+    end
 
     plugin :all_verbs
     plugin :dav_verbs
diff --git a/test/pro_tacts/test_config.rb b/test/pro_tacts/test_config.rb
index 0faf130..1524d8f 100644
--- a/test/pro_tacts/test_config.rb
+++ b/test/pro_tacts/test_config.rb
@@ -28,4 +28,13 @@ class ConfigTest < Minitest::Test
     refute ProTacts::Config.new("PRO_TACTS_DEBUG" => "no").debug?
     refute ProTacts::Config.new("PRO_TACTS_DEBUG" => "0").debug?
   end
+
+  def test_debug_log_path_defaults_to_a_file
+    assert_equal "log/debug.log", ProTacts::Config.new({}).debug_log_path
+  end
+
+  def test_debug_log_path_is_overridable
+    assert_equal "/tmp/dav.log", ProTacts::Config.new("PRO_TACTS_DEBUG_LOG" => "/tmp/dav.log").debug_log_path
+    assert_equal "stderr", ProTacts::Config.new("PRO_TACTS_DEBUG_LOG" => "stderr").debug_log_path
+  end
 end
diff --git a/test/pro_tacts/test_debug_logger.rb b/test/pro_tacts/test_debug_logger.rb
index e253a0a..8ecd32f 100644
--- a/test/pro_tacts/test_debug_logger.rb
+++ b/test/pro_tacts/test_debug_logger.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 require "minitest/autorun"
+require "logger"
 require "stringio"
 
 require "pro_tacts/debug_logger"
@@ -8,7 +9,8 @@ require "pro_tacts/debug_logger"
 class DebugLoggerTest < Minitest::Test
   def setup
     @io = StringIO.new
-    @app = ProTacts::DebugLogger.new(echo_app, io: @io)
+    @logger = Logger.new(@io)
+    @app = ProTacts::DebugLogger.new(echo_app, logger: @logger)
   end
 
   # Echoes a fixed response so assertions can target what the logger adds.
@@ -51,7 +53,7 @@ class DebugLoggerTest < Minitest::Test
     read = nil
     app = ProTacts::DebugLogger.new(
       ->(e) { read = e["rack.input"].read; [200, {}, [""]] },
-      io: StringIO.new
+      logger: Logger.new(StringIO.new)
     )
 
     app.call(env(body: "<x/>"))
@@ -81,4 +83,17 @@ class DebugLoggerTest < Minitest::Test
     assert_equal "text/xml", headers["Content-Type"]
     assert_equal ["<multistatus/>"], body
   end
+
+  class OpenLogTest < Minitest::Test
+    def test_appends_timestamped_lines_to_a_file_it_creates
+      require "tmpdir"
+      Dir.mktmpdir do |dir|
+        path = File.join(dir, "nested", "debug.log")
+        logger = ProTacts::DebugLogger.open_log(path)
+        logger.debug(">> PROPFIND / HTTP/1.1")
+
+        assert_match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3} >> PROPFIND \/ HTTP\/1.1\n/, File.read(path))
+      end
+    end
+  end
 end