Repository: TwP/logging (Ruby)

Issue:
Support UTC time formats
@andrewtran634 asked a great question a while back:

> Quick question, is there a way to format so that date is in UTC instead of local time?

Alas, currently there is not a way to format the date in UTC instead of the local time. The time for a logging event is set when the [event is created](https://github.com/TwP/logging/blob/master/lib/logging/log_event.rb#L32), and that also determines the timezone that will be used when the time is formatted by the layouts.

I think the way to enable this feature is to provide a global [`utc_offset`](http://ruby-doc.org/core-2.3.0/Time.html#method-i-localtime) for the Logging library. This will default to the local time zone mirroring the current functionality. But you can set this to be "+00:00" for UTC. Layouts can override this global `utc_offset` so that one appender could output in the local time zone, while another appender can output in UTC.


Discussion:

Patch:
diff --git a/lib/logging.rb b/lib/logging.rb
index cf04944e..4f639be2 100644
--- a/lib/logging.rb
+++ b/lib/logging.rb
@@ -311,6 +311,31 @@ def backtrace( b = nil )
           end
     end
 
+    # Set the default UTC offset used when formatting time values sent to the
+    # appenders. If left unset, the default local time zone will be used for
+    # time values. This method accepts the `utc_offset` format supported by the
+    # `Time#localtime` method in Ruby.
+    #
+    # Passing "UTC" or `0` as the UTC offset will cause all times to be reported
+    # in the UTC timezone.
+    #
+    #   Logging.utc_offset = "-07:00"  # Mountain Standard Time in North America
+    #   Logging.utc_offset = "+01:00"  # Central European Time
+    #   Logging.utc_offset = "UTC"     # UTC
+    #   Logging.utc_offset = 0         # UTC
+    #
+    def utc_offset=( value )
+      @utc_offset = case value
+        when nil;             nil
+        when "UTC", "GMT", 0; 0
+        else
+          Time.now.localtime(value)
+          value
+        end
+    end
+
+    attr_reader :utc_offset
+
     # Used to define a `basepath` that will be removed from filenames when
     # reporting tracing information for log events. Normally you would set this
     # to the root of your project:
@@ -491,6 +516,7 @@ def reset
       remove_instance_variable :@basepath  if defined? @basepath
       remove_const :MAX_LEVEL_LENGTH if const_defined? :MAX_LEVEL_LENGTH
       remove_const :OBJ_FORMAT if const_defined? :OBJ_FORMAT
+      self.utc_offset = nil
       self
     end
 
diff --git a/lib/logging/layout.rb b/lib/logging/layout.rb
index 4b48d1e0..7ba25e80 100644
--- a/lib/logging/layout.rb
+++ b/lib/logging/layout.rb
@@ -41,7 +41,8 @@ def initialize( opts = {} )
                   when :inspect, :yaml, :json; f
                   else :string end
 
-    self.backtrace = opts.fetch(:backtrace, ::Logging.backtrace)
+    self.backtrace  = opts.fetch(:backtrace,  ::Logging.backtrace)
+    self.utc_offset = opts.fetch(:utc_offset, ::Logging.utc_offset)
   end
 
   # call-seq:
@@ -63,6 +64,50 @@ def backtrace=( value )
   attr_reader :backtrace
   alias :backtrace? :backtrace
 
+  # Set the UTC offset used when formatting time values. If left unset, the
+  # default local time zone will be used for time values. This method accepts
+  # the `utc_offset` format supported by the `Time#localtime` method in Ruby.
+  #
+  # Passing "UTC" or `0` as the UTC offset will cause all times to be reported
+  # in the UTC timezone.
+  #
+  #   layout.utc_offset = "-07:00"  # Mountain Standard Time in North America
+  #   layout.utc_offset = "+01:00"  # Central European Time
+  #   layout.utc_offset = "UTC"     # UTC
+  #   layout.utc_offset = 0         # UTC
+  #
+  def utc_offset=( value )
+    @utc_offset = case value
+      when nil;             nil
+      when "UTC", "GMT", 0; 0
+      else
+        Time.now.localtime(value)
+        value
+      end
+  end
+
+  # Returns the UTC offset.
+  attr_reader :utc_offset
+
+  # Internal: Helper method that applies the UTC offset to the given `time`
+  # instance. A new Time is returned that is equivalent to the original `time`
+  # but pinned to the timezone given by the UTC offset.
+  #
+  # If a UTC offset has not been set, then the original `time` instance is
+  # returned unchanged.
+  #
+  def apply_utc_offset( time )
+    return time if utc_offset.nil?
+
+    time = time.dup
+    if utc_offset == 0
+      time.utc
+    else
+      time.localtime(utc_offset)
+    end
+    time
+  end
+
   # call-seq:
   #    format( event )
   #
diff --git a/lib/logging/layouts/parseable.rb b/lib/logging/layouts/parseable.rb
index 34483400..1dac4d0b 100644
--- a/lib/logging/layouts/parseable.rb
+++ b/lib/logging/layouts/parseable.rb
@@ -177,8 +177,9 @@ def self.yaml( opts = {} )
     #
     # Creates a new Parseable layout using the following options:
     #
-    #    :style  => :json or :yaml
-    #    :items  => %w[timestamp level logger message]
+    #    :style      => :json or :yaml
+    #    :items      => %w[timestamp level logger message]
+    #    :utc_offset =>  "-06:00" or -21600 or "UTC"
     #
     def initialize( opts = {} )
       super
@@ -241,9 +242,11 @@ def create_format_method
       else raise ArgumentError, "unknown format style '#@style'" end
     end
 
-    # Convert the given time _value_ into an ISO8601 formatted time string.
+    # Convert the given `time` into an ISO8601 formatted time string.
     #
-    def iso8601_format( value )
+    def iso8601_format( time )
+      value = apply_utc_offset(time)
+
       str = value.strftime('%Y-%m-%dT%H:%M:%S')
       str << ('.%06d' % value.usec)
 
diff --git a/lib/logging/layouts/pattern.rb b/lib/logging/layouts/pattern.rb
index 4791faa7..301e4325 100644
--- a/lib/logging/layouts/pattern.rb
+++ b/lib/logging/layouts/pattern.rb
@@ -164,6 +164,7 @@ class Pattern < ::Logging::Layout
     def self.create_date_format_methods( pl )
       code = "undef :format_date if method_defined? :format_date\n"
       code << "def format_date( time )\n"
+      code << "time = apply_utc_offset(time)\n"
       if pl.date_method.nil?
         if pl.date_pattern =~ %r/%s/
           code << "time.strftime('#{pl.date_pattern.gsub('%s','%6N')}')\n"
@@ -203,7 +204,8 @@ def self.create_format_method( pl )
     #
     #    :pattern       =>  "[%d] %-5l -- %c : %m\n"
     #    :date_pattern  =>  "%Y-%m-%d %H:%M:%S"
-    #    :date_method   =>  'usec' or 'to_s'
+    #    :date_method   =>  "usec" or "to_s"
+    #    :utc_offset    =>  "-06:00" or -21600 or "UTC"
     #    :color_scheme  =>  :default
     #
     # If used, :date_method will supersede :date_pattern.
@@ -216,7 +218,7 @@ def self.create_format_method( pl )
     #
     def initialize( opts = {} )
       super
-      @created_at = Time.now
+      @created_at = Time.now.freeze
 
       @date_pattern = opts.fetch(:date_pattern, nil)
       @date_method = opts.fetch(:date_method, nil)
diff --git a/lib/logging/log_event.rb b/lib/logging/log_event.rb
index c1b867e7..b66fe63c 100644
--- a/lib/logging/log_event.rb
+++ b/lib/logging/log_event.rb
@@ -29,7 +29,7 @@ def initialize( logger, level, data, caller_tracing )
       self.logger = logger
       self.level  = level
       self.data   = data
-      self.time   = Time.now
+      self.time   = Time.now.freeze
 
       if caller_tracing
         stack = Kernel.caller[CALLER_INDEX]


Test patch:
diff --git a/test/layouts/test_json.rb b/test/layouts/test_json.rb
index 0d73259c..9e4c5485 100644
--- a/test/layouts/test_json.rb
+++ b/test/layouts/test_json.rb
@@ -166,6 +166,19 @@ def test_ndc_output
       assert_match %r/"ndc":\[\]/, format
     end
 
+    def test_utc_offset
+      layout = Logging.layouts.json(:items => %w[timestamp])
+      event = Logging::LogEvent.new('TimestampLogger', @levels['info'], 'log message', false)
+      event.time = Time.utc(2016, 12, 1, 12, 0, 0).freeze
+
+      assert_equal %Q/{"timestamp":"2016-12-01T12:00:00.000000Z"}\n/, layout.format(event)
+
+      layout.utc_offset = "-06:00"
+      assert_equal %Q/{"timestamp":"2016-12-01T06:00:00.000000-06:00"}\n/, layout.format(event)
+
+      layout.utc_offset = "+01:00"
+      assert_equal %Q/{"timestamp":"2016-12-01T13:00:00.000000+01:00"}\n/, layout.format(event)
+    end
   end  # class TestJson
 end  # module TestLayouts
 end  # module TestLogging
diff --git a/test/layouts/test_pattern.rb b/test/layouts/test_pattern.rb
index b7599d1d..b7afbfc5 100644
--- a/test/layouts/test_pattern.rb
+++ b/test/layouts/test_pattern.rb
@@ -230,6 +230,19 @@ def test_pattern_ndc
       assert_equal 'context a', @layout.format(event)
     end
 
+    def test_utc_offset
+      layout = Logging.layouts.pattern(:pattern => "%d", :utc_offset => "UTC")
+      event = Logging::LogEvent.new('DateLogger', @levels['info'], 'log message', false)
+      event.time = Time.utc(2016, 12, 1, 12, 0, 0).freeze
+
+      assert_equal "2016-12-01T12:00:00", layout.format(event)
+
+      layout.utc_offset = "-06:00"
+      assert_equal "2016-12-01T06:00:00", layout.format(event)
+
+      layout.utc_offset = "+01:00"
+      assert_equal "2016-12-01T13:00:00", layout.format(event)
+    end
   end  # TestBasic
 end  # TestLayouts
 end  # TestLogging
diff --git a/test/layouts/test_yaml.rb b/test/layouts/test_yaml.rb
index 7282e2af..8df420d5 100644
--- a/test/layouts/test_yaml.rb
+++ b/test/layouts/test_yaml.rb
@@ -148,6 +148,20 @@ def test_ndc_output
       assert_match %r/\nndc: \[\]\n/, format
     end
 
+    def test_utc_offset
+      layout = Logging.layouts.yaml(:items => %w[timestamp])
+      event = Logging::LogEvent.new('TimestampLogger', @levels['info'], 'log message', false)
+      event.time = Time.utc(2016, 12, 1, 12, 0, 0).freeze
+
+      assert_equal %Q{---\ntimestamp: '2016-12-01T12:00:00.000000Z'\n}, layout.format(event)
+
+      layout.utc_offset = "-06:00"
+      assert_equal %Q{---\ntimestamp: '2016-12-01T06:00:00.000000-06:00'\n}, layout.format(event)
+
+      layout.utc_offset = "+01:00"
+      assert_equal %Q{---\ntimestamp: '2016-12-01T13:00:00.000000+01:00'\n}, layout.format(event)
+    end
+
   private
 
     def assert_yaml_match( expected, actual )
diff --git a/test/test_layout.rb b/test/test_layout.rb
index 89419b4a..8aea26f2 100644
--- a/test/test_layout.rb
+++ b/test/test_layout.rb
@@ -120,6 +120,46 @@ def test_backtrace_accessors
       @layout.backtrace = 'on'
       assert_equal true, @layout.backtrace
     end
+
+    def test_utc_offset
+      assert_nil @layout.utc_offset
+
+      @layout.utc_offset = 0
+      assert_equal 0, @layout.utc_offset
+
+      @layout.utc_offset = "UTC"
+      assert_equal 0, @layout.utc_offset
+
+      @layout.utc_offset = "+01:00"
+      assert_equal "+01:00", @layout.utc_offset
+
+      assert_raise(ArgumentError) {@layout.utc_offset = "06:00"}
+
+      @layout.utc_offset   = nil
+      ::Logging.utc_offset = "UTC"
+      assert_nil @layout.utc_offset
+
+      layout = ::Logging::Layout.new
+      assert_equal 0, layout.utc_offset
+    end
+
+    def test_apply_utc_offset
+      time = Time.now.freeze
+
+      offset_time = @layout.apply_utc_offset(time)
+      assert_same time, offset_time
+
+      @layout.utc_offset = "UTC"
+      offset_time = @layout.apply_utc_offset(time)
+      assert_not_same time, offset_time
+      assert offset_time.utc?
+
+      @layout.utc_offset = "+01:00"
+      offset_time = @layout.apply_utc_offset(time)
+      assert_not_same time, offset_time
+      assert !offset_time.utc?
+      assert_equal 3600, offset_time.utc_offset
+    end
   end  # class TestLayout
 end  # module TestLogging
 
diff --git a/test/test_logging.rb b/test/test_logging.rb
index d6d3d314..b29daa86 100644
--- a/test/test_logging.rb
+++ b/test/test_logging.rb
@@ -39,6 +39,21 @@ def test_backtrace
       assert_raise(ArgumentError) {::Logging.backtrace 'foo'}
     end
 
+    def test_utc_offset
+      assert_nil ::Logging.utc_offset
+
+      ::Logging.utc_offset = 0
+      assert_equal 0, ::Logging.utc_offset
+
+      ::Logging.utc_offset = "UTC"
+      assert_equal 0, ::Logging.utc_offset
+
+      ::Logging.utc_offset = "+01:00"
+      assert_equal "+01:00", ::Logging.utc_offset
+
+      assert_raise(ArgumentError) {::Logging.utc_offset = "06:00"}
+    end
+
     def test_basepath
       assert_nil ::Logging.basepath